tailscale/config.rs
1//! Types and utilities for configuring a Tailscale [`Device`](crate::Device).
2
3use std::path::Path;
4
5use serde::Serializer;
6use ts_control::ExitProxyConfig;
7use ts_keys::PersistState;
8
9use crate::keys::NodeState;
10
11const CONTROL_URL_VAR: &str = "TS_CONTROL_URL";
12const HOSTNAME_VAR: &str = "TS_HOSTNAME";
13const AUTHKEY_VAR: &str = "TS_AUTH_KEY";
14const CLIENT_ID_VAR: &str = "TS_CLIENT_ID";
15const CLIENT_SECRET_VAR: &str = "TS_CLIENT_SECRET";
16const ID_TOKEN_VAR: &str = "TS_ID_TOKEN";
17const AUDIENCE_VAR: &str = "TS_AUDIENCE";
18
19/// Config for connecting to Tailscale.
20pub struct Config {
21 /// The cryptographic keys representing this node's identity.
22 pub key_state: PersistState,
23
24 // TODO(npry): let clients also define an app name once the sdk-level name moves
25 // to a dedicated field
26 /// The name of this client.
27 ///
28 /// This is reported to control in the `Hostinfo.App` field.
29 pub client_name: Option<String>,
30
31 /// The URL of the control server to connect to.
32 pub control_server_url: url::Url,
33
34 /// Allow fetching the control server's machine public key (`GET /key`) over plain **http** when
35 /// [`control_server_url`](Config::control_server_url) is `http://`.
36 ///
37 /// By default (`false`) the key bootstrap is always upgraded to `https`, even for an `http://`
38 /// control URL — so registration **fails** against a control plane that only serves plain http
39 /// (e.g. a self-hosted Headscale on a `http://host:port` LAN endpoint / NodePort with no TLS).
40 /// Set `true` for such a deployment. Only safe when you control both ends over a trusted network
41 /// path; no effect when the control URL is `https://`. Fail-closed default is `false`.
42 pub allow_http_key_fetch: bool,
43
44 /// The hostname this node will request.
45 ///
46 /// If left blank, uses the hostname reported by the OS.
47 pub requested_hostname: Option<String>,
48
49 /// Tags this node will request.
50 pub requested_tags: Vec<String>,
51
52 /// Whether this node registers as *ephemeral*.
53 ///
54 /// This is the equivalent of `tailscale up --ephemeral`. An ephemeral node is
55 /// garbage-collected by the control server shortly after it disconnects, which is the right
56 /// default for short-lived clients. A long-lived node that must survive brief disconnects —
57 /// such as a persistent exit node or subnet router — should set this to `false`, or control
58 /// will GC it out of the tailnet while it is momentarily offline. Defaults to `true`.
59 pub ephemeral: bool,
60
61 /// Whether to automatically re-authenticate when this node's node key expires (rotate the node
62 /// key + re-register with the stored auth key, Go `doLogin`) instead of going terminally offline.
63 ///
64 /// Defaults to `true`: an auth-key-registered node whose key expires recovers itself
65 /// automatically — the common reusable-auth-key deployment (a persistent exit node / subnet
66 /// router) self-heals rather than requiring manual re-pairing. Set to `false` for the historical,
67 /// most conservative behavior (an expired key surfaces
68 /// [`DeviceState::Expired`](ts_runtime::DeviceState::Expired) and the node stays offline until
69 /// re-paired). Even when `true`, auto-reauth is gated on a usable auth key being retained and
70 /// Tailnet Lock NOT being enforced; a one-shot auth key that was already consumed cannot
71 /// re-register and degrades to the terminal state.
72 pub reauth_on_expiry: bool,
73
74 /// Whether to accept (and route traffic to) subnet routes advertised by peers.
75 ///
76 /// This is the equivalent of `tailscale up --accept-routes`. Defaults to `false`: only each
77 /// peer's own tailnet address is reachable. Set to `true` to use peers that act as subnet
78 /// routers, so traffic destined for an advertised subnet egresses via the advertising peer.
79 pub accept_routes: bool,
80
81 /// Whether to accept the tailnet's DNS configuration (MagicDNS + pushed resolvers/search
82 /// domains).
83 ///
84 /// This is the equivalent of `tailscale up --accept-dns` (the `CorpDNS` pref). **Defaults to
85 /// `true`**, matching Go's `NewPrefs()`. When `true`, the MagicDNS responder serves the
86 /// control-pushed DNS config. When `false`, the node ignores the pushed DNS config and the
87 /// responder answers every query `REFUSED` — so a node can join the tailnet for connectivity
88 /// without taking over its DNS. Runtime-settable via
89 /// [`Device::set_accept_dns`](crate::Device::set_accept_dns).
90 pub accept_dns: bool,
91
92 /// The peer to route internet-bound traffic through (exit node).
93 ///
94 /// This is the equivalent of `tailscale up --exit-node`. The peer may be named by stable node
95 /// ID, tailnet IP, or MagicDNS name via [`ExitNodeSelector`](crate::ExitNodeSelector) (a bare
96 /// IP or name can be parsed with `selector.parse()`). Defaults to `None`: internet-bound
97 /// traffic has no overlay route and is dropped (fail-closed). When set to a peer that
98 /// advertises a default route, all traffic not matching a more-specific route egresses through
99 /// that peer. The selection is re-resolved as the netmap changes.
100 pub exit_node: Option<ts_control::ExitNodeSelector>,
101
102 /// Subnet routes to advertise as a subnet router.
103 ///
104 /// This is the equivalent of `tailscale up --advertise-routes`. Defaults to empty: this node
105 /// advertises no routes. Each prefix is sent to the control server in `HostInfo.RoutableIPs`;
106 /// once the route is approved, peers with `accept_routes` may send traffic for that subnet
107 /// through this node. Only IPv4 prefixes are advertised — IPv6 prefixes are dropped to uphold
108 /// the IPv6-off posture (we never forward IPv6, so advertising it would be a black hole).
109 pub advertise_routes: Vec<ipnet::IpNet>,
110
111 /// Whether to advertise this node as an exit node.
112 ///
113 /// This is the equivalent of `tailscale up --advertise-exit-node`. Defaults to `false`. When
114 /// `true`, the default route `0.0.0.0/0` is advertised so that, once approved, other peers may
115 /// route their internet-bound traffic out through this node's real origin IP. Because that
116 /// means *other* peers' traffic egresses via our IP, it is strictly opt-in. `::/0` is never
117 /// advertised (IPv6-off).
118 pub advertise_exit_node: bool,
119
120 /// TCP ports the inbound forwarder accepts and splices to real OS sockets, for every advertised
121 /// route ([`advertise_routes`](Config::advertise_routes) / [`advertise_exit_node`](Config::advertise_exit_node)).
122 ///
123 /// Acting as a subnet router or exit node means inbound overlay flows to advertised
124 /// destinations are dialed out as real OS connections (mirroring Go `tsnet`'s forwarders). The
125 /// underlying netstack has no all-port accept mode, so the set of forwarded ports is explicit
126 /// rather than the full 1–65535 range. Defaults to empty: a node may advertise routes but
127 /// forward nothing until ports are configured (fail-closed — nothing is dialed).
128 pub forward_tcp_ports: Vec<u16>,
129
130 /// UDP ports the inbound forwarder accepts and splices to real OS sockets, for every advertised
131 /// route. See [`forward_tcp_ports`](Config::forward_tcp_ports); defaults to empty.
132 pub forward_udp_ports: Vec<u16>,
133
134 /// Forward **all** TCP/UDP ports (1–65535) on every advertised route, like a Go subnet router.
135 ///
136 /// This is the equivalent of a `tailscale up --advertise-routes` node forwarding every port,
137 /// instead of the explicit [`forward_tcp_ports`](Config::forward_tcp_ports) /
138 /// [`forward_udp_ports`](Config::forward_udp_ports) sets. When `true`, those explicit sets are
139 /// ignored and the forwarder runs an on-demand per-port listener manager. Anti-leak is
140 /// unchanged: every flow still routes through the same dialer chokepoint, so
141 /// [`forward_exit_egress`](Config::forward_exit_egress) still governs exit-node egress. Defaults
142 /// to `false`.
143 pub forward_all_ports: bool,
144
145 /// Whether exit-node (`0.0.0.0/0`) inbound flows are actually egressed via **this host's real
146 /// origin IP**.
147 ///
148 /// Anti-leak opt-in, separate from [`advertise_exit_node`](Config::advertise_exit_node):
149 /// advertising the default route only offers this node as an exit to control; it does not by
150 /// itself egress a peer's internet-bound traffic. Defaults to `false` (fail-closed): the
151 /// forwarder structurally refuses exit-node egress, dropping `0.0.0.0/0` flows at dial time
152 /// rather than leaking them out our real IP. Set to `true` only on a node whose real IP *is* the
153 /// intended egress (e.g. a residential exit), never on a host whose IP must stay hidden (e.g. a
154 /// cloud VPS). Subnet routes are dialed identically regardless of this flag.
155 pub forward_exit_egress: bool,
156
157 /// Shields-up (Go `tailscale set --shields-up` / `ipn` `ShieldsUp`): when `true`, refuse all
158 /// **inbound** connections from peers that terminate on this node. The packet filter drops
159 /// inbound packets destined to this node's own addresses; forwarded subnet/exit transit and
160 /// replies to connections this node itself initiated are unaffected. Defaults to `false`.
161 pub block_incoming: bool,
162
163 /// Optional upstream proxy that exit-node egress is routed through, so the node egresses via
164 /// the proxy's IP rather than its own origin IP.
165 ///
166 /// This is a **product capability beyond strict Go `tsnet` parity**: it lets a cloud exit node
167 /// route the traffic it egresses through a residential proxy provider configured by the
168 /// deployer, so the cloud host's real IP never appears upstream. Only consulted when
169 /// [`forward_exit_egress`](Config::forward_exit_egress) is `true`. When `Some`, the forwarder is
170 /// wired with a SOCKS5 / HTTP `CONNECT` proxy dialer that **fails closed** — any proxy connect
171 /// or handshake failure drops the flow rather than dialing direct, so the real IP never leaks.
172 /// When `None` (the default) and exit egress is enabled, egress uses this host's real IP. See
173 /// the proxy-egress section of the repo's `AGENTS.md`/`CLAUDE.md`.
174 pub exit_proxy: Option<ExitProxyConfig>,
175
176 /// Per-direction TCP send/receive buffer size (bytes) for the userspace netstack, or `None` to
177 /// use the netstack default (256 KiB per direction, ~512 KiB per socket).
178 ///
179 /// The underlying smoltcp stack has no TCP window auto-tuning, so this value is the hard cap on
180 /// a single flow's bandwidth-delay product: at an 80 ms RTT a 16 KiB window throttles a flow to
181 /// ~1.6 Mbps, which visibly slows large model-API responses even at 1x. Each socket allocates
182 /// this size for both its rx and tx buffer, so a socket consumes ~2× this value. The default
183 /// (256 KiB) suits high-RTT links carrying a few large flows; lower it on memory-constrained
184 /// deployments running many concurrent sockets. Applies to both the application and forwarder
185 /// netstacks.
186 pub tcp_buffer_size: Option<usize>,
187
188 /// WireGuard persistent-keepalive interval applied to every peer, or `None` to disable
189 /// (`PersistentKeepalive`; this is the equivalent of Tailscale setting `PersistentKeepalive=25`
190 /// on a peer when control marks it `KeepAlive=true`).
191 ///
192 /// When `Some(interval)` (the default, `Some(25s)`), each peer emits an empty authenticated
193 /// keepalive after `interval` of outbound silence, holding the path/NAT mapping warm. This is the
194 /// load-bearing fix for **idle DERP-relayed sessions wedging**: on a userspace-netstack node whose
195 /// only path to a peer is the relay, an idle session otherwise ages past expiry with no traffic to
196 /// keep it warm and no timer to refresh it, so the next dial rehandshakes over a cold path and
197 /// loops forever. The persistent keepalive re-arms unconditionally (unlike the reactive WireGuard
198 /// §6.5 keepalive, which is armed only by inbound traffic and dies ~10s after the last inbound
199 /// packet) and the empty packet deliberately does **not** advance the session's rotation/expiry
200 /// timers, so a genuinely dead peer is still detected and rekey still fires on schedule.
201 ///
202 /// Set to `None` to opt out (e.g. an embedder that has its own keepalive strategy or only ever
203 /// runs over a direct, always-warm path). The default is on because this fork's primary
204 /// deployment is the relayed case the wedge bites.
205 pub persistent_keepalive_interval: Option<std::time::Duration>,
206
207 /// Whether to enable IPv6 **on the tailnet overlay** (peer-to-peer reachability over the node's
208 /// Tailscale IPv6 address). Defaults to `false`: the node is IPv4-only on the overlay.
209 ///
210 /// This is an opt-in for general embedders that want Go `tsnet`-style dual-stack overlay
211 /// reachability. It is deliberately **off by default** to preserve this fork's sacred anti-leak
212 /// posture: its primary deployment is a privacy proxy / cloud exit node where IPv6 is disabled
213 /// everywhere to prevent tunnel-bypass IP leakage. When `false`, behavior is byte-for-byte the
214 /// historical IPv4-only path: the underlay binds `0.0.0.0:0`, IPv6 candidates/STUN are refused,
215 /// the netstack is handed no IPv6 overlay address, and MagicDNS answers AAAA as NODATA.
216 ///
217 /// **This flag governs only the overlay.** It has NO effect on the exit-node / forwarder egress
218 /// path: exit and subnet egress to the public internet stays hardcoded IPv4 in `ts_forwarder`
219 /// regardless of this flag, so the residential-proxy / real-origin-IP isolation invariant can
220 /// never be weakened by enabling overlay IPv6. On a host with IPv6 disabled at the kernel, the
221 /// dual-stack overlay bind simply fails and the node stays inert on IPv6 rather than panicking.
222 pub enable_ipv6: bool,
223
224 /// Whether to run an internal OS network-link monitor that automatically re-binds the underlay
225 /// socket and re-probes connectivity (re-ping, re-STUN, re-netcheck) on a link change — a Wi-Fi
226 /// switch, sleep/wake, or default-route change. Defaults to `false`.
227 ///
228 /// Off by default to preserve this fork's pure-engine posture (per `AGENTS.md` this is a pure
229 /// engine, not a daemon): the embedder normally owns OS network-monitoring and calls
230 /// [`Device::rebind`](crate::Device::rebind) itself. When `false`, the runtime starts **zero**
231 /// extra monitor threads or sockets and behaves byte-for-byte as before; the manual
232 /// `Device::rebind` path is always available regardless of this flag.
233 ///
234 /// Enabling it requires the crate to be built with the `network-monitor` feature; setting this
235 /// `true` without that feature is a hard error at device startup (never a silent no-op). In this
236 /// slice the monitor has no OS backend wired yet, so enabling it spawns the supervisor against a
237 /// no-op event source (the Linux/macOS backends land in later slices).
238 pub network_monitor: bool,
239
240 /// The fixed UDP port magicsock binds for WireGuard + disco, or `None` for an OS-chosen
241 /// ephemeral port (Go `tailscaled --port`; Go's `ListenPort`). Defaults to `None`.
242 ///
243 /// `None` (the default) preserves the historical behavior: the underlay socket binds `0.0.0.0:0`
244 /// and the OS picks an ephemeral port (Go's port `0`). `Some(p)` pins the bind to port `p` so the
245 /// node's UDP endpoint is stable across restarts — what an operator behind a fixed-pinhole
246 /// firewall needs (Go's daemon defaults this to `41641`, but the engine default stays `None` to
247 /// keep today's behavior). If `p` is already taken at startup the bind **falls back to an
248 /// ephemeral port** rather than failing bring-up (mirroring magicsock's rebind fallback): a port
249 /// collision must not take the node down. A later [`Device::rebind`](crate::Device::rebind)
250 /// re-prefers whatever port is currently bound, so a successful pin carries across rebinds.
251 ///
252 /// Governs **only** the bound port — never the bind family: the IPv4-only-by-default,
253 /// fail-closed underlay posture (`enable_ipv6` alone widens the family) is unchanged.
254 pub wireguard_listen_port: Option<u16>,
255
256 /// How this node's **application** overlay data path is realized.
257 ///
258 /// Defaults to [`TransportMode::Netstack`](ts_control::TransportMode::Netstack), the userspace
259 /// smoltcp netstack used by the fork's primary unprivileged proxy / exit-node deployment.
260 /// [`TransportMode::Tun`](ts_control::TransportMode::Tun) instead routes the node's overlay
261 /// packets through a real kernel TUN interface (for embedders that want the host OS networking
262 /// stack to see the tailnet directly); it requires privileges (root / `CAP_NET_ADMIN`) and a
263 /// platform with TUN support. This governs only the application data path — never the
264 /// exit-node / forwarder egress path, which keeps its own IPv4-only userspace netstack.
265 pub transport_mode: ts_control::TransportMode,
266
267 /// Whether to ask control to wire this node up server-side for Tailscale Funnel, even when no
268 /// Funnel endpoint is currently active (Go `tsnet`'s "would like to be wired up for Funnel"
269 /// signal, `HostInfo.WireIngress`, capver 113).
270 ///
271 /// When `true`, registration and map requests set `HostInfo.WireIngress` so control provisions
272 /// the DNS / ingress records a Funnel node needs, making a later
273 /// [`Device::listen_funnel`](crate::Device::listen_funnel) (or
274 /// `serve`) session work immediately. Defaults to `false` (fail-closed): a node requests Funnel
275 /// wiring only when explicitly opted in.
276 ///
277 /// Note this fork cannot yet *terminate* public Funnel ingress — `Device::listen_funnel` is
278 /// fail-closed (no client-side ACME engine, and a self-hosted control plane provides no public
279 /// ingress relay). Setting this flag only requests server-side wiring; it does not by itself
280 /// make Funnel live.
281 pub wire_ingress: bool,
282
283 /// VIP services this node advertises that it **hosts** (`svc:<dns-label>` names), the advertise
284 /// side of Tailscale VIP services (Go `tsnet`'s `Hostinfo.ServicesHash` + c2n
285 /// `GET /vip-services`).
286 ///
287 /// Each entry is a full `svc:`-prefixed name. The valid names (each validated as a well-formed
288 /// `svc:<dns-label>`; malformed names are dropped and logged) are hashed into
289 /// `HostInfo.ServicesHash` on registration and every map request, and reported when control
290 /// fetches the hosted-service list via the c2n `/vip-services` endpoint. Defaults to empty:
291 /// advertise nothing (the hash is `""`, behavior unchanged). Actually *hosting* a service still
292 /// requires control to assign it a VIP and the node to be tagged.
293 pub advertise_services: Vec<String>,
294
295 /// Whether to advertise this node as an **app connector** (`tailscale set --advertise-connector`,
296 /// Go `Prefs.AppConnector.Advertise`). Defaults to `false`.
297 ///
298 /// When `true`, registration and every map request set `HostInfo.AppConnector = Some(true)`,
299 /// mirroring Go's `applyPrefsToHostinfoLocked` (`hi.AppConnector.Set(prefs.AppConnector().Advertise)`).
300 /// This advertises only the *capability* to control — the faithful engine minimum, exactly the
301 /// boundary Go draws between advertising and the data path. The app-connector data path itself
302 /// (control-pushed connector domain routes, the 4via6 domain→route mapping, the per-domain DNS
303 /// observation that learns target IPs) is a separate subsystem this fork does not implement, so a
304 /// node advertising this serves no connector traffic until that layer exists — identical in effect
305 /// to Go advertising the bool before control has assigned any domains.
306 pub advertise_app_connector: bool,
307
308 /// Whether this node opts in to admin-console-triggered auto-updates
309 /// (`tailscale set --auto-update`, Go `Prefs.AutoUpdate.Apply`). Defaults to `None`.
310 ///
311 /// When `Some(true)`, registration and every map request set `HostInfo.AllowsUpdate = true`,
312 /// mirroring Go's `applyPrefsToHostinfoLocked`
313 /// (`hi.AllowsUpdate = … || prefs.AutoUpdate().Apply.EqualBool(true)`), so the admin console knows
314 /// the node accepts remote update triggers. This advertises the bool only: **this fork runs no
315 /// updater** (it is an embeddable engine, not a packaged daemon), so it never *applies* an update —
316 /// the self-update machinery is a daemon / OS-package concern. `Some(false)` and `None` both leave
317 /// `AllowsUpdate` unset (advertise that the node does not accept remote updates); the tri-state
318 /// mirrors Go's `opt.Bool` (unset vs explicitly-off vs on).
319 pub auto_update_apply: Option<bool>,
320
321 /// Whether a background updater should *check* for available updates (Go `Prefs.AutoUpdate.Check`).
322 /// Defaults to `false`.
323 ///
324 /// **Carried pref only — the engine never acts on it and it is never sent to control.** In Go this
325 /// gates a purely local background update-check loop in the daemon; it is not part of `Hostinfo`
326 /// and never crosses the control wire. This fork has no updater (engine, not daemon), so the value
327 /// is stored and threaded through to [`ts_control::Config`] solely so a downstream daemon can carry
328 /// the pref. Storing it (rather than dropping it) is the faithful mirror of tsnet's pref state.
329 pub auto_update_check: bool,
330
331 /// The OS username permitted to operate this node over a local management API
332 /// (`tailscale set --operator`, Go `Prefs.OperatorUser`). Defaults to `None`.
333 ///
334 /// **Carried pref only — the engine never acts on it and it is never sent to control.** In Go this
335 /// is purely a daemon-side LocalAPI authorization check (which Unix uid may drive the daemon
336 /// without root); it never touches the control protocol. This fork is a pure engine with no local
337 /// API to gate, so the value is stored and threaded through to [`ts_control::Config`] solely for a
338 /// downstream daemon that exposes a local API to consult. Faithful mirror of tsnet pref state.
339 pub operator_user: Option<String>,
340
341 /// A local display label for this node's login profile (Go `Prefs.ProfileName`, set via
342 /// `tailscale switch` / profile management). Defaults to `None`.
343 ///
344 /// **Carried pref only — the engine never acts on it and it is never sent to control.** In Go this
345 /// is a client-local cosmetic name for the login profile; it is never advertised in `Hostinfo`
346 /// (distinct from the [`requested_hostname`](Config::requested_hostname) the node requests). The
347 /// value is stored and threaded through to [`ts_control::Config`] solely for a downstream daemon's
348 /// profile UI. Faithful mirror of tsnet pref state.
349 pub node_nickname: Option<String>,
350
351 /// Whether device-posture identity collection is enabled (`tailscale set --posture-checking`,
352 /// Go `Prefs.PostureChecking`). Defaults to `false`.
353 ///
354 /// **Carried pref only — the engine never acts on it and it is never sent to control.** There is
355 /// deliberately **no `Hostinfo.PostureChecking` field** to wire it to: posture is a
356 /// control-to-node (c2n) *pull* mechanism — control requests posture attributes (serial numbers,
357 /// etc.) from the node on demand — which this fork does not implement. With no c2n posture
358 /// responder, control simply never pulls posture identity, byte-for-byte identical to the
359 /// posture-disabled case, so storing the pref is the faithful mirror. The value is threaded through
360 /// to [`ts_control::Config`] for a downstream daemon that implements the c2n posture endpoint.
361 pub posture_checking: bool,
362
363 /// Whether this node runs a local web client (`tailscale set --webclient`,
364 /// Go `Prefs.RunWebClient`). Defaults to `false`.
365 ///
366 /// **Carried pref only — the engine never acts on it and it is never sent to control.** In Go this
367 /// gates a daemon-hosted local web-client HTTP server (the device-management web UI on
368 /// `100.x:5252`); it is a separate subsystem, not advertised in `Hostinfo`. This fork has no
369 /// web-client server, so the value is stored and threaded through to [`ts_control::Config`] solely
370 /// for a downstream daemon that does. Faithful mirror of tsnet pref state.
371 pub run_web_client: bool,
372
373 /// Whether a peer using this node as an exit node may also reach this node's **local LAN**
374 /// (`tailscale set --exit-node-allow-lan-access`, Go `Prefs.ExitNodeAllowLANAccess`). Defaults to
375 /// `false`.
376 ///
377 /// **Carried pref only for now — the engine does not yet act on it and it is never sent to
378 /// control.** In Go this is an **OS-router route-shaping** flag: when acting as an exit node it
379 /// controls whether the host router excludes the local LAN ranges from the routes pulled through
380 /// the tunnel. On a platform with no host router it has "no effect" — and this fork's default data
381 /// path is the userspace netstack, which has no host-route layer to shape. The value is stored and
382 /// threaded through to [`ts_control::Config`] so a downstream daemon (or a future host-route layer
383 /// in this engine) can consume it; until such a layer exists it is inert. Never advertised.
384 pub exit_node_allow_lan_access: bool,
385
386 /// Whether the control plane may invoke this node's LocalAPI through the c2n
387 /// `/remoteapi/localapi/*` proxy (Go `Prefs.RemoteConfig`, `feature/remoteconfig`). Defaults to
388 /// `false`.
389 ///
390 /// Tailscale's default posture is per-feature *double* opt-in: the tailnet admin can ask for
391 /// something server-side, but the local machine owner still consents to each individual setting.
392 /// This pref is upstream's one deliberate exception — a single client-side "I trust the tailnet
393 /// admin" switch. While it is `true`, control may invoke any LocalAPI endpoint this node serves
394 /// with no further local consent. Appropriate when the tailnet admin owns the machine (a
395 /// corporate fleet device); **not** appropriate on a personal or BYOD device.
396 ///
397 /// Only meaningful when this node actually serves a LocalAPI — in this fork that is the
398 /// [`tsnet`](crate::tsnet) facade, which installs its in-process LocalAPI as the proxy target
399 /// when it builds the device. A bare [`Device`](crate::Device) has no LocalAPI, so the c2n prefix
400 /// route does not exist for it and the path takes the responder's `unknown c2n path` `400`
401 /// regardless of this pref (matching an upstream build with the `remoteconfig` feature omitted).
402 pub remote_config: bool,
403
404 /// This node's LocalAPI, for control's c2n `/remoteapi/localapi/*` proxy to dispatch into
405 /// (Go `localapi.NewHandler(...)` inside `handleC2NRemoteAPI`).
406 ///
407 /// `None` — the default — leaves the c2n prefix route unregistered, so `/remoteapi/localapi/*`
408 /// takes the `unknown c2n path` `400`. The [`tsnet`](crate::tsnet) facade sets this to its
409 /// in-process LocalAPI when it builds the device; set it directly only to expose a LocalAPI of
410 /// your own. Requests reaching it are gated solely by
411 /// [`remote_config`](Config::remote_config).
412 pub c2n_local_api: Option<std::sync::Arc<dyn ts_control::LocalApi>>,
413
414 /// Filesystem directory that received Taildrop files land in, or `None` to disable Taildrop
415 /// (the default, fail-closed).
416 ///
417 /// When `Some(dir)` **and** a peerAPI port is configured (Taildrop is served on the shared
418 /// peerAPI listener, so it needs the same bind), the runtime serves the Taildrop peerAPI route
419 /// `PUT /v0/put/<name>` and writes incoming files under `dir` (created if absent). When `None`,
420 /// no Taildrop server is run and a peer's `PUT` is refused (`403`). The embedder consumes
421 /// received files via the [`Device::taildrop_waiting_files`](crate::Device::taildrop_waiting_files)
422 /// / [`taildrop_open_file`](crate::Device::taildrop_open_file) /
423 /// [`taildrop_delete_file`](crate::Device::taildrop_delete_file) methods.
424 pub taildrop_dir: Option<std::path::PathBuf>,
425
426 /// Directory the last full network map is cached in, or `None` (the default) to never persist
427 /// one — the cold-start netmap cache (Go `nodecap.CacheNetworkMaps`, capability version 135).
428 ///
429 /// A configured directory only makes caching *possible*. Nothing is written unless control also
430 /// grants this node the `cache-network-maps` node attribute and withholds
431 /// `disable-cache-network-maps`, and a later netmap that withdraws the grant deletes what was
432 /// cached. When a cache is present at start-up it is replayed onto the netmap bus before control
433 /// answers the first map poll, so peer connectivity can start coming up sooner; control's own
434 /// netmap replaces it moments later.
435 ///
436 /// The cached frame carries the tailnet's peer list (with public keys and endpoints), the DNS
437 /// configuration and the packet filter, so the directory is created `0700` and the file `0600`
438 /// on Unix. [`Server::dir`](crate::Server::dir) fills this in as `<dir>/netmap-cache`.
439 pub netmap_cache_dir: Option<std::path::PathBuf>,
440
441 /// Pre-auth key for non-interactive registration (Go `tsnet.Server.AuthKey`). When set, used as
442 /// the registration auth key. If it is an OAuth client secret (prefix `tskey-client-`) and the
443 /// `identity-federation` feature is enabled, it is exchanged for an auth key before registration.
444 /// Falls back to the `TS_AUTH_KEY` env var (see [`auth_key_from_env`]). Defaults to `None`.
445 pub auth_key: Option<String>,
446
447 /// OAuth client ID for workload-identity federation (Go `tsnet.Server.ClientID`). SaaS-only;
448 /// requires the `identity-federation` feature. With [`id_token`](Config::id_token) or
449 /// [`audience`](Config::audience), the node exchanges an IdP-issued OIDC token for a Tailscale
450 /// auth key. Defaults to `None` (`TS_CLIENT_ID` env fallback).
451 pub client_id: Option<String>,
452
453 /// OAuth client secret used to mint auth keys via OAuth (Go `tsnet.Server.ClientSecret`).
454 /// SaaS-only; requires the `identity-federation` feature. Defaults to `None` (`TS_CLIENT_SECRET`).
455 ///
456 /// Treat as **fully operator-trusted input**: a `tskey-client-…?baseURL=…` secret redirects the
457 /// credential exchange to that host, so a hostile value would exfiltrate the secret and the
458 /// minted auth key. Never source it from a less-trusted origin.
459 pub client_secret: Option<String>,
460
461 /// IdP-issued OIDC ID token to exchange with control for an auth key via workload-identity
462 /// federation (Go `tsnet.Server.IDToken`). SaaS-only; requires the `identity-federation` feature
463 /// and [`client_id`](Config::client_id). Mutually exclusive with [`audience`](Config::audience).
464 /// Defaults to `None` (`TS_ID_TOKEN`).
465 pub id_token: Option<String>,
466
467 /// Audience for requesting an OIDC ID token from the ambient workload identity (GitHub Actions /
468 /// GCP / AWS), to exchange for an auth key via workload-identity federation (Go
469 /// `tsnet.Server.Audience`). SaaS-only; requires the `identity-federation` feature +
470 /// [`client_id`](Config::client_id). Mutually exclusive with [`id_token`](Config::id_token).
471 /// Defaults to `None` (`TS_AUDIENCE`).
472 pub audience: Option<String>,
473}
474
475impl Config {
476 /// Create a new config with its [`key_state`](Config::key_state) populated from the specified key file and using
477 /// default options for other configuration.
478 ///
479 /// See [`load_key_file`] for more details and an alternative with more options for reading
480 /// the key file.
481 pub async fn default_with_key_file(p: impl AsRef<Path>) -> Result<Self, crate::Error> {
482 Ok(Config {
483 key_state: load_key_file(p, Default::default()).await?,
484 ..Default::default()
485 })
486 }
487
488 /// Run the application overlay over a real kernel **TUN** interface instead of the default
489 /// userspace netstack — a builder shortcut for setting
490 /// [`transport_mode`](Config::transport_mode) to
491 /// [`TransportMode::Tun`](ts_control::TransportMode::Tun).
492 ///
493 /// `name` is the desired interface name (`None` lets the OS pick, e.g. `utunN` on macOS); `mtu`
494 /// is the interface MTU (`None` uses the transport default; Tailscale's overlay MTU is 1280).
495 /// TUN mode requires root / `CAP_NET_ADMIN` and the engine's `tun` feature to be enabled.
496 /// Chainable: `Config::default().use_tun(Some("tailscale0".into()), None)`.
497 #[must_use]
498 pub fn use_tun(mut self, name: Option<String>, mtu: Option<u16>) -> Self {
499 self.transport_mode = ts_control::TransportMode::Tun(ts_control::TunConfig { name, mtu });
500 self
501 }
502
503 /// Construct a default config, setting certain fields from environment variables.
504 ///
505 /// The fields are only set if the corresponding environment variable is present, using
506 /// the default value otherwise.
507 ///
508 /// Loads:
509 ///
510 /// - `control_server_url` from `TS_CONTROL_URL`
511 /// - `requested_hostname` from `TS_HOSTNAME`
512 /// - `auth_key` from `TS_AUTH_KEY`
513 /// - `client_id` from `TS_CLIENT_ID`
514 /// - `client_secret` from `TS_CLIENT_SECRET`
515 /// - `id_token` from `TS_ID_TOKEN`
516 /// - `audience` from `TS_AUDIENCE`
517 pub fn default_from_env() -> Config {
518 let mut config = Config::default();
519
520 if let Ok(u) = std::env::var(CONTROL_URL_VAR) {
521 match u.parse() {
522 Ok(u) => config.control_server_url = u,
523 Err(e) => {
524 tracing::error!(error = %e, "parsing {CONTROL_URL_VAR} (fall back to default value)");
525 }
526 }
527 };
528
529 config.requested_hostname = std::env::var(HOSTNAME_VAR).ok();
530
531 if let Some(auth_key) = auth_key_from_env() {
532 config.auth_key = Some(auth_key);
533 }
534 if let Ok(client_id) = std::env::var(CLIENT_ID_VAR) {
535 config.client_id = Some(client_id);
536 }
537 if let Ok(client_secret) = std::env::var(CLIENT_SECRET_VAR) {
538 config.client_secret = Some(client_secret);
539 }
540 if let Ok(id_token) = std::env::var(ID_TOKEN_VAR) {
541 config.id_token = Some(id_token);
542 }
543 if let Ok(audience) = std::env::var(AUDIENCE_VAR) {
544 config.audience = Some(audience);
545 }
546
547 config
548 }
549
550 /// Rotate this config's node key in place for an embedder-driven re-registration, mirroring Go's
551 /// `regen` flow: the current node key is recorded as the old key and a fresh node key is
552 /// generated. Re-create the [`Device`](crate::Device) from this config to perform the rotation;
553 /// the next registration sends the prior key as `OldNodeKey` for key continuity.
554 ///
555 /// Reactive and embedder-driven by design (you decide when to rotate, e.g. after observing
556 /// [`Device::self_key_expired`](crate::Device::self_key_expired) flip, or on a policy of your
557 /// own). This fork does not auto-rotate before expiry — neither does Go, which treats key expiry
558 /// as a deliberate periodic re-authentication checkpoint. Rotation still requires a valid auth
559 /// key, exactly like a fresh registration.
560 pub fn rotate_node_key(&mut self) {
561 self.key_state.rotate_node_key();
562 }
563}
564
565/// Load an auth key from the `TS_AUTH_KEY` environment variable.
566pub fn auth_key_from_env() -> Option<String> {
567 std::env::var(AUTHKEY_VAR).ok()
568}
569
570/// Load key state from a path on the filesystem, or create a file with a new key state if
571/// one doesn't exist.
572///
573/// The `bad_format` argument allows you to specify whether an existing file should be
574/// overwritten if the contents can't be parsed.
575pub async fn load_key_file(
576 p: impl AsRef<Path>,
577 bad_format: BadFormatBehavior,
578) -> Result<PersistState, crate::Error> {
579 let p = p.as_ref();
580
581 tracing::trace!(key_file = %p.display(), "loading key file");
582
583 let key_file = load_or_init::<KeyFile>(
584 &p,
585 Default::default,
586 |x| match x {
587 #[allow(deprecated)]
588 KeyFile::Old(old) => Some(KeyFile::New(KeyFileNew {
589 key_state: PersistState::from(&old.key_state),
590 })),
591 _ => None,
592 },
593 bad_format,
594 )
595 .await?;
596 Ok(key_file.key_state())
597}
598
599#[derive(serde::Deserialize)]
600#[serde(untagged)]
601enum KeyFile {
602 #[deprecated]
603 Old(KeyFileOld),
604 New(KeyFileNew),
605}
606
607impl KeyFile {
608 #[allow(deprecated)]
609 pub fn key_state(&self) -> PersistState {
610 match self {
611 Self::Old(old) => (&old.key_state).into(),
612 Self::New(new) => new.key_state.clone(),
613 }
614 }
615}
616
617impl Default for KeyFile {
618 fn default() -> Self {
619 KeyFile::New(KeyFileNew::default())
620 }
621}
622
623impl serde::Serialize for KeyFile {
624 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
625 where
626 S: Serializer,
627 {
628 KeyFileNew {
629 key_state: self.key_state(),
630 }
631 .serialize(serializer)
632 }
633}
634
635#[derive(serde::Deserialize, serde::Serialize, Default)]
636struct KeyFileNew {
637 key_state: PersistState,
638}
639
640#[derive(serde::Deserialize)]
641struct KeyFileOld {
642 key_state: NodeState,
643}
644
645impl From<&Config> for ts_control::Config {
646 fn from(value: &Config) -> ts_control::Config {
647 ts_control::Config {
648 client_name: value.client_name.clone(),
649 hostname: value.requested_hostname.clone(),
650 server_url: value.control_server_url.clone(),
651 tags: value.requested_tags.clone(),
652 ephemeral: value.ephemeral,
653 reauth_on_expiry: value.reauth_on_expiry,
654 accept_routes: value.accept_routes,
655 accept_dns: value.accept_dns,
656 exit_node: value.exit_node.clone(),
657 advertise_routes: value.advertise_routes.clone(),
658 advertise_exit_node: value.advertise_exit_node,
659 forward_tcp_ports: value.forward_tcp_ports.clone(),
660 forward_udp_ports: value.forward_udp_ports.clone(),
661 forward_all_ports: value.forward_all_ports,
662 forward_exit_egress: value.forward_exit_egress,
663 block_incoming: value.block_incoming,
664 exit_proxy: value.exit_proxy.clone(),
665 tcp_buffer_size: value.tcp_buffer_size,
666 persistent_keepalive_interval: value.persistent_keepalive_interval,
667 peerapi_port: None,
668 taildrop_dir: value.taildrop_dir.clone(),
669 netmap_cache_dir: value.netmap_cache_dir.clone(),
670 enable_ipv6: value.enable_ipv6,
671 network_monitor: value.network_monitor,
672 wireguard_listen_port: value.wireguard_listen_port,
673 transport_mode: value.transport_mode.clone(),
674 wire_ingress: value.wire_ingress,
675 // A fresh runtime-local flag (default `false`): the runtime flips it when
676 // `Device::listen_funnel` starts a listener. Not derived from the embedder config.
677 ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
678 advertise_services: value.advertise_services.clone(),
679 advertise_app_connector: value.advertise_app_connector,
680 auto_update_apply: value.auto_update_apply,
681 auto_update_check: value.auto_update_check,
682 operator_user: value.operator_user.clone(),
683 node_nickname: value.node_nickname.clone(),
684 posture_checking: value.posture_checking,
685 run_web_client: value.run_web_client,
686 exit_node_allow_lan_access: value.exit_node_allow_lan_access,
687 allow_http_key_fetch: value.allow_http_key_fetch,
688 remote_config: value.remote_config,
689 local_api: value.c2n_local_api.clone(),
690 }
691 }
692}
693
694impl Default for Config {
695 fn default() -> Self {
696 Self {
697 key_state: Default::default(),
698 client_name: None,
699 control_server_url: ts_control::DEFAULT_CONTROL_SERVER.clone(),
700 allow_http_key_fetch: false,
701 requested_hostname: None,
702 requested_tags: vec![],
703 ephemeral: true,
704 reauth_on_expiry: true,
705 accept_routes: false,
706 accept_dns: true,
707 exit_node: None,
708 advertise_routes: vec![],
709 advertise_exit_node: false,
710 forward_tcp_ports: vec![],
711 forward_udp_ports: vec![],
712 forward_all_ports: false,
713 forward_exit_egress: false,
714 block_incoming: false,
715 exit_proxy: None,
716 tcp_buffer_size: None,
717 persistent_keepalive_interval: Some(ts_control::DEFAULT_PERSISTENT_KEEPALIVE),
718 enable_ipv6: false,
719 network_monitor: false,
720 wireguard_listen_port: None,
721 transport_mode: ts_control::TransportMode::default(),
722 wire_ingress: false,
723 advertise_services: vec![],
724 advertise_app_connector: false,
725 auto_update_apply: None,
726 auto_update_check: false,
727 operator_user: None,
728 node_nickname: None,
729 posture_checking: false,
730 run_web_client: false,
731 exit_node_allow_lan_access: false,
732 remote_config: false,
733 c2n_local_api: None,
734 taildrop_dir: None,
735 netmap_cache_dir: None,
736 auth_key: None,
737 client_id: None,
738 client_secret: None,
739 id_token: None,
740 audience: None,
741 }
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 // The `From<&Config> for ts_control::Config` impl hand-copies every field, so it silently
750 // drops any field a future edit forgets to add. These tests assert each dataplane field
751 // crosses the boundary, with special attention to the anti-leak ones (`forward_exit_egress`,
752 // `exit_proxy`) whose loss would change egress behavior.
753 #[tokio::test]
754 async fn from_config_threads_all_dataplane_fields() {
755 let cfg = Config {
756 accept_routes: true,
757 // Set to the non-default (`false`) so its crossing is observable (default is `true`).
758 accept_dns: false,
759 advertise_exit_node: true,
760 forward_all_ports: true,
761 forward_exit_egress: true,
762 forward_tcp_ports: vec![80, 443],
763 forward_udp_ports: vec![53],
764 tcp_buffer_size: Some(1024 * 128),
765 persistent_keepalive_interval: Some(std::time::Duration::from_secs(17)),
766 enable_ipv6: true,
767 network_monitor: true,
768 wireguard_listen_port: Some(41641),
769 wire_ingress: true,
770 transport_mode: ts_control::TransportMode::Tun(ts_control::TunConfig {
771 name: Some("tailscale0".to_owned()),
772 mtu: Some(1280),
773 }),
774 advertise_routes: vec!["10.0.0.0/24".parse().unwrap()],
775 requested_tags: vec!["tag:exit".to_owned()],
776 advertise_services: vec!["svc:samba".to_owned()],
777 advertise_app_connector: true,
778 auto_update_apply: Some(true),
779 auto_update_check: true,
780 operator_user: Some("alice".to_owned()),
781 node_nickname: Some("laptop".to_owned()),
782 posture_checking: true,
783 run_web_client: true,
784 exit_node_allow_lan_access: true,
785 remote_config: true,
786 c2n_local_api: Some(std::sync::Arc::new(NoRoutesLocalApi)),
787 ephemeral: false,
788 exit_proxy: Some(ExitProxyConfig {
789 addr: "198.51.100.9:8080".parse().unwrap(),
790 scheme: ts_control::ExitProxyScheme::Socks5,
791 auth: Some(("u".to_owned(), "p".to_owned())),
792 }),
793 taildrop_dir: Some(std::path::PathBuf::from("/var/lib/taildrop")),
794 ..Default::default()
795 };
796
797 let control: ts_control::Config = (&cfg).into();
798
799 assert!(control.accept_routes);
800 assert!(
801 !control.accept_dns,
802 "accept_dns crosses the boundary (set false)"
803 );
804 assert!(control.advertise_exit_node);
805 assert!(control.forward_all_ports);
806 assert!(control.forward_exit_egress);
807 assert!(!control.ephemeral);
808 assert_eq!(control.forward_tcp_ports, vec![80, 443]);
809 assert_eq!(control.forward_udp_ports, vec![53]);
810 assert_eq!(control.tcp_buffer_size, Some(1024 * 128));
811 assert_eq!(
812 control.persistent_keepalive_interval,
813 Some(std::time::Duration::from_secs(17))
814 );
815 assert_eq!(control.tags, vec!["tag:exit".to_owned()]);
816 let proxy = control.exit_proxy.expect("exit_proxy crosses the boundary");
817 assert_eq!(proxy.addr, "198.51.100.9:8080".parse().unwrap());
818 assert_eq!(proxy.scheme, ts_control::ExitProxyScheme::Socks5);
819 assert_eq!(proxy.auth, Some(("u".to_owned(), "p".to_owned())));
820 assert!(control.enable_ipv6);
821 assert!(
822 control.network_monitor,
823 "network_monitor crosses the boundary (set true)"
824 );
825 assert_eq!(
826 control.wireguard_listen_port,
827 Some(41641),
828 "wireguard_listen_port crosses the boundary"
829 );
830 assert!(control.wire_ingress);
831 assert_eq!(control.advertise_services, vec!["svc:samba".to_owned()]);
832 assert_eq!(
833 control.taildrop_dir,
834 Some(std::path::PathBuf::from("/var/lib/taildrop"))
835 );
836 assert_eq!(
837 control.transport_mode,
838 ts_control::TransportMode::Tun(ts_control::TunConfig {
839 name: Some("tailscale0".to_owned()),
840 mtu: Some(1280),
841 })
842 );
843 // up/set pref fields cross the boundary: two advertise-side, six store-only carried prefs.
844 assert!(control.advertise_app_connector);
845 assert_eq!(control.auto_update_apply, Some(true));
846 assert!(control.auto_update_check);
847 assert_eq!(control.operator_user.as_deref(), Some("alice"));
848 assert_eq!(control.node_nickname.as_deref(), Some("laptop"));
849 assert!(control.posture_checking);
850 assert!(control.run_web_client);
851 assert!(control.exit_node_allow_lan_access);
852 assert!(
853 control.remote_config,
854 "remote_config crosses the boundary (set true)"
855 );
856 // The LocalAPI hook is the c2n `/remoteapi/localapi/*` proxy's target: losing it in the
857 // hand-copy would silently unregister the prefix route rather than fail to compile.
858 let local_api = control
859 .local_api
860 .as_ref()
861 .expect("c2n_local_api crosses the boundary");
862 assert_eq!(
863 local_api.serve("GET", "/localapi/v0/status", "").await,
864 NO_ROUTES_RESPONSE,
865 "the installed LocalAPI is the one that crossed, not a stand-in"
866 );
867 }
868
869 /// A [`ts_control::LocalApi`] that serves nothing, used to prove the hook itself crosses the
870 /// `From<&Config>` boundary without standing up the facade's real LocalAPI server.
871 struct NoRoutesLocalApi;
872
873 /// What [`NoRoutesLocalApi`] answers with, whatever it is asked.
874 const NO_ROUTES_RESPONSE: &str = "HTTP/1.1 404 Not Found\r\n\r\nnot found";
875
876 impl ts_control::LocalApi for NoRoutesLocalApi {
877 fn serve<'a>(
878 &'a self,
879 _method: &'a str,
880 _target: &'a str,
881 _body: &'a str,
882 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = String> + Send + 'a>> {
883 Box::pin(async { NO_ROUTES_RESPONSE.to_owned() })
884 }
885 }
886
887 /// All eight up/set pref fields default off/None on a fresh top-level `Config`, and the defaults
888 /// cross the `From<&Config>` boundary unchanged. Fail-closed: a default node advertises no
889 /// app-connector / auto-update and carries no operator/nickname/posture/webclient/LAN-access pref.
890 #[test]
891 fn from_config_default_up_set_pref_fields_off() {
892 let cfg = Config::default();
893 // Defaults on the top-level config.
894 assert!(!cfg.advertise_app_connector);
895 assert_eq!(cfg.auto_update_apply, None);
896 assert!(!cfg.auto_update_check);
897 assert_eq!(cfg.operator_user, None);
898 assert_eq!(cfg.node_nickname, None);
899 assert!(!cfg.posture_checking);
900 assert!(!cfg.run_web_client);
901 assert!(!cfg.exit_node_allow_lan_access);
902 assert!(!cfg.remote_config);
903 assert!(cfg.c2n_local_api.is_none());
904
905 // And they cross the boundary defaulted off.
906 let control: ts_control::Config = (&cfg).into();
907 assert!(!control.advertise_app_connector);
908 assert_eq!(control.auto_update_apply, None);
909 assert!(!control.auto_update_check);
910 assert_eq!(control.operator_user, None);
911 assert_eq!(control.node_nickname, None);
912 assert!(!control.posture_checking);
913 assert!(!control.run_web_client);
914 assert!(!control.exit_node_allow_lan_access);
915 assert!(
916 !control.remote_config,
917 "fail-closed: control cannot drive the LocalAPI of a default node"
918 );
919 assert!(
920 control.local_api.is_none(),
921 "a default node registers no c2n LocalAPI proxy route"
922 );
923 }
924
925 #[test]
926 fn from_config_default_is_netstack_transport() {
927 // The unprivileged userspace netstack is the safe default; opting into a kernel TUN
928 // interface (which needs root) must be explicit.
929 let control: ts_control::Config = (&Config::default()).into();
930 assert_eq!(control.transport_mode, ts_control::TransportMode::Netstack);
931 }
932
933 /// The WireGuard listen port defaults to `None` (OS-chosen ephemeral, today's behavior) and
934 /// crosses the control boundary unchanged. A daemon that wants Go's `--port 41641` sets it
935 /// explicitly; the engine never pins a port by default.
936 #[test]
937 fn from_config_default_wireguard_listen_port_is_none() {
938 let cfg = Config::default();
939 assert_eq!(cfg.wireguard_listen_port, None);
940 let control: ts_control::Config = (&cfg).into();
941 assert_eq!(control.wireguard_listen_port, None);
942 }
943
944 #[test]
945 fn from_config_default_has_no_exit_proxy() {
946 let control: ts_control::Config = (&Config::default()).into();
947 assert!(control.exit_proxy.is_none());
948 assert!(!control.forward_exit_egress);
949 }
950
951 /// Persistent keepalive is **on by default at 25s** — this is the idle-wedge fix's safe default
952 /// for the relayed case (an idle DERP-relayed session would otherwise age out and wedge). The
953 /// default mirrors `ts_control::DEFAULT_PERSISTENT_KEEPALIVE` and crosses the control boundary.
954 #[test]
955 fn from_config_default_enables_persistent_keepalive_25s() {
956 let cfg = Config::default();
957 assert_eq!(
958 cfg.persistent_keepalive_interval,
959 Some(std::time::Duration::from_secs(25))
960 );
961 let control: ts_control::Config = (&cfg).into();
962 assert_eq!(
963 control.persistent_keepalive_interval,
964 Some(ts_control::DEFAULT_PERSISTENT_KEEPALIVE)
965 );
966 }
967
968 #[test]
969 fn wif_fields_default_none() {
970 // Workload-identity-federation config is SaaS-only and opt-in: a default config never
971 // carries an auth key or any OAuth/OIDC federation material.
972 let cfg = Config::default();
973 assert!(cfg.auth_key.is_none());
974 assert!(cfg.client_id.is_none());
975 assert!(cfg.client_secret.is_none());
976 assert!(cfg.id_token.is_none());
977 assert!(cfg.audience.is_none());
978 }
979
980 #[test]
981 fn from_config_default_is_ipv4_only() {
982 // The IPv6-off posture is the safe default: enabling overlay IPv6 must be an explicit opt-in.
983 let control: ts_control::Config = (&Config::default()).into();
984 assert!(!control.enable_ipv6);
985 }
986
987 /// `use_tun` is a chainable builder that sets `transport_mode` to `Tun(TunConfig { name, mtu })`,
988 /// and the selection threads through to the control config. Also exercises the facade re-exports
989 /// `tailscale::TransportMode` / `tailscale::TunConfig` by naming them without the `ts_control::`
990 /// path (the whole point of the re-export — a downstream crate can use only the facade).
991 #[test]
992 fn use_tun_builder_sets_transport_mode() {
993 use crate::{TransportMode, TunConfig};
994
995 // Default is netstack.
996 assert_eq!(Config::default().transport_mode, TransportMode::Netstack);
997
998 let cfg = Config::default().use_tun(Some("tailscale0".to_string()), Some(1280));
999 assert_eq!(
1000 cfg.transport_mode,
1001 TransportMode::Tun(TunConfig {
1002 name: Some("tailscale0".to_string()),
1003 mtu: Some(1280),
1004 })
1005 );
1006
1007 // The selection crosses the From<&Config> boundary into the control config.
1008 let control: ts_control::Config = (&cfg).into();
1009 assert_eq!(
1010 control.transport_mode,
1011 TransportMode::Tun(TunConfig {
1012 name: Some("tailscale0".to_string()),
1013 mtu: Some(1280),
1014 })
1015 );
1016 }
1017}
1018
1019/// What to do if the key file can't be parsed.
1020///
1021/// Default behavior: return an error.
1022#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
1023pub enum BadFormatBehavior {
1024 /// Return an error.
1025 #[default]
1026 Error,
1027
1028 /// Overwrite the file with a newly-generated set of keys.
1029 Overwrite,
1030}
1031
1032/// Attempt to load a file from a path. If it doesn't exist, create it with the
1033/// specified default value.
1034#[tracing::instrument(skip_all, fields(?bad_format_behavior, path = %path.as_ref().display()))]
1035async fn load_or_init<KeyState>(
1036 path: impl AsRef<Path>,
1037 default: impl FnOnce() -> KeyState,
1038 migrate: impl FnOnce(&KeyState) -> Option<KeyState>,
1039 bad_format_behavior: BadFormatBehavior,
1040) -> Result<KeyState, crate::Error>
1041where
1042 KeyState: serde::Serialize + serde::de::DeserializeOwned,
1043{
1044 let path = path.as_ref();
1045
1046 tokio::fs::create_dir_all(path.parent().unwrap())
1047 .await
1048 .map_err(|e| {
1049 tracing::error!(error = %e, "creating parent dirs for key file");
1050 crate::Error::KeyFileWrite
1051 })?;
1052
1053 match tokio::fs::read(path).await {
1054 Ok(contents) => match serde_json::from_slice::<KeyState>(&contents) {
1055 Ok(state) => {
1056 if let Some(migrated) = migrate(&state) {
1057 match try_write(path, &migrated).await {
1058 Ok(_) => {
1059 tracing::info!("migrated key file to new disco-less format");
1060 return Ok(migrated);
1061 }
1062 Err(e) => {
1063 tracing::error!(error = %e, "unable to migrate key file");
1064 }
1065 }
1066 }
1067
1068 return Ok(state);
1069 }
1070 Err(e) => match bad_format_behavior {
1071 BadFormatBehavior::Error => {
1072 tracing::error!(error = %e, "parsing key file");
1073 return Err(crate::Error::KeyFileRead);
1074 }
1075 BadFormatBehavior::Overwrite => {
1076 tracing::warn!(
1077 error = %e,
1078 config_file_contents_len = contents.len(),
1079 "failed loading version from key file, overwriting",
1080 );
1081 }
1082 },
1083 },
1084 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1085 Err(e) => {
1086 tracing::error!(error = %e, path = %path.display(), "reading key file");
1087 return Err(crate::Error::KeyFileRead);
1088 }
1089 }
1090
1091 let value = default();
1092 try_write(path, &value).await?;
1093 Ok(value)
1094}
1095
1096async fn try_write(
1097 path: impl AsRef<Path>,
1098 value: &impl serde::Serialize,
1099) -> Result<(), crate::Error> {
1100 tokio::fs::write(
1101 path,
1102 serde_json::to_vec(value).map_err(|e| {
1103 tracing::error!(error = %e, "serializing key state");
1104 crate::Error::KeyFileWrite
1105 })?,
1106 )
1107 .await
1108 .map_err(|e| {
1109 tracing::error!(error = %e, "saving key state");
1110 crate::Error::KeyFileWrite
1111 })?;
1112
1113 Ok(())
1114}