Status: design, with a compiling reference skeleton landed behind the tsnet cargo feature
(src/tsnet.rs).
Scope: a thin ergonomics layer only. No new engine, no new published crate, no dataplane /
control / netstack changes.
Companion: docs/TSNET_PARITY.md is the authoritative field/method parity
matrix; this doc designs the shape that wraps the surface that matrix enumerates. Where they
overlap, the matrix's verdicts are authoritative.
Compiled 2026-07-12 against GeiserX/tailscale-rs feat/tsnet-facade (base 3ea6d32, the parity
commit).
The fork's native embedding surface — Device + Config — is already a near-complete
superset of Go tsnet.Server (parity matrix
"Bottom line"). It is idiomatic Rust: build a Config, then
let dev = Device::new(&config, auth_key).await?;Go's shape is different — a struct you fill in, then call:
srv := &tsnet.Server{Hostname: "web", AuthKey: key}
ln, _ := srv.Listen("tcp", ":80") // Start() happens lazily on first call
defer srv.Close()A large body of code and muscle memory targets that shape. The goal is a Server type that presents
Go's lifecycle model and names while forwarding straight to the existing Device.
"Go-idiomatic" is scoped to the shape, not the types. A thin facade must not re-wrap the
engine's returns into net.Conn / net.Listener trait objects — that would be a translation layer,
not a facade, and would discard the fork's typed values. So the design draws a deliberate line:
| Mirrors Go | Stays Rust-native |
|---|---|
Settable fields on Server, read at first use |
Field types (Option<String>, Vec<String>, PathBuf) |
Lazy start; up / close; Listen/Dial/Loopback/ListenFunnel/ListenService names |
Return types: DialConn, netstack::TcpListener, … (no trait-object wrapping) |
network, addr strings ("tcp", ":80") accepted for familiarity |
Parsed to typed SocketAddr before hitting the engine |
A single lifecycle error (Go's opaque error) |
Typed errors preserved on specialized calls (FunnelError, ServiceError) |
- Not a re-implementation. Every method is a one-liner delegation to
Device. - Not a new crate. It ships as
#[cfg(feature = "tsnet")] pub mod tsnetin the root crate, so it can iterate againstDevice/Configin-tree with no versioning surface. Extracting atailscale-tsnetcrate later is a pure re-export move if ever wanted (see §12). - Not an engine change. Where Go exposes a knob the engine doesn't yet thread (e.g.
FunnelTLSConfig), the facade surfaces the option and documents the gap rather than faking it.
| Decision | Choice | Rationale |
|---|---|---|
| Module | tailscale::tsnet |
Matches the Go package name; discoverable; use tailscale::tsnet::Server. |
| Wrapper type | tsnet::Server |
Same name/role as Go's tsnet.Server. |
| Feature flag | tsnet (Cargo.toml: tsnet = ["dep:base64"]) |
Off by default. Reuses the crate's existing serde_json/tokio/thiserror/rand; adds one new optional, direct dependency edge — base64 (HTTP Basic-auth for the loopback LocalAPI). It was already in the workspace graph, so no new crate is compiled — but it is a new direct edge for tailscale, not literally "zero new deps". |
| Lifecycle error | tsnet::Error |
One Go-shaped error for the lifecycle path (§7). |
| State store | tsnet::StateStore / FileStore / MemStore |
Go ipn.StateStore / store.FileStore / mem.Store analogs (§8). |
| Funnel options | tsnet::FunnelOptions (funnel_only, with_tls) |
Rust-idiomatic collapse of Go's variadic ...FunnelOption (§9). |
| Service listener | tsnet::ServiceListener |
Go's *tsnet.ServiceListener (net.Listener + FQDN) analog (§10). |
| Loopback result | tsnet::Loopback |
Names Go's (addr, proxyCred, localAPICred, err) tuple — both creds + a running LocalAPI HTTP server (§11). |
| LocalAPI client | tsnet::LocalClient |
Go's LocalClient() return — a client that round-trips through the loopback LocalAPI (§11). |
Methods keep Go's names in Rust snake_case: start, up, close, dial, listen,
listen_packet, listen_funnel, listen_service, loopback, tailscale_ips, cert_domains,
status, logout. The full LocalClient surface is reached via Server::device() (§6).
This is the crux. Go's Server fields "may be changed until the first method call" (tsnet.go:205);
the first Dial/Listen/… triggers a lazy Start(). The engine anchor is:
// src/lib.rs:324
pub async fn new(config: &Config, auth_key: Option<String>) -> Result<Device, Error>Device::new is Go's Start (it spawns the runtime and connects during construction). So the
facade holds the settable fields plus a lazily-initialized Device:
pub struct Server {
pub hostname: Option<String>, // + the other Go-parity fields (§5)
// …
configure: Option<ConfigureHook>, // escape hatch to Config (§5)
device: tokio::sync::OnceCell<Device>,
}- First method call builds and starts. Each method calls
self.started().await?, which isself.device.get_or_try_init(|| self.build_and_start()).await.build_and_startmaps the fields onto aConfig(§5), applies theconfigurehook, thenDevice::new(&config, auth_key). OnceCell, notMutex<Option<Device>>. Async init-once with no re-lock on the hot path; methods borrow&Devicefor the device's life.- Rust enforces Go's ordering at compile time — for free. Methods borrow
&self; mutating a field needs&mut self. So "set fields before the first method call" becomes a borrow-checker guarantee for the common single-owner case, stricter (and safer) than Go's runtime looseness. For shared use (Arc<Server>across tasks for concurrent listeners), fields are simply frozen at share time — which is exactly the intended contract.
| Go | tsnet::Server |
Delegates to | Note |
|---|---|---|---|
Start() error |
start(&self) -> Result<(), Error> |
Device::new (once) |
Idempotent via OnceCell. |
Up(ctx) (*Status, error) |
up(&self, Option<Duration>) -> Result<Status, Error> |
wait_until_running + status |
Typed RegistrationError on failure — richer than Go's status blob. |
Close() error |
close(self, Option<Duration>) -> bool |
Device::shutdown |
Consumes self; OnceCell::into_inner() reclaims the Device; never-started ⇒ true. |
| (implicit) | device(&self) -> Result<&Device, Error> |
— | Escape hatch to the full engine surface. |
caller sets fields first method call delegation
┌────────────────┐ build_config() ┌───────────┐ Device::new ┌──────────┐
│ tsnet::Server │ ─────────────────▶ │ Config │ ────────────▶ │ Device │
│ hostname … │ (§5 mapping) │ key_state │ (§3 lazy) │ (engine) │
│ dir / store ──┼── §8 StateStore ──▶│ = ident │ └────┬─────┘
└────────────────┘ └───────────┘ │
│ listen()/dial()/loopback()/listen_funnel()/listen_service() … │
└────────────────────────────────────────────────────────────────┘
thin forwarding, engine types returned
Go's exported Server fields become Server fields that are copied onto a fresh Config in
build_config. Line numbers are src/config.rs unless noted; verdicts reconcile with the parity
matrix §2.
Go Server field |
tsnet::Server field |
→ Config field / call |
Verdict | Notes |
|---|---|---|---|---|
Hostname string |
hostname: Option<String> |
requested_hostname :47 |
PRESENT | |
AuthKey string |
auth_key: Option<String> |
auth_key :402 and the auth_key arg of Device::new |
PRESENT | Env fallback TS_AUTH_KEY still honored by the engine. |
ControlURL string |
control_url: Option<String> |
control_server_url :32 (parsed) |
PRESENT | Bad URL ⇒ Error::InvalidControlUrl. None ⇒ engine default. |
Ephemeral bool |
ephemeral: bool |
ephemeral :59 |
PRESENT | Default nuance: Go defaults false; bare Config::default() defaults true (:657). The facade forces Go's default by always writing config.ephemeral = self.ephemeral (default false). |
AdvertiseTags []string |
advertise_tags: Vec<String> |
requested_tags :50 |
PRESENT | |
Port uint16 |
port: Option<u16> |
wireguard_listen_port :254 |
PRESENT | Pin nuance tracked under tsr-reh3. |
RunWebClient bool |
run_web_client: bool |
run_web_client :371 |
PARTIAL | Pref carried, no embedded web client runs (matrix §2a). |
ClientID string |
client_id: Option<String> |
client_id :408 |
PRESENT | |
ClientSecret string |
client_secret: Option<String> |
client_secret :416 |
PRESENT | |
IDToken string |
id_token: Option<String> |
id_token :422 |
PRESENT | |
Audience string |
audience: Option<String> |
audience :429 |
PRESENT | |
Tun tun.Device |
tun: Option<TunSpec> |
use_tun(name, mtu) :455 → transport_mode :265 |
PRESENT (diff. shape) | Rust selects TUN by name/MTU, not a caller tun.Device. |
Dir string |
dir: Option<PathBuf> |
→ key_state :22 via key file (§8) |
PARTIAL (identity only) | Real gap designed over key_state; see §8. |
Store ipn.StateStore |
store: Option<Arc<dyn StateStore>> |
→ key_state :22 (§8) |
PARTIAL (identity only) | Pluggable store trait; see §8. |
Logf / UserLogf logger.Logf |
— (omitted) | — | ABSENT | No injectable logger; backend uses tracing. Accepting-and-dropping a closure would be dishonest, so it is left off (matrix §2a, tsr-reh3). |
Go's Server exposes only the tsnet knobs. The fork's Config has many more (accept-routes,
exit nodes, residential-proxy exit egress, TKA, tcp_buffer_size, …). To stay thin without
becoming lossy, the facade sets only the Go-parity subset and offers one hook:
pub fn configure<F: Fn(&mut Config) + Send + Sync + 'static>(&mut self, f: F) -> &mut SelfIt runs after the field mapping and before Device::new, so power users reach the full Config
without leaving the facade. Server::device() remains the deeper escape hatch to the whole engine
surface.
Every Server method is a thin forward. LocalClient's ~40 methods are not re-wrapped (that
would bloat the facade); the headline ones are folded onto Server, and the rest are reached through
Server::device() — mirroring the fork's own "Device is the in-process client" decision
(matrix §4, §5.5b).
| Go | tsnet::Server |
Delegates to (src/lib.rs) |
Return |
|---|---|---|---|
Dial(ctx, net, addr) |
dial(net, addr) |
dial :610 |
DialConn |
| — | dial_tcp(addr) |
dial_tcp :641 |
netstack::TcpStream |
Listen(net, addr) |
listen(net, addr) |
tcp_listen :457 (addr parsed) |
netstack::TcpListener |
ListenPacket(net, addr) |
listen_packet(net, addr) |
listen_packet :697 |
netstack::UdpSocket |
ListenFunnel(net, addr, opts…) |
listen_funnel(cfg, opts) |
listen_funnel :1874 |
FunnelAcceptedReceiver (§9) |
ListenService(name, mode) |
listen_service(name, mode) |
listen_service :1940 |
ServiceListener (§10) |
Loopback() |
loopback() |
loopback :774 + facade LocalAPI server |
Loopback (§11) |
LocalClient() |
local_client() |
facade LocalAPI HTTP client over the loopback | [LocalClient] (§11) |
TailscaleIPs() |
tailscale_ips() |
tailscale_ips :434 |
(Ipv4Addr, Option<Ipv6Addr>) |
CertDomains() |
cert_domains() |
cert_domains :860 |
Vec<String> |
LocalClient().Status |
status() |
status :1207 |
Status |
LocalClient().Logout |
logout() |
logout :1364 |
() |
Sys() / LocalClient() |
device() |
— | &Device (full surface) |
Feature-gated (mirroring the engine's own gates), included in the design but exercised only with the
matching feature: http_client() → Device::http_connector (hyper), listen_ssh(...) →
ssh::listen_ssh (ssh, a documented superset — matrix §5.5a), listen_tls(...) →
Device::listen_tls (acme for real issuance; the acceptor is ring-only), and cert_pair(...) →
Device::cert_pair (acme-gated — Go LocalClient().CertPair, the on-disk .crt/.key PEM pair).
All three validate the cert name/serve config at the facade boundary (fail-fast, before the lazy
device start) and keep the fork's typed ts_control::CertError, not the unified lifecycle Error.
Go returns one opaque error everywhere. The fork returns typed errors. The design unifies only
the lifecycle path and preserves the specialized typed errors — because that is where the type
information is actually actionable and where the engine already draws the line.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
Device(#[from] crate::Error), // engine/device error
Registration(#[from] RegistrationError), // Up / wait_until_running (permanent vs transient vs needs-login)
InvalidControlUrl(url::ParseError), // building Config from a bad control_url
InvalidAddr { addr: String, source: std::net::AddrParseError }, // parsing "tcp"/":80"
Store(std::io::Error), // Dir/Store persistence
State(serde_json::Error), // (de)serializing the identity blob
Logout(#[from] crate::LogoutError),
}#[from]conversions make delegation one-liners (Ok(dev.status().await?)).crate::ErrorisCopy+std::error::Error;RegistrationError,LogoutErrorarethiserrorenums — all compose cleanly.- Specialized calls keep their engine errors as a variant of a thin wrapper that also keeps a
lazy-start (lifecycle) failure distinct — the wrapper must start the node before it can funnel/serve,
and a startup failure is not the same thing as an access/bind refusal, so it is never misreported as
one:
listen_funnel→ [ListenFunnelError]: theFunnel(ts_control::FunnelError)variant is the engine's fail-closed access gate (NotAllowed/PortNotAllowed, plusCert) verbatim; theStart([Error])variant carries a lazy-start failure with its real cause — never flattened toFunnelError::NotAllowed(which would read as an ACL denial for a node that simply never registered).listen_service→ [ListenServiceError]: theService(ServiceError)variant keeps the engine error (whoseUntaggedHostvariant is Go'sErrUntaggedServiceHost, §10); theStart([Error])variant carries a lazy-start failure — never flattened toServiceError::Listen(a listener-bind failure).
#[non_exhaustive]so new variants (e.g. whenLogfor prefs-persistence land) are not a breaking change.impl std::error::Errormeans a caller who wants Go's "just an error" can stillBox<dyn Error>/?it uniformly.
Rationale for not flattening everything into one enum: Go's single error loses the
NotAllowed-vs-PortNotAllowed-vs-UntaggedHost distinction that the fork worked to make typed and
fail-closed. Collapsing it in the facade would be a regression in expressiveness dressed as parity.
The parity matrix flags this as the one genuinely unbuilt headline gap (§5.3): Go's Dir
resolves to a FileStore at Dir/tailscaled.state and persists the full node state (prefs +
netmap + machine key); the fork persists only node identity keys, via a JSON key file loaded once
at construction.
Config::key_state: PersistState (config.rs:22) is the sole durable state.
PersistState::default() (ts_keys/src/keystate.rs:96) mints fresh keys client-side —
MachinePrivateKey::random(), NodePrivateKey::random(), NetworkLockPrivateKey::random() — so
identity is created at config time, not during registration. Persistence is therefore a simple
load-or-init: read the blob if present, else generate once and write. Config::default_with_key_file
(config.rs:438) already does exactly this for a file path.
pub trait StateStore: Send + Sync {
fn read_state(&self, id: &str) -> std::io::Result<Option<Vec<u8>>>;
fn write_state(&self, id: &str, value: &[u8]) -> std::io::Result<()>;
}
pub struct FileStore { /* dir/tailscale-rs.state */ } // Go store.FileStore
#[derive(Default)] pub struct MemStore { /* HashMap */ } // Go mem.Store
pub const STATE_KEY: &str = "_tailscale-rs/persist";Resolution order in build_config (honest and minimal):
storeset → round-tripPersistStateas JSON underSTATE_KEY(serde_json). Absent ⇒ mint (PersistState::default()) and write back. Enables DB / secret-manager / custom backends.dirset (nostore) → reuseConfig::default_with_key_file(dir/STATE_FILE)— the engine's own key-file format and migration path (KeyFileOld→KeyFileNew), zero new serialization. This is the standard on-disk identity.- neither →
Config::default()⇒ an ephemeral in-memory identity (fresh every run), loudly documented. (A future refinement can mirror Go's default ofos.UserConfigDir()/tsnet-<binary>; deferred to avoid adding a directory-resolution dependency now.)
The trait deliberately uses the general KV shape (not a read_persist()/write_persist() pair) so it
stays forward-compatible: when the engine grows prefs/netmap persistence, additional keys slot in
with no caller change. Node-key rotation (Config::rotate_node_key, config.rs:517) leaves
re-serialization to the owner; the facade's write-back point is construction, and a rotation is
modeled as "mutate identity → re-create the Server" (the OnceCell is single-shot by design).
A
tsnetDir/Storehere persists node identity keys — the state that actually governs node continuity (a stable identity across restarts instead of re-registering every boot). Prefs and netmap persistence remain an engine-level gap (parity §5.3); theStateStoretrait is shaped to absorb them later without an API break.
Go models funnel knobs as a variadic ...FunnelOption with two constructors, both of which exist in
current Go (verified against the pinned commit):
func FunnelOnly() FunnelOption // reject tailnet-internal conns
func FunnelTLSConfig(conf *tls.Config) FunnelOption // bring-your-own TLSThe Rust-idiomatic collapse of variadic options is one options value with Go-named constructors:
#[derive(Default)]
pub struct FunnelOptions {
pub funnel_only: bool, // Go FunnelOnly()
pub tls: Option<crate::TlsAcceptor>, // Go FunnelTLSConfig(conf) — see gap below
}
impl FunnelOptions {
pub fn funnel_only() -> Self { /* funnel_only = true */ }
pub fn with_tls(self, acceptor: crate::TlsAcceptor) -> Self { /* … */ }
}
impl From<FunnelOptions> for ts_control::FunnelOptions { /* funnel_only passthrough */ }funnel_onlymaps straight to the engine's existingts_control::FunnelOptions { funnel_only }(ts_control/src/serve.rs:287).FunnelTLSConfigis a designed-over engine gap. The engine'sDevice::listen_funnel(lib.rs:1874) always builds its own acceptor from the node's*.ts.netcert and currently ignoresoptsfor TLS. The facade therefore acceptstlsfor surface stability,warn!s if set, and documents the exact engine change needed to honor it (thread the acceptor intoFunnelManager::new). This matches how the engine already treatsfunnel_onlyas partly a no-op on the relay-fed ingress path — the facade is faithful about it rather than silently dropping it.- Shape delta to document: Go's
ListenFunnel(net, addr, opts…)returns anet.Listener; the fork returns aFunnelAcceptedReceiverand takes aServeConfig(MagicDNS name + port + target) instead of a bareaddr. The facade forwards the fork's shape directly. A future convenience overloadlisten_funnel_addr(net, addr, opts)can synthesize theServeConfigfromcert_domains()+ServeTarget::Accept; kept out of the thin core to avoid guessing the target.
ListenService is where current Go has moved ahead of the fork, so the design both re-exports the
existing types and records the deltas precisely.
- Fork (
ts_control::ServiceMode,service.rs:29):enum { Tcp { port }, Http { port } }— re-exported at the crate root already, so the facade re-exports it unchanged (thin). - Current Go:
ServiceModeis now an interface with richer structs —ServiceModeTCP { Port, TerminateTLS, PROXYProtocolVersion }andServiceModeHTTP { Port, HTTPS, AcceptAppCaps, PROXYProtocol }. The fork covers onlyPort.TerminateTLS,HTTPS,PROXYProtocol*, andAcceptAppCapsare engine-level gaps documented here; the facade does not invent fields the engine can't honor. When the engine grows them, the enum variants gain fields and the facade re-export follows for free.
Current Go's ListenService returns *ServiceListener — a net.Listener plus an FQDN. The
fork's Device::listen_service returns a bare netstack::TcpListener. The facade adds the missing
newtype (thin — it wraps the returned listener and one resolved string):
pub struct ServiceListener { inner: netstack::TcpListener, fqdn: String }
impl ServiceListener { pub fn fqdn(&self) -> &str; pub fn into_inner(self) -> netstack::TcpListener; }
impl std::ops::Deref for ServiceListener { type Target = netstack::TcpListener; /* .accept() */ }fqdn is filled from Device::self_node().await?.fqdn(false) (ts_control/src/node.rs:248). Deref
means callers keep .accept()-ing the overlay listener transparently.
Go is a sentinel var ErrUntaggedServiceHost = errors.New("service hosts must be tagged nodes"). The
fork already models it as a typed variant — ServiceError::UntaggedHost (service.rs:62),
enforced in resolve_service_listen (service.rs:115). The facade preserves that typed variant
(better than a sentinel: matches!(e, ServiceError::UntaggedHost)), and its rustdoc names the Go
equivalence so a Go reader finds it.
tsnet::Loopback { address, proxy_cred, local_api_address, local_api_cred } implements the full
Go Loopback() (addr, proxyCred, localAPICred, err) surface: both credentials, plus an
in-process LocalAPI HTTP server that is actually running and serving (Go localapi.Handler on
the loopback). This closes the gap the parity matrix flagged (matrix §5.1, tsr-ask7).
- SOCKS5 half — the engine's own
Device::loopback(lib.rs:774) is unchanged: it binds127.0.0.1:0and serves SOCKS5 (RFC 1928/1929) gated byproxy_cred(usernametsnet). - LocalAPI half —
Server::loopbacklayers a facade-owned HTTP/1.1 server on a second127.0.0.1:0listener, gated by a separatelocal_api_cred(HTTP Basic-auth password; any username, matching Go). It servesGET /localapi/v0/statusbacked byDevice::status, returning its JSON. The server is hand-rolled overtokioTCP — the crate'shyperis HTTP/2-client-only (Cargo.toml), so there is no ready-made HTTP server to reuse; this mirrors how the SOCKS5 half hand-rolls its own protocol insrc/loopback.rs. Thetsnetfeature adds one new optional, direct dependency edge —base64(Basic-auth decode;tsnet = ["dep:base64"]) — whilerand(the credential) is already a direct dependency.base64was already in the workspace graph, so no new crate is compiled; it is a new direct edge fortailscale, not literally "zero new deps". No TLS, so the ring-only crypto invariant is untouched. Server::local_client()(GoLocalClient()) returns a dependency-freeLocalClientthat round-trips authenticatedGETs through that server (/localapi/v0/status), andServer::http_client()(GoHTTPClient(),#[cfg(feature = "hyper")]) returns ahyper_utilclient overDevice::http_connector.
Go muxes SOCKS5 + LocalAPI on a single listener (first-byte demux). This fork runs two
127.0.0.1 listeners — one per function — rather than re-implementing (or demuxing over) the proven
engine SOCKS5 path. The observable contract is identical: a proxy gated by proxy_cred, a LocalAPI
gated by local_api_cred. Both SocketAddrs are returned. This is documented at the Loopback
type, per the project's "surface the delta" rule.
Like Go's s.loopbackListener, both listeners live for the Server's lifetime and are torn down by
Server::close (which aborts both accept loops and gracefully shuts the device down). loopback()
is idempotent (cached in a OnceCell), returning the same addresses + credentials each call —
also matching Go's if s.loopbackListener == nil guard. The device is held as Arc<Device> so the
LocalAPI server can carry a Weak<Device> without blocking close from reclaiming the device by
value for a graceful shutdown.
# Cargo.toml [features]
tsnet = ["dep:base64"] # one new optional *direct* dep edge: base64 (loopback LocalAPI Basic-auth),
# already in the workspace graph (no new crate compiled). Otherwise reuses
# serde_json / tokio / thiserror / rand already in [dependencies].// src/lib.rs
#[cfg(feature = "tsnet")]
pub mod tsnet;Because it is a gated module in the root crate, it compiles against Device/Config with no
version boundary. If a standalone tailscale-tsnet crate is ever wanted, it becomes a trivial
re-export shell over this module — deferred until the shape has settled in-tree, per the task's "NOT
a new published crate initially".
Go:
srv := &tsnet.Server{Hostname: "web", AuthKey: key, Dir: "/var/lib/web"}
defer srv.Close()
ln, _ := srv.Listen("tcp", ":80")Facade (tsnet::Server):
use tailscale::tsnet::Server;
let mut srv = Server::new(); // Go's `&tsnet.Server{}`
srv.hostname = Some("web".into()); // set public fields (Go field assignment)
srv.auth_key = Some(key);
srv.dir = Some("/var/lib/web".into());
let ln = srv.listen("tcp", ":80").await?; // builds Config, Device::new, tcp_listen — once
// … srv.close(None).await;Rust nuance (documented, not incidental).
Servercarries two private fields — the lazyOnceCell<Device>and the optionalconfigurehook — so the Go-style struct-literalServer { hostname: …, ..Default::default() }is not available to external callers (Rust requires every field visible for literal syntax). The entry point isServer::new()followed by per-field assignment on the public fields, which is unaffected by the private fields and reads the same as Go'ss := &tsnet.Server{}; s.Hostname = …. This is a deliberate trade: the private lazy-start state is what makes methods take&self(enablingArc<Server>concurrent use), and it is what turns "set fields before first call" into a borrow-checker guarantee (§3).
Reaching a fork superset without leaving the facade:
let mut srv = Server::new();
srv.hostname = Some("exit".into());
srv.configure(|c| { c.advertise_exit_node = true; c.accept_routes = true; });
let status = srv.up(Some(Duration::from_secs(30))).await?;Native Device remains fully available (the facade is additive, not a replacement).
All commands run on the Rust 1.95.0 toolchain the repo pins (loopback/LocalAPI work on
feat/tsnet-loopback, based on feat/tsnet-facade).
| Check | Command | Result |
|---|---|---|
| Strict lint (lib + tests + examples) | cargo clippy -p geiserx_tailscale --features tsnet --all-targets -- -D warnings |
✅ clean (0 warnings) |
| Logic tests | cargo test -p geiserx_tailscale --features tsnet tsnet:: |
✅ 33 passed; 0 failed |
| Facade builds | cargo build -p geiserx_tailscale --features tsnet |
✅ Finished |
http_client path (hyper) |
cargo clippy -p geiserx_tailscale --features tsnet,hyper --all-targets -- -D warnings |
✅ clean (0 warnings) |
src/tsnet.rs implements the load-bearing shape end-to-end against the real engine — Server +
field→Config mapping + lazy OnceCell start + Error + StateStore/FileStore/MemStore +
FunnelOptions + ServiceListener, plus the full loopback surface (§11): Loopback (both
credentials), the in-process LocalAPI HTTP server, LocalClient, and the hyper-gated
http_client.
The 33 tests are hermetic (no live control server): address parsing, MemStore/FileStore/Dir
identity round-trips, FunnelOptions → engine mapping, the Go-default (ephemeral == false), the
build_config field mapping, and — new for the loopback work — the LocalAPI HTTP framing/auth as
pure functions (parse_head, basic_auth_password (username-ignored), constant-time cred_ok,
parse_response), the status_json serializer, and two end-to-end tests that drive the real
in-process LocalAPI server over a 127.0.0.1 socket with a mock status backend (200 with the right
cred, 401 without / wrong cred, 404 for unknown paths) and round-trip a LocalClient through it. A
Server: Send + Sync compile-time assertion guards Arc<Server> concurrent use.
Each is documented at its call site; none blocks the facade shape.
| Item | Where | Parity ref |
|---|---|---|
Thread FunnelTLSConfig acceptor into Device::listen_funnel |
FunnelOptions::tls warns until then |
§9 |
ServiceMode TerminateTLS / HTTPS / PROXYProtocol* / AcceptAppCaps |
enum gains fields; re-export follows | §10, matrix §3 |
Prefs + netmap persistence (full Go Store semantics) |
StateStore KV already shaped for it |
§8, matrix §5.3 |
localAPICred + in-process LocalAPI HTTP on loopback |
resolved | §11, tsr-ask7 |
Logf / UserLogf injectable loggers, LogtailWriter |
fields omitted rather than faked | matrix §5.2, tsr-reh3 |
listen_funnel_addr(net, addr, opts) convenience overload |
synthesize ServeConfig from cert_domains() |
§9 |
tsnet::Server gives Go users the exact shape they expect — settable fields, lazy start, Up/Close,
Listen/Dial/ListenFunnel/ListenService/Loopback — as a thin, feature-gated, zero-new-dep
wrapper that forwards to the existing Device/Config engine. It keeps Rust's typed returns and
fail-closed typed errors, turns Go's "set fields before first call" rule into a compile-time
guarantee, designs the one real gap (Dir/Store) honestly over key_state, and gives first-class
treatment to the Funnel/Service package-level types the parity matrix under-covers — surfacing, not
faking, the deltas that need engine work.