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 mut config = self.build_config().await?;
712
713        // Install this facade's LocalAPI as the target of control's c2n `/remoteapi/localapi/*`
714        // proxy, so the route table the loopback listener serves is the one control reaches. The
715        // hook has to be on the `Config` that builds the device, but its backend needs the built
716        // device — so it goes in empty and is attached the instant `Device::new` returns. Registering
717        // it is not the same as enabling it: the proxy stays refused until the local machine opts in
718        // with `Config::remote_config` (Go `Prefs.RemoteConfig`), which the `configure` hook above
719        // may have just set.
720        let c2n_local_api = Arc::new(C2nLocalApi::default());
721        config.c2n_local_api = Some(c2n_local_api.clone());
722
723        let device = Arc::new(Device::new(&config, self.auth_key.clone()).await?);
724        c2n_local_api.attach(&device);
725        Ok(device)
726    }
727
728    /// Get the wrapped device (as the shared [`Arc`]), starting it on first call (Go's lazy
729    /// `Start`).
730    async fn started(&self) -> Result<&Arc<Device>, Error> {
731        self.device.get_or_try_init(|| self.build_and_start()).await
732    }
733
734    /// Connect to the tailnet (Go `Start`). Idempotent — subsequent calls are no-ops.
735    pub async fn start(&self) -> Result<(), Error> {
736        self.started().await.map(|_| ())
737    }
738
739    /// Connect and wait until the node is `Running`, returning its status (Go `Up`). `timeout`
740    /// `None` waits forever.
741    pub async fn up(&self, timeout: Option<Duration>) -> Result<Status, Error> {
742        let dev = self.started().await?;
743        dev.wait_until_running(timeout).await?;
744        Ok(dev.status().await?)
745    }
746
747    /// The wrapped [`Device`] (Go `LocalClient`-and-more), starting it if needed. The escape hatch to
748    /// the full engine surface (`whois`, `ping`, `set_*` prefs, taildrop, TKA, …).
749    pub async fn device(&self) -> Result<&Device, Error> {
750        Ok(&**self.started().await?)
751    }
752
753    /// Dial a tailnet address over TCP or UDP (Go `Dial(ctx, network, address)`), returning the
754    /// tsnet-shaped [`DialConn`](crate::DialConn) whose arm matches the transport.
755    ///
756    /// `network` is one of `"tcp"`, `"tcp4"`, `"tcp6"`, `"udp"`, `"udp4"`, `"udp6"`; `addr` is a
757    /// `host:port` string — a MagicDNS name or an IP literal (bracketed for IPv6,
758    /// `[2001:db8::1]:443`). The network string is parsed at the facade boundary, so an unsupported
759    /// network is a typed [`Error::UnsupportedNetwork`] reported **before** the device is started
760    /// (fail-fast, no network I/O). For the common case, [`Server::dial_tcp`] / [`Server::dial_udp`]
761    /// hand back the transport's stream / socket directly.
762    ///
763    /// Routing: the unsuffixed `"tcp"`/`"udp"` forward to the transport-specific typed accessors
764    /// [`Device::dial_tcp`](crate::Device::dial_tcp) / [`Device::dial_udp`](crate::Device::dial_udp)
765    /// (for `Family::Any` these *are* [`Device::dial`](crate::Device::dial)'s arms); the family-pinned
766    /// `…4`/`…6` forms forward to [`Device::dial`], which enforces the v4/v6 constraint that the
767    /// family-agnostic sub-calls do not.
768    pub async fn dial(&self, network: &str, addr: &str) -> Result<crate::DialConn, Error> {
769        // Parse the Go-style network string first: an unknown network is a typed facade error that
770        // never starts the device (Go's `Dial` also rejects unknown networks up front).
771        let net = parse_network(network).map_err(|_| Error::UnsupportedNetwork {
772            network: network.to_string(),
773        })?;
774        let dev = self.started().await?;
775        Ok(match (net.transport, net.family) {
776            // Unsuffixed tcp/udp: the family follows the resolved address, so these are exactly the
777            // transport-specific typed Device calls — route over them and wrap into `DialConn`.
778            (Transport::Tcp, Family::Any) => crate::DialConn::Tcp(dev.dial_tcp(addr).await?),
779            (Transport::Udp, Family::Any) => crate::DialConn::Udp(dev.dial_udp(addr).await?),
780            // Family-pinned (tcp4/tcp6/udp4/udp6): forward the whole network string so the engine
781            // enforces the v4/v6 family that the family-agnostic sub-calls above would ignore.
782            _ => dev.dial(network, addr).await?,
783        })
784    }
785
786    /// Dial a tailnet TCP address, yielding the overlay stream directly — the common case of
787    /// [`Server::dial`] for `"tcp"`. This is the building block for HTTP-over-tailnet: a `hyper`/
788    /// `reqwest` connector dials with `dial_tcp(&format!("{host}:{port}"))`, mirroring Go
789    /// `tsnet.Server.HTTPClient`.
790    pub async fn dial_tcp(&self, addr: &str) -> Result<netstack::TcpStream, Error> {
791        Ok(self.started().await?.dial_tcp(addr).await?)
792    }
793
794    /// Dial a tailnet UDP address, yielding the connected overlay socket directly — the `"udp"`
795    /// sibling of [`Server::dial_tcp`] and the common case of [`Server::dial`] for `"udp"`.
796    ///
797    /// Returns a [`ConnectedUdpSocket`](crate::ConnectedUdpSocket) (`send`/`recv` against a fixed
798    /// peer) — the connected-`net.Conn` shape Go's `Dial("udp", …)` returns, as opposed to
799    /// [`Server::listen_packet`]'s unconnected packet socket.
800    pub async fn dial_udp(&self, addr: &str) -> Result<crate::ConnectedUdpSocket, Error> {
801        Ok(self.started().await?.dial_udp(addr).await?)
802    }
803
804    /// Listen for inbound TCP on the tailnet (Go `Listen`).
805    ///
806    /// `network` is a **stream** network — `"tcp"`, `"tcp4"`, or `"tcp6"`; a `"udp*"` or unknown
807    /// value is [`Error::InvalidNetwork`] (Go's `net.Listen` likewise rejects a packet network).
808    /// `addr` may be a bare `":80"` — the wildcard host, `0.0.0.0` for `tcp`/`tcp4` and `[::]` for
809    /// `tcp6` — or a full `"100.x.y.z:80"`. Returns the std::net-style overlay
810    /// [`netstack::TcpListener`] (backed by `ts_netstack_smoltcp`): `.accept()` it for inbound
811    /// streams, exactly as Go `.Accept()`s the returned `net.Listener`.
812    pub async fn listen(&self, network: &str, addr: &str) -> Result<netstack::TcpListener, Error> {
813        let net = parse_network(network)?;
814        if net.transport != Transport::Tcp {
815            return Err(Error::InvalidNetwork {
816                network: network.to_string(),
817            });
818        }
819        let sa = parse_listen_addr(addr, net.family)?;
820        Ok(self.started().await?.tcp_listen(sa).await?)
821    }
822
823    /// Listen for inbound UDP on the tailnet (Go `ListenPacket`).
824    ///
825    /// `network` is a **packet** network — `"udp"`, `"udp4"`, or `"udp6"`; a `"tcp*"` or unknown
826    /// value is [`Error::InvalidNetwork`]. `addr` is a `host:port` **IP literal** (a bare `":0"` is
827    /// filled with the family's wildcard host); like Go's `ListenPacket`, a MagicDNS name is **not**
828    /// accepted — and neither does [`Server::listen`] accept one (both bind by IP literal; only
829    /// [`Server::dial`] resolves MagicDNS names). An unspecified host binds this node's tailnet
830    /// address. Returns the std::net-style overlay [`netstack::UdpSocket`] (backed by
831    /// `ts_netstack_smoltcp`), a `net.PacketConn` analog (`recv_from`/`send_to`).
832    pub async fn listen_packet(
833        &self,
834        network: &str,
835        addr: &str,
836    ) -> Result<netstack::UdpSocket, Error> {
837        let net = parse_network(network)?;
838        if net.transport != Transport::Udp {
839            return Err(Error::InvalidNetwork {
840                network: network.to_string(),
841            });
842        }
843        // Fill a bare `":0"` with the family wildcard, then let the engine do the family-aware bind
844        // (unspecified host ⇒ this node's tailnet address, IPv6 gating, name rejection).
845        let addr = normalize_listen_addr(addr, net.family);
846        Ok(self.started().await?.listen_packet(network, &addr).await?)
847    }
848
849    /// Expose a tailnet TLS service to the public internet via Tailscale Funnel (Go `ListenFunnel`).
850    ///
851    /// A thin wrapper over [`Device::listen_funnel`](crate::Device::listen_funnel): it lazily starts
852    /// the node, converts [`FunnelOptions`] into the engine's [`ts_control::FunnelOptions`] serve
853    /// config, and hands `cfg` (the MagicDNS name + tailnet port) straight through. On success it
854    /// yields the engine's [`FunnelAcceptedReceiver`](ts_runtime::funnel::FunnelAcceptedReceiver).
855    ///
856    /// Errors are a [`ListenFunnelError`], which keeps a **lifecycle/start** failure
857    /// ([`Start`](ListenFunnelError::Start)) distinct from the engine's typed Funnel error
858    /// ([`Funnel`](ListenFunnelError::Funnel)): a node that never registered surfaces as a startup
859    /// failure carrying its real cause, never misdiagnosed as a Funnel access denial.
860    pub async fn listen_funnel(
861        &self,
862        cfg: &crate::ServeConfig,
863        opts: FunnelOptions,
864    ) -> Result<ts_runtime::funnel::FunnelAcceptedReceiver, ListenFunnelError> {
865        // `?` maps a lazy-start failure (`Error`) to `ListenFunnelError::Start`, preserving the
866        // real cause — it is NOT flattened to `FunnelError::NotAllowed` (an access denial).
867        let dev = self.started().await?;
868        // `?` maps the engine's typed `FunnelError` to `ListenFunnelError::Funnel`, unchanged.
869        Ok(dev.listen_funnel(cfg, opts.into()).await?)
870    }
871
872    /// Host a Tailscale VIP service (Go `ListenService`), returning a Go-shaped [`ServiceListener`]
873    /// (overlay listener + resolved FQDN).
874    ///
875    /// A thin wrapper over [`Device::listen_service`](crate::Device::listen_service): it lazily
876    /// starts the node, passes `name` + the [`ServiceMode`] serve config straight through, and pairs
877    /// the returned overlay listener with the node's resolved service FQDN.
878    ///
879    /// Errors are a [`ListenServiceError`], which keeps a **lifecycle/start** failure
880    /// ([`Start`](ListenServiceError::Start)) distinct from the engine's typed [`ServiceError`]
881    /// ([`Service`](ListenServiceError::Service), whose `UntaggedHost` == Go
882    /// `ErrUntaggedServiceHost`): a node that never registered surfaces as a startup failure
883    /// carrying its real cause, never misdiagnosed as a listener bind failure.
884    pub async fn listen_service(
885        &self,
886        name: &str,
887        mode: ServiceMode,
888    ) -> Result<ServiceListener, ListenServiceError> {
889        // `?` maps a lazy-start failure (`Error`) to `ListenServiceError::Start`, preserving the
890        // real cause — it is NOT flattened to `ServiceError::Listen` (a bind failure).
891        let dev = self.started().await?;
892        let inner = dev.listen_service(name, mode).await?;
893        let fqdn = dev
894            .self_node()
895            .await
896            .map(|n| n.fqdn(false))
897            .unwrap_or_default();
898        Ok(ServiceListener { inner, fqdn })
899    }
900
901    /// Start (once) the loopback surface and return its addresses + both credentials (Go
902    /// `Loopback() (addr, proxyCred, localAPICred, err)`).
903    ///
904    /// Brings up two things, living for the [`Server`]'s lifetime (torn down by [`Server::close`]):
905    ///
906    /// * a **SOCKS5 proxy** onto the tailnet (the engine's [`Device::loopback`](crate::Device::loopback)),
907    ///   authenticated with [`Loopback::proxy_cred`]; and
908    /// * an **in-process LocalAPI HTTP server** authenticated with the separate
909    ///   [`Loopback::local_api_cred`] (HTTP Basic-auth password), serving `GET
910    ///   /localapi/v0/status`.
911    ///
912    /// Idempotent: repeated calls return the same addresses and credentials. See [`Loopback`] for
913    /// the one honest delta from Go (two `127.0.0.1` listeners rather than one muxed listener).
914    pub async fn loopback(&self) -> Result<Loopback, Error> {
915        let rt = self.ensure_loopback().await?;
916        Ok(Loopback {
917            address: rt.socks_address,
918            proxy_cred: rt.proxy_cred.clone(),
919            local_api_address: rt.local_api_address,
920            local_api_cred: rt.local_api_cred.clone(),
921        })
922    }
923
924    /// A [`LocalClient`] for this node's in-process LocalAPI HTTP server (Go
925    /// `tsnet.Server.LocalClient()`), starting the loopback surface if needed.
926    ///
927    /// The returned client authenticates with the loopback's `local_api_cred` and speaks plain HTTP
928    /// to `127.0.0.1` (no `hyper` dependency). For typed status prefer the in-process [`Server::status`];
929    /// the `LocalClient` is the Go-shaped path that actually round-trips through the LocalAPI server.
930    pub async fn local_client(&self) -> Result<LocalClient, Error> {
931        let rt = self.ensure_loopback().await?;
932        Ok(LocalClient {
933            address: rt.local_api_address,
934            cred: rt.local_api_cred.clone(),
935        })
936    }
937
938    /// Start (once) and cache the loopback runtime: the engine SOCKS5 proxy plus a facade-owned
939    /// in-process LocalAPI HTTP server on its own `127.0.0.1` listener.
940    async fn ensure_loopback(&self) -> Result<&LoopbackRt, Error> {
941        // Capture the device Arc first (outside the closure) so we can downgrade it to a `Weak` for
942        // the LocalAPI backend — the server must not keep the device alive past `Server::close`.
943        let device = self.started().await?.clone();
944        self.loopback_rt
945            .get_or_try_init(|| build_loopback_rt(device))
946            .await
947    }
948
949    /// A `hyper` HTTP client whose every request egresses over the tailnet (Go
950    /// `tsnet.Server.HTTPClient()`), built over [`Device::http_connector`](crate::Device::http_connector).
951    ///
952    /// The exact analog of Go's `&http.Client{Transport: &http.Transport{DialContext: s.Dial}}`: a
953    /// pooled [`hyper_util`] client wired to the tailnet connector, with TLS/redirects/pooling left
954    /// to the client (the connector is plaintext — see [`TailnetConnector`](crate::http::TailnetConnector)
955    /// for wrapping it in TLS). `B` is your request body type (e.g. `String`, or
956    /// `http_body_util::Full<Bytes>`).
957    ///
958    /// Available only with the **`hyper`** crate feature (as in the engine).
959    #[cfg(feature = "hyper")]
960    pub async fn http_client<B>(
961        &self,
962    ) -> Result<hyper_util::client::legacy::Client<crate::http::TailnetConnector, B>, Error>
963    where
964        B: hyper::body::Body + Send + 'static,
965        B::Data: Send,
966    {
967        let connector = self.started().await?.http_connector().await?;
968        Ok(
969            hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
970                .build(connector),
971        )
972    }
973
974    /// This node's tailnet addresses (Go `TailscaleIPs`). The tuple shape mirrors
975    /// [`Device::tailscale_ips`](crate::Device::tailscale_ips) verbatim (v6 is `None` on an
976    /// IPv4-only tailnet).
977    pub async fn tailscale_ips(
978        &self,
979    ) -> Result<(std::net::Ipv4Addr, Option<std::net::Ipv6Addr>), Error> {
980        Ok(self.started().await?.tailscale_ips().await?)
981    }
982
983    /// Build a [`TlsAcceptor`](crate::TlsAcceptor) that terminates TLS for `cfg.name` on the tailnet
984    /// overlay using this node's own certificate (Go `tsnet.Server.ListenTLS`'s cert path).
985    ///
986    /// A thin delegation to [`Device::listen_tls`](crate::Device::listen_tls). The serve config is
987    /// validated at the facade boundary **first** — a non-tailnet `cfg.name` or a zero `cfg.port` is a
988    /// typed [`ts_control::CertError`] returned *before* the lazy device start, so a misconfiguration
989    /// never touches the network (fail-fast, exactly as [`Server::dial`]/[`Server::listen`] reject a
990    /// bad network/address up front). The certificate is then acquired through
991    /// [`ts_control::tls`] via the node's ACME-aware cert path.
992    ///
993    /// **`acme` feature.** Issuance is fail-closed: with the **`acme`** feature this issues a real
994    /// Let's Encrypt certificate (DNS-01, published via the node's `set-dns` RPC — SaaS-only); without
995    /// it (the default) it surfaces [`ts_control::CertError::Unimplemented`] rather than ever serving a
996    /// self-signed cert or downgrading to plaintext. Either way the acceptor is **ring-only**
997    /// ([`ts_control::tls_acceptor`] pins the `ring` provider — no aws-lc/openssl).
998    ///
999    /// This keeps the fork's typed [`ts_control::CertError`] (matching [`Device::listen_tls`]), not the
1000    /// unified lifecycle [`Error`] — the cert path is a specialized, typed surface (design doc §7).
1001    /// Like Go's `ListenTLS`, terminate accepted overlay streams (from a [`Server::listen`] listener)
1002    /// with [`ts_control::accept_tls`], reusing the one acceptor across connections.
1003    pub async fn listen_tls(
1004        &self,
1005        cfg: &crate::ServeConfig,
1006    ) -> Result<crate::TlsAcceptor, ts_control::CertError> {
1007        // Fail-fast at the facade boundary: reject a bad serve config (non-tailnet name / zero port)
1008        // before the lazy device start, so a misconfiguration never reaches the network. The wrapped
1009        // `Device::listen_tls` validates again internally (idempotent) — this only surfaces the
1010        // identical typed error earlier, matching the dial/listen "reject before starting" contract.
1011        cfg.validate()?;
1012        self.started()
1013            .await
1014            .map_err(start_failed_cert)?
1015            .listen_tls(cfg)
1016            .await
1017    }
1018
1019    /// Issue a real Let's Encrypt certificate for this node's MagicDNS `name` and return the **PEM
1020    /// pair** `(cert_chain_pem, key_pem)` — the analog of Go `LocalClient().CertPair` /
1021    /// `CertPairWithValidity`, for writing an on-disk `.crt` + `.key`. A thin delegation to
1022    /// [`Device::cert_pair`](crate::Device::cert_pair); **`acme` feature only** (the wrapped engine
1023    /// method is itself `acme`-gated).
1024    ///
1025    /// The `name` is checked at the facade boundary first — a non-tailnet (`*.ts.net`) name is
1026    /// [`ts_control::CertError::NotTailnetName`] before any device start (anti-leak: this fork never
1027    /// mints certs for off-tailnet names). `min_validity` is accepted for Go signature parity but does
1028    /// not change behavior: this fork keeps no cert cache and always issues fresh, so a freshly issued
1029    /// (full-lifetime) cert satisfies any `min_validity` (see [`Device::cert_pair`]). The second tuple
1030    /// element is **secret key material** — persist it to a `0600` file and never log it. Fail-closed
1031    /// and ring-only, like [`Server::listen_tls`].
1032    #[cfg(feature = "acme")]
1033    pub async fn cert_pair(
1034        &self,
1035        name: &str,
1036        min_validity: Option<Duration>,
1037    ) -> Result<(String, String), ts_control::CertError> {
1038        // Anti-leak name check up front (matches the wrapped engine method), before the device start.
1039        if !ts_control::is_tailnet_name(name) {
1040            return Err(ts_control::CertError::NotTailnetName(name.to_string()));
1041        }
1042        self.started()
1043            .await
1044            .map_err(start_failed_cert)?
1045            .cert_pair(name, min_validity)
1046            .await
1047    }
1048
1049    /// The DNS names this node may obtain TLS certificates for (Go `tsnet.Server.CertDomains`).
1050    ///
1051    /// A thin delegation to [`Device::cert_domains`](crate::Device::cert_domains): the `CertDomains`
1052    /// control pushed in the netmap DNS config — the names to request a cert for via
1053    /// [`Server::listen_tls`] (or, with `acme`, `cert_pair`). Empty before the first netmap, or when
1054    /// control granted none (Go returns a clone of `nm.DNS.CertDomains`). Unlike the cert-*issuance*
1055    /// calls this only reads netmap state, so it returns the unified lifecycle [`Error`].
1056    pub async fn cert_domains(&self) -> Result<Vec<String>, Error> {
1057        Ok(self.started().await?.cert_domains().await?)
1058    }
1059
1060    // --- folded LocalClient surface (Go `LocalClient().X`); the rest live on `Device`) ---
1061
1062    /// Node + peer status (Go `LocalClient().Status`).
1063    pub async fn status(&self) -> Result<Status, Error> {
1064        Ok(self.started().await?.status().await?)
1065    }
1066
1067    /// Log this node out (Go `LocalClient().Logout`).
1068    pub async fn logout(&self) -> Result<(), Error> {
1069        self.started().await?.logout().await?;
1070        Ok(())
1071    }
1072
1073    /// Stop the server (Go `Close`). Consumes `self`; returns whether shutdown completed within
1074    /// `timeout` (`None` = wait forever). A never-started server closes cleanly.
1075    ///
1076    /// Tears down the loopback surface first (aborting the SOCKS5 and LocalAPI accept loops), then
1077    /// gracefully shuts the wrapped device down. The LocalAPI server holds only a [`Weak`] to the
1078    /// device, so this reclaims the sole strong reference for the graceful shutdown.
1079    pub async fn close(self, timeout: Option<Duration>) -> bool {
1080        // Abort the SOCKS5 + LocalAPI accept loops (and drop their `Weak` device refs) before
1081        // reclaiming the device by value.
1082        drop(self.loopback_rt);
1083        match self.device.into_inner() {
1084            None => true,
1085            Some(arc) => match Arc::into_inner(arc) {
1086                Some(dev) => dev.shutdown(timeout).await,
1087                // A LocalAPI request raced `close` and briefly holds the last strong ref; it is
1088                // released the instant that request returns, and the device tears down then — we
1089                // just could not reclaim it by value for a graceful shutdown.
1090                None => false,
1091            },
1092        }
1093    }
1094}
1095
1096/// Map a facade lazy-start failure into the cert surface's typed error. The cert calls
1097/// ([`Server::listen_tls`], `cert_pair`) return [`ts_control::CertError`] — not the unified
1098/// [`Error`] — to preserve the specialized typed surface (design doc §7), so a device that fails to
1099/// start is surfaced as a [`ts_control::CertError::Io`] carrying the underlying reason rather than
1100/// swallowed. (The start error only fires for an already-validated config.)
1101fn start_failed_cert(e: Error) -> ts_control::CertError {
1102    ts_control::CertError::Io(std::io::Error::other(format!(
1103        "server failed to start: {e}"
1104    )))
1105}
1106
1107// ---------------------------------------------------------------------------------------------
1108// Go-style `network` / `addr` string parsing — the `"tcp"`/`"udp"` + `":80"` surface.
1109//
1110// The facade owns this (like every string→typed step): it turns Go's loose `net.Listen`/
1111// `net.ListenPacket` strings into the typed values the engine wants and into the facade's *own*
1112// typed [`Error`], rather than leaking the engine's opaque `BadRequest`. The accepted network set
1113// mirrors the engine's own [`Device::dial`](crate::Device::dial) so `listen`/`listen_packet`
1114// accept exactly what `dial` does.
1115// ---------------------------------------------------------------------------------------------
1116
1117/// The transport a Go `network` string selects.
1118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1119enum Transport {
1120    Tcp,
1121    Udp,
1122}
1123
1124/// The address family a Go `network` suffix forces (`…4`/`…6`), or [`Family::Any`] for the bare
1125/// `"tcp"`/`"udp"`. It picks the wildcard host a bare `":port"` binds on (v4 vs v6).
1126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1127enum Family {
1128    Any,
1129    V4,
1130    V6,
1131}
1132
1133/// A parsed Go `network` string: its transport and address family.
1134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1135struct Network {
1136    transport: Transport,
1137    family: Family,
1138}
1139
1140/// Parse a Go-style `network` string (the first argument of `net.Listen`/`net.ListenPacket`) into a
1141/// typed [`Network`]. Accepts exactly the tsnet set — `"tcp"`, `"tcp4"`, `"tcp6"`, `"udp"`,
1142/// `"udp4"`, `"udp6"` — matching the engine's own [`Device::dial`](crate::Device::dial); anything
1143/// else (including the empty string) is [`Error::InvalidNetwork`].
1144fn parse_network(network: &str) -> Result<Network, Error> {
1145    let (transport, family) = match network {
1146        "tcp" => (Transport::Tcp, Family::Any),
1147        "tcp4" => (Transport::Tcp, Family::V4),
1148        "tcp6" => (Transport::Tcp, Family::V6),
1149        "udp" => (Transport::Udp, Family::Any),
1150        "udp4" => (Transport::Udp, Family::V4),
1151        "udp6" => (Transport::Udp, Family::V6),
1152        _ => {
1153            return Err(Error::InvalidNetwork {
1154                network: network.to_string(),
1155            });
1156        }
1157    };
1158    Ok(Network { transport, family })
1159}
1160
1161/// Normalize a Go-style listen `addr`: a bare `":port"` gets the wildcard host for `family`
1162/// (`0.0.0.0` for v4/any, `[::]` for v6 — matching Go's `net.Listen("tcp6", ":80")` ⇒ `[::]:80`).
1163/// An `addr` with an explicit host (an IP literal or a name) is returned unchanged.
1164fn normalize_listen_addr(addr: &str, family: Family) -> String {
1165    if addr.starts_with(':') {
1166        match family {
1167            Family::V6 => format!("[::]{addr}"),
1168            Family::Any | Family::V4 => format!("0.0.0.0{addr}"),
1169        }
1170    } else {
1171        addr.to_string()
1172    }
1173}
1174
1175/// Parse a Go-style listen `addr` into a [`SocketAddr`], filling a bare `":port"` with the family's
1176/// wildcard host (see [`normalize_listen_addr`]). A family-pinned `family` (`V4`/`V6`, i.e. a
1177/// `tcp4`/`tcp6` network) additionally rejects an explicit host literal of the *other* family —
1178/// `parse_listen_addr("[::1]:80", Family::V4)` is [`Error::AddrFamilyMismatch`], exactly as Go's
1179/// `net.Listen("tcp4", "[::1]:80")` errors — while `Family::Any` accepts either.
1180fn parse_listen_addr(addr: &str, family: Family) -> Result<SocketAddr, Error> {
1181    let sa: SocketAddr = normalize_listen_addr(addr, family)
1182        .parse()
1183        .map_err(|source| Error::InvalidAddr {
1184            addr: addr.to_string(),
1185            source,
1186        })?;
1187    // A `…4`/`…6` network pins the family: reject an explicit host literal that parsed to the other
1188    // family. A bare `":port"` already got the matching wildcard from `normalize_listen_addr`, so it
1189    // never reaches here; `Family::Any` (bare `tcp`/`udp`) follows the address and accepts either.
1190    let mismatch = match family {
1191        Family::Any => None,
1192        Family::V4 => (!sa.is_ipv4()).then_some("IPv4"),
1193        Family::V6 => (!sa.is_ipv6()).then_some("IPv6"),
1194    };
1195    match mismatch {
1196        Some(want) => Err(Error::AddrFamilyMismatch {
1197            addr: addr.to_string(),
1198            want,
1199        }),
1200        None => Ok(sa),
1201    }
1202}
1203
1204// ---------------------------------------------------------------------------------------------
1205// Loopback runtime: the engine SOCKS5 proxy + the facade's in-process LocalAPI HTTP server.
1206// ---------------------------------------------------------------------------------------------
1207
1208/// The running loopback surface, cached on [`Server`] and torn down on [`Server::close`].
1209struct LoopbackRt {
1210    socks_address: SocketAddr,
1211    proxy_cred: String,
1212    local_api_address: SocketAddr,
1213    local_api_cred: String,
1214    /// Aborts the engine SOCKS5 accept loop on drop (RAII).
1215    _socks_handle: crate::LoopbackHandle,
1216    /// Aborts the in-process LocalAPI accept loop on drop (via the [`Drop`] impl below).
1217    localapi_task: AbortHandle,
1218}
1219
1220impl Drop for LoopbackRt {
1221    fn drop(&mut self) {
1222        // Stop accepting new LocalAPI connections. In-flight requests hold only a `Weak<Device>` and
1223        // finish on their own. (`_socks_handle` aborts the SOCKS5 loop via its own `Drop`.)
1224        self.localapi_task.abort();
1225    }
1226}
1227
1228/// The target of control's c2n `/remoteapi/localapi/*` proxy: this facade's LocalAPI routes,
1229/// reachable over the control connection instead of over the loopback listener (Go
1230/// `feature/remoteconfig`'s `handleC2NRemoteAPI`, which builds a `localapi.Handler` over the same
1231/// `LocalBackend` the loopback one serves).
1232///
1233/// Installed on [`Config::c2n_local_api`](crate::Config::c2n_local_api) by
1234/// [`Server::build_and_start`], which is *before* the device exists — so the backend is filled in by
1235/// [`attach`](Self::attach) the moment [`Device::new`] returns. Like the loopback server it holds a
1236/// [`Weak`], so an in-flight c2n request never blocks [`Server::close`] from reclaiming the device.
1237///
1238/// One state answers without reaching a route at all: before `attach`, a window that closes in the
1239/// statement after the one that opens it — control cannot have delivered a c2n ping inside it, since
1240/// the map poll that would carry one has not been read yet. That answers `503 device unavailable`
1241/// rather than pretending the endpoint does not exist. Once attached, a device that has since been
1242/// reclaimed is the *backend's* failure and surfaces as the LocalAPI's own `500`, exactly as it does
1243/// for a loopback request.
1244#[derive(Default)]
1245struct C2nLocalApi {
1246    /// The LocalAPI status backend, set once by [`attach`](Self::attach).
1247    status: std::sync::OnceLock<localapi::StatusFn>,
1248}
1249
1250impl C2nLocalApi {
1251    /// Point this hook at the built device. Called once; a second call is ignored.
1252    fn attach(&self, device: &Arc<Device>) {
1253        let _already_attached = self.status.set(device_status_fn(Arc::downgrade(device)));
1254    }
1255}
1256
1257impl ts_control::LocalApi for C2nLocalApi {
1258    fn serve<'a>(
1259        &'a self,
1260        method: &'a str,
1261        target: &'a str,
1262        _body: &'a str,
1263    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = String> + Send + 'a>> {
1264        Box::pin(async move {
1265            let resp = match self.status.get() {
1266                Some(status) => localapi::route(method, target, status).await,
1267                None => localapi::response(
1268                    503,
1269                    "Service Unavailable",
1270                    "text/plain",
1271                    b"device unavailable",
1272                    &[],
1273                ),
1274            };
1275            String::from_utf8_lossy(&resp).into_owned()
1276        })
1277    }
1278}
1279
1280/// A LocalAPI status backend over a [`Weak`] device handle: upgrade for just long enough to read
1281/// status, and report the shutdown as an error rather than keeping the device alive. Shared by the
1282/// loopback LocalAPI server and the c2n proxy so both serve the same status.
1283fn device_status_fn(weak: Weak<Device>) -> localapi::StatusFn {
1284    Arc::new(move || {
1285        let weak = weak.clone();
1286        Box::pin(async move {
1287            match weak.upgrade() {
1288                Some(dev) => dev
1289                    .status()
1290                    .await
1291                    .map(|s| status_json(&s))
1292                    .map_err(|e| e.to_string()),
1293                None => Err("device has shut down".to_string()),
1294            }
1295        })
1296    })
1297}
1298
1299/// Build the loopback runtime: start the engine SOCKS5 proxy, bind a second `127.0.0.1` listener for
1300/// the LocalAPI, mint a *separate* credential, and spawn the in-process LocalAPI HTTP server backed
1301/// by a [`Weak`] handle to `device`.
1302async fn build_loopback_rt(device: Arc<Device>) -> Result<LoopbackRt, Error> {
1303    // SOCKS5 half — the engine's own loopback (address + proxy_cred + RAII handle), unchanged.
1304    let (socks_address, proxy_cred, socks_handle) = device.loopback().await?;
1305
1306    // LocalAPI half — a facade-owned HTTP server on its own host-loopback listener.
1307    let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
1308        .await
1309        .map_err(Error::Loopback)?;
1310    let local_api_address = listener.local_addr().map_err(Error::Loopback)?;
1311    let local_api_cred = gen_cred();
1312
1313    // Backend: a `Weak<Device>` so the spawned server never blocks `Server::close` from reclaiming
1314    // the device by value. Each request upgrades it just long enough to read status.
1315    let status = device_status_fn(Arc::downgrade(&device));
1316
1317    let task = tokio::spawn(localapi::serve(listener, local_api_cred.clone(), status));
1318
1319    Ok(LoopbackRt {
1320        socks_address,
1321        proxy_cred,
1322        local_api_address,
1323        local_api_cred,
1324        _socks_handle: socks_handle,
1325        localapi_task: task.abort_handle(),
1326    })
1327}
1328
1329/// Generate a 16-byte random credential rendered as 32 lowercase-hex chars (Go uses
1330/// `hex.EncodeToString(crand[16])`; no new dependency — reuses `rand`, like the SOCKS5 half).
1331fn gen_cred() -> String {
1332    let bytes: [u8; 16] = rand::random();
1333    bytes.iter().map(|b| format!("{b:02x}")).collect()
1334}
1335
1336/// Serialize a [`StatusNode`] into a JSON value using explicit conversions (the fork's status types
1337/// are not `serde` types, and their field crates don't enable serde features here).
1338fn status_node_json(n: &StatusNode) -> serde_json::Value {
1339    serde_json::json!({
1340        "stable_id": n.stable_id.0,
1341        "display_name": n.display_name,
1342        "ipv4": n.ipv4.to_string(),
1343        "ipv6": n.ipv6.to_string(),
1344        "online": n.online,
1345        // Unix seconds — a feature-free encoding (chrono is `default-features = false` here, so the
1346        // `to_rfc3339`/`Display` formatters are unavailable; `timestamp()` is always present).
1347        "last_seen": n.last_seen.map(|t| t.timestamp()),
1348        "allowed_routes": n.allowed_routes.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
1349        "is_exit_node": n.is_exit_node,
1350        "cur_addr": n.cur_addr.map(|a| a.to_string()),
1351        "relay": n.relay,
1352        "ssh_host_keys": n.ssh_host_keys,
1353    })
1354}
1355
1356/// Serialize a [`Status`] snapshot into the LocalAPI `/status` JSON body.
1357fn status_json(s: &Status) -> Vec<u8> {
1358    let value = serde_json::json!({
1359        "self": s.self_node.as_ref().map(status_node_json),
1360        "peers": s.peers.iter().map(status_node_json).collect::<Vec<_>>(),
1361        "active_exit_node": s.active_exit_node.as_ref().map(|id| id.0.clone()),
1362        "magic_dns_suffix": s.magic_dns_suffix,
1363    });
1364    serde_json::to_vec(&value).unwrap_or_else(|_| b"{}".to_vec())
1365}
1366
1367/// Find the first occurrence of `needle` in `hay` (splits an HTTP head from its body — no dep).
1368fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
1369    if needle.is_empty() || hay.len() < needle.len() {
1370        return None;
1371    }
1372    hay.windows(needle.len()).position(|w| w == needle)
1373}
1374
1375/// Parse an HTTP/1.x response into `(status_code, body_bytes)`. Used by [`LocalClient`].
1376fn parse_response(resp: &[u8]) -> Option<(u16, Vec<u8>)> {
1377    let head_end = find_subslice(resp, b"\r\n\r\n")?;
1378    let head = std::str::from_utf8(&resp[..head_end]).ok()?;
1379    let status_line = head.split("\r\n").next()?;
1380    // "HTTP/1.1 200 OK" — the status code is the second whitespace-separated token.
1381    let code: u16 = status_line.split_whitespace().nth(1)?.parse().ok()?;
1382    Some((code, resp[head_end + 4..].to_vec()))
1383}
1384
1385/// Perform an authenticated LocalAPI `GET` over plain HTTP to `127.0.0.1` (dependency-free client).
1386async fn localapi_client_get(
1387    addr: SocketAddr,
1388    cred: &str,
1389    path: &str,
1390) -> std::io::Result<(u16, Vec<u8>)> {
1391    let mut sock = TcpStream::connect(addr).await?;
1392    // HTTP Basic auth with an empty username (Go ignores the username; the password is the cred).
1393    let auth = STANDARD.encode(format!(":{cred}"));
1394    // Send Go's anti-DNS-rebinding header (`Sec-Tailscale: localapi`) the server now requires in
1395    // addition to Basic auth — a browser rebinding attack cannot set this custom header.
1396    let req = format!(
1397        "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"
1398    );
1399    sock.write_all(req.as_bytes()).await?;
1400    // The server replies with `Connection: close`, so reading to EOF yields the whole response.
1401    let mut resp = Vec::new();
1402    sock.read_to_end(&mut resp).await?;
1403    parse_response(&resp).ok_or_else(|| {
1404        std::io::Error::new(std::io::ErrorKind::InvalidData, "malformed HTTP response")
1405    })
1406}
1407
1408/// The in-process LocalAPI HTTP server (Go's `localapi.Handler` served on the loopback). A minimal,
1409/// dependency-free HTTP/1.1 server: the crate's `hyper` is HTTP/2-**client**-only, so this hand-rolls
1410/// request framing exactly as the SOCKS5 half hand-rolls its own protocol in `src/loopback.rs`.
1411///
1412/// **Scope (vs Go).** Unlike Go's full `localapi.Handler` (dozens of endpoints), this serves the one
1413/// route the facade needs — `GET /localapi/v0/status` — and returns `404` for every other
1414/// path/method. Every request must additionally carry Go's `Sec-Tailscale: localapi` request header
1415/// (anti-DNS-rebinding) on top of the Basic-auth credential, or it is rejected `403` before auth.
1416mod localapi {
1417    use super::{Duration, STANDARD, TcpListener, TcpStream, find_subslice};
1418    use base64::Engine as _;
1419    use std::future::Future;
1420    use std::pin::Pin;
1421    use std::sync::Arc;
1422    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
1423    use tokio::sync::Semaphore;
1424
1425    /// Upper bound on the buffered HTTP request head (request line + headers). LocalAPI requests are
1426    /// tiny; a client that floods the head is rejected rather than buffered unbounded.
1427    const MAX_HEAD: usize = 8 * 1024;
1428    /// Deadline for reading a full request head and writing the response.
1429    const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1430    /// Cap on concurrent LocalAPI connections (loopback-only, but bounded for hygiene).
1431    const MAX_CONCURRENT: usize = 64;
1432    /// Go's anti-DNS-rebinding request header. Every LocalAPI request must carry
1433    /// `Sec-Tailscale: localapi` in addition to the Basic-auth credential: a browser steered at the
1434    /// loopback listener by a rebinding attack cannot set this custom header cross-origin (it is not
1435    /// CORS-safelisted, so `fetch` may send it only after a preflight this server never approves), so
1436    /// requiring it keeps browser-driven callers out even if they learn the port and credential.
1437    const SEC_TAILSCALE_HEADER: &str = "Sec-Tailscale";
1438    /// The one accepted value of [`SEC_TAILSCALE_HEADER`] (Go compares `== "localapi"`).
1439    const SEC_TAILSCALE_VALUE: &str = "localapi";
1440
1441    /// A cloneable, `'static` async backend for `GET /localapi/v0/status`: returns the JSON body
1442    /// bytes, or an error string mapped to HTTP 500. Boxed so tests can inject a mock backend
1443    /// without a live [`Device`](crate::Device).
1444    pub(super) type StatusFn = Arc<
1445        dyn Fn() -> Pin<Box<dyn Future<Output = Result<Vec<u8>, String>> + Send>> + Send + Sync,
1446    >;
1447
1448    /// Serve the LocalAPI on `listener` until the task is aborted (by [`super::LoopbackRt`]'s drop).
1449    pub(super) async fn serve(listener: TcpListener, cred: String, status: StatusFn) {
1450        let sem = Arc::new(Semaphore::new(MAX_CONCURRENT));
1451        loop {
1452            // Back-pressure at the cap: acquire before accepting.
1453            let permit = match sem.clone().acquire_owned().await {
1454                Ok(p) => p,
1455                Err(_) => return,
1456            };
1457            let (sock, _peer) = match listener.accept().await {
1458                Ok(pair) => pair,
1459                Err(e) => {
1460                    tracing::warn!(error = %e, "loopback LocalAPI accept failed; stopping accept loop");
1461                    return;
1462                }
1463            };
1464            let cred = cred.clone();
1465            let status = status.clone();
1466            tokio::spawn(async move {
1467                let _permit = permit;
1468                match tokio::time::timeout(REQUEST_TIMEOUT, handle(sock, &cred, &status)).await {
1469                    Ok(Ok(())) => {}
1470                    Ok(Err(e)) => tracing::debug!(error = %e, "loopback LocalAPI connection ended"),
1471                    Err(_) => tracing::debug!("loopback LocalAPI request timed out"),
1472                }
1473            });
1474        }
1475    }
1476
1477    /// Serve one LocalAPI connection: read the head, authenticate, route, respond, close.
1478    async fn handle(mut sock: TcpStream, cred: &str, status: &StatusFn) -> std::io::Result<()> {
1479        // Read up to the end of the header block (CRLF CRLF), capped.
1480        let mut buf = Vec::with_capacity(1024);
1481        let mut chunk = [0u8; 1024];
1482        let head_len = loop {
1483            if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
1484                break pos;
1485            }
1486            if buf.len() > MAX_HEAD {
1487                let r = response(
1488                    431,
1489                    "Request Header Fields Too Large",
1490                    "text/plain",
1491                    b"header too large",
1492                    &[],
1493                );
1494                sock.write_all(&r).await?;
1495                return Ok(());
1496            }
1497            let n = sock.read(&mut chunk).await?;
1498            if n == 0 {
1499                return Ok(()); // client closed before sending a full head
1500            }
1501            buf.extend_from_slice(&chunk[..n]);
1502        };
1503
1504        let Some((method, target, password, sec_tailscale)) = parse_head(&buf[..head_len]) else {
1505            let r = response(400, "Bad Request", "text/plain", b"bad request", &[]);
1506            sock.write_all(&r).await?;
1507            return Ok(());
1508        };
1509
1510        // Anti-DNS-rebinding gate (Go's `Sec-Tailscale: localapi`), checked *before* the credential:
1511        // block browser-driven (rebinding) callers even when they know the port + cred, since they
1512        // cannot set this custom header. See [`SEC_TAILSCALE_HEADER`].
1513        if sec_tailscale.as_deref() != Some(SEC_TAILSCALE_VALUE) {
1514            let r = response(
1515                403,
1516                "Forbidden",
1517                "text/plain",
1518                b"missing 'Sec-Tailscale: localapi' header",
1519                &[],
1520            );
1521            sock.write_all(&r).await?;
1522            return Ok(());
1523        }
1524
1525        // Auth: the Basic-auth password must equal the cred (any username, matching Go).
1526        if !password.as_deref().is_some_and(|p| cred_ok(p, cred)) {
1527            let r = response(
1528                401,
1529                "Unauthorized",
1530                "text/plain",
1531                b"unauthorized",
1532                &[("WWW-Authenticate", "Basic realm=\"tailscale localapi\"")],
1533            );
1534            sock.write_all(&r).await?;
1535            return Ok(());
1536        }
1537
1538        let resp = route(&method, &target, status).await;
1539        sock.write_all(&resp).await?;
1540        Ok(())
1541    }
1542
1543    /// Route one *already authorized* LocalAPI request to its complete HTTP/1.1 response.
1544    ///
1545    /// Split out of [`handle`] because this server has two front doors onto the same routes: the
1546    /// loopback listener (which authorizes with the anti-DNS-rebinding header plus the Basic-auth
1547    /// credential before getting here) and control's c2n `/remoteapi/localapi/*` proxy (authorized
1548    /// by the [`Config::remote_config`](crate::Config::remote_config) pref, checked in
1549    /// `ts_control`'s c2n responder). Neither gate belongs to the routing, and Go likewise builds
1550    /// the proxied handler with no `RequiredPassword` — so the auth lives in the callers and the
1551    /// route table is shared.
1552    ///
1553    /// `target` is a request target, so it may carry a query string; the query is ignored, as none
1554    /// of the one route this facade serves reads it (Go's `localapi.Handler` also dispatches on
1555    /// `URL.Path` alone).
1556    pub(super) async fn route(method: &str, target: &str, status: &StatusFn) -> Vec<u8> {
1557        let path = target.split('?').next().unwrap_or(target);
1558        match (method, path) {
1559            ("GET", "/localapi/v0/status") => match status().await {
1560                Ok(body) => response(200, "OK", "application/json", &body, &[]),
1561                Err(_) => response(
1562                    500,
1563                    "Internal Server Error",
1564                    "text/plain",
1565                    b"status error",
1566                    &[],
1567                ),
1568            },
1569            _ => response(404, "Not Found", "text/plain", b"not found", &[]),
1570        }
1571    }
1572
1573    /// Parse an HTTP request head into `(method, request_target, basic_auth_password,
1574    /// sec_tailscale)`. `None` when the request line is malformed. `sec_tailscale` carries the
1575    /// `Sec-Tailscale` request-header value (Go's anti-DNS-rebinding token), or `None` when absent.
1576    pub(super) fn parse_head(
1577        head: &[u8],
1578    ) -> Option<(String, String, Option<String>, Option<String>)> {
1579        let text = std::str::from_utf8(head).ok()?;
1580        let mut lines = text.split("\r\n");
1581        let mut request_line = lines.next()?.split(' ');
1582        let method = request_line.next()?.to_string();
1583        let target = request_line.next()?.to_string();
1584        request_line.next()?; // require the HTTP-version token
1585        let mut password = None;
1586        let mut sec_tailscale = None;
1587        for line in lines {
1588            let Some((name, value)) = line.split_once(':') else {
1589                continue;
1590            };
1591            let name = name.trim();
1592            if name.eq_ignore_ascii_case("authorization") {
1593                password = basic_auth_password(value.trim());
1594            } else if name.eq_ignore_ascii_case(SEC_TAILSCALE_HEADER) {
1595                sec_tailscale = Some(value.trim().to_string());
1596            }
1597        }
1598        Some((method, target, password, sec_tailscale))
1599    }
1600
1601    /// Decode `Basic <base64(user:pass)>` into the password (Go ignores the username). `None` if the
1602    /// header is not Basic auth or is malformed.
1603    pub(super) fn basic_auth_password(value: &str) -> Option<String> {
1604        // The auth scheme is case-insensitive (RFC 7617; Go's `r.BasicAuth` uses `EqualFold`), so
1605        // `Basic`/`basic`/`BASIC`/… all authenticate.
1606        let (scheme, b64) = value.split_once(' ')?;
1607        if !scheme.eq_ignore_ascii_case("basic") {
1608            return None;
1609        }
1610        let decoded = STANDARD.decode(b64.trim()).ok()?;
1611        let decoded = String::from_utf8(decoded).ok()?;
1612        // "user:pass" — the username (before the first colon) is ignored; a header with no colon at
1613        // all is malformed Basic auth and yields no password (→ 401).
1614        decoded
1615            .split_once(':')
1616            .map(|(_user, pass)| pass.to_string())
1617    }
1618
1619    /// Constant-time credential comparison (don't leak the cred via early-exit timing).
1620    pub(super) fn cred_ok(provided: &str, expected: &str) -> bool {
1621        let (a, b) = (provided.as_bytes(), expected.as_bytes());
1622        if a.len() != b.len() {
1623            return false;
1624        }
1625        let mut diff = 0u8;
1626        for (x, y) in a.iter().zip(b.iter()) {
1627            diff |= x ^ y;
1628        }
1629        diff == 0
1630    }
1631
1632    /// Build a complete HTTP/1.1 response with `Connection: close`.
1633    pub(super) fn response(
1634        code: u16,
1635        reason: &str,
1636        content_type: &str,
1637        body: &[u8],
1638        extra_headers: &[(&str, &str)],
1639    ) -> Vec<u8> {
1640        let mut head = format!(
1641            "HTTP/1.1 {code} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n",
1642            body.len()
1643        );
1644        for (name, value) in extra_headers {
1645            head.push_str(name);
1646            head.push_str(": ");
1647            head.push_str(value);
1648            head.push_str("\r\n");
1649        }
1650        head.push_str("\r\n");
1651        let mut out = head.into_bytes();
1652        out.extend_from_slice(body);
1653        out
1654    }
1655}
1656
1657#[cfg(test)]
1658mod tests {
1659    use super::*;
1660
1661    // --- the `network` string parser (`"tcp"`/`"udp"` + family suffix) ---
1662
1663    #[test]
1664    fn parse_network_accepts_the_tsnet_set() {
1665        // Exactly Go's net.Listen/net.ListenPacket set — and the engine's own Device::dial set.
1666        assert_eq!(
1667            parse_network("tcp").unwrap(),
1668            Network {
1669                transport: Transport::Tcp,
1670                family: Family::Any
1671            }
1672        );
1673        assert_eq!(parse_network("tcp4").unwrap().family, Family::V4);
1674        assert_eq!(parse_network("tcp6").unwrap().family, Family::V6);
1675        assert_eq!(parse_network("udp").unwrap().transport, Transport::Udp);
1676        assert_eq!(parse_network("udp4").unwrap().family, Family::V4);
1677        assert_eq!(parse_network("udp6").unwrap().family, Family::V6);
1678    }
1679
1680    #[test]
1681    fn parse_network_rejects_unknown_strings() {
1682        // Unknown transports, bad suffixes, the empty string, wrong case, and stray whitespace all
1683        // fail as the typed InvalidNetwork (never a silent default).
1684        for n in ["", "tcp5", "sctp", "unix", "TCP", "udp7", "ip", "tcp ", "0"] {
1685            assert!(
1686                matches!(parse_network(n), Err(Error::InvalidNetwork { network }) if network == n),
1687                "network {n:?} must be rejected as InvalidNetwork carrying the offending value"
1688            );
1689        }
1690    }
1691
1692    // --- the listen-address parser (`":80"` ⇒ family wildcard, or a full `ip:port`) ---
1693
1694    #[test]
1695    fn parse_colon_port_is_family_aware_wildcard() {
1696        // A bare ":port" binds the wildcard host of the network's family.
1697        let v4 = parse_listen_addr(":8080", Family::Any).unwrap();
1698        assert!(v4.ip().is_unspecified() && v4.is_ipv4());
1699        assert_eq!(v4.port(), 8080);
1700        assert!(parse_listen_addr(":80", Family::V4).unwrap().is_ipv4());
1701
1702        // ...and `tcp6`/`udp6` bind the v6 wildcard `[::]` (Go `net.Listen("tcp6", ":80")`).
1703        let v6 = parse_listen_addr(":80", Family::V6).unwrap();
1704        assert!(v6.ip().is_unspecified() && v6.is_ipv6());
1705        assert_eq!(v6.port(), 80);
1706    }
1707
1708    #[test]
1709    fn parse_full_addr_is_used_verbatim() {
1710        let sa = parse_listen_addr("100.64.0.1:443", Family::Any).unwrap();
1711        assert_eq!(sa.port(), 443);
1712        assert_eq!(sa.ip().to_string(), "100.64.0.1");
1713    }
1714
1715    #[test]
1716    fn parse_bad_addr_is_typed_error() {
1717        let err = parse_listen_addr("not-an-addr", Family::Any).unwrap_err();
1718        assert!(matches!(err, Error::InvalidAddr { .. }));
1719    }
1720
1721    #[test]
1722    fn parse_listen_addr_enforces_the_pinned_family_on_explicit_hosts() {
1723        // A `tcp4`/`tcp6` network pins the family: an explicit host literal of the *other* family is
1724        // rejected, exactly as Go `net.Listen("tcp4", "[::1]:80")` errors (this is the case a bare
1725        // ":port" — filled with the matching wildcard — never reaches).
1726        assert!(
1727            matches!(
1728                parse_listen_addr("[::1]:80", Family::V4),
1729                Err(Error::AddrFamilyMismatch { want: "IPv4", ref addr }) if addr == "[::1]:80"
1730            ),
1731            "a v6 literal under tcp4 must be AddrFamilyMismatch(IPv4)"
1732        );
1733        assert!(
1734            matches!(
1735                parse_listen_addr("127.0.0.1:80", Family::V6),
1736                Err(Error::AddrFamilyMismatch { want: "IPv6", .. })
1737            ),
1738            "a v4 literal under tcp6 must be AddrFamilyMismatch(IPv6)"
1739        );
1740        // The matching family passes through unchanged...
1741        assert!(matches!(
1742            parse_listen_addr("[::1]:80", Family::V6),
1743            Ok(SocketAddr::V6(_))
1744        ));
1745        assert!(matches!(
1746            parse_listen_addr("127.0.0.1:80", Family::V4),
1747            Ok(SocketAddr::V4(_))
1748        ));
1749        // ...and the family-agnostic bare `tcp`/`udp` (Any) follows the address, accepting either.
1750        assert!(
1751            parse_listen_addr("[::1]:80", Family::Any)
1752                .unwrap()
1753                .is_ipv6()
1754        );
1755        assert!(
1756            parse_listen_addr("127.0.0.1:80", Family::Any)
1757                .unwrap()
1758                .is_ipv4()
1759        );
1760    }
1761
1762    #[test]
1763    fn normalize_listen_addr_only_fills_a_bare_port() {
1764        // A bare ":port" is filled with the family wildcard; an explicit host is left untouched
1765        // (including a name, which the engine's ListenPacket then rejects — the facade doesn't
1766        // pre-judge it).
1767        assert_eq!(normalize_listen_addr(":0", Family::Any), "0.0.0.0:0");
1768        assert_eq!(normalize_listen_addr(":0", Family::V4), "0.0.0.0:0");
1769        assert_eq!(normalize_listen_addr(":0", Family::V6), "[::]:0");
1770        assert_eq!(normalize_listen_addr("0.0.0.0:0", Family::V4), "0.0.0.0:0");
1771        assert_eq!(normalize_listen_addr("[::]:53", Family::V6), "[::]:53");
1772        assert_eq!(normalize_listen_addr("host:53", Family::Any), "host:53");
1773    }
1774
1775    // --- the parser is *wired into* listen()/listen_packet(): a wrong-transport or unknown network,
1776    //     and a bad address, are rejected up front — before the lazy Device::new / any network I/O,
1777    //     which is what makes these hermetic (they never reach `started()`). ---
1778
1779    #[tokio::test]
1780    async fn listen_rejects_a_non_tcp_network_before_starting() {
1781        let s = Server::new();
1782        assert!(matches!(
1783            s.listen("udp", ":80").await,
1784            Err(Error::InvalidNetwork { network }) if network == "udp"
1785        ));
1786        assert!(matches!(
1787            s.listen("sctp", ":80").await,
1788            Err(Error::InvalidNetwork { .. })
1789        ));
1790    }
1791
1792    #[tokio::test]
1793    async fn listen_reports_a_bad_addr_before_starting() {
1794        let s = Server::new();
1795        assert!(matches!(
1796            s.listen("tcp", "not-an-addr").await,
1797            Err(Error::InvalidAddr { .. })
1798        ));
1799    }
1800
1801    #[tokio::test]
1802    async fn listen_rejects_a_family_mismatched_explicit_host_before_starting() {
1803        // `listen("tcp4", "[::1]:80")` must fail like Go `net.Listen` — enforced at the facade
1804        // boundary, before the lazy `Device::new` / any network I/O (hermetic: never reaches
1805        // `started()`). This is the family check for an *explicit* host literal, not just ":port".
1806        let s = Server::new();
1807        assert!(matches!(
1808            s.listen("tcp4", "[::1]:80").await,
1809            Err(Error::AddrFamilyMismatch { want: "IPv4", .. })
1810        ));
1811        assert!(matches!(
1812            s.listen("tcp6", "127.0.0.1:80").await,
1813            Err(Error::AddrFamilyMismatch { want: "IPv6", .. })
1814        ));
1815    }
1816
1817    #[tokio::test]
1818    async fn listen_packet_rejects_a_non_udp_network_before_starting() {
1819        let s = Server::new();
1820        assert!(matches!(
1821            s.listen_packet("tcp", "0.0.0.0:0").await,
1822            Err(Error::InvalidNetwork { network }) if network == "tcp"
1823        ));
1824        assert!(matches!(
1825            s.listen_packet("nope", "0.0.0.0:0").await,
1826            Err(Error::InvalidNetwork { .. })
1827        ));
1828    }
1829
1830    // --- listen_tls()/cert_pair(): the serve config / cert name is validated at the facade boundary,
1831    //     so a non-tailnet name or a zero port is the typed CertError rejected up front — before the
1832    //     lazy Device::new / any cert issuance / any network I/O (hermetic: never reaches `started()`).
1833    //     This holds with or without the `acme` feature; real ACME issuance needs a live SaaS tailnet
1834    //     and is out of scope for a unit test. ---
1835
1836    #[tokio::test]
1837    async fn listen_tls_rejects_a_non_tailnet_name_before_starting() {
1838        let s = Server::new();
1839        let cfg = ts_control::ServeConfig {
1840            name: "example.com".into(), // not a `*.ts.net` tailnet name (anti-leak)
1841            port: 443,
1842            target: ts_control::ServeTarget::Accept,
1843        };
1844        assert!(matches!(
1845            s.listen_tls(&cfg).await,
1846            Err(ts_control::CertError::NotTailnetName(n)) if n == "example.com"
1847        ));
1848    }
1849
1850    #[tokio::test]
1851    async fn listen_tls_rejects_a_zero_port_before_starting() {
1852        let s = Server::new();
1853        let cfg = ts_control::ServeConfig {
1854            name: "host.tailnet.ts.net".into(), // a valid tailnet name...
1855            port: 0, // ...but port 0 is rejected by ServeConfig::validate
1856            target: ts_control::ServeTarget::Accept,
1857        };
1858        assert!(matches!(
1859            s.listen_tls(&cfg).await,
1860            Err(ts_control::CertError::Acme(_))
1861        ));
1862    }
1863
1864    #[test]
1865    fn start_failure_maps_to_a_typed_cert_io_error() {
1866        // A lazy-start failure on the cert path is surfaced (not swallowed) as CertError::Io carrying
1867        // the underlying reason — exercised directly on the mapper, since a real start needs a live
1868        // tailnet. This is why listen_tls/cert_pair keep the typed CertError rather than the unified
1869        // lifecycle Error (design doc §7).
1870        let e = start_failed_cert(Error::Store(std::io::Error::other("boom")));
1871        assert!(matches!(e, ts_control::CertError::Io(_)));
1872        let msg = e.to_string();
1873        assert!(msg.contains("server failed to start"), "got {msg:?}");
1874        assert!(
1875            msg.contains("boom"),
1876            "underlying reason must be preserved, got {msg:?}"
1877        );
1878    }
1879
1880    #[cfg(feature = "acme")]
1881    #[tokio::test]
1882    async fn cert_pair_rejects_a_non_tailnet_name_before_starting() {
1883        // The `acme`-gated PEM-pair path applies the same anti-leak name check at the facade boundary,
1884        // before the device is started. (Compiled + linted under `--features "tsnet acme"`.)
1885        let s = Server::new();
1886        assert!(matches!(
1887            s.cert_pair("example.com", None).await,
1888            Err(ts_control::CertError::NotTailnetName(n)) if n == "example.com"
1889        ));
1890    }
1891
1892    #[test]
1893    fn server_default_is_go_shaped() {
1894        // Go's zero-value Server is not ephemeral and has no persistence.
1895        let s = Server::new();
1896        assert!(!s.ephemeral);
1897        assert!(s.dir.is_none());
1898        assert!(s.store.is_none());
1899        assert!(s.hostname.is_none());
1900    }
1901
1902    #[test]
1903    fn mem_store_round_trips() {
1904        let store = MemStore::default();
1905        assert!(store.read_state(STATE_KEY).unwrap().is_none());
1906        store.write_state(STATE_KEY, b"blob").unwrap();
1907        assert_eq!(
1908            store.read_state(STATE_KEY).unwrap().as_deref(),
1909            Some(&b"blob"[..])
1910        );
1911    }
1912
1913    #[test]
1914    fn funnel_options_map_to_engine() {
1915        let engine: ts_control::FunnelOptions = FunnelOptions::funnel_only().into();
1916        assert!(engine.funnel_only);
1917    }
1918
1919    #[test]
1920    fn funnel_options_default_maps_to_non_funnel_only() {
1921        // The zero-value options serve both public Funnel and tailnet-internal ingress (Go's default
1922        // when neither `FunnelOnly()` nor a TLS override is passed).
1923        let engine: ts_control::FunnelOptions = FunnelOptions::default().into();
1924        assert!(!engine.funnel_only);
1925    }
1926
1927    // --- listen_funnel / listen_service: the wrapper's lifecycle-vs-typed error split ---
1928    //
1929    // A *successful* funnel/service listen needs a live tailnet + Funnel-enabled ACL (kept in
1930    // integration/e2e). The hermetic, unit-testable contribution of the facade is the error split:
1931    // the wrapper must lazily start the node first, and a start failure must surface as a lifecycle
1932    // `Start` error carrying the real cause — never collapsed into the engine's access/bind error.
1933    // These lock that contract using the same no-network idiom as the dial/start tests: a bad
1934    // `control_url` fails fast at config build (`InvalidControlUrl`) before any I/O.
1935
1936    /// A `ServeConfig` whose contents are irrelevant here: the lazy start fails before it is ever
1937    /// read. Valid-shaped so the call type-checks (`Accept` = hand the stream back, like `ListenTLS`).
1938    fn dummy_serve_config() -> crate::ServeConfig {
1939        crate::ServeConfig {
1940            name: "node.example.ts.net".into(),
1941            port: 443,
1942            target: crate::ServeTarget::Accept,
1943        }
1944    }
1945
1946    #[tokio::test]
1947    async fn listen_funnel_reports_a_start_failure_not_a_funnel_denial() {
1948        // Regression for the skeleton's lossy `.map_err(|_| FunnelError::NotAllowed)`: a node that
1949        // never registered must NOT be reported as lacking the "funnel"/"https" attributes. It must
1950        // surface as `ListenFunnelError::Start` carrying the real `InvalidControlUrl` cause.
1951        let mut s = Server::new();
1952        s.control_url = Some("not a url".into());
1953        let cfg = dummy_serve_config();
1954        // Bind by-ref so the Display/source asserts can touch the error without needing the Ok type
1955        // (`FunnelAcceptedReceiver`) to be `Debug`.
1956        let res = s.listen_funnel(&cfg, FunnelOptions::default()).await;
1957        match &res {
1958            Err(e @ ListenFunnelError::Start(Error::InvalidControlUrl(_))) => {
1959                // Non-lossy: the wrapper's message embeds the real cause and its source() chains to it.
1960                assert!(
1961                    e.to_string().contains("invalid control URL"),
1962                    "start error dropped its underlying cause from Display: {e}"
1963                );
1964                assert!(
1965                    std::error::Error::source(e).is_some(),
1966                    "start error must expose the underlying Error as its source"
1967                );
1968            }
1969            Err(ListenFunnelError::Start(e)) => panic!("start failed with the wrong cause: {e:?}"),
1970            Err(ListenFunnelError::Funnel(f)) => {
1971                panic!("a startup failure was misdiagnosed as a Funnel error: {f:?}")
1972            }
1973            Ok(_) => panic!("a bad control_url must not yield a live funnel listener"),
1974        }
1975    }
1976
1977    #[tokio::test]
1978    async fn listen_service_reports_a_start_failure_not_a_bind_error() {
1979        // Regression sibling for the skeleton's `ServiceError::Listen("server failed to start: …")`:
1980        // a lazy-start failure must be a lifecycle `Start` error, not the engine's *bind* failure.
1981        let mut s = Server::new();
1982        s.control_url = Some("not a url".into());
1983        let res = s
1984            .listen_service("svc:web", ServiceMode::Tcp { port: 80 })
1985            .await;
1986        match &res {
1987            Err(e @ ListenServiceError::Start(Error::InvalidControlUrl(_))) => {
1988                assert!(
1989                    e.to_string().contains("invalid control URL"),
1990                    "start error dropped its underlying cause from Display: {e}"
1991                );
1992                assert!(
1993                    std::error::Error::source(e).is_some(),
1994                    "start error must expose the underlying Error as its source"
1995                );
1996            }
1997            Err(ListenServiceError::Start(e)) => panic!("start failed with the wrong cause: {e:?}"),
1998            Err(ListenServiceError::Service(se)) => {
1999                panic!("a startup failure was misdiagnosed as a ServiceError: {se:?}")
2000            }
2001            Ok(_) => panic!("a bad control_url must not yield a live service listener"),
2002        }
2003    }
2004
2005    #[test]
2006    fn listen_funnel_error_carries_the_engine_funnel_error_unchanged() {
2007        // The fix adds a `Start` path WITHOUT swallowing the engine's typed error: a genuine access
2008        // denial still arrives fully typed via the `Funnel` variant (the `?`/`From` passthrough).
2009        let e: ListenFunnelError = ts_control::FunnelError::PortNotAllowed(8443).into();
2010        assert!(
2011            matches!(
2012                e,
2013                ListenFunnelError::Funnel(ts_control::FunnelError::PortNotAllowed(8443))
2014            ),
2015            "engine FunnelError must pass through as ListenFunnelError::Funnel, unchanged"
2016        );
2017    }
2018
2019    #[test]
2020    fn listen_service_error_carries_the_engine_service_error_unchanged() {
2021        let e: ListenServiceError = ServiceError::UntaggedHost.into();
2022        assert!(
2023            matches!(e, ListenServiceError::Service(ServiceError::UntaggedHost)),
2024            "engine ServiceError must pass through as ListenServiceError::Service, unchanged"
2025        );
2026    }
2027
2028    #[test]
2029    fn documented_construction_idiom_compiles() {
2030        // Mirrors docs/TSNET_FACADE_DESIGN.md §13: `Server::new()` + per-field assignment on the
2031        // public fields (the private lazy-state fields do not block this), plus the `configure`
2032        // escape hatch and a custom `store`. If this compiles, the documented idiom is valid.
2033        let mut srv = Server::new();
2034        srv.hostname = Some("web".into());
2035        srv.auth_key = Some("tskey-xxxx".into());
2036        srv.dir = Some("/var/lib/web".into());
2037        srv.ephemeral = false;
2038        srv.advertise_tags = vec!["tag:web".into()];
2039        srv.port = Some(41641);
2040        srv.configure(|c| c.accept_routes = true);
2041        srv.store = Some(Arc::new(MemStore::default()));
2042        assert_eq!(srv.hostname.as_deref(), Some("web"));
2043        assert!(!srv.ephemeral);
2044        assert!(srv.store.is_some());
2045        assert!(srv.configure.is_some());
2046    }
2047
2048    // Compile-time assertion: a shared server must be usable across tasks (Arc<Server> + Send/Sync),
2049    // which requires the wrapped Device to be Send + Sync.
2050    fn _assert_send_sync<T: Send + Sync>() {}
2051    #[allow(dead_code)]
2052    fn _server_is_send_sync() {
2053        _assert_send_sync::<Server>();
2054    }
2055
2056    // -----------------------------------------------------------------------------------------
2057    // Config surface: the Go-named `Server` fields → `Config` mapping (docs/TSNET_FACADE_DESIGN.md
2058    // §5) and the `Dir`/`Store` state-root shim over `Config::key_state` (§8). These exercise the
2059    // private async `build_config` directly (same-module access), so the field translation and the
2060    // identity round-trip are *asserted*, not merely compiled.
2061    // -----------------------------------------------------------------------------------------
2062
2063    /// A unique, empty scratch dir for an on-disk state test. The facade adds **no** `tempfile`
2064    /// dependency (the zero-new-dep constraint), so this rolls its own: namespaced by pid + a
2065    /// per-test label (concurrent test binaries never collide) and wiped up front so a stale run
2066    /// can't mask a bug.
2067    fn scratch_dir(label: &str) -> PathBuf {
2068        let pid = std::process::id();
2069        let dir = std::env::temp_dir().join(format!("tsnet-rs-test-{pid}-{label}"));
2070        std::fs::remove_dir_all(&dir).ok();
2071        dir
2072    }
2073
2074    #[tokio::test]
2075    async fn build_config_maps_every_go_field_onto_config() {
2076        // Every Go-parity field set to a non-default; assert build_config copies each onto the
2077        // matching Config field — the §5 mapping table, row by row.
2078        let mut srv = Server::new();
2079        srv.hostname = Some("web".into());
2080        srv.auth_key = Some("tskey-auth-xxxx".into());
2081        srv.control_url = Some("https://control.example.com".into());
2082        srv.ephemeral = false;
2083        srv.advertise_tags = vec!["tag:web".into(), "tag:prod".into()];
2084        srv.port = Some(41641);
2085        srv.run_web_client = true;
2086        srv.client_id = Some("cid".into());
2087        srv.client_secret = Some("csecret".into());
2088        srv.id_token = Some("idtok".into());
2089        srv.audience = Some("aud".into());
2090
2091        let cfg = srv.build_config().await.unwrap();
2092
2093        assert_eq!(cfg.requested_hostname.as_deref(), Some("web"));
2094        assert_eq!(cfg.auth_key.as_deref(), Some("tskey-auth-xxxx"));
2095        assert_eq!(cfg.control_server_url.scheme(), "https");
2096        assert_eq!(
2097            cfg.control_server_url.host_str(),
2098            Some("control.example.com")
2099        );
2100        assert_eq!(
2101            cfg.requested_tags,
2102            vec!["tag:web".to_string(), "tag:prod".to_string()]
2103        );
2104        assert_eq!(cfg.wireguard_listen_port, Some(41641));
2105        assert!(cfg.run_web_client);
2106        assert_eq!(cfg.client_id.as_deref(), Some("cid"));
2107        assert_eq!(cfg.client_secret.as_deref(), Some("csecret"));
2108        assert_eq!(cfg.id_token.as_deref(), Some("idtok"));
2109        assert_eq!(cfg.audience.as_deref(), Some("aud"));
2110        // Tun unset ⇒ the default userspace netstack transport is preserved.
2111        assert_eq!(cfg.transport_mode, crate::TransportMode::Netstack);
2112    }
2113
2114    #[tokio::test]
2115    async fn build_config_maps_go_zero_value_defaults() {
2116        // The complement of `build_config_maps_every_go_field_onto_config`: a Go zero-value `Server`
2117        // (no fields set) must map to the matching `Config` *defaults* — the None/empty passthrough
2118        // direction of the §5 table — never a stale or invented value. (`ephemeral` has its own
2119        // Go-parity override, asserted separately in `build_config_forces_go_default_ephemeral`.)
2120        let cfg = Server::new().build_config().await.unwrap();
2121        assert!(cfg.requested_hostname.is_none(), "unset hostname ⇒ None");
2122        assert!(cfg.requested_tags.is_empty(), "no advertise_tags ⇒ empty");
2123        assert!(cfg.wireguard_listen_port.is_none(), "unset port ⇒ None");
2124        assert!(!cfg.run_web_client, "run_web_client defaults off");
2125        assert!(cfg.auth_key.is_none(), "unset auth_key ⇒ None");
2126        assert!(
2127            cfg.client_id.is_none() && cfg.client_secret.is_none(),
2128            "unset OAuth/WIF client fields ⇒ None"
2129        );
2130        assert!(
2131            cfg.id_token.is_none() && cfg.audience.is_none(),
2132            "unset id_token/audience ⇒ None"
2133        );
2134        // No `tun` requested ⇒ the default userspace netstack transport.
2135        assert_eq!(cfg.transport_mode, crate::TransportMode::Netstack);
2136    }
2137
2138    #[tokio::test]
2139    async fn build_config_forces_go_default_ephemeral() {
2140        // Go's zero-value Server is a *persistent* node, yet a bare Config::default() is ephemeral.
2141        // build_config must force Go's default by always writing config.ephemeral = self.ephemeral.
2142        assert!(
2143            Config::default().ephemeral,
2144            "precondition: a bare Config defaults to ephemeral=true"
2145        );
2146        let cfg = Server::new().build_config().await.unwrap();
2147        assert!(
2148            !cfg.ephemeral,
2149            "a default tsnet::Server maps to a non-ephemeral Config (Go parity)"
2150        );
2151
2152        // …and an explicit opt-in is honored.
2153        let mut srv = Server::new();
2154        srv.ephemeral = true;
2155        assert!(srv.build_config().await.unwrap().ephemeral);
2156    }
2157
2158    #[tokio::test]
2159    async fn build_config_none_control_url_keeps_engine_default() {
2160        let cfg = Server::new().build_config().await.unwrap();
2161        assert_eq!(cfg.control_server_url, Config::default().control_server_url);
2162    }
2163
2164    #[tokio::test]
2165    async fn build_config_rejects_a_bad_control_url() {
2166        let mut srv = Server::new();
2167        srv.control_url = Some("not a url".into());
2168        // `Config` isn't `Debug`, so match on the result rather than `unwrap_err()`.
2169        assert!(matches!(
2170            srv.build_config().await,
2171            Err(Error::InvalidControlUrl(_))
2172        ));
2173    }
2174
2175    #[tokio::test]
2176    async fn build_config_tun_selects_kernel_tun_transport() {
2177        let mut srv = Server::new();
2178        srv.tun = Some(TunSpec {
2179            name: Some("tailscale0".into()),
2180            mtu: Some(1280),
2181        });
2182        let cfg = srv.build_config().await.unwrap();
2183        assert_eq!(
2184            cfg.transport_mode,
2185            crate::TransportMode::Tun(crate::TunConfig {
2186                name: Some("tailscale0".into()),
2187                mtu: Some(1280),
2188            })
2189        );
2190    }
2191
2192    #[tokio::test]
2193    async fn build_config_runs_configure_hook_after_mapping() {
2194        // The escape hatch reaches fork-superset Config knobs that have no Go tsnet field, and runs
2195        // after the field mapping — so both the hook's writes and the mapped fields are present.
2196        let mut srv = Server::new();
2197        srv.hostname = Some("exit".into());
2198        srv.configure(|c| {
2199            c.advertise_exit_node = true;
2200            c.accept_routes = true;
2201        });
2202        let cfg = srv.build_config().await.unwrap();
2203        assert!(cfg.advertise_exit_node);
2204        assert!(cfg.accept_routes);
2205        assert_eq!(cfg.requested_hostname.as_deref(), Some("exit"));
2206    }
2207
2208    #[test]
2209    fn file_store_round_trips_on_disk() {
2210        // The on-disk StateStore (Go store.FileStore): write-then-read, and a fresh store over the
2211        // same dir still sees the value (identity survives a process restart).
2212        let dir = scratch_dir("filestore");
2213        let store = FileStore::new(dir.clone());
2214        assert!(
2215            store.read_state(STATE_KEY).unwrap().is_none(),
2216            "never-written ⇒ None (a missing file is not an error)"
2217        );
2218        store.write_state(STATE_KEY, b"identity-blob").unwrap();
2219        assert!(
2220            dir.join(STATE_FILE).exists(),
2221            "FileStore::new persists under dir/STATE_FILE"
2222        );
2223        assert_eq!(
2224            store.read_state(STATE_KEY).unwrap().as_deref(),
2225            Some(&b"identity-blob"[..])
2226        );
2227        assert_eq!(
2228            FileStore::new(dir.clone())
2229                .read_state(STATE_KEY)
2230                .unwrap()
2231                .as_deref(),
2232            Some(&b"identity-blob"[..]),
2233            "a fresh FileStore over the same dir reloads the persisted value"
2234        );
2235        std::fs::remove_dir_all(&dir).ok();
2236    }
2237
2238    #[test]
2239    fn file_store_at_writes_the_exact_path() {
2240        let dir = scratch_dir("filestore-at");
2241        let path = dir.join("custom.state");
2242        FileStore::at(path.clone())
2243            .write_state(STATE_KEY, b"x")
2244            .unwrap();
2245        assert!(
2246            path.exists(),
2247            "FileStore::at writes to the exact path given"
2248        );
2249        std::fs::remove_dir_all(&dir).ok();
2250    }
2251
2252    #[tokio::test]
2253    async fn dir_persists_node_identity_across_builds() {
2254        // The headline `Dir` shim over Config::key_state (§8): a Dir-rooted node writes the engine
2255        // key file once and reloads the SAME identity on the next boot, instead of re-minting.
2256        let dir = scratch_dir("dir-state-root");
2257
2258        let mut srv = Server::new();
2259        srv.dir = Some(dir.clone());
2260        let cfg1 = srv.build_config().await.unwrap();
2261        assert!(
2262            dir.join(STATE_FILE).exists(),
2263            "Dir persists identity to dir/STATE_FILE via the engine key-file format"
2264        );
2265
2266        let mut srv2 = Server::new();
2267        srv2.dir = Some(dir.clone());
2268        let cfg2 = srv2.build_config().await.unwrap();
2269        assert_eq!(
2270            serde_json::to_vec(&cfg1.key_state).unwrap(),
2271            serde_json::to_vec(&cfg2.key_state).unwrap(),
2272            "a Dir-rooted node reloads a stable identity rather than re-minting each boot"
2273        );
2274
2275        std::fs::remove_dir_all(&dir).ok();
2276    }
2277
2278    #[tokio::test]
2279    async fn custom_store_round_trips_identity_through_build_config() {
2280        // A custom StateStore is the pluggable backend (Go ipn.StateStore): build_config mints and
2281        // writes the identity blob under STATE_KEY, and a second server on the same store reloads it.
2282        let store: Arc<dyn StateStore> = Arc::new(MemStore::default());
2283
2284        let mut srv = Server::new();
2285        srv.store = Some(store.clone());
2286        let cfg1 = srv.build_config().await.unwrap();
2287        assert!(
2288            store.read_state(STATE_KEY).unwrap().is_some(),
2289            "the store now holds the minted identity blob"
2290        );
2291
2292        let mut srv2 = Server::new();
2293        srv2.store = Some(store.clone());
2294        let cfg2 = srv2.build_config().await.unwrap();
2295        assert_eq!(
2296            serde_json::to_vec(&cfg1.key_state).unwrap(),
2297            serde_json::to_vec(&cfg2.key_state).unwrap(),
2298            "a shared store yields a stable identity across servers"
2299        );
2300    }
2301
2302    #[tokio::test]
2303    async fn store_takes_precedence_over_dir() {
2304        // §8 resolution order: an explicit `store` wins over `dir`. The identity lands in the store
2305        // and the Dir key file is never written.
2306        let dir = scratch_dir("store-precedence");
2307        let store: Arc<dyn StateStore> = Arc::new(MemStore::default());
2308
2309        let mut srv = Server::new();
2310        srv.dir = Some(dir.clone());
2311        srv.store = Some(store.clone());
2312        srv.build_config().await.unwrap();
2313
2314        assert!(store.read_state(STATE_KEY).unwrap().is_some());
2315        assert!(
2316            !dir.join(STATE_FILE).exists(),
2317            "store must take precedence over dir (design §8)"
2318        );
2319
2320        std::fs::remove_dir_all(&dir).ok();
2321    }
2322
2323    // --- focused lifecycle tests: start / up / close over
2324    //     Device::new → wait_until_running → status → shutdown ---
2325    //
2326    // The *successful* round-trip needs a live control server, so it stays in integration/e2e. The
2327    // hermetic, unit-testable half of the lifecycle is its fail-fast and no-op behavior: the lazy
2328    // build shared by `start`/`up` runs `build_config` *before* `Device::new`, so a bad config is
2329    // reported without any network I/O; and `close` on a server whose `OnceCell` never initialized
2330    // is a clean no-op. (The field→`Config` mapping itself is covered by the tests above.)
2331
2332    #[tokio::test]
2333    async fn close_on_never_started_server_is_clean() {
2334        // Go `Close()` before any method call: the `OnceCell` is empty, so there is no `Device` to
2335        // `shutdown` and it reports success immediately (the `None => true` arm) — for both an
2336        // unbounded and a finite timeout.
2337        assert!(Server::new().close(None).await);
2338        assert!(Server::new().close(Some(Duration::from_millis(1))).await);
2339    }
2340
2341    #[tokio::test]
2342    async fn start_fails_fast_on_bad_config_before_touching_the_network() {
2343        // `start` maps the fields onto a `Config` and only then calls `Device::new`; a bad
2344        // `control_url` fails in that mapping, so it surfaces as the typed `InvalidControlUrl` with
2345        // no network I/O — never a hang, never a panic. (`Config`/`Status` aren't `Debug`, so match
2346        // on the result rather than `unwrap`.)
2347        let mut s = Server::new();
2348        s.control_url = Some("not a url".into());
2349        assert!(matches!(s.start().await, Err(Error::InvalidControlUrl(_))));
2350    }
2351
2352    #[tokio::test]
2353    async fn up_fails_fast_on_bad_config() {
2354        // `up` shares the same lazy-build entrypoint as `start`, so it fails fast on a bad config
2355        // instead of blocking in `wait_until_running`.
2356        let mut s = Server::new();
2357        s.control_url = Some("not a url".into());
2358        assert!(matches!(s.up(None).await, Err(Error::InvalidControlUrl(_))));
2359    }
2360
2361    #[tokio::test]
2362    async fn close_is_clean_after_a_failed_start() {
2363        // A failed lazy start leaves the `OnceCell` uninitialized (`get_or_try_init` stores nothing
2364        // on error), so a subsequent `close` still finds no `Device` and returns cleanly.
2365        let mut s = Server::new();
2366        s.control_url = Some("not a url".into());
2367        assert!(matches!(s.start().await, Err(Error::InvalidControlUrl(_))));
2368        assert!(s.close(None).await);
2369    }
2370
2371    // -----------------------------------------------------------------------------------------
2372    // Dial surface: Go-style `network`-string parsing + fail-fast, over
2373    // Device::dial / dial_tcp / dial_udp.
2374    //
2375    // A *successful* dial establishes an overlay connection, so it needs a live tailnet (kept in
2376    // integration/e2e). The hermetic, unit-testable half is the facade's own contribution: it
2377    // parses the `network` string BEFORE starting the device, so an unsupported network is a typed
2378    // `UnsupportedNetwork` with no network I/O, and a supported one only then proceeds to the lazy
2379    // start (which a bad `Config` still fails fast, never hangs). These assert that ordering — and
2380    // that the typed accessors `dial_tcp`/`dial_udp` are wired to the engine.
2381    // -----------------------------------------------------------------------------------------
2382
2383    #[tokio::test]
2384    async fn dial_rejects_unsupported_network_fail_fast() {
2385        // Every non-tsnet network string is a typed facade error, echoing the offending value, and
2386        // returns WITHOUT starting the device: a default `Server` has no control server, so if this
2387        // touched the network it would block — returning at all proves the parse is up front.
2388        for n in [
2389            "", "TCP", "tcp5", "sctp", "unix", "ip", "udplite", "tcp ", " udp",
2390        ] {
2391            match Server::new().dial(n, "host:80").await {
2392                Err(Error::UnsupportedNetwork { network }) => assert_eq!(network, n),
2393                Err(e) => panic!("dial({n:?}) should be UnsupportedNetwork, got Err({e:?})"),
2394                Ok(_) => panic!("dial({n:?}) should be UnsupportedNetwork, got Ok(conn)"),
2395            }
2396        }
2397    }
2398
2399    #[tokio::test]
2400    async fn dial_accepts_every_tsnet_network_then_reaches_lazy_start() {
2401        // The six supported networks all pass the facade parse and proceed to the lazy start; with a
2402        // bad control_url that start fails fast at `build_config` (InvalidControlUrl) — never a hang,
2403        // and never UnsupportedNetwork. This proves both that the tsnet set is accepted and that the
2404        // parse precedes the device build. (The `addr` is a realistic per-network example but is not
2405        // reached here — the bad config surfaces before any address resolution; `addr` parsing is
2406        // covered by the `dial` module's own `split_host_port` tests.)
2407        for (n, addr) in [
2408            ("tcp", "host:80"),
2409            ("tcp4", "1.2.3.4:80"),
2410            ("tcp6", "[2001:db8::1]:80"),
2411            ("udp", "host:53"),
2412            ("udp4", "1.2.3.4:53"),
2413            ("udp6", "[2001:db8::1]:53"),
2414        ] {
2415            let mut s = Server::new();
2416            s.control_url = Some("not a url".into());
2417            assert!(
2418                matches!(s.dial(n, addr).await, Err(Error::InvalidControlUrl(_))),
2419                "dial({n:?}, {addr:?}) with a bad control_url should fail fast at config build"
2420            );
2421        }
2422    }
2423
2424    #[tokio::test]
2425    async fn dial_parses_network_before_touching_config() {
2426        // Ordering: an unsupported network is reported even when the control_url is ALSO invalid,
2427        // because the network parse happens before the lazy start that would surface the bad URL.
2428        let mut s = Server::new();
2429        s.control_url = Some("not a url".into());
2430        match s.dial("sctp", "host:80").await {
2431            Err(Error::UnsupportedNetwork { network }) => assert_eq!(network, "sctp"),
2432            Err(e) => panic!("network parse must precede config build, got Err({e:?})"),
2433            Ok(_) => panic!("network parse must precede config build, got Ok(conn)"),
2434        }
2435    }
2436
2437    #[tokio::test]
2438    async fn dial_tcp_and_dial_udp_fail_fast_on_bad_config() {
2439        // The direct typed accessors (dial_tcp → TcpStream, dial_udp → ConnectedUdpSocket) go
2440        // straight to the lazy start, so a bad `Config` fails them fast too — exercising that both
2441        // are wired to the engine and share the fail-fast contract (never a hang).
2442        let mut s = Server::new();
2443        s.control_url = Some("not a url".into());
2444        assert!(matches!(
2445            s.dial_tcp("host:80").await,
2446            Err(Error::InvalidControlUrl(_))
2447        ));
2448
2449        let mut s = Server::new();
2450        s.control_url = Some("not a url".into());
2451        assert!(matches!(
2452            s.dial_udp("host:80").await,
2453            Err(Error::InvalidControlUrl(_))
2454        ));
2455    }
2456
2457    // -----------------------------------------------------------------------------------------
2458    // Loopback dual-credential + in-process LocalAPI HTTP server (the headline gap this closes).
2459    // These are hermetic: the HTTP framing/auth/routing are pure functions, and the server is
2460    // exercised end-to-end over a real `127.0.0.1` socket with a *mock* status backend — no live
2461    // `Device`/tailnet needed. (The successful in-process `Server::loopback` round-trip needs a
2462    // running control server and stays in integration/e2e.)
2463    // -----------------------------------------------------------------------------------------
2464
2465    /// A mock status backend returning a fixed JSON body — the stand-in for `Device::status`.
2466    fn mock_status(body: &'static [u8]) -> localapi::StatusFn {
2467        Arc::new(move || Box::pin(async move { Ok(body.to_vec()) }))
2468    }
2469
2470    /// Control's c2n `/remoteapi/localapi/*` proxy reaches this facade's LocalAPI through
2471    /// [`C2nLocalApi`], which serves the *same* route table as the loopback listener — Go's
2472    /// `handleC2NRemoteAPI` likewise builds its `localapi.Handler` over the same backend.
2473    ///
2474    /// The backend is injected into the slot [`C2nLocalApi::attach`] fills (that call needs a live
2475    /// `Arc<Device>`, which a hermetic test cannot build; all it does is wrap the device in
2476    /// [`device_status_fn`]). The proxy is authorized upstream, in `ts_control`'s c2n responder, so
2477    /// there is deliberately no credential or `Sec-Tailscale` gate here: the paths that reach
2478    /// `serve` have already passed the `Config::remote_config` check.
2479    #[tokio::test]
2480    async fn c2n_local_api_serves_the_same_routes_as_the_loopback_server() {
2481        use ts_control::LocalApi as _;
2482
2483        let hook = C2nLocalApi::default();
2484        assert!(hook.status.set(mock_status(br#"{"ok":true}"#)).is_ok());
2485
2486        let served = hook.serve("GET", "/localapi/v0/status", "").await;
2487        let (code, body) = parse_response(served.as_bytes()).expect("a full HTTP/1.1 response");
2488        assert_eq!(code, 200);
2489        assert_eq!(body, br#"{"ok":true}"#);
2490
2491        // The query string rides through the proxy (`ts_control` keeps it) and is ignored by the
2492        // route table, exactly as it is for a loopback request.
2493        let (code, body) = parse_response(
2494            hook.serve("GET", "/localapi/v0/status?peers=true", "")
2495                .await
2496                .as_bytes(),
2497        )
2498        .expect("a full HTTP/1.1 response");
2499        assert_eq!(code, 200);
2500        assert_eq!(body, br#"{"ok":true}"#);
2501
2502        // Everything else this facade does not serve is a LocalAPI 404 — distinct from the c2n
2503        // responder's own `400 unknown c2n path`, so control can tell "no such LocalAPI endpoint"
2504        // apart from "no such c2n route".
2505        let (code, _) =
2506            parse_response(hook.serve("GET", "/localapi/v0/prefs", "").await.as_bytes())
2507                .expect("a full HTTP/1.1 response");
2508        assert_eq!(code, 404);
2509        let (code, _) = parse_response(
2510            hook.serve("POST", "/localapi/v0/status", "")
2511                .await
2512                .as_bytes(),
2513        )
2514        .expect("a full HTTP/1.1 response");
2515        assert_eq!(code, 404);
2516    }
2517
2518    /// Before [`C2nLocalApi::attach`] there is no backend to route to at all, so the proxy answers
2519    /// `503` instead of claiming the endpoint does not exist. (A device reclaimed *after* attach is
2520    /// a backend failure and surfaces as the LocalAPI's own `500`, like any loopback request.)
2521    #[tokio::test]
2522    async fn c2n_local_api_reports_503_before_it_is_attached() {
2523        use ts_control::LocalApi as _;
2524
2525        let hook = C2nLocalApi::default();
2526        let (code, body) = parse_response(
2527            hook.serve("GET", "/localapi/v0/status", "")
2528                .await
2529                .as_bytes(),
2530        )
2531        .expect("a full HTTP/1.1 response");
2532        assert_eq!(code, 503);
2533        assert_eq!(body, b"device unavailable");
2534    }
2535
2536    #[test]
2537    fn gen_cred_is_32_lowercase_hex() {
2538        let cred = gen_cred();
2539        assert_eq!(cred.len(), 32, "16 random bytes → 32 hex chars (Go parity)");
2540        assert!(
2541            cred.chars()
2542                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
2543        );
2544        assert_ne!(gen_cred(), gen_cred(), "credentials are random per call");
2545    }
2546
2547    #[test]
2548    fn find_subslice_locates_header_terminator() {
2549        assert_eq!(find_subslice(b"ab\r\n\r\ncd", b"\r\n\r\n"), Some(2));
2550        assert_eq!(find_subslice(b"no terminator", b"\r\n\r\n"), None);
2551        assert_eq!(find_subslice(b"", b"\r\n\r\n"), None);
2552    }
2553
2554    #[test]
2555    fn parse_response_extracts_code_and_body() {
2556        let (code, body) =
2557            parse_response(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi").unwrap();
2558        assert_eq!(code, 200);
2559        assert_eq!(body, b"hi");
2560        assert_eq!(parse_response(b"garbage without terminator"), None);
2561    }
2562
2563    #[test]
2564    fn parse_head_reads_method_target_auth_and_sec_tailscale() {
2565        // "user:pass" base64 = dXNlcjpwYXNz
2566        let head = b"GET /localapi/v0/status HTTP/1.1\r\nHost: x\r\nSec-Tailscale: localapi\r\nAuthorization: Basic dXNlcjpwYXNz\r\nAccept: */*";
2567        let (method, target, password, sec_tailscale) = localapi::parse_head(head).unwrap();
2568        assert_eq!(method, "GET");
2569        assert_eq!(target, "/localapi/v0/status");
2570        assert_eq!(password.as_deref(), Some("pass"));
2571        assert_eq!(
2572            sec_tailscale.as_deref(),
2573            Some("localapi"),
2574            "captures the anti-rebinding header"
2575        );
2576
2577        // A head *without* the header parses fine with `sec_tailscale = None` (the handler then 403s).
2578        let no_hdr =
2579            b"GET /localapi/v0/status HTTP/1.1\r\nHost: x\r\nAuthorization: Basic dXNlcjpwYXNz";
2580        let (_, _, _, sec_tailscale) = localapi::parse_head(no_hdr).unwrap();
2581        assert_eq!(sec_tailscale, None);
2582    }
2583
2584    #[test]
2585    fn parse_head_rejects_malformed_request_line() {
2586        assert!(localapi::parse_head(b"GET-only-one-token").is_none());
2587        assert!(
2588            localapi::parse_head(b"GET /x").is_none(),
2589            "needs a version token"
2590        );
2591    }
2592
2593    #[test]
2594    fn basic_auth_password_ignores_username() {
2595        // Go authenticates on the password only; the username is ignored.
2596        // base64("anyuser:the-cred") and base64(":the-cred") both yield "the-cred".
2597        let with_user = STANDARD.encode("anyuser:the-cred");
2598        let no_user = STANDARD.encode(":the-cred");
2599        assert_eq!(
2600            localapi::basic_auth_password(&format!("Basic {with_user}")).as_deref(),
2601            Some("the-cred")
2602        );
2603        assert_eq!(
2604            localapi::basic_auth_password(&format!("Basic {no_user}")).as_deref(),
2605            Some("the-cred")
2606        );
2607        // The auth scheme is case-insensitive (RFC 7617 / Go `EqualFold`).
2608        assert_eq!(
2609            localapi::basic_auth_password(&format!("bAsIc {with_user}")).as_deref(),
2610            Some("the-cred")
2611        );
2612        // A non-Basic scheme, no scheme, or garbage base64 is not accepted.
2613        assert!(localapi::basic_auth_password("Bearer xyz").is_none());
2614        assert!(localapi::basic_auth_password("Basic !!!not-base64").is_none());
2615        assert!(localapi::basic_auth_password("no-space-token").is_none());
2616    }
2617
2618    #[test]
2619    fn cred_ok_matches_only_exact_credentials() {
2620        assert!(localapi::cred_ok("abc123", "abc123"));
2621        assert!(!localapi::cred_ok("abc123", "abc124"));
2622        assert!(
2623            !localapi::cred_ok("abc", "abc123"),
2624            "length mismatch is a mismatch"
2625        );
2626        assert!(!localapi::cred_ok("", "x"));
2627    }
2628
2629    #[test]
2630    fn status_json_serializes_status_snapshot() {
2631        // Build a snapshot and assert the emitted JSON reflects it (the fork's `Status` is not a
2632        // serde type, so `status_json` builds the object by hand — this pins that mapping).
2633        use crate::StableNodeId;
2634        let node = StatusNode {
2635            stable_id: StableNodeId("nabc123".to_string()),
2636            display_name: "web.tail0.ts.net".to_string(),
2637            ipv4: "100.64.0.1".parse().unwrap(),
2638            ipv6: "fd7a:115c:a1e0::1".parse().unwrap(),
2639            online: Some(true),
2640            last_seen: None,
2641            allowed_routes: vec![],
2642            is_exit_node: false,
2643            cur_addr: None,
2644            relay: Some("nyc".to_string()),
2645            ssh_host_keys: vec![],
2646        };
2647        let status = Status {
2648            self_node: Some(node),
2649            peers: vec![],
2650            active_exit_node: None,
2651            magic_dns_suffix: Some("tail0.ts.net".to_string()),
2652        };
2653        let bytes = status_json(&status);
2654        let v: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
2655        assert_eq!(v["self"]["stable_id"], "nabc123");
2656        assert_eq!(v["self"]["display_name"], "web.tail0.ts.net");
2657        assert_eq!(v["self"]["ipv4"], "100.64.0.1");
2658        assert_eq!(v["self"]["online"], true);
2659        assert_eq!(v["self"]["relay"], "nyc");
2660        assert_eq!(v["magic_dns_suffix"], "tail0.ts.net");
2661        assert!(v["peers"].as_array().unwrap().is_empty());
2662        assert!(v["active_exit_node"].is_null());
2663    }
2664
2665    #[tokio::test]
2666    async fn localapi_server_authenticates_and_routes_over_real_socket() {
2667        // Bind the real in-process LocalAPI server on 127.0.0.1 with a mock status backend, then
2668        // drive it with the dependency-free client — exercising accept → parse → auth → route →
2669        // respond end-to-end.
2670        let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
2671            .await
2672            .unwrap();
2673        let addr = listener.local_addr().unwrap();
2674        let cred = "s3cr3t-cred".to_string();
2675        let task = tokio::spawn(localapi::serve(
2676            listener,
2677            cred.clone(),
2678            mock_status(br#"{"ok":true}"#),
2679        ));
2680
2681        // Correct credential → 200 + the backend's JSON body.
2682        let (code, body) = localapi_client_get(addr, &cred, "/localapi/v0/status")
2683            .await
2684            .unwrap();
2685        assert_eq!(code, 200);
2686        assert_eq!(body, br#"{"ok":true}"#);
2687
2688        // Authenticated but unknown path → 404.
2689        let (code, _) = localapi_client_get(addr, &cred, "/localapi/v0/nope")
2690            .await
2691            .unwrap();
2692        assert_eq!(code, 404);
2693
2694        // Wrong credential (the client still sends the Sec-Tailscale header) → 401.
2695        let (code, _) = localapi_client_get(addr, "wrong-cred", "/localapi/v0/status")
2696            .await
2697            .unwrap();
2698        assert_eq!(code, 401);
2699
2700        // Missing Authorization header, but *with* the required Sec-Tailscale header → 401.
2701        {
2702            use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
2703            let mut sock = TcpStream::connect(addr).await.unwrap();
2704            sock.write_all(
2705                b"GET /localapi/v0/status HTTP/1.1\r\nHost: x\r\nSec-Tailscale: localapi\r\nConnection: close\r\n\r\n",
2706            )
2707            .await
2708            .unwrap();
2709            let mut resp = Vec::new();
2710            sock.read_to_end(&mut resp).await.unwrap();
2711            assert_eq!(parse_response(&resp).unwrap().0, 401);
2712        }
2713
2714        // Anti-DNS-rebinding: a *valid* credential but NO `Sec-Tailscale` header is rejected 403,
2715        // before auth is even considered (Go's browser-rebinding guard).
2716        {
2717            use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
2718            let auth = STANDARD.encode(format!(":{cred}"));
2719            let mut sock = TcpStream::connect(addr).await.unwrap();
2720            sock.write_all(
2721                format!(
2722                    "GET /localapi/v0/status HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {auth}\r\nConnection: close\r\n\r\n"
2723                )
2724                .as_bytes(),
2725            )
2726            .await
2727            .unwrap();
2728            let mut resp = Vec::new();
2729            sock.read_to_end(&mut resp).await.unwrap();
2730            assert_eq!(
2731                parse_response(&resp).unwrap().0,
2732                403,
2733                "no Sec-Tailscale header → 403 even with a valid credential"
2734            );
2735        }
2736
2737        task.abort();
2738    }
2739
2740    #[tokio::test]
2741    async fn local_client_round_trips_through_the_localapi_server() {
2742        // `LocalClient` is what `Server::local_client()` hands back: point one at a running server
2743        // and assert its accessors + `status()`/`get()` round-trip through real HTTP.
2744        let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
2745            .await
2746            .unwrap();
2747        let addr = listener.local_addr().unwrap();
2748        let cred = "local-api-cred".to_string();
2749        let task = tokio::spawn(localapi::serve(
2750            listener,
2751            cred.clone(),
2752            mock_status(br#"{"self":null,"peers":[]}"#),
2753        ));
2754
2755        let client = LocalClient {
2756            address: addr,
2757            cred: cred.clone(),
2758        };
2759        assert_eq!(client.address(), addr);
2760        assert_eq!(client.credential(), cred);
2761
2762        let body = client.status().await.unwrap();
2763        assert_eq!(body, br#"{"self":null,"peers":[]}"#);
2764
2765        let (code, _) = client.get("/localapi/v0/status").await.unwrap();
2766        assert_eq!(code, 200);
2767
2768        // A wrong-credential client sees the 401 surfaced as an error from `status()`.
2769        let bad = LocalClient {
2770            address: addr,
2771            cred: "nope".to_string(),
2772        };
2773        assert!(matches!(bad.status().await, Err(Error::Loopback(_))));
2774
2775        task.abort();
2776    }
2777
2778    #[test]
2779    fn loopback_result_carries_both_distinct_credentials() {
2780        // Shape assertion: the `Loopback` result exposes both Go credentials + both addresses, and
2781        // `Clone`/`Debug` derive (so it can be logged/stored). Distinctness of the two creds is the
2782        // point of this task.
2783        let lb = Loopback {
2784            address: "127.0.0.1:1080".parse().unwrap(),
2785            proxy_cred: "proxy".to_string(),
2786            local_api_address: "127.0.0.1:1081".parse().unwrap(),
2787            local_api_cred: "localapi".to_string(),
2788        };
2789        let cloned = lb.clone();
2790        assert_ne!(cloned.proxy_cred, cloned.local_api_cred);
2791        assert_ne!(cloned.address, cloned.local_api_address);
2792        assert!(format!("{cloned:?}").contains("local_api_cred"));
2793    }
2794}