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