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