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