Skip to main content

tailscale/
tsnet.rs

1//! A Go-idiomatic [`tsnet.Server`](https://pkg.go.dev/tailscale.com/tsnet#Server)-shaped facade over
2//! [`Device`] and [`Config`], behind the `tsnet` cargo feature.
3//!
4//! # Why this exists
5//!
6//! The fork's native embedding surface is [`Device`] + [`Config`]: you build a `Config`, then
7//! `Device::new(&config, auth_key).await`. That is idiomatic Rust (construct-from-config, typed
8//! errors) and is a documented *superset* of Go `tsnet` (see `docs/TSNET_PARITY.md`). But a large
9//! body of existing code — and muscle memory — is written against Go's shape:
10//!
11//! ```go
12//! srv := &tsnet.Server{Hostname: "web", AuthKey: key}
13//! ln, _ := srv.Listen("tcp", ":80")
14//! defer srv.Close()
15//! ```
16//!
17//! [`Server`] is a **thin, Go-shaped ergonomics layer** over the existing engine. It changes *nothing*
18//! about the dataplane, control, or netstack: it maps a set of Go-`tsnet.Server`-parity fields onto
19//! [`Config`], defers construction of the wrapped [`Device`] until the first method call (Go's
20//! "fields may be changed until the first method call"), and forwards every call straight to the
21//! [`Device`] it wraps. It is deliberately **not** a re-implementation and **not** a new crate.
22//!
23//! # What stays Rust-native (on purpose)
24//!
25//! "Go-idiomatic" here means the **lifecycle model and method names** (settable fields, lazy
26//! `start`, `up`/`close`, `Listen`/`Dial`/`Loopback`/`ListenFunnel`/`ListenService`), *not* Go's
27//! return types. A thin facade must not re-wrap the engine's types into `net.Conn`/`net.Listener`
28//! trait objects — that would be a translation layer, not a facade, and would throw away the fork's
29//! typed returns. So:
30//!
31//! * inbound/outbound connections keep their engine types ([`DialConn`](crate::DialConn),
32//!   [`netstack::TcpListener`], …);
33//! * specialized calls keep the fork's **typed** errors ([`ServiceError`],
34//!   [`FunnelError`](ts_control::FunnelError)) — carried unchanged as a variant of a thin wrapper
35//!   ([`ListenFunnelError`] / [`ListenServiceError`]) whose other variant keeps a lazy-start failure
36//!   distinct from the engine's typed error (so a node that never registered is never misreported as
37//!   an access denial); the plain *lifecycle* path — a single opaque `error` in Go — unifies into
38//!   [`Error`];
39//! * addresses are accepted as Go-style `network, addr` **strings** (`"tcp"`, `":80"`) for
40//!   familiarity, and parsed to the typed [`SocketAddr`] the engine wants.
41//!
42//! See `docs/TSNET_FACADE_DESIGN.md` for the full rationale, the field-by-field mapping table, and
43//! the state-root (`Dir`/`Store`) design over [`Config::key_state`](crate::Config::key_state).
44//!
45//! # Go `tsnet.Server` → `tsnet::Server`, at a glance
46//!
47//! Construct with [`Server::new`], set the public fields where Go sets struct fields, then call a
48//! method — which lazily builds and starts the wrapped [`Device`]. Method names are Go's (in Rust
49//! snake_case); return types stay the fork's typed values (see *What stays Rust-native*, above). Each
50//! item's own rustdoc names its Go equivalent; the full field-by-field mapping with verdicts lives in
51//! `docs/TSNET_FACADE_DESIGN.md`, and the authoritative parity matrix in `docs/TSNET_PARITY.md`.
52//!
53//! ## Fields — set before the first method call
54//!
55//! | Go `tsnet.Server` field | Field on [`Server`] |
56//! |---|---|
57//! | `Hostname` | [`Server::hostname`] |
58//! | `AuthKey` | [`Server::auth_key`] |
59//! | `ControlURL` | [`Server::control_url`] |
60//! | `Ephemeral` | [`Server::ephemeral`] |
61//! | `AdvertiseTags` | [`Server::advertise_tags`] |
62//! | `Port` | [`Server::port`] |
63//! | `Dir` | [`Server::dir`] |
64//! | `Store` | [`Server::store`] (a [`StateStore`]) |
65//! | `Tun` | [`Server::tun`] |
66//! | `RunWebClient` | [`Server::run_web_client`] |
67//! | `ClientID` / `ClientSecret` / `IDToken` / `Audience` | [`Server::client_id`] / [`Server::client_secret`] / [`Server::id_token`] / [`Server::audience`] |
68//! | *(no Go field — fork escape hatch to [`Config`])* | [`Server::configure`] |
69//!
70//! ## Methods — each lazily starts the node on first use
71//!
72//! | Go `tsnet.Server` | Method | Returns |
73//! |---|---|---|
74//! | `Start()` | [`Server::start`] | `()` |
75//! | `Up(ctx)` | [`Server::up`] | [`Status`] |
76//! | `Dial(ctx, network, addr)` | [`Server::dial`] | [`DialConn`](crate::DialConn) |
77//! | *(TCP fast path)* | [`Server::dial_tcp`] | [`netstack::TcpStream`] |
78//! | *(UDP fast path)* | [`Server::dial_udp`] | [`ConnectedUdpSocket`](crate::ConnectedUdpSocket) |
79//! | `Listen(network, addr)` | [`Server::listen`] | [`netstack::TcpListener`] |
80//! | `ListenPacket(network, addr)` | [`Server::listen_packet`] | [`netstack::UdpSocket`] |
81//! | `ListenFunnel(…)` | [`Server::listen_funnel`] | a Funnel accept receiver |
82//! | `ListenService(name, mode)` | [`Server::listen_service`] | [`ServiceListener`] |
83//! | `Loopback()` | [`Server::loopback`] | [`Loopback`] |
84//! | `LocalClient()` | [`Server::local_client`] | [`LocalClient`] |
85//! | `HTTPClient()` | `Server::http_client` (feature `hyper`) | a `hyper` client |
86//! | `TailscaleIPs()` | [`Server::tailscale_ips`] | `(Ipv4Addr, Option<Ipv6Addr>)` |
87//! | `CertDomains()` | [`Server::cert_domains`] | `Vec<String>` |
88//! | `LocalClient().Status` | [`Server::status`] | [`Status`] |
89//! | `LocalClient().Logout` | [`Server::logout`] | `()` |
90//! | `Close()` | [`Server::close`] | `bool` (shut down cleanly within the timeout?) |
91//! | `Sys()` / full `LocalClient()` | [`Server::device`] | [`Device`] reference — the whole engine surface |
92//!
93//! Lifecycle errors unify into the Go-shaped [`Error`]; the specialized Funnel/Service calls keep
94//! their fork-typed errors while distinguishing a lazy-start failure from the engine error
95//! ([`Server::listen_funnel`] → [`ListenFunnelError`] over [`ts_control::FunnelError`],
96//! [`Server::listen_service`] → [`ListenServiceError`] over [`ServiceError`]).
97
98use std::net::SocketAddr;
99use std::path::PathBuf;
100use std::sync::{Arc, Weak};
101use std::time::Duration;
102
103use base64::{Engine as _, engine::general_purpose::STANDARD};
104use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
105use tokio::net::{TcpListener, TcpStream};
106use tokio::sync::OnceCell;
107use tokio::task::AbortHandle;
108use ts_keys::PersistState;
109
110use crate::config::Config;
111use crate::netstack;
112use crate::{Device, RegistrationError, ServiceError, ServiceMode, Status, StatusNode};
113
114// ---------------------------------------------------------------------------------------------
115// State store — the `Dir` / `Store` design (Go `ipn.StateStore` + `FileStore`), over `key_state`.
116// ---------------------------------------------------------------------------------------------
117
118/// Default on-disk state file name under [`Server::dir`] (Go persists node state to
119/// `Dir/tailscaled.state`; this fork persists only node **identity keys**, see the module docs and
120/// `docs/TSNET_FACADE_DESIGN.md` §"State root").
121pub const STATE_FILE: &str = "tailscale-rs.state";
122
123/// The well-known key under which the node identity blob ([`PersistState`]) is stored in a
124/// [`StateStore`] (Go stores the machine key + profile under fixed keys in the `StateStore`).
125pub const STATE_KEY: &str = "_tailscale-rs/persist";
126
127/// Subdirectory of [`Server::dir`] the cold-start netmap cache lives in (Go caches a profile's
128/// netmap under a `netmap-cache` directory of its own). Held apart from the identity state file so
129/// dropping the cache — which control can ask for at any time — cannot reach the node's keys.
130pub const NETMAP_CACHE_DIR: &str = "netmap-cache";
131
132/// A pluggable key/value state store — the Rust analog of Go's `ipn.StateStore`
133/// ([`Server::store`]).
134///
135/// **Scope (be honest).** Unlike Go — which persists the *full* node state (prefs + netmap +
136/// machine key) — this fork's engine only persists node **identity keys**. So a [`StateStore`] here
137/// round-trips exactly one value: the [`PersistState`] identity blob under [`STATE_KEY`]. The trait
138/// is intentionally the general KV shape so it stays forward-compatible: when the engine grows
139/// prefs/netmap persistence, more keys slot in with no caller change. See the design doc.
140pub trait StateStore: Send + Sync {
141    /// Read the value for `id`, or `Ok(None)` if it has never been written.
142    fn read_state(&self, id: &str) -> std::io::Result<Option<Vec<u8>>>;
143    /// Durably write `value` for `id`.
144    fn write_state(&self, id: &str, value: &[u8]) -> std::io::Result<()>;
145}
146
147/// An on-disk JSON [`StateStore`] rooted at a single file (Go `store.FileStore`).
148///
149/// Prefer setting [`Server::dir`] for the standard on-disk identity — that path reuses the engine's
150/// own key-file format and migration ([`Config::default_with_key_file`]). Use an explicit
151/// `FileStore`/custom [`StateStore`] only for a non-default location or a bespoke backend.
152pub struct FileStore {
153    path: PathBuf,
154}
155
156impl FileStore {
157    /// A file store writing to `dir/`[`STATE_FILE`].
158    pub fn new(dir: impl Into<PathBuf>) -> Self {
159        Self {
160            path: dir.into().join(STATE_FILE),
161        }
162    }
163
164    /// A file store writing to an exact file path.
165    pub fn at(path: impl Into<PathBuf>) -> Self {
166        Self { path: path.into() }
167    }
168}
169
170impl StateStore for FileStore {
171    fn read_state(&self, _id: &str) -> std::io::Result<Option<Vec<u8>>> {
172        match std::fs::read(&self.path) {
173            Ok(bytes) => Ok(Some(bytes)),
174            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
175            Err(e) => Err(e),
176        }
177    }
178
179    fn write_state(&self, _id: &str, value: &[u8]) -> std::io::Result<()> {
180        if let Some(parent) = self.path.parent() {
181            std::fs::create_dir_all(parent)?;
182        }
183        std::fs::write(&self.path, value)
184    }
185}
186
187/// An in-memory [`StateStore`] (Go `mem.Store`): identity is regenerated every run and never
188/// persisted. This is the implicit behavior when neither [`Server::dir`] nor [`Server::store`] is
189/// set; provide it explicitly to be unambiguous.
190#[derive(Default)]
191pub struct MemStore {
192    inner: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
193}
194
195impl StateStore for MemStore {
196    fn read_state(&self, id: &str) -> std::io::Result<Option<Vec<u8>>> {
197        Ok(self
198            .inner
199            .lock()
200            .unwrap_or_else(|e| e.into_inner())
201            .get(id)
202            .cloned())
203    }
204
205    fn write_state(&self, id: &str, value: &[u8]) -> std::io::Result<()> {
206        self.inner
207            .lock()
208            .unwrap_or_else(|e| e.into_inner())
209            .insert(id.to_string(), value.to_vec());
210        Ok(())
211    }
212}
213
214// ---------------------------------------------------------------------------------------------
215// Errors — unify only the *lifecycle* path (Go's single opaque `error`); preserve typed errors
216// on the specialized calls (Funnel/Service), matching the engine.
217// ---------------------------------------------------------------------------------------------
218
219/// The unified error for [`Server`]'s lifecycle surface (`start`/`up`/`dial`/`listen`/…).
220///
221/// Go returns a single opaque `error` everywhere; this fork returns *typed* errors. [`Error`] is the
222/// Go-shaped unification for the lifecycle path only — the specialized calls that already carry rich
223/// typed errors keep them, wrapped so a lazy-start failure stays distinct from the engine error:
224/// [`Server::listen_funnel`] → [`ListenFunnelError`] (over [`ts_control::FunnelError`]),
225/// [`Server::listen_service`] → [`ListenServiceError`] (over [`ServiceError`]).
226#[derive(Debug, thiserror::Error)]
227#[non_exhaustive]
228pub enum Error {
229    /// The wrapped [`Device`]/engine returned an error.
230    #[error("device error: {0}")]
231    Device(#[from] crate::Error),
232
233    /// Registration did not reach `Running` (Go `Up` failure). Carries the typed, actionable reason
234    /// (permanent vs transient vs needs-login) — richer than Go's status blob.
235    #[error("registration error: {0}")]
236    Registration(#[from] RegistrationError),
237
238    /// [`Server::control_url`] was set but is not a valid URL.
239    #[error("invalid control URL: {0}")]
240    InvalidControlUrl(url::ParseError),
241
242    /// A Go-style `network, addr` string could not be parsed into a [`SocketAddr`].
243    #[error("invalid address {addr:?}: {source}")]
244    InvalidAddr {
245        /// The offending address string.
246        addr: String,
247        /// The parse failure.
248        source: std::net::AddrParseError,
249    },
250
251    /// A family-pinned listen `network` (`"tcp4"`/`"tcp6"`) was given an explicit host literal of the
252    /// *other* address family — e.g. [`Server::listen`]`("tcp4", "[::1]:80")`. Go's `net.Listen`
253    /// rejects the same mismatch (`tcp4` binds only IPv4 addresses, `tcp6` only IPv6). A bare
254    /// `":port"` never trips this: it is filled with the family's own wildcard host first.
255    #[error(
256        "address {addr:?} is not an {want} address (required by the family-pinned listen network)"
257    )]
258    AddrFamilyMismatch {
259        /// The offending address string, whose family differs from the network's.
260        addr: String,
261        /// The address family the `…4`/`…6` network required: `"IPv4"` (`tcp4`) or `"IPv6"` (`tcp6`).
262        want: &'static str,
263    },
264
265    /// A Go-style dial `network` string was not one of the supported
266    /// `"tcp"`/`"tcp4"`/`"tcp6"`/`"udp"`/`"udp4"`/`"udp6"` (Go's `Dial` likewise rejects an unknown
267    /// network). Reported by [`Server::dial`] at the facade boundary, *before* the device is started.
268    #[error("unsupported network {network:?} (want tcp, tcp4, tcp6, udp, udp4, or udp6)")]
269    UnsupportedNetwork {
270        /// The offending `network` string.
271        network: String,
272    },
273
274    /// The Go-style `network` string was not one the call accepts. [`Server::listen`] takes a stream
275    /// network (`"tcp"`, `"tcp4"`, `"tcp6"`); [`Server::listen_packet`] a packet network (`"udp"`,
276    /// `"udp4"`, `"udp6"`). An unknown string, or the wrong transport for the call (e.g. `"udp"`
277    /// passed to `listen`), lands here — mirroring Go's `net.Listen`/`net.ListenPacket` rejecting a
278    /// bad network with `net.UnknownNetworkError`.
279    #[error("invalid or unsupported network {network:?}")]
280    InvalidNetwork {
281        /// The offending network string.
282        network: String,
283    },
284
285    /// The [`StateStore`] backing [`Server::dir`]/[`Server::store`] failed an I/O operation.
286    #[error("state store I/O error: {0}")]
287    Store(std::io::Error),
288
289    /// The persisted node identity could not be (de)serialized.
290    #[error("node identity (de)serialization error: {0}")]
291    State(serde_json::Error),
292
293    /// A [`Server::logout`] call failed.
294    #[error("logout error: {0}")]
295    Logout(#[from] crate::LogoutError),
296
297    /// The loopback proxy / in-process LocalAPI HTTP server hit an I/O error (binding its
298    /// `127.0.0.1` listener, or a [`LocalClient`] request to it).
299    #[error("loopback I/O error: {0}")]
300    Loopback(std::io::Error),
301}
302
303// ---------------------------------------------------------------------------------------------
304// Funnel / Service first-class types.
305// ---------------------------------------------------------------------------------------------
306
307/// Options for [`Server::listen_funnel`] — the Rust analog of Go's variadic `...FunnelOption`
308/// (`FunnelOnly`, `FunnelTLSConfig`).
309///
310/// Go models funnel knobs as option functions; the Rust-idiomatic shape is one options value with
311/// Go-named constructors. Build it with [`FunnelOptions::funnel_only`] / [`FunnelOptions::with_tls`]
312/// or struct-literal syntax.
313#[derive(Default)]
314pub struct FunnelOptions {
315    /// Reject tailnet-internal connections, serving *only* public Funnel ingress (Go `FunnelOnly()`).
316    pub funnel_only: bool,
317
318    /// Bring-your-own TLS termination (Go `FunnelTLSConfig(*tls.Config)`). `None` (the default) lets
319    /// the engine terminate with the node's own `*.ts.net` certificate.
320    ///
321    /// **Engine gap (tracked).** The current engine [`Device::listen_funnel`](crate::Device::listen_funnel)
322    /// always builds its own acceptor from the node cert and does not yet thread a caller-supplied
323    /// one; a non-`None` value here is surfaced (a `warn!`) and otherwise ignored until the engine
324    /// accepts an override. See `docs/TSNET_FACADE_DESIGN.md` §"Funnel".
325    pub tls: Option<crate::TlsAcceptor>,
326}
327
328impl FunnelOptions {
329    /// Go `FunnelOnly()` — serve only public Funnel ingress.
330    pub fn funnel_only() -> Self {
331        Self {
332            funnel_only: true,
333            ..Default::default()
334        }
335    }
336
337    /// Go `FunnelTLSConfig(conf)` — supply the TLS acceptor to terminate Funnel ingress with.
338    pub fn with_tls(mut self, acceptor: crate::TlsAcceptor) -> Self {
339        self.tls = Some(acceptor);
340        self
341    }
342}
343
344impl From<FunnelOptions> for ts_control::FunnelOptions {
345    fn from(o: FunnelOptions) -> Self {
346        if o.tls.is_some() {
347            tracing::warn!(
348                "tsnet::FunnelOptions::tls (FunnelTLSConfig) is set but the engine terminates Funnel \
349                 with the node's own certificate; the supplied acceptor is ignored (see design doc)"
350            );
351        }
352        ts_control::FunnelOptions {
353            funnel_only: o.funnel_only,
354        }
355    }
356}
357
358/// Why [`Server::listen_funnel`] failed — a **lifecycle/start** failure kept distinct from the
359/// engine's typed Funnel error.
360///
361/// The wrapper lazily starts the node before it can funnel, so two very different failures are
362/// possible: the node never came up (bad config, registration/`Up` failure — a *lifecycle* error),
363/// or the node is up but the fail-closed Funnel gate denied the request (missing `funnel`/`https`
364/// node attributes, a disallowed port, or a certificate failure). Collapsing the former into
365/// [`ts_control::FunnelError::NotAllowed`] would misreport a startup failure as an *access denial* —
366/// telling the operator to fix their tailnet ACLs when the node simply never registered. This enum
367/// keeps them apart and preserves the real underlying cause on either path.
368#[derive(Debug, thiserror::Error)]
369#[non_exhaustive]
370pub enum ListenFunnelError {
371    /// The node could not be lazily built or brought up — a lifecycle failure, *not* a Funnel
372    /// denial. Carries the real underlying [`Error`] (e.g. [`Error::InvalidControlUrl`], a
373    /// [`Error::Registration`], or a device build error) so the true cause is never lost.
374    #[error("failed to start the node before listening on funnel: {0}")]
375    Start(#[from] Error),
376
377    /// The node is up, but the engine's fail-closed Funnel path returned a typed
378    /// [`ts_control::FunnelError`] — the node-attribute/port access gate
379    /// ([`NotAllowed`](ts_control::FunnelError::NotAllowed) /
380    /// [`PortNotAllowed`](ts_control::FunnelError::PortNotAllowed)) or certificate assembly
381    /// ([`Cert`](ts_control::FunnelError::Cert)). Passed through unchanged.
382    #[error(transparent)]
383    Funnel(#[from] ts_control::FunnelError),
384}
385
386/// Why [`Server::listen_service`] failed — a **lifecycle/start** failure kept distinct from the
387/// engine's typed VIP-service error.
388///
389/// As with [`ListenFunnelError`], the wrapper must lazily start the node first, so a startup failure
390/// (bad config, registration/`Up` failure) is a *lifecycle* error — not the same thing as the
391/// engine's [`ServiceError`] (invalid name, untagged host, no assigned VIP, or a listener bind
392/// failure). Collapsing a start failure into [`ServiceError::Listen`] would misreport it as a
393/// bind failure; this enum keeps the two apart and preserves the real underlying cause.
394#[derive(Debug, thiserror::Error)]
395#[non_exhaustive]
396pub enum ListenServiceError {
397    /// The node could not be lazily built or brought up — a lifecycle failure, *not* a listener
398    /// bind failure. Carries the real underlying [`Error`] so the true cause is never lost.
399    #[error("failed to start the node before listening on service: {0}")]
400    Start(#[from] Error),
401
402    /// The node is up, but the engine's fail-closed VIP-service path returned a typed
403    /// [`ServiceError`] (invalid name, [`UntaggedHost`](ServiceError::UntaggedHost),
404    /// [`NoAssignedVip`](ServiceError::NoAssignedVip), or a genuine
405    /// [`Listen`](ServiceError::Listen) bind failure). Passed through unchanged.
406    #[error(transparent)]
407    Service(#[from] ServiceError),
408}
409
410/// A listener for a hosted Tailscale VIP service, the Rust analog of Go's `*tsnet.ServiceListener`
411/// (a `net.Listener` plus the service's `FQDN`).
412///
413/// Deref-forwards to the underlying overlay [`netstack::TcpListener`] so you can `.accept()` on it;
414/// [`ServiceListener::fqdn`] adds the resolved fully-qualified service name.
415pub struct ServiceListener {
416    inner: netstack::TcpListener,
417    fqdn: String,
418}
419
420impl ServiceListener {
421    /// The fully-qualified domain name of the hosted service (Go `ServiceListener.FQDN`).
422    pub fn fqdn(&self) -> &str {
423        &self.fqdn
424    }
425
426    /// Consume the wrapper, yielding the underlying overlay listener.
427    pub fn into_inner(self) -> netstack::TcpListener {
428        self.inner
429    }
430}
431
432impl std::ops::Deref for ServiceListener {
433    type Target = netstack::TcpListener;
434    fn deref(&self) -> &Self::Target {
435        &self.inner
436    }
437}
438
439/// The result of [`Server::loopback`] — the full Go `Loopback() (addr, proxyCred, localAPICred,
440/// err)` surface: **both** credentials, and an in-process LocalAPI HTTP server actually running.
441///
442/// # Go parity, and the one honest delta
443///
444/// Go serves the SOCKS5 proxy *and* the LocalAPI on a **single** muxed loopback listener. This fork
445/// runs two `127.0.0.1` listeners instead — [`address`](Self::address) for the SOCKS5 proxy
446/// ([`proxy_cred`](Self::proxy_cred)) and [`local_api_address`](Self::local_api_address) for the
447/// LocalAPI HTTP server ([`local_api_cred`](Self::local_api_cred)) — because the SOCKS5 half is the
448/// engine's own [`Device::loopback`](crate::Device::loopback) and the LocalAPI half is layered on
449/// top without re-implementing (or first-byte-demuxing) the proven proxy path. The observable
450/// contract is identical: a SOCKS5 proxy gated by `proxy_cred`, and a LocalAPI gated by
451/// `local_api_cred`. This delta is surfaced, not faked (see `docs/TSNET_FACADE_DESIGN.md` §11).
452///
453/// # Lifecycle
454///
455/// Like Go's `s.loopbackListener`, both listeners live for the [`Server`]'s lifetime and are torn
456/// down by [`Server::close`] — [`Server::loopback`] is idempotent and returns the same addresses and
457/// credentials on every call. There is no per-result handle to drop.
458#[derive(Clone)]
459#[non_exhaustive]
460pub struct Loopback {
461    /// The bound `127.0.0.1:<port>` address of the SOCKS5 proxy (Go's `addr`, SOCKS5 half).
462    pub address: SocketAddr,
463    /// The SOCKS5 proxy credential (Go's `proxyCred`; username is fixed to `"tsnet"`).
464    pub proxy_cred: String,
465    /// The bound `127.0.0.1:<port>` address of the in-process LocalAPI HTTP server.
466    pub local_api_address: SocketAddr,
467    /// The LocalAPI credential (Go's `localAPICred`): the HTTP Basic-auth **password** the LocalAPI
468    /// server requires (any username is accepted, matching Go). Reach it with [`LocalClient`].
469    pub local_api_cred: String,
470}
471
472impl std::fmt::Debug for Loopback {
473    /// Redacts both credentials — `proxy_cred` and `local_api_cred` are secrets and must never reach
474    /// `{:?}`/`tracing` output. Field *names* are preserved so the shape is still legible.
475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        f.debug_struct("Loopback")
477            .field("address", &self.address)
478            .field("proxy_cred", &"<redacted>")
479            .field("local_api_address", &self.local_api_address)
480            .field("local_api_cred", &"<redacted>")
481            .finish()
482    }
483}
484
485/// A minimal client for the in-process LocalAPI HTTP server started alongside the loopback proxy —
486/// the Rust analog of what Go's `tsnet.Server.LocalClient()` returns (a `*local.Client` wired to the
487/// node's own LocalAPI).
488///
489/// Obtain one with [`Server::local_client`]. It authenticates every request with the loopback's
490/// `local_api_cred` (HTTP Basic auth, empty username) and speaks plain HTTP to `127.0.0.1`, so it is
491/// dependency-free (no `hyper`) and needs only the `tsnet` feature.
492///
493/// The fork's [`Status`] is not a `serde` type, so [`status`](Self::status) returns
494/// the server's raw JSON bytes rather than a deserialized struct; for typed status prefer
495/// [`Server::status`] (the in-process path). For arbitrary endpoints use [`get`](Self::get).
496#[derive(Clone)]
497pub struct LocalClient {
498    address: SocketAddr,
499    cred: String,
500}
501
502impl std::fmt::Debug for LocalClient {
503    /// Redacts `cred` — the LocalAPI Basic-auth password is a secret and must never reach
504    /// `{:?}`/`tracing` output. Field *names* are preserved so the shape is still legible.
505    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
506        f.debug_struct("LocalClient")
507            .field("address", &self.address)
508            .field("cred", &"<redacted>")
509            .finish()
510    }
511}
512
513impl LocalClient {
514    /// The `127.0.0.1:<port>` address of the LocalAPI HTTP server this client talks to.
515    pub fn address(&self) -> SocketAddr {
516        self.address
517    }
518
519    /// The LocalAPI credential (HTTP Basic-auth password) this client sends.
520    pub fn credential(&self) -> &str {
521        &self.cred
522    }
523
524    /// `GET /localapi/v0/status` — the node + peer status as raw JSON bytes (Go
525    /// `LocalClient().Status`, over the loopback). Errors if the server answers non-`200`.
526    pub async fn status(&self) -> Result<Vec<u8>, Error> {
527        match self.get("/localapi/v0/status").await? {
528            (200, body) => Ok(body),
529            (code, _) => Err(Error::Loopback(std::io::Error::other(format!(
530                "localapi /status returned HTTP {code}"
531            )))),
532        }
533    }
534
535    /// Perform an authenticated `GET` against an arbitrary LocalAPI `path` (e.g.
536    /// `"/localapi/v0/status"`), returning `(http_status_code, body_bytes)`.
537    ///
538    /// The facade's in-process server implements only `GET /localapi/v0/status` (unlike Go's full
539    /// `localapi.Handler`); any other path returns HTTP `404`. Every request also automatically
540    /// carries Go's `Sec-Tailscale: localapi` anti-DNS-rebinding header alongside the credential.
541    pub async fn get(&self, path: &str) -> Result<(u16, Vec<u8>), Error> {
542        localapi_client_get(self.address, &self.cred, path)
543            .await
544            .map_err(Error::Loopback)
545    }
546}
547
548/// How to run the application overlay over a real kernel TUN interface (Go `Server.Tun`).
549#[derive(Clone, Debug, Default)]
550pub struct TunSpec {
551    /// The desired interface name (`None` lets the OS pick, e.g. `utunN`).
552    pub name: Option<String>,
553    /// The interface MTU (`None` uses the transport default).
554    pub mtu: Option<u16>,
555}
556
557// ---------------------------------------------------------------------------------------------
558// Server — the facade.
559// ---------------------------------------------------------------------------------------------
560
561/// A hook to customize the derived [`Config`] after the field mapping and before the device is
562/// built — the escape hatch to fork capabilities with no Go `tsnet` field. See [`Server::configure`].
563pub type ConfigureHook = Box<dyn Fn(&mut Config) + Send + Sync>;
564
565/// An embedded Tailscale node, shaped like Go's `tsnet.Server`.
566///
567/// Set the public fields (they map onto [`Config`]; see the table in `docs/TSNET_FACADE_DESIGN.md`),
568/// then call any method — the wrapped [`Device`] is constructed lazily on the **first** method call
569/// (Go: "fields may be changed until the first method call"). In Rust this ordering is enforced by
570/// the borrow checker: methods borrow `&self`, so you cannot mutate a field after the first call.
571///
572/// For fork capabilities beyond Go `tsnet` parity (accept-routes, exit nodes, residential-proxy exit
573/// egress, …), reach the underlying [`Config`] with [`Server::configure`], or drop to the full
574/// engine surface with [`Server::device`].
575#[derive(Default)]
576pub struct Server {
577    /// Hostname to present to control (Go `Hostname`) → [`Config::requested_hostname`]. Empty ⇒
578    /// engine default.
579    pub hostname: Option<String>,
580
581    /// Auth key to register with (Go `AuthKey`) → [`Config::auth_key`] and the `auth_key` arg of
582    /// [`Device::new`]. Falls back to `TS_AUTH_KEY`.
583    pub auth_key: Option<String>,
584
585    /// Coordination server URL (Go `ControlURL`) → [`Config::control_server_url`]. `None` ⇒ the
586    /// fork's default control server.
587    pub control_url: Option<String>,
588
589    /// Register as an ephemeral node (Go `Ephemeral`) → [`Config::ephemeral`]. **Defaults to
590    /// `false`** to match Go (note: bare [`Config::default`] defaults this to `true`).
591    pub ephemeral: bool,
592
593    /// ACL tags to advertise (Go `AdvertiseTags`) → [`Config::requested_tags`].
594    pub advertise_tags: Vec<String>,
595
596    /// WireGuard/peer-to-peer UDP port pin (Go `Port`) → [`Config::wireguard_listen_port`].
597    pub port: Option<u16>,
598
599    /// Run the node web client (Go `RunWebClient`) → [`Config::run_web_client`]. *Partial:* the pref
600    /// is carried but no embedded web client runs (see `docs/TSNET_PARITY.md`).
601    pub run_web_client: bool,
602
603    /// OAuth/WIF client id (Go `ClientID`) → [`Config::client_id`].
604    pub client_id: Option<String>,
605    /// OAuth client secret (Go `ClientSecret`) → [`Config::client_secret`].
606    pub client_secret: Option<String>,
607    /// IdP-issued OIDC token (Go `IDToken`) → [`Config::id_token`].
608    pub id_token: Option<String>,
609    /// Audience for a requested ID token (Go `Audience`) → [`Config::audience`].
610    pub audience: Option<String>,
611
612    /// State directory (Go `Dir`). Persists node **identity keys** to `dir/`[`STATE_FILE`] via the
613    /// engine's key-file format. Absent `store`, this is the standard on-disk identity.
614    pub dir: Option<PathBuf>,
615
616    /// A pluggable state store (Go `Store`). Takes precedence over [`Server::dir`]. `None` + no
617    /// `dir` ⇒ an ephemeral in-memory identity (fresh every run).
618    pub store: Option<Arc<dyn StateStore>>,
619
620    /// Run over a real kernel TUN interface instead of the userspace netstack (Go `Tun`) →
621    /// [`Config::use_tun`].
622    pub tun: Option<TunSpec>,
623
624    /// Optional hook to reach the full [`Config`] (fork supersets) after the field mapping and
625    /// before [`Device::new`]. Set via [`Server::configure`].
626    configure: Option<ConfigureHook>,
627
628    /// The wrapped device, built once on first use. Held in an [`Arc`] so the in-process LocalAPI
629    /// HTTP server (spawned by [`Server::loopback`]) can hold a [`Weak`] handle to it without
630    /// blocking [`Server::close`] from reclaiming and gracefully shutting the device down.
631    device: OnceCell<Arc<Device>>,
632
633    /// The running loopback SOCKS5 proxy + in-process LocalAPI HTTP server, started once on the
634    /// first [`Server::loopback`]/[`Server::local_client`] and torn down on [`Server::close`]
635    /// (mirrors Go's `s.loopbackListener` living for the server's lifetime).
636    loopback_rt: OnceCell<LoopbackRt>,
637}
638
639impl Server {
640    /// A new server with Go-default fields (not ephemeral, default control server, no persistence).
641    pub fn new() -> Self {
642        Self::default()
643    }
644
645    /// Register a hook to customize the derived [`Config`] just before the device is built — the
646    /// escape hatch to fork capabilities that have no Go `tsnet` field. Ignored if the server has
647    /// already started.
648    pub fn configure<F>(&mut self, f: F) -> &mut Self
649    where
650        F: Fn(&mut Config) + Send + Sync + 'static,
651    {
652        self.configure = Some(Box::new(f));
653        self
654    }
655
656    /// Resolve the node identity [`PersistState`] from the configured store/dir, or `None` for an
657    /// ephemeral in-memory identity.
658    async fn resolve_key_state(&self) -> Result<Option<PersistState>, Error> {
659        // Explicit custom store: round-trip the identity blob under STATE_KEY.
660        if let Some(store) = &self.store {
661            return match store.read_state(STATE_KEY).map_err(Error::Store)? {
662                Some(bytes) => Ok(Some(serde_json::from_slice(&bytes).map_err(Error::State)?)),
663                None => {
664                    let fresh = PersistState::default();
665                    let bytes = serde_json::to_vec(&fresh).map_err(Error::State)?;
666                    store.write_state(STATE_KEY, &bytes).map_err(Error::Store)?;
667                    Ok(Some(fresh))
668                }
669            };
670        }
671        // No store: `dir` is handled by reusing the engine key-file path in `build_config`.
672        Ok(None)
673    }
674
675    /// Map the Go-`tsnet.Server`-parity fields onto a fresh [`Config`].
676    async fn build_config(&self) -> Result<Config, Error> {
677        // Base: reuse the engine's own key-file load-or-init when a `dir` is set and no custom store
678        // is in play; otherwise start from `Config::default` and overlay any resolved identity.
679        let mut config = match (&self.store, &self.dir) {
680            (None, Some(dir)) => Config::default_with_key_file(dir.join(STATE_FILE)).await?,
681            _ => {
682                let mut c = Config::default();
683                if let Some(ks) = self.resolve_key_state().await? {
684                    c.key_state = ks;
685                }
686                c
687            }
688        };
689
690        // Ephemeral defaults to Go's `false` here even though bare `Config::default` is `true`.
691        config.ephemeral = self.ephemeral;
692        config.requested_hostname = self.hostname.clone();
693        config.requested_tags = self.advertise_tags.clone();
694        config.wireguard_listen_port = self.port;
695        config.run_web_client = self.run_web_client;
696        config.auth_key = self.auth_key.clone();
697        config.client_id = self.client_id.clone();
698        config.client_secret = self.client_secret.clone();
699        config.id_token = self.id_token.clone();
700        config.audience = self.audience.clone();
701
702        // Cold-start netmap cache (Go `nodecap.CacheNetworkMaps`): a state directory is what makes
703        // persistence possible, exactly as Go gates its cache on the profile having a writable
704        // directory. It stays inert until control grants the attribute, so setting it here costs a
705        // node whose tailnet never asks for caching nothing at all — no file is ever created.
706        // Its own subdirectory, not `dir` itself, so discarding the cache can never touch the
707        // identity keys sharing that root.
708        if let Some(dir) = &self.dir {
709            config.netmap_cache_dir = Some(dir.join(NETMAP_CACHE_DIR));
710        }
711
712        if let Some(raw) = &self.control_url {
713            config.control_server_url = raw.parse().map_err(Error::InvalidControlUrl)?;
714        }
715        if let Some(tun) = &self.tun {
716            config = config.use_tun(tun.name.clone(), tun.mtu);
717        }
718        if let Some(hook) = &self.configure {
719            hook(&mut config);
720        }
721        Ok(config)
722    }
723
724    /// Build + start the wrapped device from the current fields.
725    async fn build_and_start(&self) -> Result<Arc<Device>, Error> {
726        let mut config = self.build_config().await?;
727
728        // Install this facade's LocalAPI as the target of control's c2n `/remoteapi/localapi/*`
729        // proxy, so the route table the loopback listener serves is the one control reaches. The
730        // hook has to be on the `Config` that builds the device, but its backend needs the built
731        // device — so it goes in empty and is attached the instant `Device::new` returns. Registering
732        // it is not the same as enabling it: the proxy stays refused until the local machine opts in
733        // with `Config::remote_config` (Go `Prefs.RemoteConfig`), which the `configure` hook above
734        // may have just set.
735        let c2n_local_api = Arc::new(C2nLocalApi::default());
736        config.c2n_local_api = Some(c2n_local_api.clone());
737
738        let device = Arc::new(Device::new(&config, self.auth_key.clone()).await?);
739        c2n_local_api.attach(&device);
740        Ok(device)
741    }
742
743    /// Get the wrapped device (as the shared [`Arc`]), starting it on first call (Go's lazy
744    /// `Start`).
745    async fn started(&self) -> Result<&Arc<Device>, Error> {
746        self.device.get_or_try_init(|| self.build_and_start()).await
747    }
748
749    /// Connect to the tailnet (Go `Start`). Idempotent — subsequent calls are no-ops.
750    pub async fn start(&self) -> Result<(), Error> {
751        self.started().await.map(|_| ())
752    }
753
754    /// Connect and wait until the node is `Running`, returning its status (Go `Up`). `timeout`
755    /// `None` waits forever.
756    pub async fn up(&self, timeout: Option<Duration>) -> Result<Status, Error> {
757        let dev = self.started().await?;
758        dev.wait_until_running(timeout).await?;
759        Ok(dev.status().await?)
760    }
761
762    /// The wrapped [`Device`] (Go `LocalClient`-and-more), starting it if needed. The escape hatch to
763    /// the full engine surface (`whois`, `ping`, `set_*` prefs, taildrop, TKA, …).
764    pub async fn device(&self) -> Result<&Device, Error> {
765        Ok(&**self.started().await?)
766    }
767
768    /// Dial a tailnet address over TCP or UDP (Go `Dial(ctx, network, address)`), returning the
769    /// tsnet-shaped [`DialConn`](crate::DialConn) whose arm matches the transport.
770    ///
771    /// `network` is one of `"tcp"`, `"tcp4"`, `"tcp6"`, `"udp"`, `"udp4"`, `"udp6"`; `addr` is a
772    /// `host:port` string — a MagicDNS name or an IP literal (bracketed for IPv6,
773    /// `[2001:db8::1]:443`). The network string is parsed at the facade boundary, so an unsupported
774    /// network is a typed [`Error::UnsupportedNetwork`] reported **before** the device is started
775    /// (fail-fast, no network I/O). For the common case, [`Server::dial_tcp`] / [`Server::dial_udp`]
776    /// hand back the transport's stream / socket directly.
777    ///
778    /// Routing: the unsuffixed `"tcp"`/`"udp"` forward to the transport-specific typed accessors
779    /// [`Device::dial_tcp`](crate::Device::dial_tcp) / [`Device::dial_udp`](crate::Device::dial_udp)
780    /// (for `Family::Any` these *are* [`Device::dial`](crate::Device::dial)'s arms); the family-pinned
781    /// `…4`/`…6` forms forward to [`Device::dial`], which enforces the v4/v6 constraint that the
782    /// family-agnostic sub-calls do not.
783    pub async fn dial(&self, network: &str, addr: &str) -> Result<crate::DialConn, Error> {
784        // Parse the Go-style network string first: an unknown network is a typed facade error that
785        // never starts the device (Go's `Dial` also rejects unknown networks up front).
786        let net = parse_network(network).map_err(|_| Error::UnsupportedNetwork {
787            network: network.to_string(),
788        })?;
789        let dev = self.started().await?;
790        Ok(match (net.transport, net.family) {
791            // Unsuffixed tcp/udp: the family follows the resolved address, so these are exactly the
792            // transport-specific typed Device calls — route over them and wrap into `DialConn`.
793            (Transport::Tcp, Family::Any) => crate::DialConn::Tcp(dev.dial_tcp(addr).await?),
794            (Transport::Udp, Family::Any) => crate::DialConn::Udp(dev.dial_udp(addr).await?),
795            // Family-pinned (tcp4/tcp6/udp4/udp6): forward the whole network string so the engine
796            // enforces the v4/v6 family that the family-agnostic sub-calls above would ignore.
797            _ => dev.dial(network, addr).await?,
798        })
799    }
800
801    /// Dial a tailnet TCP address, yielding the overlay stream directly — the common case of
802    /// [`Server::dial`] for `"tcp"`. This is the building block for HTTP-over-tailnet: a `hyper`/
803    /// `reqwest` connector dials with `dial_tcp(&format!("{host}:{port}"))`, mirroring Go
804    /// `tsnet.Server.HTTPClient`.
805    pub async fn dial_tcp(&self, addr: &str) -> Result<netstack::TcpStream, Error> {
806        Ok(self.started().await?.dial_tcp(addr).await?)
807    }
808
809    /// Dial a tailnet UDP address, yielding the connected overlay socket directly — the `"udp"`
810    /// sibling of [`Server::dial_tcp`] and the common case of [`Server::dial`] for `"udp"`.
811    ///
812    /// Returns a [`ConnectedUdpSocket`](crate::ConnectedUdpSocket) (`send`/`recv` against a fixed
813    /// peer) — the connected-`net.Conn` shape Go's `Dial("udp", …)` returns, as opposed to
814    /// [`Server::listen_packet`]'s unconnected packet socket.
815    pub async fn dial_udp(&self, addr: &str) -> Result<crate::ConnectedUdpSocket, Error> {
816        Ok(self.started().await?.dial_udp(addr).await?)
817    }
818
819    /// Listen for inbound TCP on the tailnet (Go `Listen`).
820    ///
821    /// `network` is a **stream** network — `"tcp"`, `"tcp4"`, or `"tcp6"`; a `"udp*"` or unknown
822    /// value is [`Error::InvalidNetwork`] (Go's `net.Listen` likewise rejects a packet network).
823    /// `addr` may be a bare `":80"` — the wildcard host, `0.0.0.0` for `tcp`/`tcp4` and `[::]` for
824    /// `tcp6` — or a full `"100.x.y.z:80"`. Returns the std::net-style overlay
825    /// [`netstack::TcpListener`] (backed by `ts_netstack_smoltcp`): `.accept()` it for inbound
826    /// streams, exactly as Go `.Accept()`s the returned `net.Listener`.
827    pub async fn listen(&self, network: &str, addr: &str) -> Result<netstack::TcpListener, Error> {
828        let net = parse_network(network)?;
829        if net.transport != Transport::Tcp {
830            return Err(Error::InvalidNetwork {
831                network: network.to_string(),
832            });
833        }
834        let sa = parse_listen_addr(addr, net.family)?;
835        Ok(self.started().await?.tcp_listen(sa).await?)
836    }
837
838    /// Listen for inbound UDP on the tailnet (Go `ListenPacket`).
839    ///
840    /// `network` is a **packet** network — `"udp"`, `"udp4"`, or `"udp6"`; a `"tcp*"` or unknown
841    /// value is [`Error::InvalidNetwork`]. `addr` is a `host:port` **IP literal** (a bare `":0"` is
842    /// filled with the family's wildcard host); like Go's `ListenPacket`, a MagicDNS name is **not**
843    /// accepted — and neither does [`Server::listen`] accept one (both bind by IP literal; only
844    /// [`Server::dial`] resolves MagicDNS names). An unspecified host binds this node's tailnet
845    /// address. Returns the std::net-style overlay [`netstack::UdpSocket`] (backed by
846    /// `ts_netstack_smoltcp`), a `net.PacketConn` analog (`recv_from`/`send_to`).
847    pub async fn listen_packet(
848        &self,
849        network: &str,
850        addr: &str,
851    ) -> Result<netstack::UdpSocket, Error> {
852        let net = parse_network(network)?;
853        if net.transport != Transport::Udp {
854            return Err(Error::InvalidNetwork {
855                network: network.to_string(),
856            });
857        }
858        // Fill a bare `":0"` with the family wildcard, then let the engine do the family-aware bind
859        // (unspecified host ⇒ this node's tailnet address, IPv6 gating, name rejection).
860        let addr = normalize_listen_addr(addr, net.family);
861        Ok(self.started().await?.listen_packet(network, &addr).await?)
862    }
863
864    /// Expose a tailnet TLS service to the public internet via Tailscale Funnel (Go `ListenFunnel`).
865    ///
866    /// A thin wrapper over [`Device::listen_funnel`](crate::Device::listen_funnel): it lazily starts
867    /// the node, converts [`FunnelOptions`] into the engine's [`ts_control::FunnelOptions`] serve
868    /// config, and hands `cfg` (the MagicDNS name + tailnet port) straight through. On success it
869    /// yields the engine's [`FunnelAcceptedReceiver`](ts_runtime::funnel::FunnelAcceptedReceiver).
870    ///
871    /// Errors are a [`ListenFunnelError`], which keeps a **lifecycle/start** failure
872    /// ([`Start`](ListenFunnelError::Start)) distinct from the engine's typed Funnel error
873    /// ([`Funnel`](ListenFunnelError::Funnel)): a node that never registered surfaces as a startup
874    /// failure carrying its real cause, never misdiagnosed as a Funnel access denial.
875    pub async fn listen_funnel(
876        &self,
877        cfg: &crate::ServeConfig,
878        opts: FunnelOptions,
879    ) -> Result<ts_runtime::funnel::FunnelAcceptedReceiver, ListenFunnelError> {
880        // `?` maps a lazy-start failure (`Error`) to `ListenFunnelError::Start`, preserving the
881        // real cause — it is NOT flattened to `FunnelError::NotAllowed` (an access denial).
882        let dev = self.started().await?;
883        // `?` maps the engine's typed `FunnelError` to `ListenFunnelError::Funnel`, unchanged.
884        Ok(dev.listen_funnel(cfg, opts.into()).await?)
885    }
886
887    /// Host a Tailscale VIP service (Go `ListenService`), returning a Go-shaped [`ServiceListener`]
888    /// (overlay listener + resolved FQDN).
889    ///
890    /// A thin wrapper over [`Device::listen_service`](crate::Device::listen_service): it lazily
891    /// starts the node, passes `name` + the [`ServiceMode`] serve config straight through, and pairs
892    /// the returned overlay listener with the node's resolved service FQDN.
893    ///
894    /// Errors are a [`ListenServiceError`], which keeps a **lifecycle/start** failure
895    /// ([`Start`](ListenServiceError::Start)) distinct from the engine's typed [`ServiceError`]
896    /// ([`Service`](ListenServiceError::Service), whose `UntaggedHost` == Go
897    /// `ErrUntaggedServiceHost`): a node that never registered surfaces as a startup failure
898    /// carrying its real cause, never misdiagnosed as a listener bind failure.
899    pub async fn listen_service(
900        &self,
901        name: &str,
902        mode: ServiceMode,
903    ) -> Result<ServiceListener, ListenServiceError> {
904        // `?` maps a lazy-start failure (`Error`) to `ListenServiceError::Start`, preserving the
905        // real cause — it is NOT flattened to `ServiceError::Listen` (a bind failure).
906        let dev = self.started().await?;
907        let inner = dev.listen_service(name, mode).await?;
908        let fqdn = dev
909            .self_node()
910            .await
911            .map(|n| n.fqdn(false))
912            .unwrap_or_default();
913        Ok(ServiceListener { inner, fqdn })
914    }
915
916    /// Start (once) the loopback surface and return its addresses + both credentials (Go
917    /// `Loopback() (addr, proxyCred, localAPICred, err)`).
918    ///
919    /// Brings up two things, living for the [`Server`]'s lifetime (torn down by [`Server::close`]):
920    ///
921    /// * a **SOCKS5 proxy** onto the tailnet (the engine's [`Device::loopback`](crate::Device::loopback)),
922    ///   authenticated with [`Loopback::proxy_cred`]; and
923    /// * an **in-process LocalAPI HTTP server** authenticated with the separate
924    ///   [`Loopback::local_api_cred`] (HTTP Basic-auth password), serving `GET
925    ///   /localapi/v0/status`.
926    ///
927    /// Idempotent: repeated calls return the same addresses and credentials. See [`Loopback`] for
928    /// the one honest delta from Go (two `127.0.0.1` listeners rather than one muxed listener).
929    pub async fn loopback(&self) -> Result<Loopback, Error> {
930        let rt = self.ensure_loopback().await?;
931        Ok(Loopback {
932            address: rt.socks_address,
933            proxy_cred: rt.proxy_cred.clone(),
934            local_api_address: rt.local_api_address,
935            local_api_cred: rt.local_api_cred.clone(),
936        })
937    }
938
939    /// A [`LocalClient`] for this node's in-process LocalAPI HTTP server (Go
940    /// `tsnet.Server.LocalClient()`), starting the loopback surface if needed.
941    ///
942    /// The returned client authenticates with the loopback's `local_api_cred` and speaks plain HTTP
943    /// to `127.0.0.1` (no `hyper` dependency). For typed status prefer the in-process [`Server::status`];
944    /// the `LocalClient` is the Go-shaped path that actually round-trips through the LocalAPI server.
945    pub async fn local_client(&self) -> Result<LocalClient, Error> {
946        let rt = self.ensure_loopback().await?;
947        Ok(LocalClient {
948            address: rt.local_api_address,
949            cred: rt.local_api_cred.clone(),
950        })
951    }
952
953    /// Start (once) and cache the loopback runtime: the engine SOCKS5 proxy plus a facade-owned
954    /// in-process LocalAPI HTTP server on its own `127.0.0.1` listener.
955    async fn ensure_loopback(&self) -> Result<&LoopbackRt, Error> {
956        // Capture the device Arc first (outside the closure) so we can downgrade it to a `Weak` for
957        // the LocalAPI backend — the server must not keep the device alive past `Server::close`.
958        let device = self.started().await?.clone();
959        self.loopback_rt
960            .get_or_try_init(|| build_loopback_rt(device))
961            .await
962    }
963
964    /// A `hyper` HTTP client whose every request egresses over the tailnet (Go
965    /// `tsnet.Server.HTTPClient()`), built over [`Device::http_connector`](crate::Device::http_connector).
966    ///
967    /// The exact analog of Go's `&http.Client{Transport: &http.Transport{DialContext: s.Dial}}`: a
968    /// pooled [`hyper_util`] client wired to the tailnet connector, with TLS/redirects/pooling left
969    /// to the client (the connector is plaintext — see [`TailnetConnector`](crate::http::TailnetConnector)
970    /// for wrapping it in TLS). `B` is your request body type (e.g. `String`, or
971    /// `http_body_util::Full<Bytes>`).
972    ///
973    /// Available only with the **`hyper`** crate feature (as in the engine).
974    #[cfg(feature = "hyper")]
975    pub async fn http_client<B>(
976        &self,
977    ) -> Result<hyper_util::client::legacy::Client<crate::http::TailnetConnector, B>, Error>
978    where
979        B: hyper::body::Body + Send + 'static,
980        B::Data: Send,
981    {
982        let connector = self.started().await?.http_connector().await?;
983        Ok(
984            hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
985                .build(connector),
986        )
987    }
988
989    /// This node's tailnet addresses (Go `TailscaleIPs`). The tuple shape mirrors
990    /// [`Device::tailscale_ips`](crate::Device::tailscale_ips) verbatim (v6 is `None` on an
991    /// IPv4-only tailnet).
992    pub async fn tailscale_ips(
993        &self,
994    ) -> Result<(std::net::Ipv4Addr, Option<std::net::Ipv6Addr>), Error> {
995        Ok(self.started().await?.tailscale_ips().await?)
996    }
997
998    /// Build a [`TlsAcceptor`](crate::TlsAcceptor) that terminates TLS for `cfg.name` on the tailnet
999    /// overlay using this node's own certificate (Go `tsnet.Server.ListenTLS`'s cert path).
1000    ///
1001    /// A thin delegation to [`Device::listen_tls`](crate::Device::listen_tls). The serve config is
1002    /// validated at the facade boundary **first** — a non-tailnet `cfg.name` or a zero `cfg.port` is a
1003    /// typed [`ts_control::CertError`] returned *before* the lazy device start, so a misconfiguration
1004    /// never touches the network (fail-fast, exactly as [`Server::dial`]/[`Server::listen`] reject a
1005    /// bad network/address up front). The certificate is then acquired through
1006    /// [`ts_control::tls`] via the node's ACME-aware cert path.
1007    ///
1008    /// **`acme` feature.** Issuance is fail-closed: with the **`acme`** feature this issues a real
1009    /// Let's Encrypt certificate (DNS-01, published via the node's `set-dns` RPC — SaaS-only); without
1010    /// it (the default) it surfaces [`ts_control::CertError::Unimplemented`] rather than ever serving a
1011    /// self-signed cert or downgrading to plaintext. Either way the acceptor is **ring-only**
1012    /// ([`ts_control::tls_acceptor`] pins the `ring` provider — no aws-lc/openssl).
1013    ///
1014    /// This keeps the fork's typed [`ts_control::CertError`] (matching [`Device::listen_tls`]), not the
1015    /// unified lifecycle [`Error`] — the cert path is a specialized, typed surface (design doc §7).
1016    /// Like Go's `ListenTLS`, terminate accepted overlay streams (from a [`Server::listen`] listener)
1017    /// with [`ts_control::accept_tls`], reusing the one acceptor across connections.
1018    pub async fn listen_tls(
1019        &self,
1020        cfg: &crate::ServeConfig,
1021    ) -> Result<crate::TlsAcceptor, ts_control::CertError> {
1022        // Fail-fast at the facade boundary: reject a bad serve config (non-tailnet name / zero port)
1023        // before the lazy device start, so a misconfiguration never reaches the network. The wrapped
1024        // `Device::listen_tls` validates again internally (idempotent) — this only surfaces the
1025        // identical typed error earlier, matching the dial/listen "reject before starting" contract.
1026        cfg.validate()?;
1027        self.started()
1028            .await
1029            .map_err(start_failed_cert)?
1030            .listen_tls(cfg)
1031            .await
1032    }
1033
1034    /// Issue a real Let's Encrypt certificate for this node's MagicDNS `name` and return the **PEM
1035    /// pair** `(cert_chain_pem, key_pem)` — the analog of Go `LocalClient().CertPair` /
1036    /// `CertPairWithValidity`, for writing an on-disk `.crt` + `.key`. A thin delegation to
1037    /// [`Device::cert_pair`](crate::Device::cert_pair); **`acme` feature only** (the wrapped engine
1038    /// method is itself `acme`-gated).
1039    ///
1040    /// The `name` is checked at the facade boundary first — a non-tailnet (`*.ts.net`) name is
1041    /// [`ts_control::CertError::NotTailnetName`] before any device start (anti-leak: this fork never
1042    /// mints certs for off-tailnet names). `min_validity` is accepted for Go signature parity but does
1043    /// not change behavior: this fork keeps no cert cache and always issues fresh, so a freshly issued
1044    /// (full-lifetime) cert satisfies any `min_validity` (see [`Device::cert_pair`]). The second tuple
1045    /// element is **secret key material** — persist it to a `0600` file and never log it. Fail-closed
1046    /// and ring-only, like [`Server::listen_tls`].
1047    #[cfg(feature = "acme")]
1048    pub async fn cert_pair(
1049        &self,
1050        name: &str,
1051        min_validity: Option<Duration>,
1052    ) -> Result<(String, String), ts_control::CertError> {
1053        // Anti-leak name check up front (matches the wrapped engine method), before the device start.
1054        if !ts_control::is_tailnet_name(name) {
1055            return Err(ts_control::CertError::NotTailnetName(name.to_string()));
1056        }
1057        self.started()
1058            .await
1059            .map_err(start_failed_cert)?
1060            .cert_pair(name, min_validity)
1061            .await
1062    }
1063
1064    /// The DNS names this node may obtain TLS certificates for (Go `tsnet.Server.CertDomains`).
1065    ///
1066    /// A thin delegation to [`Device::cert_domains`](crate::Device::cert_domains): the `CertDomains`
1067    /// control pushed in the netmap DNS config — the names to request a cert for via
1068    /// [`Server::listen_tls`] (or, with `acme`, `cert_pair`). Empty before the first netmap, or when
1069    /// control granted none (Go returns a clone of `nm.DNS.CertDomains`). Unlike the cert-*issuance*
1070    /// calls this only reads netmap state, so it returns the unified lifecycle [`Error`].
1071    pub async fn cert_domains(&self) -> Result<Vec<String>, Error> {
1072        Ok(self.started().await?.cert_domains().await?)
1073    }
1074
1075    // --- folded LocalClient surface (Go `LocalClient().X`); the rest live on `Device`) ---
1076
1077    /// Node + peer status (Go `LocalClient().Status`).
1078    pub async fn status(&self) -> Result<Status, Error> {
1079        Ok(self.started().await?.status().await?)
1080    }
1081
1082    /// Log this node out (Go `LocalClient().Logout`).
1083    pub async fn logout(&self) -> Result<(), Error> {
1084        self.started().await?.logout().await?;
1085        Ok(())
1086    }
1087
1088    /// Stop the server (Go `Close`). Consumes `self`; returns whether shutdown completed within
1089    /// `timeout` (`None` = wait forever). A never-started server closes cleanly.
1090    ///
1091    /// Tears down the loopback surface first (aborting the SOCKS5 and LocalAPI accept loops), then
1092    /// gracefully shuts the wrapped device down. The LocalAPI server holds only a [`Weak`] to the
1093    /// device, so this reclaims the sole strong reference for the graceful shutdown.
1094    pub async fn close(self, timeout: Option<Duration>) -> bool {
1095        // Abort the SOCKS5 + LocalAPI accept loops (and drop their `Weak` device refs) before
1096        // reclaiming the device by value.
1097        drop(self.loopback_rt);
1098        match self.device.into_inner() {
1099            None => true,
1100            Some(arc) => match Arc::into_inner(arc) {
1101                Some(dev) => dev.shutdown(timeout).await,
1102                // A LocalAPI request raced `close` and briefly holds the last strong ref; it is
1103                // released the instant that request returns, and the device tears down then — we
1104                // just could not reclaim it by value for a graceful shutdown.
1105                None => false,
1106            },
1107        }
1108    }
1109}
1110
1111/// Map a facade lazy-start failure into the cert surface's typed error. The cert calls
1112/// ([`Server::listen_tls`], `cert_pair`) return [`ts_control::CertError`] — not the unified
1113/// [`Error`] — to preserve the specialized typed surface (design doc §7), so a device that fails to
1114/// start is surfaced as a [`ts_control::CertError::Io`] carrying the underlying reason rather than
1115/// swallowed. (The start error only fires for an already-validated config.)
1116fn start_failed_cert(e: Error) -> ts_control::CertError {
1117    ts_control::CertError::Io(std::io::Error::other(format!(
1118        "server failed to start: {e}"
1119    )))
1120}
1121
1122// ---------------------------------------------------------------------------------------------
1123// Go-style `network` / `addr` string parsing — the `"tcp"`/`"udp"` + `":80"` surface.
1124//
1125// The facade owns this (like every string→typed step): it turns Go's loose `net.Listen`/
1126// `net.ListenPacket` strings into the typed values the engine wants and into the facade's *own*
1127// typed [`Error`], rather than leaking the engine's opaque `BadRequest`. The accepted network set
1128// mirrors the engine's own [`Device::dial`](crate::Device::dial) so `listen`/`listen_packet`
1129// accept exactly what `dial` does.
1130// ---------------------------------------------------------------------------------------------
1131
1132/// The transport a Go `network` string selects.
1133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1134enum Transport {
1135    Tcp,
1136    Udp,
1137}
1138
1139/// The address family a Go `network` suffix forces (`…4`/`…6`), or [`Family::Any`] for the bare
1140/// `"tcp"`/`"udp"`. It picks the wildcard host a bare `":port"` binds on (v4 vs v6).
1141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1142enum Family {
1143    Any,
1144    V4,
1145    V6,
1146}
1147
1148/// A parsed Go `network` string: its transport and address family.
1149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1150struct Network {
1151    transport: Transport,
1152    family: Family,
1153}
1154
1155/// Parse a Go-style `network` string (the first argument of `net.Listen`/`net.ListenPacket`) into a
1156/// typed [`Network`]. Accepts exactly the tsnet set — `"tcp"`, `"tcp4"`, `"tcp6"`, `"udp"`,
1157/// `"udp4"`, `"udp6"` — matching the engine's own [`Device::dial`](crate::Device::dial); anything
1158/// else (including the empty string) is [`Error::InvalidNetwork`].
1159fn parse_network(network: &str) -> Result<Network, Error> {
1160    let (transport, family) = match network {
1161        "tcp" => (Transport::Tcp, Family::Any),
1162        "tcp4" => (Transport::Tcp, Family::V4),
1163        "tcp6" => (Transport::Tcp, Family::V6),
1164        "udp" => (Transport::Udp, Family::Any),
1165        "udp4" => (Transport::Udp, Family::V4),
1166        "udp6" => (Transport::Udp, Family::V6),
1167        _ => {
1168            return Err(Error::InvalidNetwork {
1169                network: network.to_string(),
1170            });
1171        }
1172    };
1173    Ok(Network { transport, family })
1174}
1175
1176/// Normalize a Go-style listen `addr`: a bare `":port"` gets the wildcard host for `family`
1177/// (`0.0.0.0` for v4/any, `[::]` for v6 — matching Go's `net.Listen("tcp6", ":80")` ⇒ `[::]:80`).
1178/// An `addr` with an explicit host (an IP literal or a name) is returned unchanged.
1179fn normalize_listen_addr(addr: &str, family: Family) -> String {
1180    if addr.starts_with(':') {
1181        match family {
1182            Family::V6 => format!("[::]{addr}"),
1183            Family::Any | Family::V4 => format!("0.0.0.0{addr}"),
1184        }
1185    } else {
1186        addr.to_string()
1187    }
1188}
1189
1190/// Parse a Go-style listen `addr` into a [`SocketAddr`], filling a bare `":port"` with the family's
1191/// wildcard host (see [`normalize_listen_addr`]). A family-pinned `family` (`V4`/`V6`, i.e. a
1192/// `tcp4`/`tcp6` network) additionally rejects an explicit host literal of the *other* family —
1193/// `parse_listen_addr("[::1]:80", Family::V4)` is [`Error::AddrFamilyMismatch`], exactly as Go's
1194/// `net.Listen("tcp4", "[::1]:80")` errors — while `Family::Any` accepts either.
1195fn parse_listen_addr(addr: &str, family: Family) -> Result<SocketAddr, Error> {
1196    let sa: SocketAddr = normalize_listen_addr(addr, family)
1197        .parse()
1198        .map_err(|source| Error::InvalidAddr {
1199            addr: addr.to_string(),
1200            source,
1201        })?;
1202    // A `…4`/`…6` network pins the family: reject an explicit host literal that parsed to the other
1203    // family. A bare `":port"` already got the matching wildcard from `normalize_listen_addr`, so it
1204    // never reaches here; `Family::Any` (bare `tcp`/`udp`) follows the address and accepts either.
1205    let mismatch = match family {
1206        Family::Any => None,
1207        Family::V4 => (!sa.is_ipv4()).then_some("IPv4"),
1208        Family::V6 => (!sa.is_ipv6()).then_some("IPv6"),
1209    };
1210    match mismatch {
1211        Some(want) => Err(Error::AddrFamilyMismatch {
1212            addr: addr.to_string(),
1213            want,
1214        }),
1215        None => Ok(sa),
1216    }
1217}
1218
1219// ---------------------------------------------------------------------------------------------
1220// Loopback runtime: the engine SOCKS5 proxy + the facade's in-process LocalAPI HTTP server.
1221// ---------------------------------------------------------------------------------------------
1222
1223/// The running loopback surface, cached on [`Server`] and torn down on [`Server::close`].
1224struct LoopbackRt {
1225    socks_address: SocketAddr,
1226    proxy_cred: String,
1227    local_api_address: SocketAddr,
1228    local_api_cred: String,
1229    /// Aborts the engine SOCKS5 accept loop on drop (RAII).
1230    _socks_handle: crate::LoopbackHandle,
1231    /// Aborts the in-process LocalAPI accept loop on drop (via the [`Drop`] impl below).
1232    localapi_task: AbortHandle,
1233}
1234
1235impl Drop for LoopbackRt {
1236    fn drop(&mut self) {
1237        // Stop accepting new LocalAPI connections. In-flight requests hold only a `Weak<Device>` and
1238        // finish on their own. (`_socks_handle` aborts the SOCKS5 loop via its own `Drop`.)
1239        self.localapi_task.abort();
1240    }
1241}
1242
1243/// The target of control's c2n `/remoteapi/localapi/*` proxy: this facade's LocalAPI routes,
1244/// reachable over the control connection instead of over the loopback listener (Go
1245/// `feature/remoteconfig`'s `handleC2NRemoteAPI`, which builds a `localapi.Handler` over the same
1246/// `LocalBackend` the loopback one serves).
1247///
1248/// Installed on [`Config::c2n_local_api`](crate::Config::c2n_local_api) by
1249/// [`Server::build_and_start`], which is *before* the device exists — so the backend is filled in by
1250/// [`attach`](Self::attach) the moment [`Device::new`] returns. Like the loopback server it holds a
1251/// [`Weak`], so an in-flight c2n request never blocks [`Server::close`] from reclaiming the device.
1252///
1253/// One state answers without reaching a route at all: before `attach`, a window that closes in the
1254/// statement after the one that opens it — control cannot have delivered a c2n ping inside it, since
1255/// the map poll that would carry one has not been read yet. That answers `503 device unavailable`
1256/// rather than pretending the endpoint does not exist. Once attached, a device that has since been
1257/// reclaimed is the *backend's* failure and surfaces as the LocalAPI's own `500`, exactly as it does
1258/// for a loopback request.
1259#[derive(Default)]
1260struct C2nLocalApi {
1261    /// The LocalAPI status backend, set once by [`attach`](Self::attach).
1262    status: std::sync::OnceLock<localapi::StatusFn>,
1263}
1264
1265impl C2nLocalApi {
1266    /// Point this hook at the built device. Called once; a second call is ignored.
1267    fn attach(&self, device: &Arc<Device>) {
1268        let _already_attached = self.status.set(device_status_fn(Arc::downgrade(device)));
1269    }
1270}
1271
1272impl ts_control::LocalApi for C2nLocalApi {
1273    fn serve<'a>(
1274        &'a self,
1275        method: &'a str,
1276        target: &'a str,
1277        _body: &'a str,
1278    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = String> + Send + 'a>> {
1279        Box::pin(async move {
1280            let resp = match self.status.get() {
1281                Some(status) => localapi::route(method, target, status).await,
1282                None => localapi::response(
1283                    503,
1284                    "Service Unavailable",
1285                    "text/plain",
1286                    b"device unavailable",
1287                    &[],
1288                ),
1289            };
1290            String::from_utf8_lossy(&resp).into_owned()
1291        })
1292    }
1293}
1294
1295/// A LocalAPI status backend over a [`Weak`] device handle: upgrade for just long enough to read
1296/// status, and report the shutdown as an error rather than keeping the device alive. Shared by the
1297/// loopback LocalAPI server and the c2n proxy so both serve the same status.
1298fn device_status_fn(weak: Weak<Device>) -> localapi::StatusFn {
1299    Arc::new(move || {
1300        let weak = weak.clone();
1301        Box::pin(async move {
1302            match weak.upgrade() {
1303                Some(dev) => dev
1304                    .status()
1305                    .await
1306                    .map(|s| status_json(&s))
1307                    .map_err(|e| e.to_string()),
1308                None => Err("device has shut down".to_string()),
1309            }
1310        })
1311    })
1312}
1313
1314/// Build the loopback runtime: start the engine SOCKS5 proxy, bind a second `127.0.0.1` listener for
1315/// the LocalAPI, mint a *separate* credential, and spawn the in-process LocalAPI HTTP server backed
1316/// by a [`Weak`] handle to `device`.
1317async fn build_loopback_rt(device: Arc<Device>) -> Result<LoopbackRt, Error> {
1318    // SOCKS5 half — the engine's own loopback (address + proxy_cred + RAII handle), unchanged.
1319    let (socks_address, proxy_cred, socks_handle) = device.loopback().await?;
1320
1321    // LocalAPI half — a facade-owned HTTP server on its own host-loopback listener.
1322    let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
1323        .await
1324        .map_err(Error::Loopback)?;
1325    let local_api_address = listener.local_addr().map_err(Error::Loopback)?;
1326    let local_api_cred = gen_cred();
1327
1328    // Backend: a `Weak<Device>` so the spawned server never blocks `Server::close` from reclaiming
1329    // the device by value. Each request upgrades it just long enough to read status.
1330    let status = device_status_fn(Arc::downgrade(&device));
1331
1332    let task = tokio::spawn(localapi::serve(listener, local_api_cred.clone(), status));
1333
1334    Ok(LoopbackRt {
1335        socks_address,
1336        proxy_cred,
1337        local_api_address,
1338        local_api_cred,
1339        _socks_handle: socks_handle,
1340        localapi_task: task.abort_handle(),
1341    })
1342}
1343
1344/// Generate a 16-byte random credential rendered as 32 lowercase-hex chars (Go uses
1345/// `hex.EncodeToString(crand[16])`; no new dependency — reuses `rand`, like the SOCKS5 half).
1346fn gen_cred() -> String {
1347    let bytes: [u8; 16] = rand::random();
1348    bytes.iter().map(|b| format!("{b:02x}")).collect()
1349}
1350
1351/// Serialize a [`StatusNode`] into a JSON value using explicit conversions (the fork's status types
1352/// are not `serde` types, and their field crates don't enable serde features here).
1353fn status_node_json(n: &StatusNode) -> serde_json::Value {
1354    serde_json::json!({
1355        "stable_id": n.stable_id.0,
1356        "display_name": n.display_name,
1357        "ipv4": n.ipv4.to_string(),
1358        "ipv6": n.ipv6.to_string(),
1359        "online": n.online,
1360        // Unix seconds — a feature-free encoding (chrono is `default-features = false` here, so the
1361        // `to_rfc3339`/`Display` formatters are unavailable; `timestamp()` is always present).
1362        "last_seen": n.last_seen.map(|t| t.timestamp()),
1363        "allowed_routes": n.allowed_routes.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
1364        "is_exit_node": n.is_exit_node,
1365        "cur_addr": n.cur_addr.map(|a| a.to_string()),
1366        "relay": n.relay,
1367        "ssh_host_keys": n.ssh_host_keys,
1368    })
1369}
1370
1371/// Serialize a [`Status`] snapshot into the LocalAPI `/status` JSON body.
1372fn status_json(s: &Status) -> Vec<u8> {
1373    let value = serde_json::json!({
1374        "self": s.self_node.as_ref().map(status_node_json),
1375        "peers": s.peers.iter().map(status_node_json).collect::<Vec<_>>(),
1376        "active_exit_node": s.active_exit_node.as_ref().map(|id| id.0.clone()),
1377        "magic_dns_suffix": s.magic_dns_suffix,
1378    });
1379    serde_json::to_vec(&value).unwrap_or_else(|_| b"{}".to_vec())
1380}
1381
1382/// Find the first occurrence of `needle` in `hay` (splits an HTTP head from its body — no dep).
1383fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
1384    if needle.is_empty() || hay.len() < needle.len() {
1385        return None;
1386    }
1387    hay.windows(needle.len()).position(|w| w == needle)
1388}
1389
1390/// Parse an HTTP/1.x response into `(status_code, body_bytes)`. Used by [`LocalClient`].
1391fn parse_response(resp: &[u8]) -> Option<(u16, Vec<u8>)> {
1392    let head_end = find_subslice(resp, b"\r\n\r\n")?;
1393    let head = std::str::from_utf8(&resp[..head_end]).ok()?;
1394    let status_line = head.split("\r\n").next()?;
1395    // "HTTP/1.1 200 OK" — the status code is the second whitespace-separated token.
1396    let code: u16 = status_line.split_whitespace().nth(1)?.parse().ok()?;
1397    Some((code, resp[head_end + 4..].to_vec()))
1398}
1399
1400/// Perform an authenticated LocalAPI `GET` over plain HTTP to `127.0.0.1` (dependency-free client).
1401async fn localapi_client_get(
1402    addr: SocketAddr,
1403    cred: &str,
1404    path: &str,
1405) -> std::io::Result<(u16, Vec<u8>)> {
1406    let mut sock = TcpStream::connect(addr).await?;
1407    // HTTP Basic auth with an empty username (Go ignores the username; the password is the cred).
1408    let auth = STANDARD.encode(format!(":{cred}"));
1409    // Send Go's anti-DNS-rebinding header (`Sec-Tailscale: localapi`) the server now requires in
1410    // addition to Basic auth — a browser rebinding attack cannot set this custom header.
1411    let req = format!(
1412        "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Tailscale: localapi\r\nAuthorization: Basic {auth}\r\nConnection: close\r\n\r\n"
1413    );
1414    sock.write_all(req.as_bytes()).await?;
1415    // The server replies with `Connection: close`, so reading to EOF yields the whole response.
1416    let mut resp = Vec::new();
1417    sock.read_to_end(&mut resp).await?;
1418    parse_response(&resp).ok_or_else(|| {
1419        std::io::Error::new(std::io::ErrorKind::InvalidData, "malformed HTTP response")
1420    })
1421}
1422
1423/// The in-process LocalAPI HTTP server (Go's `localapi.Handler` served on the loopback). A minimal,
1424/// dependency-free HTTP/1.1 server: the crate's `hyper` is HTTP/2-**client**-only, so this hand-rolls
1425/// request framing exactly as the SOCKS5 half hand-rolls its own protocol in `src/loopback.rs`.
1426///
1427/// **Scope (vs Go).** Unlike Go's full `localapi.Handler` (dozens of endpoints), this serves the one
1428/// route the facade needs — `GET /localapi/v0/status` — and returns `404` for every other
1429/// path/method. Every request must additionally carry Go's `Sec-Tailscale: localapi` request header
1430/// (anti-DNS-rebinding) on top of the Basic-auth credential, or it is rejected `403` before auth.
1431mod localapi {
1432    use super::{Duration, STANDARD, TcpListener, TcpStream, find_subslice};
1433    use base64::Engine as _;
1434    use std::future::Future;
1435    use std::pin::Pin;
1436    use std::sync::Arc;
1437    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
1438    use tokio::sync::Semaphore;
1439
1440    /// Upper bound on the buffered HTTP request head (request line + headers). LocalAPI requests are
1441    /// tiny; a client that floods the head is rejected rather than buffered unbounded.
1442    const MAX_HEAD: usize = 8 * 1024;
1443    /// Deadline for reading a full request head and writing the response.
1444    const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1445    /// Cap on concurrent LocalAPI connections (loopback-only, but bounded for hygiene).
1446    const MAX_CONCURRENT: usize = 64;
1447    /// Go's anti-DNS-rebinding request header. Every LocalAPI request must carry
1448    /// `Sec-Tailscale: localapi` in addition to the Basic-auth credential: a browser steered at the
1449    /// loopback listener by a rebinding attack cannot set this custom header cross-origin (it is not
1450    /// CORS-safelisted, so `fetch` may send it only after a preflight this server never approves), so
1451    /// requiring it keeps browser-driven callers out even if they learn the port and credential.
1452    const SEC_TAILSCALE_HEADER: &str = "Sec-Tailscale";
1453    /// The one accepted value of [`SEC_TAILSCALE_HEADER`] (Go compares `== "localapi"`).
1454    const SEC_TAILSCALE_VALUE: &str = "localapi";
1455
1456    /// A cloneable, `'static` async backend for `GET /localapi/v0/status`: returns the JSON body
1457    /// bytes, or an error string mapped to HTTP 500. Boxed so tests can inject a mock backend
1458    /// without a live [`Device`](crate::Device).
1459    pub(super) type StatusFn = Arc<
1460        dyn Fn() -> Pin<Box<dyn Future<Output = Result<Vec<u8>, String>> + Send>> + Send + Sync,
1461    >;
1462
1463    /// Serve the LocalAPI on `listener` until the task is aborted (by [`super::LoopbackRt`]'s drop).
1464    pub(super) async fn serve(listener: TcpListener, cred: String, status: StatusFn) {
1465        let sem = Arc::new(Semaphore::new(MAX_CONCURRENT));
1466        loop {
1467            // Back-pressure at the cap: acquire before accepting.
1468            let permit = match sem.clone().acquire_owned().await {
1469                Ok(p) => p,
1470                Err(_) => return,
1471            };
1472            let (sock, _peer) = match listener.accept().await {
1473                Ok(pair) => pair,
1474                Err(e) => {
1475                    tracing::warn!(error = %e, "loopback LocalAPI accept failed; stopping accept loop");
1476                    return;
1477                }
1478            };
1479            let cred = cred.clone();
1480            let status = status.clone();
1481            tokio::spawn(async move {
1482                let _permit = permit;
1483                match tokio::time::timeout(REQUEST_TIMEOUT, handle(sock, &cred, &status)).await {
1484                    Ok(Ok(())) => {}
1485                    Ok(Err(e)) => tracing::debug!(error = %e, "loopback LocalAPI connection ended"),
1486                    Err(_) => tracing::debug!("loopback LocalAPI request timed out"),
1487                }
1488            });
1489        }
1490    }
1491
1492    /// Serve one LocalAPI connection: read the head, authenticate, route, respond, close.
1493    async fn handle(mut sock: TcpStream, cred: &str, status: &StatusFn) -> std::io::Result<()> {
1494        // Read up to the end of the header block (CRLF CRLF), capped.
1495        let mut buf = Vec::with_capacity(1024);
1496        let mut chunk = [0u8; 1024];
1497        let head_len = loop {
1498            if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
1499                break pos;
1500            }
1501            if buf.len() > MAX_HEAD {
1502                let r = response(
1503                    431,
1504                    "Request Header Fields Too Large",
1505                    "text/plain",
1506                    b"header too large",
1507                    &[],
1508                );
1509                sock.write_all(&r).await?;
1510                return Ok(());
1511            }
1512            let n = sock.read(&mut chunk).await?;
1513            if n == 0 {
1514                return Ok(()); // client closed before sending a full head
1515            }
1516            buf.extend_from_slice(&chunk[..n]);
1517        };
1518
1519        let Some((method, target, password, sec_tailscale)) = parse_head(&buf[..head_len]) else {
1520            let r = response(400, "Bad Request", "text/plain", b"bad request", &[]);
1521            sock.write_all(&r).await?;
1522            return Ok(());
1523        };
1524
1525        // Anti-DNS-rebinding gate (Go's `Sec-Tailscale: localapi`), checked *before* the credential:
1526        // block browser-driven (rebinding) callers even when they know the port + cred, since they
1527        // cannot set this custom header. See [`SEC_TAILSCALE_HEADER`].
1528        if sec_tailscale.as_deref() != Some(SEC_TAILSCALE_VALUE) {
1529            let r = response(
1530                403,
1531                "Forbidden",
1532                "text/plain",
1533                b"missing 'Sec-Tailscale: localapi' header",
1534                &[],
1535            );
1536            sock.write_all(&r).await?;
1537            return Ok(());
1538        }
1539
1540        // Auth: the Basic-auth password must equal the cred (any username, matching Go).
1541        if !password.as_deref().is_some_and(|p| cred_ok(p, cred)) {
1542            let r = response(
1543                401,
1544                "Unauthorized",
1545                "text/plain",
1546                b"unauthorized",
1547                &[("WWW-Authenticate", "Basic realm=\"tailscale localapi\"")],
1548            );
1549            sock.write_all(&r).await?;
1550            return Ok(());
1551        }
1552
1553        let resp = route(&method, &target, status).await;
1554        sock.write_all(&resp).await?;
1555        Ok(())
1556    }
1557
1558    /// Route one *already authorized* LocalAPI request to its complete HTTP/1.1 response.
1559    ///
1560    /// Split out of [`handle`] because this server has two front doors onto the same routes: the
1561    /// loopback listener (which authorizes with the anti-DNS-rebinding header plus the Basic-auth
1562    /// credential before getting here) and control's c2n `/remoteapi/localapi/*` proxy (authorized
1563    /// by the [`Config::remote_config`](crate::Config::remote_config) pref, checked in
1564    /// `ts_control`'s c2n responder). Neither gate belongs to the routing, and Go likewise builds
1565    /// the proxied handler with no `RequiredPassword` — so the auth lives in the callers and the
1566    /// route table is shared.
1567    ///
1568    /// `target` is a request target, so it may carry a query string; the query is ignored, as none
1569    /// of the one route this facade serves reads it (Go's `localapi.Handler` also dispatches on
1570    /// `URL.Path` alone).
1571    pub(super) async fn route(method: &str, target: &str, status: &StatusFn) -> Vec<u8> {
1572        let path = target.split('?').next().unwrap_or(target);
1573        match (method, path) {
1574            ("GET", "/localapi/v0/status") => match status().await {
1575                Ok(body) => response(200, "OK", "application/json", &body, &[]),
1576                Err(_) => response(
1577                    500,
1578                    "Internal Server Error",
1579                    "text/plain",
1580                    b"status error",
1581                    &[],
1582                ),
1583            },
1584            _ => response(404, "Not Found", "text/plain", b"not found", &[]),
1585        }
1586    }
1587
1588    /// Parse an HTTP request head into `(method, request_target, basic_auth_password,
1589    /// sec_tailscale)`. `None` when the request line is malformed. `sec_tailscale` carries the
1590    /// `Sec-Tailscale` request-header value (Go's anti-DNS-rebinding token), or `None` when absent.
1591    pub(super) fn parse_head(
1592        head: &[u8],
1593    ) -> Option<(String, String, Option<String>, Option<String>)> {
1594        let text = std::str::from_utf8(head).ok()?;
1595        let mut lines = text.split("\r\n");
1596        let mut request_line = lines.next()?.split(' ');
1597        let method = request_line.next()?.to_string();
1598        let target = request_line.next()?.to_string();
1599        request_line.next()?; // require the HTTP-version token
1600        let mut password = None;
1601        let mut sec_tailscale = None;
1602        for line in lines {
1603            let Some((name, value)) = line.split_once(':') else {
1604                continue;
1605            };
1606            let name = name.trim();
1607            if name.eq_ignore_ascii_case("authorization") {
1608                password = basic_auth_password(value.trim());
1609            } else if name.eq_ignore_ascii_case(SEC_TAILSCALE_HEADER) {
1610                sec_tailscale = Some(value.trim().to_string());
1611            }
1612        }
1613        Some((method, target, password, sec_tailscale))
1614    }
1615
1616    /// Decode `Basic <base64(user:pass)>` into the password (Go ignores the username). `None` if the
1617    /// header is not Basic auth or is malformed.
1618    pub(super) fn basic_auth_password(value: &str) -> Option<String> {
1619        // The auth scheme is case-insensitive (RFC 7617; Go's `r.BasicAuth` uses `EqualFold`), so
1620        // `Basic`/`basic`/`BASIC`/… all authenticate.
1621        let (scheme, b64) = value.split_once(' ')?;
1622        if !scheme.eq_ignore_ascii_case("basic") {
1623            return None;
1624        }
1625        let decoded = STANDARD.decode(b64.trim()).ok()?;
1626        let decoded = String::from_utf8(decoded).ok()?;
1627        // "user:pass" — the username (before the first colon) is ignored; a header with no colon at
1628        // all is malformed Basic auth and yields no password (→ 401).
1629        decoded
1630            .split_once(':')
1631            .map(|(_user, pass)| pass.to_string())
1632    }
1633
1634    /// Constant-time credential comparison (don't leak the cred via early-exit timing).
1635    pub(super) fn cred_ok(provided: &str, expected: &str) -> bool {
1636        let (a, b) = (provided.as_bytes(), expected.as_bytes());
1637        if a.len() != b.len() {
1638            return false;
1639        }
1640        let mut diff = 0u8;
1641        for (x, y) in a.iter().zip(b.iter()) {
1642            diff |= x ^ y;
1643        }
1644        diff == 0
1645    }
1646
1647    /// Build a complete HTTP/1.1 response with `Connection: close`.
1648    pub(super) fn response(
1649        code: u16,
1650        reason: &str,
1651        content_type: &str,
1652        body: &[u8],
1653        extra_headers: &[(&str, &str)],
1654    ) -> Vec<u8> {
1655        let mut head = format!(
1656            "HTTP/1.1 {code} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n",
1657            body.len()
1658        );
1659        for (name, value) in extra_headers {
1660            head.push_str(name);
1661            head.push_str(": ");
1662            head.push_str(value);
1663            head.push_str("\r\n");
1664        }
1665        head.push_str("\r\n");
1666        let mut out = head.into_bytes();
1667        out.extend_from_slice(body);
1668        out
1669    }
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674    use super::*;
1675
1676    // --- the `network` string parser (`"tcp"`/`"udp"` + family suffix) ---
1677
1678    #[test]
1679    fn parse_network_accepts_the_tsnet_set() {
1680        // Exactly Go's net.Listen/net.ListenPacket set — and the engine's own Device::dial set.
1681        assert_eq!(
1682            parse_network("tcp").unwrap(),
1683            Network {
1684                transport: Transport::Tcp,
1685                family: Family::Any
1686            }
1687        );
1688        assert_eq!(parse_network("tcp4").unwrap().family, Family::V4);
1689        assert_eq!(parse_network("tcp6").unwrap().family, Family::V6);
1690        assert_eq!(parse_network("udp").unwrap().transport, Transport::Udp);
1691        assert_eq!(parse_network("udp4").unwrap().family, Family::V4);
1692        assert_eq!(parse_network("udp6").unwrap().family, Family::V6);
1693    }
1694
1695    #[test]
1696    fn parse_network_rejects_unknown_strings() {
1697        // Unknown transports, bad suffixes, the empty string, wrong case, and stray whitespace all
1698        // fail as the typed InvalidNetwork (never a silent default).
1699        for n in ["", "tcp5", "sctp", "unix", "TCP", "udp7", "ip", "tcp ", "0"] {
1700            assert!(
1701                matches!(parse_network(n), Err(Error::InvalidNetwork { network }) if network == n),
1702                "network {n:?} must be rejected as InvalidNetwork carrying the offending value"
1703            );
1704        }
1705    }
1706
1707    // --- the listen-address parser (`":80"` ⇒ family wildcard, or a full `ip:port`) ---
1708
1709    #[test]
1710    fn parse_colon_port_is_family_aware_wildcard() {
1711        // A bare ":port" binds the wildcard host of the network's family.
1712        let v4 = parse_listen_addr(":8080", Family::Any).unwrap();
1713        assert!(v4.ip().is_unspecified() && v4.is_ipv4());
1714        assert_eq!(v4.port(), 8080);
1715        assert!(parse_listen_addr(":80", Family::V4).unwrap().is_ipv4());
1716
1717        // ...and `tcp6`/`udp6` bind the v6 wildcard `[::]` (Go `net.Listen("tcp6", ":80")`).
1718        let v6 = parse_listen_addr(":80", Family::V6).unwrap();
1719        assert!(v6.ip().is_unspecified() && v6.is_ipv6());
1720        assert_eq!(v6.port(), 80);
1721    }
1722
1723    #[test]
1724    fn parse_full_addr_is_used_verbatim() {
1725        let sa = parse_listen_addr("100.64.0.1:443", Family::Any).unwrap();
1726        assert_eq!(sa.port(), 443);
1727        assert_eq!(sa.ip().to_string(), "100.64.0.1");
1728    }
1729
1730    #[test]
1731    fn parse_bad_addr_is_typed_error() {
1732        let err = parse_listen_addr("not-an-addr", Family::Any).unwrap_err();
1733        assert!(matches!(err, Error::InvalidAddr { .. }));
1734    }
1735
1736    #[test]
1737    fn parse_listen_addr_enforces_the_pinned_family_on_explicit_hosts() {
1738        // A `tcp4`/`tcp6` network pins the family: an explicit host literal of the *other* family is
1739        // rejected, exactly as Go `net.Listen("tcp4", "[::1]:80")` errors (this is the case a bare
1740        // ":port" — filled with the matching wildcard — never reaches).
1741        assert!(
1742            matches!(
1743                parse_listen_addr("[::1]:80", Family::V4),
1744                Err(Error::AddrFamilyMismatch { want: "IPv4", ref addr }) if addr == "[::1]:80"
1745            ),
1746            "a v6 literal under tcp4 must be AddrFamilyMismatch(IPv4)"
1747        );
1748        assert!(
1749            matches!(
1750                parse_listen_addr("127.0.0.1:80", Family::V6),
1751                Err(Error::AddrFamilyMismatch { want: "IPv6", .. })
1752            ),
1753            "a v4 literal under tcp6 must be AddrFamilyMismatch(IPv6)"
1754        );
1755        // The matching family passes through unchanged...
1756        assert!(matches!(
1757            parse_listen_addr("[::1]:80", Family::V6),
1758            Ok(SocketAddr::V6(_))
1759        ));
1760        assert!(matches!(
1761            parse_listen_addr("127.0.0.1:80", Family::V4),
1762            Ok(SocketAddr::V4(_))
1763        ));
1764        // ...and the family-agnostic bare `tcp`/`udp` (Any) follows the address, accepting either.
1765        assert!(
1766            parse_listen_addr("[::1]:80", Family::Any)
1767                .unwrap()
1768                .is_ipv6()
1769        );
1770        assert!(
1771            parse_listen_addr("127.0.0.1:80", Family::Any)
1772                .unwrap()
1773                .is_ipv4()
1774        );
1775    }
1776
1777    #[test]
1778    fn normalize_listen_addr_only_fills_a_bare_port() {
1779        // A bare ":port" is filled with the family wildcard; an explicit host is left untouched
1780        // (including a name, which the engine's ListenPacket then rejects — the facade doesn't
1781        // pre-judge it).
1782        assert_eq!(normalize_listen_addr(":0", Family::Any), "0.0.0.0:0");
1783        assert_eq!(normalize_listen_addr(":0", Family::V4), "0.0.0.0:0");
1784        assert_eq!(normalize_listen_addr(":0", Family::V6), "[::]:0");
1785        assert_eq!(normalize_listen_addr("0.0.0.0:0", Family::V4), "0.0.0.0:0");
1786        assert_eq!(normalize_listen_addr("[::]:53", Family::V6), "[::]:53");
1787        assert_eq!(normalize_listen_addr("host:53", Family::Any), "host:53");
1788    }
1789
1790    // --- the parser is *wired into* listen()/listen_packet(): a wrong-transport or unknown network,
1791    //     and a bad address, are rejected up front — before the lazy Device::new / any network I/O,
1792    //     which is what makes these hermetic (they never reach `started()`). ---
1793
1794    #[tokio::test]
1795    async fn listen_rejects_a_non_tcp_network_before_starting() {
1796        let s = Server::new();
1797        assert!(matches!(
1798            s.listen("udp", ":80").await,
1799            Err(Error::InvalidNetwork { network }) if network == "udp"
1800        ));
1801        assert!(matches!(
1802            s.listen("sctp", ":80").await,
1803            Err(Error::InvalidNetwork { .. })
1804        ));
1805    }
1806
1807    #[tokio::test]
1808    async fn listen_reports_a_bad_addr_before_starting() {
1809        let s = Server::new();
1810        assert!(matches!(
1811            s.listen("tcp", "not-an-addr").await,
1812            Err(Error::InvalidAddr { .. })
1813        ));
1814    }
1815
1816    #[tokio::test]
1817    async fn listen_rejects_a_family_mismatched_explicit_host_before_starting() {
1818        // `listen("tcp4", "[::1]:80")` must fail like Go `net.Listen` — enforced at the facade
1819        // boundary, before the lazy `Device::new` / any network I/O (hermetic: never reaches
1820        // `started()`). This is the family check for an *explicit* host literal, not just ":port".
1821        let s = Server::new();
1822        assert!(matches!(
1823            s.listen("tcp4", "[::1]:80").await,
1824            Err(Error::AddrFamilyMismatch { want: "IPv4", .. })
1825        ));
1826        assert!(matches!(
1827            s.listen("tcp6", "127.0.0.1:80").await,
1828            Err(Error::AddrFamilyMismatch { want: "IPv6", .. })
1829        ));
1830    }
1831
1832    #[tokio::test]
1833    async fn listen_packet_rejects_a_non_udp_network_before_starting() {
1834        let s = Server::new();
1835        assert!(matches!(
1836            s.listen_packet("tcp", "0.0.0.0:0").await,
1837            Err(Error::InvalidNetwork { network }) if network == "tcp"
1838        ));
1839        assert!(matches!(
1840            s.listen_packet("nope", "0.0.0.0:0").await,
1841            Err(Error::InvalidNetwork { .. })
1842        ));
1843    }
1844
1845    // --- listen_tls()/cert_pair(): the serve config / cert name is validated at the facade boundary,
1846    //     so a non-tailnet name or a zero port is the typed CertError rejected up front — before the
1847    //     lazy Device::new / any cert issuance / any network I/O (hermetic: never reaches `started()`).
1848    //     This holds with or without the `acme` feature; real ACME issuance needs a live SaaS tailnet
1849    //     and is out of scope for a unit test. ---
1850
1851    #[tokio::test]
1852    async fn listen_tls_rejects_a_non_tailnet_name_before_starting() {
1853        let s = Server::new();
1854        let cfg = ts_control::ServeConfig {
1855            name: "example.com".into(), // not a `*.ts.net` tailnet name (anti-leak)
1856            port: 443,
1857            target: ts_control::ServeTarget::Accept,
1858        };
1859        assert!(matches!(
1860            s.listen_tls(&cfg).await,
1861            Err(ts_control::CertError::NotTailnetName(n)) if n == "example.com"
1862        ));
1863    }
1864
1865    #[tokio::test]
1866    async fn listen_tls_rejects_a_zero_port_before_starting() {
1867        let s = Server::new();
1868        let cfg = ts_control::ServeConfig {
1869            name: "host.tailnet.ts.net".into(), // a valid tailnet name...
1870            port: 0, // ...but port 0 is rejected by ServeConfig::validate
1871            target: ts_control::ServeTarget::Accept,
1872        };
1873        assert!(matches!(
1874            s.listen_tls(&cfg).await,
1875            Err(ts_control::CertError::Acme(_))
1876        ));
1877    }
1878
1879    #[test]
1880    fn start_failure_maps_to_a_typed_cert_io_error() {
1881        // A lazy-start failure on the cert path is surfaced (not swallowed) as CertError::Io carrying
1882        // the underlying reason — exercised directly on the mapper, since a real start needs a live
1883        // tailnet. This is why listen_tls/cert_pair keep the typed CertError rather than the unified
1884        // lifecycle Error (design doc §7).
1885        let e = start_failed_cert(Error::Store(std::io::Error::other("boom")));
1886        assert!(matches!(e, ts_control::CertError::Io(_)));
1887        let msg = e.to_string();
1888        assert!(msg.contains("server failed to start"), "got {msg:?}");
1889        assert!(
1890            msg.contains("boom"),
1891            "underlying reason must be preserved, got {msg:?}"
1892        );
1893    }
1894
1895    #[cfg(feature = "acme")]
1896    #[tokio::test]
1897    async fn cert_pair_rejects_a_non_tailnet_name_before_starting() {
1898        // The `acme`-gated PEM-pair path applies the same anti-leak name check at the facade boundary,
1899        // before the device is started. (Compiled + linted under `--features "tsnet acme"`.)
1900        let s = Server::new();
1901        assert!(matches!(
1902            s.cert_pair("example.com", None).await,
1903            Err(ts_control::CertError::NotTailnetName(n)) if n == "example.com"
1904        ));
1905    }
1906
1907    #[test]
1908    fn server_default_is_go_shaped() {
1909        // Go's zero-value Server is not ephemeral and has no persistence.
1910        let s = Server::new();
1911        assert!(!s.ephemeral);
1912        assert!(s.dir.is_none());
1913        assert!(s.store.is_none());
1914        assert!(s.hostname.is_none());
1915    }
1916
1917    #[test]
1918    fn mem_store_round_trips() {
1919        let store = MemStore::default();
1920        assert!(store.read_state(STATE_KEY).unwrap().is_none());
1921        store.write_state(STATE_KEY, b"blob").unwrap();
1922        assert_eq!(
1923            store.read_state(STATE_KEY).unwrap().as_deref(),
1924            Some(&b"blob"[..])
1925        );
1926    }
1927
1928    #[test]
1929    fn funnel_options_map_to_engine() {
1930        let engine: ts_control::FunnelOptions = FunnelOptions::funnel_only().into();
1931        assert!(engine.funnel_only);
1932    }
1933
1934    #[test]
1935    fn funnel_options_default_maps_to_non_funnel_only() {
1936        // The zero-value options serve both public Funnel and tailnet-internal ingress (Go's default
1937        // when neither `FunnelOnly()` nor a TLS override is passed).
1938        let engine: ts_control::FunnelOptions = FunnelOptions::default().into();
1939        assert!(!engine.funnel_only);
1940    }
1941
1942    // --- listen_funnel / listen_service: the wrapper's lifecycle-vs-typed error split ---
1943    //
1944    // A *successful* funnel/service listen needs a live tailnet + Funnel-enabled ACL (kept in
1945    // integration/e2e). The hermetic, unit-testable contribution of the facade is the error split:
1946    // the wrapper must lazily start the node first, and a start failure must surface as a lifecycle
1947    // `Start` error carrying the real cause — never collapsed into the engine's access/bind error.
1948    // These lock that contract using the same no-network idiom as the dial/start tests: a bad
1949    // `control_url` fails fast at config build (`InvalidControlUrl`) before any I/O.
1950
1951    /// A `ServeConfig` whose contents are irrelevant here: the lazy start fails before it is ever
1952    /// read. Valid-shaped so the call type-checks (`Accept` = hand the stream back, like `ListenTLS`).
1953    fn dummy_serve_config() -> crate::ServeConfig {
1954        crate::ServeConfig {
1955            name: "node.example.ts.net".into(),
1956            port: 443,
1957            target: crate::ServeTarget::Accept,
1958        }
1959    }
1960
1961    #[tokio::test]
1962    async fn listen_funnel_reports_a_start_failure_not_a_funnel_denial() {
1963        // Regression for the skeleton's lossy `.map_err(|_| FunnelError::NotAllowed)`: a node that
1964        // never registered must NOT be reported as lacking the "funnel"/"https" attributes. It must
1965        // surface as `ListenFunnelError::Start` carrying the real `InvalidControlUrl` cause.
1966        let mut s = Server::new();
1967        s.control_url = Some("not a url".into());
1968        let cfg = dummy_serve_config();
1969        // Bind by-ref so the Display/source asserts can touch the error without needing the Ok type
1970        // (`FunnelAcceptedReceiver`) to be `Debug`.
1971        let res = s.listen_funnel(&cfg, FunnelOptions::default()).await;
1972        match &res {
1973            Err(e @ ListenFunnelError::Start(Error::InvalidControlUrl(_))) => {
1974                // Non-lossy: the wrapper's message embeds the real cause and its source() chains to it.
1975                assert!(
1976                    e.to_string().contains("invalid control URL"),
1977                    "start error dropped its underlying cause from Display: {e}"
1978                );
1979                assert!(
1980                    std::error::Error::source(e).is_some(),
1981                    "start error must expose the underlying Error as its source"
1982                );
1983            }
1984            Err(ListenFunnelError::Start(e)) => panic!("start failed with the wrong cause: {e:?}"),
1985            Err(ListenFunnelError::Funnel(f)) => {
1986                panic!("a startup failure was misdiagnosed as a Funnel error: {f:?}")
1987            }
1988            Ok(_) => panic!("a bad control_url must not yield a live funnel listener"),
1989        }
1990    }
1991
1992    #[tokio::test]
1993    async fn listen_service_reports_a_start_failure_not_a_bind_error() {
1994        // Regression sibling for the skeleton's `ServiceError::Listen("server failed to start: …")`:
1995        // a lazy-start failure must be a lifecycle `Start` error, not the engine's *bind* failure.
1996        let mut s = Server::new();
1997        s.control_url = Some("not a url".into());
1998        let res = s
1999            .listen_service("svc:web", ServiceMode::Tcp { port: 80 })
2000            .await;
2001        match &res {
2002            Err(e @ ListenServiceError::Start(Error::InvalidControlUrl(_))) => {
2003                assert!(
2004                    e.to_string().contains("invalid control URL"),
2005                    "start error dropped its underlying cause from Display: {e}"
2006                );
2007                assert!(
2008                    std::error::Error::source(e).is_some(),
2009                    "start error must expose the underlying Error as its source"
2010                );
2011            }
2012            Err(ListenServiceError::Start(e)) => panic!("start failed with the wrong cause: {e:?}"),
2013            Err(ListenServiceError::Service(se)) => {
2014                panic!("a startup failure was misdiagnosed as a ServiceError: {se:?}")
2015            }
2016            Ok(_) => panic!("a bad control_url must not yield a live service listener"),
2017        }
2018    }
2019
2020    #[test]
2021    fn listen_funnel_error_carries_the_engine_funnel_error_unchanged() {
2022        // The fix adds a `Start` path WITHOUT swallowing the engine's typed error: a genuine access
2023        // denial still arrives fully typed via the `Funnel` variant (the `?`/`From` passthrough).
2024        let e: ListenFunnelError = ts_control::FunnelError::PortNotAllowed(8443).into();
2025        assert!(
2026            matches!(
2027                e,
2028                ListenFunnelError::Funnel(ts_control::FunnelError::PortNotAllowed(8443))
2029            ),
2030            "engine FunnelError must pass through as ListenFunnelError::Funnel, unchanged"
2031        );
2032    }
2033
2034    #[test]
2035    fn listen_service_error_carries_the_engine_service_error_unchanged() {
2036        let e: ListenServiceError = ServiceError::UntaggedHost.into();
2037        assert!(
2038            matches!(e, ListenServiceError::Service(ServiceError::UntaggedHost)),
2039            "engine ServiceError must pass through as ListenServiceError::Service, unchanged"
2040        );
2041    }
2042
2043    #[test]
2044    fn documented_construction_idiom_compiles() {
2045        // Mirrors docs/TSNET_FACADE_DESIGN.md §13: `Server::new()` + per-field assignment on the
2046        // public fields (the private lazy-state fields do not block this), plus the `configure`
2047        // escape hatch and a custom `store`. If this compiles, the documented idiom is valid.
2048        let mut srv = Server::new();
2049        srv.hostname = Some("web".into());
2050        srv.auth_key = Some("tskey-xxxx".into());
2051        srv.dir = Some("/var/lib/web".into());
2052        srv.ephemeral = false;
2053        srv.advertise_tags = vec!["tag:web".into()];
2054        srv.port = Some(41641);
2055        srv.configure(|c| c.accept_routes = true);
2056        srv.store = Some(Arc::new(MemStore::default()));
2057        assert_eq!(srv.hostname.as_deref(), Some("web"));
2058        assert!(!srv.ephemeral);
2059        assert!(srv.store.is_some());
2060        assert!(srv.configure.is_some());
2061    }
2062
2063    // Compile-time assertion: a shared server must be usable across tasks (Arc<Server> + Send/Sync),
2064    // which requires the wrapped Device to be Send + Sync.
2065    fn _assert_send_sync<T: Send + Sync>() {}
2066    #[allow(dead_code)]
2067    fn _server_is_send_sync() {
2068        _assert_send_sync::<Server>();
2069    }
2070
2071    // -----------------------------------------------------------------------------------------
2072    // Config surface: the Go-named `Server` fields → `Config` mapping (docs/TSNET_FACADE_DESIGN.md
2073    // §5) and the `Dir`/`Store` state-root shim over `Config::key_state` (§8). These exercise the
2074    // private async `build_config` directly (same-module access), so the field translation and the
2075    // identity round-trip are *asserted*, not merely compiled.
2076    // -----------------------------------------------------------------------------------------
2077
2078    /// A unique, empty scratch dir for an on-disk state test. The facade adds **no** `tempfile`
2079    /// dependency (the zero-new-dep constraint), so this rolls its own: namespaced by pid + a
2080    /// per-test label (concurrent test binaries never collide) and wiped up front so a stale run
2081    /// can't mask a bug.
2082    fn scratch_dir(label: &str) -> PathBuf {
2083        let pid = std::process::id();
2084        let dir = std::env::temp_dir().join(format!("tsnet-rs-test-{pid}-{label}"));
2085        std::fs::remove_dir_all(&dir).ok();
2086        dir
2087    }
2088
2089    #[tokio::test]
2090    async fn build_config_maps_every_go_field_onto_config() {
2091        // Every Go-parity field set to a non-default; assert build_config copies each onto the
2092        // matching Config field — the §5 mapping table, row by row.
2093        let mut srv = Server::new();
2094        srv.hostname = Some("web".into());
2095        srv.auth_key = Some("tskey-auth-xxxx".into());
2096        srv.control_url = Some("https://control.example.com".into());
2097        srv.ephemeral = false;
2098        srv.advertise_tags = vec!["tag:web".into(), "tag:prod".into()];
2099        srv.port = Some(41641);
2100        srv.run_web_client = true;
2101        srv.client_id = Some("cid".into());
2102        srv.client_secret = Some("csecret".into());
2103        srv.id_token = Some("idtok".into());
2104        srv.audience = Some("aud".into());
2105
2106        let cfg = srv.build_config().await.unwrap();
2107
2108        assert_eq!(cfg.requested_hostname.as_deref(), Some("web"));
2109        assert_eq!(cfg.auth_key.as_deref(), Some("tskey-auth-xxxx"));
2110        assert_eq!(cfg.control_server_url.scheme(), "https");
2111        assert_eq!(
2112            cfg.control_server_url.host_str(),
2113            Some("control.example.com")
2114        );
2115        assert_eq!(
2116            cfg.requested_tags,
2117            vec!["tag:web".to_string(), "tag:prod".to_string()]
2118        );
2119        assert_eq!(cfg.wireguard_listen_port, Some(41641));
2120        assert!(cfg.run_web_client);
2121        assert_eq!(cfg.client_id.as_deref(), Some("cid"));
2122        assert_eq!(cfg.client_secret.as_deref(), Some("csecret"));
2123        assert_eq!(cfg.id_token.as_deref(), Some("idtok"));
2124        assert_eq!(cfg.audience.as_deref(), Some("aud"));
2125        // Tun unset ⇒ the default userspace netstack transport is preserved.
2126        assert_eq!(cfg.transport_mode, crate::TransportMode::Netstack);
2127    }
2128
2129    #[tokio::test]
2130    async fn build_config_maps_go_zero_value_defaults() {
2131        // The complement of `build_config_maps_every_go_field_onto_config`: a Go zero-value `Server`
2132        // (no fields set) must map to the matching `Config` *defaults* — the None/empty passthrough
2133        // direction of the §5 table — never a stale or invented value. (`ephemeral` has its own
2134        // Go-parity override, asserted separately in `build_config_forces_go_default_ephemeral`.)
2135        let cfg = Server::new().build_config().await.unwrap();
2136        assert!(cfg.requested_hostname.is_none(), "unset hostname ⇒ None");
2137        assert!(cfg.requested_tags.is_empty(), "no advertise_tags ⇒ empty");
2138        assert!(cfg.wireguard_listen_port.is_none(), "unset port ⇒ None");
2139        assert!(!cfg.run_web_client, "run_web_client defaults off");
2140        assert!(cfg.auth_key.is_none(), "unset auth_key ⇒ None");
2141        assert!(
2142            cfg.client_id.is_none() && cfg.client_secret.is_none(),
2143            "unset OAuth/WIF client fields ⇒ None"
2144        );
2145        assert!(
2146            cfg.id_token.is_none() && cfg.audience.is_none(),
2147            "unset id_token/audience ⇒ None"
2148        );
2149        // No `tun` requested ⇒ the default userspace netstack transport.
2150        assert_eq!(cfg.transport_mode, crate::TransportMode::Netstack);
2151    }
2152
2153    #[tokio::test]
2154    async fn build_config_forces_go_default_ephemeral() {
2155        // Go's zero-value Server is a *persistent* node, yet a bare Config::default() is ephemeral.
2156        // build_config must force Go's default by always writing config.ephemeral = self.ephemeral.
2157        assert!(
2158            Config::default().ephemeral,
2159            "precondition: a bare Config defaults to ephemeral=true"
2160        );
2161        let cfg = Server::new().build_config().await.unwrap();
2162        assert!(
2163            !cfg.ephemeral,
2164            "a default tsnet::Server maps to a non-ephemeral Config (Go parity)"
2165        );
2166
2167        // …and an explicit opt-in is honored.
2168        let mut srv = Server::new();
2169        srv.ephemeral = true;
2170        assert!(srv.build_config().await.unwrap().ephemeral);
2171    }
2172
2173    #[tokio::test]
2174    async fn build_config_none_control_url_keeps_engine_default() {
2175        let cfg = Server::new().build_config().await.unwrap();
2176        assert_eq!(cfg.control_server_url, Config::default().control_server_url);
2177    }
2178
2179    #[tokio::test]
2180    async fn build_config_rejects_a_bad_control_url() {
2181        let mut srv = Server::new();
2182        srv.control_url = Some("not a url".into());
2183        // `Config` isn't `Debug`, so match on the result rather than `unwrap_err()`.
2184        assert!(matches!(
2185            srv.build_config().await,
2186            Err(Error::InvalidControlUrl(_))
2187        ));
2188    }
2189
2190    #[tokio::test]
2191    async fn build_config_tun_selects_kernel_tun_transport() {
2192        let mut srv = Server::new();
2193        srv.tun = Some(TunSpec {
2194            name: Some("tailscale0".into()),
2195            mtu: Some(1280),
2196        });
2197        let cfg = srv.build_config().await.unwrap();
2198        assert_eq!(
2199            cfg.transport_mode,
2200            crate::TransportMode::Tun(crate::TunConfig {
2201                name: Some("tailscale0".into()),
2202                mtu: Some(1280),
2203            })
2204        );
2205    }
2206
2207    #[tokio::test]
2208    async fn build_config_runs_configure_hook_after_mapping() {
2209        // The escape hatch reaches fork-superset Config knobs that have no Go tsnet field, and runs
2210        // after the field mapping — so both the hook's writes and the mapped fields are present.
2211        let mut srv = Server::new();
2212        srv.hostname = Some("exit".into());
2213        srv.configure(|c| {
2214            c.advertise_exit_node = true;
2215            c.accept_routes = true;
2216        });
2217        let cfg = srv.build_config().await.unwrap();
2218        assert!(cfg.advertise_exit_node);
2219        assert!(cfg.accept_routes);
2220        assert_eq!(cfg.requested_hostname.as_deref(), Some("exit"));
2221    }
2222
2223    #[test]
2224    fn file_store_round_trips_on_disk() {
2225        // The on-disk StateStore (Go store.FileStore): write-then-read, and a fresh store over the
2226        // same dir still sees the value (identity survives a process restart).
2227        let dir = scratch_dir("filestore");
2228        let store = FileStore::new(dir.clone());
2229        assert!(
2230            store.read_state(STATE_KEY).unwrap().is_none(),
2231            "never-written ⇒ None (a missing file is not an error)"
2232        );
2233        store.write_state(STATE_KEY, b"identity-blob").unwrap();
2234        assert!(
2235            dir.join(STATE_FILE).exists(),
2236            "FileStore::new persists under dir/STATE_FILE"
2237        );
2238        assert_eq!(
2239            store.read_state(STATE_KEY).unwrap().as_deref(),
2240            Some(&b"identity-blob"[..])
2241        );
2242        assert_eq!(
2243            FileStore::new(dir.clone())
2244                .read_state(STATE_KEY)
2245                .unwrap()
2246                .as_deref(),
2247            Some(&b"identity-blob"[..]),
2248            "a fresh FileStore over the same dir reloads the persisted value"
2249        );
2250        std::fs::remove_dir_all(&dir).ok();
2251    }
2252
2253    #[test]
2254    fn file_store_at_writes_the_exact_path() {
2255        let dir = scratch_dir("filestore-at");
2256        let path = dir.join("custom.state");
2257        FileStore::at(path.clone())
2258            .write_state(STATE_KEY, b"x")
2259            .unwrap();
2260        assert!(
2261            path.exists(),
2262            "FileStore::at writes to the exact path given"
2263        );
2264        std::fs::remove_dir_all(&dir).ok();
2265    }
2266
2267    #[tokio::test]
2268    async fn dir_roots_the_netmap_cache_and_no_dir_leaves_it_off() {
2269        // `Dir` is what makes the cold-start netmap cache possible (Go gates its cache on the
2270        // profile having a writable directory). It lands in a subdirectory of its own so control
2271        // asking the node to drop its cache can never reach the identity state file next to it —
2272        // and a node with no `Dir` gets no cache at all, which is the conformant default.
2273        let dir = scratch_dir("netmap-cache-root");
2274
2275        let mut srv = Server::new();
2276        srv.dir = Some(dir.clone());
2277        let cfg = srv.build_config().await.unwrap();
2278        assert_eq!(
2279            cfg.netmap_cache_dir.as_deref(),
2280            Some(dir.join(NETMAP_CACHE_DIR).as_path()),
2281            "Dir roots the netmap cache at dir/NETMAP_CACHE_DIR"
2282        );
2283        assert_ne!(
2284            cfg.netmap_cache_dir.as_deref(),
2285            Some(dir.as_path()),
2286            "the cache must not share a directory with the identity state file"
2287        );
2288        assert!(
2289            !dir.join(NETMAP_CACHE_DIR).exists(),
2290            "configuring the cache must not create it: nothing is written until control grants \
2291             the attribute"
2292        );
2293
2294        let cfg = Server::new().build_config().await.unwrap();
2295        assert_eq!(
2296            cfg.netmap_cache_dir, None,
2297            "with no Dir there is no storage, so no netmap is ever cached"
2298        );
2299
2300        std::fs::remove_dir_all(&dir).ok();
2301    }
2302
2303    #[tokio::test]
2304    async fn dir_persists_node_identity_across_builds() {
2305        // The headline `Dir` shim over Config::key_state (§8): a Dir-rooted node writes the engine
2306        // key file once and reloads the SAME identity on the next boot, instead of re-minting.
2307        let dir = scratch_dir("dir-state-root");
2308
2309        let mut srv = Server::new();
2310        srv.dir = Some(dir.clone());
2311        let cfg1 = srv.build_config().await.unwrap();
2312        assert!(
2313            dir.join(STATE_FILE).exists(),
2314            "Dir persists identity to dir/STATE_FILE via the engine key-file format"
2315        );
2316
2317        let mut srv2 = Server::new();
2318        srv2.dir = Some(dir.clone());
2319        let cfg2 = srv2.build_config().await.unwrap();
2320        assert_eq!(
2321            serde_json::to_vec(&cfg1.key_state).unwrap(),
2322            serde_json::to_vec(&cfg2.key_state).unwrap(),
2323            "a Dir-rooted node reloads a stable identity rather than re-minting each boot"
2324        );
2325
2326        std::fs::remove_dir_all(&dir).ok();
2327    }
2328
2329    #[tokio::test]
2330    async fn custom_store_round_trips_identity_through_build_config() {
2331        // A custom StateStore is the pluggable backend (Go ipn.StateStore): build_config mints and
2332        // writes the identity blob under STATE_KEY, and a second server on the same store reloads it.
2333        let store: Arc<dyn StateStore> = Arc::new(MemStore::default());
2334
2335        let mut srv = Server::new();
2336        srv.store = Some(store.clone());
2337        let cfg1 = srv.build_config().await.unwrap();
2338        assert!(
2339            store.read_state(STATE_KEY).unwrap().is_some(),
2340            "the store now holds the minted identity blob"
2341        );
2342
2343        let mut srv2 = Server::new();
2344        srv2.store = Some(store.clone());
2345        let cfg2 = srv2.build_config().await.unwrap();
2346        assert_eq!(
2347            serde_json::to_vec(&cfg1.key_state).unwrap(),
2348            serde_json::to_vec(&cfg2.key_state).unwrap(),
2349            "a shared store yields a stable identity across servers"
2350        );
2351    }
2352
2353    #[tokio::test]
2354    async fn store_takes_precedence_over_dir() {
2355        // §8 resolution order: an explicit `store` wins over `dir`. The identity lands in the store
2356        // and the Dir key file is never written.
2357        let dir = scratch_dir("store-precedence");
2358        let store: Arc<dyn StateStore> = Arc::new(MemStore::default());
2359
2360        let mut srv = Server::new();
2361        srv.dir = Some(dir.clone());
2362        srv.store = Some(store.clone());
2363        srv.build_config().await.unwrap();
2364
2365        assert!(store.read_state(STATE_KEY).unwrap().is_some());
2366        assert!(
2367            !dir.join(STATE_FILE).exists(),
2368            "store must take precedence over dir (design §8)"
2369        );
2370
2371        std::fs::remove_dir_all(&dir).ok();
2372    }
2373
2374    // --- focused lifecycle tests: start / up / close over
2375    //     Device::new → wait_until_running → status → shutdown ---
2376    //
2377    // The *successful* round-trip needs a live control server, so it stays in integration/e2e. The
2378    // hermetic, unit-testable half of the lifecycle is its fail-fast and no-op behavior: the lazy
2379    // build shared by `start`/`up` runs `build_config` *before* `Device::new`, so a bad config is
2380    // reported without any network I/O; and `close` on a server whose `OnceCell` never initialized
2381    // is a clean no-op. (The field→`Config` mapping itself is covered by the tests above.)
2382
2383    #[tokio::test]
2384    async fn close_on_never_started_server_is_clean() {
2385        // Go `Close()` before any method call: the `OnceCell` is empty, so there is no `Device` to
2386        // `shutdown` and it reports success immediately (the `None => true` arm) — for both an
2387        // unbounded and a finite timeout.
2388        assert!(Server::new().close(None).await);
2389        assert!(Server::new().close(Some(Duration::from_millis(1))).await);
2390    }
2391
2392    #[tokio::test]
2393    async fn start_fails_fast_on_bad_config_before_touching_the_network() {
2394        // `start` maps the fields onto a `Config` and only then calls `Device::new`; a bad
2395        // `control_url` fails in that mapping, so it surfaces as the typed `InvalidControlUrl` with
2396        // no network I/O — never a hang, never a panic. (`Config`/`Status` aren't `Debug`, so match
2397        // on the result rather than `unwrap`.)
2398        let mut s = Server::new();
2399        s.control_url = Some("not a url".into());
2400        assert!(matches!(s.start().await, Err(Error::InvalidControlUrl(_))));
2401    }
2402
2403    #[tokio::test]
2404    async fn up_fails_fast_on_bad_config() {
2405        // `up` shares the same lazy-build entrypoint as `start`, so it fails fast on a bad config
2406        // instead of blocking in `wait_until_running`.
2407        let mut s = Server::new();
2408        s.control_url = Some("not a url".into());
2409        assert!(matches!(s.up(None).await, Err(Error::InvalidControlUrl(_))));
2410    }
2411
2412    #[tokio::test]
2413    async fn close_is_clean_after_a_failed_start() {
2414        // A failed lazy start leaves the `OnceCell` uninitialized (`get_or_try_init` stores nothing
2415        // on error), so a subsequent `close` still finds no `Device` and returns cleanly.
2416        let mut s = Server::new();
2417        s.control_url = Some("not a url".into());
2418        assert!(matches!(s.start().await, Err(Error::InvalidControlUrl(_))));
2419        assert!(s.close(None).await);
2420    }
2421
2422    // -----------------------------------------------------------------------------------------
2423    // Dial surface: Go-style `network`-string parsing + fail-fast, over
2424    // Device::dial / dial_tcp / dial_udp.
2425    //
2426    // A *successful* dial establishes an overlay connection, so it needs a live tailnet (kept in
2427    // integration/e2e). The hermetic, unit-testable half is the facade's own contribution: it
2428    // parses the `network` string BEFORE starting the device, so an unsupported network is a typed
2429    // `UnsupportedNetwork` with no network I/O, and a supported one only then proceeds to the lazy
2430    // start (which a bad `Config` still fails fast, never hangs). These assert that ordering — and
2431    // that the typed accessors `dial_tcp`/`dial_udp` are wired to the engine.
2432    // -----------------------------------------------------------------------------------------
2433
2434    #[tokio::test]
2435    async fn dial_rejects_unsupported_network_fail_fast() {
2436        // Every non-tsnet network string is a typed facade error, echoing the offending value, and
2437        // returns WITHOUT starting the device: a default `Server` has no control server, so if this
2438        // touched the network it would block — returning at all proves the parse is up front.
2439        for n in [
2440            "", "TCP", "tcp5", "sctp", "unix", "ip", "udplite", "tcp ", " udp",
2441        ] {
2442            match Server::new().dial(n, "host:80").await {
2443                Err(Error::UnsupportedNetwork { network }) => assert_eq!(network, n),
2444                Err(e) => panic!("dial({n:?}) should be UnsupportedNetwork, got Err({e:?})"),
2445                Ok(_) => panic!("dial({n:?}) should be UnsupportedNetwork, got Ok(conn)"),
2446            }
2447        }
2448    }
2449
2450    #[tokio::test]
2451    async fn dial_accepts_every_tsnet_network_then_reaches_lazy_start() {
2452        // The six supported networks all pass the facade parse and proceed to the lazy start; with a
2453        // bad control_url that start fails fast at `build_config` (InvalidControlUrl) — never a hang,
2454        // and never UnsupportedNetwork. This proves both that the tsnet set is accepted and that the
2455        // parse precedes the device build. (The `addr` is a realistic per-network example but is not
2456        // reached here — the bad config surfaces before any address resolution; `addr` parsing is
2457        // covered by the `dial` module's own `split_host_port` tests.)
2458        for (n, addr) in [
2459            ("tcp", "host:80"),
2460            ("tcp4", "1.2.3.4:80"),
2461            ("tcp6", "[2001:db8::1]:80"),
2462            ("udp", "host:53"),
2463            ("udp4", "1.2.3.4:53"),
2464            ("udp6", "[2001:db8::1]:53"),
2465        ] {
2466            let mut s = Server::new();
2467            s.control_url = Some("not a url".into());
2468            assert!(
2469                matches!(s.dial(n, addr).await, Err(Error::InvalidControlUrl(_))),
2470                "dial({n:?}, {addr:?}) with a bad control_url should fail fast at config build"
2471            );
2472        }
2473    }
2474
2475    #[tokio::test]
2476    async fn dial_parses_network_before_touching_config() {
2477        // Ordering: an unsupported network is reported even when the control_url is ALSO invalid,
2478        // because the network parse happens before the lazy start that would surface the bad URL.
2479        let mut s = Server::new();
2480        s.control_url = Some("not a url".into());
2481        match s.dial("sctp", "host:80").await {
2482            Err(Error::UnsupportedNetwork { network }) => assert_eq!(network, "sctp"),
2483            Err(e) => panic!("network parse must precede config build, got Err({e:?})"),
2484            Ok(_) => panic!("network parse must precede config build, got Ok(conn)"),
2485        }
2486    }
2487
2488    #[tokio::test]
2489    async fn dial_tcp_and_dial_udp_fail_fast_on_bad_config() {
2490        // The direct typed accessors (dial_tcp → TcpStream, dial_udp → ConnectedUdpSocket) go
2491        // straight to the lazy start, so a bad `Config` fails them fast too — exercising that both
2492        // are wired to the engine and share the fail-fast contract (never a hang).
2493        let mut s = Server::new();
2494        s.control_url = Some("not a url".into());
2495        assert!(matches!(
2496            s.dial_tcp("host:80").await,
2497            Err(Error::InvalidControlUrl(_))
2498        ));
2499
2500        let mut s = Server::new();
2501        s.control_url = Some("not a url".into());
2502        assert!(matches!(
2503            s.dial_udp("host:80").await,
2504            Err(Error::InvalidControlUrl(_))
2505        ));
2506    }
2507
2508    // -----------------------------------------------------------------------------------------
2509    // Loopback dual-credential + in-process LocalAPI HTTP server (the headline gap this closes).
2510    // These are hermetic: the HTTP framing/auth/routing are pure functions, and the server is
2511    // exercised end-to-end over a real `127.0.0.1` socket with a *mock* status backend — no live
2512    // `Device`/tailnet needed. (The successful in-process `Server::loopback` round-trip needs a
2513    // running control server and stays in integration/e2e.)
2514    // -----------------------------------------------------------------------------------------
2515
2516    /// A mock status backend returning a fixed JSON body — the stand-in for `Device::status`.
2517    fn mock_status(body: &'static [u8]) -> localapi::StatusFn {
2518        Arc::new(move || Box::pin(async move { Ok(body.to_vec()) }))
2519    }
2520
2521    /// Control's c2n `/remoteapi/localapi/*` proxy reaches this facade's LocalAPI through
2522    /// [`C2nLocalApi`], which serves the *same* route table as the loopback listener — Go's
2523    /// `handleC2NRemoteAPI` likewise builds its `localapi.Handler` over the same backend.
2524    ///
2525    /// The backend is injected into the slot [`C2nLocalApi::attach`] fills (that call needs a live
2526    /// `Arc<Device>`, which a hermetic test cannot build; all it does is wrap the device in
2527    /// [`device_status_fn`]). The proxy is authorized upstream, in `ts_control`'s c2n responder, so
2528    /// there is deliberately no credential or `Sec-Tailscale` gate here: the paths that reach
2529    /// `serve` have already passed the `Config::remote_config` check.
2530    #[tokio::test]
2531    async fn c2n_local_api_serves_the_same_routes_as_the_loopback_server() {
2532        use ts_control::LocalApi as _;
2533
2534        let hook = C2nLocalApi::default();
2535        assert!(hook.status.set(mock_status(br#"{"ok":true}"#)).is_ok());
2536
2537        let served = hook.serve("GET", "/localapi/v0/status", "").await;
2538        let (code, body) = parse_response(served.as_bytes()).expect("a full HTTP/1.1 response");
2539        assert_eq!(code, 200);
2540        assert_eq!(body, br#"{"ok":true}"#);
2541
2542        // The query string rides through the proxy (`ts_control` keeps it) and is ignored by the
2543        // route table, exactly as it is for a loopback request.
2544        let (code, body) = parse_response(
2545            hook.serve("GET", "/localapi/v0/status?peers=true", "")
2546                .await
2547                .as_bytes(),
2548        )
2549        .expect("a full HTTP/1.1 response");
2550        assert_eq!(code, 200);
2551        assert_eq!(body, br#"{"ok":true}"#);
2552
2553        // Everything else this facade does not serve is a LocalAPI 404 — distinct from the c2n
2554        // responder's own `400 unknown c2n path`, so control can tell "no such LocalAPI endpoint"
2555        // apart from "no such c2n route".
2556        let (code, _) =
2557            parse_response(hook.serve("GET", "/localapi/v0/prefs", "").await.as_bytes())
2558                .expect("a full HTTP/1.1 response");
2559        assert_eq!(code, 404);
2560        let (code, _) = parse_response(
2561            hook.serve("POST", "/localapi/v0/status", "")
2562                .await
2563                .as_bytes(),
2564        )
2565        .expect("a full HTTP/1.1 response");
2566        assert_eq!(code, 404);
2567    }
2568
2569    /// Before [`C2nLocalApi::attach`] there is no backend to route to at all, so the proxy answers
2570    /// `503` instead of claiming the endpoint does not exist. (A device reclaimed *after* attach is
2571    /// a backend failure and surfaces as the LocalAPI's own `500`, like any loopback request.)
2572    #[tokio::test]
2573    async fn c2n_local_api_reports_503_before_it_is_attached() {
2574        use ts_control::LocalApi as _;
2575
2576        let hook = C2nLocalApi::default();
2577        let (code, body) = parse_response(
2578            hook.serve("GET", "/localapi/v0/status", "")
2579                .await
2580                .as_bytes(),
2581        )
2582        .expect("a full HTTP/1.1 response");
2583        assert_eq!(code, 503);
2584        assert_eq!(body, b"device unavailable");
2585    }
2586
2587    #[test]
2588    fn gen_cred_is_32_lowercase_hex() {
2589        let cred = gen_cred();
2590        assert_eq!(cred.len(), 32, "16 random bytes → 32 hex chars (Go parity)");
2591        assert!(
2592            cred.chars()
2593                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
2594        );
2595        assert_ne!(gen_cred(), gen_cred(), "credentials are random per call");
2596    }
2597
2598    #[test]
2599    fn find_subslice_locates_header_terminator() {
2600        assert_eq!(find_subslice(b"ab\r\n\r\ncd", b"\r\n\r\n"), Some(2));
2601        assert_eq!(find_subslice(b"no terminator", b"\r\n\r\n"), None);
2602        assert_eq!(find_subslice(b"", b"\r\n\r\n"), None);
2603    }
2604
2605    #[test]
2606    fn parse_response_extracts_code_and_body() {
2607        let (code, body) =
2608            parse_response(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi").unwrap();
2609        assert_eq!(code, 200);
2610        assert_eq!(body, b"hi");
2611        assert_eq!(parse_response(b"garbage without terminator"), None);
2612    }
2613
2614    #[test]
2615    fn parse_head_reads_method_target_auth_and_sec_tailscale() {
2616        // "user:pass" base64 = dXNlcjpwYXNz
2617        let head = b"GET /localapi/v0/status HTTP/1.1\r\nHost: x\r\nSec-Tailscale: localapi\r\nAuthorization: Basic dXNlcjpwYXNz\r\nAccept: */*";
2618        let (method, target, password, sec_tailscale) = localapi::parse_head(head).unwrap();
2619        assert_eq!(method, "GET");
2620        assert_eq!(target, "/localapi/v0/status");
2621        assert_eq!(password.as_deref(), Some("pass"));
2622        assert_eq!(
2623            sec_tailscale.as_deref(),
2624            Some("localapi"),
2625            "captures the anti-rebinding header"
2626        );
2627
2628        // A head *without* the header parses fine with `sec_tailscale = None` (the handler then 403s).
2629        let no_hdr =
2630            b"GET /localapi/v0/status HTTP/1.1\r\nHost: x\r\nAuthorization: Basic dXNlcjpwYXNz";
2631        let (_, _, _, sec_tailscale) = localapi::parse_head(no_hdr).unwrap();
2632        assert_eq!(sec_tailscale, None);
2633    }
2634
2635    #[test]
2636    fn parse_head_rejects_malformed_request_line() {
2637        assert!(localapi::parse_head(b"GET-only-one-token").is_none());
2638        assert!(
2639            localapi::parse_head(b"GET /x").is_none(),
2640            "needs a version token"
2641        );
2642    }
2643
2644    #[test]
2645    fn basic_auth_password_ignores_username() {
2646        // Go authenticates on the password only; the username is ignored.
2647        // base64("anyuser:the-cred") and base64(":the-cred") both yield "the-cred".
2648        let with_user = STANDARD.encode("anyuser:the-cred");
2649        let no_user = STANDARD.encode(":the-cred");
2650        assert_eq!(
2651            localapi::basic_auth_password(&format!("Basic {with_user}")).as_deref(),
2652            Some("the-cred")
2653        );
2654        assert_eq!(
2655            localapi::basic_auth_password(&format!("Basic {no_user}")).as_deref(),
2656            Some("the-cred")
2657        );
2658        // The auth scheme is case-insensitive (RFC 7617 / Go `EqualFold`).
2659        assert_eq!(
2660            localapi::basic_auth_password(&format!("bAsIc {with_user}")).as_deref(),
2661            Some("the-cred")
2662        );
2663        // A non-Basic scheme, no scheme, or garbage base64 is not accepted.
2664        assert!(localapi::basic_auth_password("Bearer xyz").is_none());
2665        assert!(localapi::basic_auth_password("Basic !!!not-base64").is_none());
2666        assert!(localapi::basic_auth_password("no-space-token").is_none());
2667    }
2668
2669    #[test]
2670    fn cred_ok_matches_only_exact_credentials() {
2671        assert!(localapi::cred_ok("abc123", "abc123"));
2672        assert!(!localapi::cred_ok("abc123", "abc124"));
2673        assert!(
2674            !localapi::cred_ok("abc", "abc123"),
2675            "length mismatch is a mismatch"
2676        );
2677        assert!(!localapi::cred_ok("", "x"));
2678    }
2679
2680    #[test]
2681    fn status_json_serializes_status_snapshot() {
2682        // Build a snapshot and assert the emitted JSON reflects it (the fork's `Status` is not a
2683        // serde type, so `status_json` builds the object by hand — this pins that mapping).
2684        use crate::StableNodeId;
2685        let node = StatusNode {
2686            stable_id: StableNodeId("nabc123".to_string()),
2687            display_name: "web.tail0.ts.net".to_string(),
2688            ipv4: "100.64.0.1".parse().unwrap(),
2689            ipv6: "fd7a:115c:a1e0::1".parse().unwrap(),
2690            tailscale_ips: vec![
2691                "100.64.0.1".parse().unwrap(),
2692                "fd7a:115c:a1e0::1".parse().unwrap(),
2693            ],
2694            online: Some(true),
2695            last_seen: None,
2696            allowed_routes: vec![],
2697            is_exit_node: false,
2698            cur_addr: None,
2699            relay: Some("nyc".to_string()),
2700            ssh_host_keys: vec![],
2701        };
2702        let status = Status {
2703            self_node: Some(node),
2704            peers: vec![],
2705            active_exit_node: None,
2706            magic_dns_suffix: Some("tail0.ts.net".to_string()),
2707        };
2708        let bytes = status_json(&status);
2709        let v: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
2710        assert_eq!(v["self"]["stable_id"], "nabc123");
2711        assert_eq!(v["self"]["display_name"], "web.tail0.ts.net");
2712        assert_eq!(v["self"]["ipv4"], "100.64.0.1");
2713        assert_eq!(v["self"]["online"], true);
2714        assert_eq!(v["self"]["relay"], "nyc");
2715        assert_eq!(v["magic_dns_suffix"], "tail0.ts.net");
2716        assert!(v["peers"].as_array().unwrap().is_empty());
2717        assert!(v["active_exit_node"].is_null());
2718    }
2719
2720    #[tokio::test]
2721    async fn localapi_server_authenticates_and_routes_over_real_socket() {
2722        // Bind the real in-process LocalAPI server on 127.0.0.1 with a mock status backend, then
2723        // drive it with the dependency-free client — exercising accept → parse → auth → route →
2724        // respond end-to-end.
2725        let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
2726            .await
2727            .unwrap();
2728        let addr = listener.local_addr().unwrap();
2729        let cred = "s3cr3t-cred".to_string();
2730        let task = tokio::spawn(localapi::serve(
2731            listener,
2732            cred.clone(),
2733            mock_status(br#"{"ok":true}"#),
2734        ));
2735
2736        // Correct credential → 200 + the backend's JSON body.
2737        let (code, body) = localapi_client_get(addr, &cred, "/localapi/v0/status")
2738            .await
2739            .unwrap();
2740        assert_eq!(code, 200);
2741        assert_eq!(body, br#"{"ok":true}"#);
2742
2743        // Authenticated but unknown path → 404.
2744        let (code, _) = localapi_client_get(addr, &cred, "/localapi/v0/nope")
2745            .await
2746            .unwrap();
2747        assert_eq!(code, 404);
2748
2749        // Wrong credential (the client still sends the Sec-Tailscale header) → 401.
2750        let (code, _) = localapi_client_get(addr, "wrong-cred", "/localapi/v0/status")
2751            .await
2752            .unwrap();
2753        assert_eq!(code, 401);
2754
2755        // Missing Authorization header, but *with* the required Sec-Tailscale header → 401.
2756        {
2757            use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
2758            let mut sock = TcpStream::connect(addr).await.unwrap();
2759            sock.write_all(
2760                b"GET /localapi/v0/status HTTP/1.1\r\nHost: x\r\nSec-Tailscale: localapi\r\nConnection: close\r\n\r\n",
2761            )
2762            .await
2763            .unwrap();
2764            let mut resp = Vec::new();
2765            sock.read_to_end(&mut resp).await.unwrap();
2766            assert_eq!(parse_response(&resp).unwrap().0, 401);
2767        }
2768
2769        // Anti-DNS-rebinding: a *valid* credential but NO `Sec-Tailscale` header is rejected 403,
2770        // before auth is even considered (Go's browser-rebinding guard).
2771        {
2772            use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
2773            let auth = STANDARD.encode(format!(":{cred}"));
2774            let mut sock = TcpStream::connect(addr).await.unwrap();
2775            sock.write_all(
2776                format!(
2777                    "GET /localapi/v0/status HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {auth}\r\nConnection: close\r\n\r\n"
2778                )
2779                .as_bytes(),
2780            )
2781            .await
2782            .unwrap();
2783            let mut resp = Vec::new();
2784            sock.read_to_end(&mut resp).await.unwrap();
2785            assert_eq!(
2786                parse_response(&resp).unwrap().0,
2787                403,
2788                "no Sec-Tailscale header → 403 even with a valid credential"
2789            );
2790        }
2791
2792        task.abort();
2793    }
2794
2795    #[tokio::test]
2796    async fn local_client_round_trips_through_the_localapi_server() {
2797        // `LocalClient` is what `Server::local_client()` hands back: point one at a running server
2798        // and assert its accessors + `status()`/`get()` round-trip through real HTTP.
2799        let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
2800            .await
2801            .unwrap();
2802        let addr = listener.local_addr().unwrap();
2803        let cred = "local-api-cred".to_string();
2804        let task = tokio::spawn(localapi::serve(
2805            listener,
2806            cred.clone(),
2807            mock_status(br#"{"self":null,"peers":[]}"#),
2808        ));
2809
2810        let client = LocalClient {
2811            address: addr,
2812            cred: cred.clone(),
2813        };
2814        assert_eq!(client.address(), addr);
2815        assert_eq!(client.credential(), cred);
2816
2817        let body = client.status().await.unwrap();
2818        assert_eq!(body, br#"{"self":null,"peers":[]}"#);
2819
2820        let (code, _) = client.get("/localapi/v0/status").await.unwrap();
2821        assert_eq!(code, 200);
2822
2823        // A wrong-credential client sees the 401 surfaced as an error from `status()`.
2824        let bad = LocalClient {
2825            address: addr,
2826            cred: "nope".to_string(),
2827        };
2828        assert!(matches!(bad.status().await, Err(Error::Loopback(_))));
2829
2830        task.abort();
2831    }
2832
2833    #[test]
2834    fn loopback_result_carries_both_distinct_credentials() {
2835        // Shape assertion: the `Loopback` result exposes both Go credentials + both addresses, and
2836        // `Clone`/`Debug` derive (so it can be logged/stored). Distinctness of the two creds is the
2837        // point of this task.
2838        let lb = Loopback {
2839            address: "127.0.0.1:1080".parse().unwrap(),
2840            proxy_cred: "proxy".to_string(),
2841            local_api_address: "127.0.0.1:1081".parse().unwrap(),
2842            local_api_cred: "localapi".to_string(),
2843        };
2844        let cloned = lb.clone();
2845        assert_ne!(cloned.proxy_cred, cloned.local_api_cred);
2846        assert_ne!(cloned.address, cloned.local_api_address);
2847        assert!(format!("{cloned:?}").contains("local_api_cred"));
2848    }
2849}