Skip to main content

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    /// Filesystem directory that received Taildrop files land in, or `None` to disable Taildrop
296    /// (the default, fail-closed).
297    ///
298    /// When `Some(dir)` **and** a peerAPI port is configured (Taildrop is served on the shared
299    /// peerAPI listener, so it needs the same bind), the runtime serves the Taildrop peerAPI route
300    /// `PUT /v0/put/<name>` and writes incoming files under `dir` (created if absent). When `None`,
301    /// no Taildrop server is run and a peer's `PUT` is refused (`403`). The embedder consumes
302    /// received files via the [`Device::taildrop_waiting_files`](crate::Device::taildrop_waiting_files)
303    /// / [`taildrop_open_file`](crate::Device::taildrop_open_file) /
304    /// [`taildrop_delete_file`](crate::Device::taildrop_delete_file) methods.
305    pub taildrop_dir: Option<std::path::PathBuf>,
306
307    /// Pre-auth key for non-interactive registration (Go `tsnet.Server.AuthKey`). When set, used as
308    /// the registration auth key. If it is an OAuth client secret (prefix `tskey-client-`) and the
309    /// `identity-federation` feature is enabled, it is exchanged for an auth key before registration.
310    /// Falls back to the `TS_AUTH_KEY` env var (see [`auth_key_from_env`]). Defaults to `None`.
311    pub auth_key: Option<String>,
312
313    /// OAuth client ID for workload-identity federation (Go `tsnet.Server.ClientID`). SaaS-only;
314    /// requires the `identity-federation` feature. With [`id_token`](Config::id_token) or
315    /// [`audience`](Config::audience), the node exchanges an IdP-issued OIDC token for a Tailscale
316    /// auth key. Defaults to `None` (`TS_CLIENT_ID` env fallback).
317    pub client_id: Option<String>,
318
319    /// OAuth client secret used to mint auth keys via OAuth (Go `tsnet.Server.ClientSecret`).
320    /// SaaS-only; requires the `identity-federation` feature. Defaults to `None` (`TS_CLIENT_SECRET`).
321    ///
322    /// Treat as **fully operator-trusted input**: a `tskey-client-…?baseURL=…` secret redirects the
323    /// credential exchange to that host, so a hostile value would exfiltrate the secret and the
324    /// minted auth key. Never source it from a less-trusted origin.
325    pub client_secret: Option<String>,
326
327    /// IdP-issued OIDC ID token to exchange with control for an auth key via workload-identity
328    /// federation (Go `tsnet.Server.IDToken`). SaaS-only; requires the `identity-federation` feature
329    /// and [`client_id`](Config::client_id). Mutually exclusive with [`audience`](Config::audience).
330    /// Defaults to `None` (`TS_ID_TOKEN`).
331    pub id_token: Option<String>,
332
333    /// Audience for requesting an OIDC ID token from the ambient workload identity (GitHub Actions /
334    /// GCP / AWS), to exchange for an auth key via workload-identity federation (Go
335    /// `tsnet.Server.Audience`). SaaS-only; requires the `identity-federation` feature +
336    /// [`client_id`](Config::client_id). Mutually exclusive with [`id_token`](Config::id_token).
337    /// Defaults to `None` (`TS_AUDIENCE`).
338    pub audience: Option<String>,
339}
340
341impl Config {
342    /// Create a new config with its [`key_state`](Config::key_state) populated from the specified key file and using
343    /// default options for other configuration.
344    ///
345    /// See [`load_key_file`] for more details and an alternative with more options for reading
346    /// the key file.
347    pub async fn default_with_key_file(p: impl AsRef<Path>) -> Result<Self, crate::Error> {
348        Ok(Config {
349            key_state: load_key_file(p, Default::default()).await?,
350            ..Default::default()
351        })
352    }
353
354    /// Run the application overlay over a real kernel **TUN** interface instead of the default
355    /// userspace netstack — a builder shortcut for setting
356    /// [`transport_mode`](Config::transport_mode) to
357    /// [`TransportMode::Tun`](ts_control::TransportMode::Tun).
358    ///
359    /// `name` is the desired interface name (`None` lets the OS pick, e.g. `utunN` on macOS); `mtu`
360    /// is the interface MTU (`None` uses the transport default; Tailscale's overlay MTU is 1280).
361    /// TUN mode requires root / `CAP_NET_ADMIN` and the engine's `tun` feature to be enabled.
362    /// Chainable: `Config::default().use_tun(Some("tailscale0".into()), None)`.
363    #[must_use]
364    pub fn use_tun(mut self, name: Option<String>, mtu: Option<u16>) -> Self {
365        self.transport_mode = ts_control::TransportMode::Tun(ts_control::TunConfig { name, mtu });
366        self
367    }
368
369    /// Construct a default config, setting certain fields from environment variables.
370    ///
371    /// The fields are only set if the corresponding environment variable is present, using
372    /// the default value otherwise.
373    ///
374    /// Loads:
375    ///
376    /// - `control_server_url` from `TS_CONTROL_URL`
377    /// - `requested_hostname` from `TS_HOSTNAME`
378    /// - `auth_key` from `TS_AUTH_KEY`
379    /// - `client_id` from `TS_CLIENT_ID`
380    /// - `client_secret` from `TS_CLIENT_SECRET`
381    /// - `id_token` from `TS_ID_TOKEN`
382    /// - `audience` from `TS_AUDIENCE`
383    pub fn default_from_env() -> Config {
384        let mut config = Config::default();
385
386        if let Ok(u) = std::env::var(CONTROL_URL_VAR) {
387            match u.parse() {
388                Ok(u) => config.control_server_url = u,
389                Err(e) => {
390                    tracing::error!(error = %e, "parsing {CONTROL_URL_VAR} (fall back to default value)");
391                }
392            }
393        };
394
395        config.requested_hostname = std::env::var(HOSTNAME_VAR).ok();
396
397        if let Some(auth_key) = auth_key_from_env() {
398            config.auth_key = Some(auth_key);
399        }
400        if let Ok(client_id) = std::env::var(CLIENT_ID_VAR) {
401            config.client_id = Some(client_id);
402        }
403        if let Ok(client_secret) = std::env::var(CLIENT_SECRET_VAR) {
404            config.client_secret = Some(client_secret);
405        }
406        if let Ok(id_token) = std::env::var(ID_TOKEN_VAR) {
407            config.id_token = Some(id_token);
408        }
409        if let Ok(audience) = std::env::var(AUDIENCE_VAR) {
410            config.audience = Some(audience);
411        }
412
413        config
414    }
415
416    /// Rotate this config's node key in place for an embedder-driven re-registration, mirroring Go's
417    /// `regen` flow: the current node key is recorded as the old key and a fresh node key is
418    /// generated. Re-create the [`Device`](crate::Device) from this config to perform the rotation;
419    /// the next registration sends the prior key as `OldNodeKey` for key continuity.
420    ///
421    /// Reactive and embedder-driven by design (you decide when to rotate, e.g. after observing
422    /// [`Device::self_key_expired`](crate::Device::self_key_expired) flip, or on a policy of your
423    /// own). This fork does not auto-rotate before expiry — neither does Go, which treats key expiry
424    /// as a deliberate periodic re-authentication checkpoint. Rotation still requires a valid auth
425    /// key, exactly like a fresh registration.
426    pub fn rotate_node_key(&mut self) {
427        self.key_state.rotate_node_key();
428    }
429}
430
431/// Load an auth key from the `TS_AUTH_KEY` environment variable.
432pub fn auth_key_from_env() -> Option<String> {
433    std::env::var(AUTHKEY_VAR).ok()
434}
435
436/// Load key state from a path on the filesystem, or create a file with a new key state if
437/// one doesn't exist.
438///
439/// The `bad_format` argument allows you to specify whether an existing file should be
440/// overwritten if the contents can't be parsed.
441pub async fn load_key_file(
442    p: impl AsRef<Path>,
443    bad_format: BadFormatBehavior,
444) -> Result<PersistState, crate::Error> {
445    let p = p.as_ref();
446
447    tracing::trace!(key_file = %p.display(), "loading key file");
448
449    let key_file = load_or_init::<KeyFile>(
450        &p,
451        Default::default,
452        |x| match x {
453            #[allow(deprecated)]
454            KeyFile::Old(old) => Some(KeyFile::New(KeyFileNew {
455                key_state: PersistState::from(&old.key_state),
456            })),
457            _ => None,
458        },
459        bad_format,
460    )
461    .await?;
462    Ok(key_file.key_state())
463}
464
465#[derive(serde::Deserialize)]
466#[serde(untagged)]
467enum KeyFile {
468    #[deprecated]
469    Old(KeyFileOld),
470    New(KeyFileNew),
471}
472
473impl KeyFile {
474    #[allow(deprecated)]
475    pub fn key_state(&self) -> PersistState {
476        match self {
477            Self::Old(old) => (&old.key_state).into(),
478            Self::New(new) => new.key_state.clone(),
479        }
480    }
481}
482
483impl Default for KeyFile {
484    fn default() -> Self {
485        KeyFile::New(KeyFileNew::default())
486    }
487}
488
489impl serde::Serialize for KeyFile {
490    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
491    where
492        S: Serializer,
493    {
494        KeyFileNew {
495            key_state: self.key_state(),
496        }
497        .serialize(serializer)
498    }
499}
500
501#[derive(serde::Deserialize, serde::Serialize, Default)]
502struct KeyFileNew {
503    key_state: PersistState,
504}
505
506#[derive(serde::Deserialize)]
507struct KeyFileOld {
508    key_state: NodeState,
509}
510
511impl From<&Config> for ts_control::Config {
512    fn from(value: &Config) -> ts_control::Config {
513        ts_control::Config {
514            client_name: value.client_name.clone(),
515            hostname: value.requested_hostname.clone(),
516            server_url: value.control_server_url.clone(),
517            tags: value.requested_tags.clone(),
518            ephemeral: value.ephemeral,
519            reauth_on_expiry: value.reauth_on_expiry,
520            accept_routes: value.accept_routes,
521            accept_dns: value.accept_dns,
522            exit_node: value.exit_node.clone(),
523            advertise_routes: value.advertise_routes.clone(),
524            advertise_exit_node: value.advertise_exit_node,
525            forward_tcp_ports: value.forward_tcp_ports.clone(),
526            forward_udp_ports: value.forward_udp_ports.clone(),
527            forward_all_ports: value.forward_all_ports,
528            forward_exit_egress: value.forward_exit_egress,
529            block_incoming: value.block_incoming,
530            exit_proxy: value.exit_proxy.clone(),
531            tcp_buffer_size: value.tcp_buffer_size,
532            persistent_keepalive_interval: value.persistent_keepalive_interval,
533            peerapi_port: None,
534            taildrop_dir: value.taildrop_dir.clone(),
535            enable_ipv6: value.enable_ipv6,
536            network_monitor: value.network_monitor,
537            wireguard_listen_port: value.wireguard_listen_port,
538            transport_mode: value.transport_mode.clone(),
539            wire_ingress: value.wire_ingress,
540            // A fresh runtime-local flag (default `false`): the runtime flips it when
541            // `Device::listen_funnel` starts a listener. Not derived from the embedder config.
542            ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
543            advertise_services: value.advertise_services.clone(),
544            allow_http_key_fetch: value.allow_http_key_fetch,
545        }
546    }
547}
548
549impl Default for Config {
550    fn default() -> Self {
551        Self {
552            key_state: Default::default(),
553            client_name: None,
554            control_server_url: ts_control::DEFAULT_CONTROL_SERVER.clone(),
555            allow_http_key_fetch: false,
556            requested_hostname: None,
557            requested_tags: vec![],
558            ephemeral: true,
559            reauth_on_expiry: true,
560            accept_routes: false,
561            accept_dns: true,
562            exit_node: None,
563            advertise_routes: vec![],
564            advertise_exit_node: false,
565            forward_tcp_ports: vec![],
566            forward_udp_ports: vec![],
567            forward_all_ports: false,
568            forward_exit_egress: false,
569            block_incoming: false,
570            exit_proxy: None,
571            tcp_buffer_size: None,
572            persistent_keepalive_interval: Some(ts_control::DEFAULT_PERSISTENT_KEEPALIVE),
573            enable_ipv6: false,
574            network_monitor: false,
575            wireguard_listen_port: None,
576            transport_mode: ts_control::TransportMode::default(),
577            wire_ingress: false,
578            advertise_services: vec![],
579            taildrop_dir: None,
580            auth_key: None,
581            client_id: None,
582            client_secret: None,
583            id_token: None,
584            audience: None,
585        }
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    // The `From<&Config> for ts_control::Config` impl hand-copies every field, so it silently
594    // drops any field a future edit forgets to add. These tests assert each dataplane field
595    // crosses the boundary, with special attention to the anti-leak ones (`forward_exit_egress`,
596    // `exit_proxy`) whose loss would change egress behavior.
597    #[test]
598    fn from_config_threads_all_dataplane_fields() {
599        let cfg = Config {
600            accept_routes: true,
601            // Set to the non-default (`false`) so its crossing is observable (default is `true`).
602            accept_dns: false,
603            advertise_exit_node: true,
604            forward_all_ports: true,
605            forward_exit_egress: true,
606            forward_tcp_ports: vec![80, 443],
607            forward_udp_ports: vec![53],
608            tcp_buffer_size: Some(1024 * 128),
609            persistent_keepalive_interval: Some(std::time::Duration::from_secs(17)),
610            enable_ipv6: true,
611            network_monitor: true,
612            wireguard_listen_port: Some(41641),
613            wire_ingress: true,
614            transport_mode: ts_control::TransportMode::Tun(ts_control::TunConfig {
615                name: Some("tailscale0".to_owned()),
616                mtu: Some(1280),
617            }),
618            advertise_routes: vec!["10.0.0.0/24".parse().unwrap()],
619            requested_tags: vec!["tag:exit".to_owned()],
620            advertise_services: vec!["svc:samba".to_owned()],
621            ephemeral: false,
622            exit_proxy: Some(ExitProxyConfig {
623                addr: "198.51.100.9:8080".parse().unwrap(),
624                scheme: ts_control::ExitProxyScheme::Socks5,
625                auth: Some(("u".to_owned(), "p".to_owned())),
626            }),
627            taildrop_dir: Some(std::path::PathBuf::from("/var/lib/taildrop")),
628            ..Default::default()
629        };
630
631        let control: ts_control::Config = (&cfg).into();
632
633        assert!(control.accept_routes);
634        assert!(
635            !control.accept_dns,
636            "accept_dns crosses the boundary (set false)"
637        );
638        assert!(control.advertise_exit_node);
639        assert!(control.forward_all_ports);
640        assert!(control.forward_exit_egress);
641        assert!(!control.ephemeral);
642        assert_eq!(control.forward_tcp_ports, vec![80, 443]);
643        assert_eq!(control.forward_udp_ports, vec![53]);
644        assert_eq!(control.tcp_buffer_size, Some(1024 * 128));
645        assert_eq!(
646            control.persistent_keepalive_interval,
647            Some(std::time::Duration::from_secs(17))
648        );
649        assert_eq!(control.tags, vec!["tag:exit".to_owned()]);
650        let proxy = control.exit_proxy.expect("exit_proxy crosses the boundary");
651        assert_eq!(proxy.addr, "198.51.100.9:8080".parse().unwrap());
652        assert_eq!(proxy.scheme, ts_control::ExitProxyScheme::Socks5);
653        assert_eq!(proxy.auth, Some(("u".to_owned(), "p".to_owned())));
654        assert!(control.enable_ipv6);
655        assert!(
656            control.network_monitor,
657            "network_monitor crosses the boundary (set true)"
658        );
659        assert_eq!(
660            control.wireguard_listen_port,
661            Some(41641),
662            "wireguard_listen_port crosses the boundary"
663        );
664        assert!(control.wire_ingress);
665        assert_eq!(control.advertise_services, vec!["svc:samba".to_owned()]);
666        assert_eq!(
667            control.taildrop_dir,
668            Some(std::path::PathBuf::from("/var/lib/taildrop"))
669        );
670        assert_eq!(
671            control.transport_mode,
672            ts_control::TransportMode::Tun(ts_control::TunConfig {
673                name: Some("tailscale0".to_owned()),
674                mtu: Some(1280),
675            })
676        );
677    }
678
679    #[test]
680    fn from_config_default_is_netstack_transport() {
681        // The unprivileged userspace netstack is the safe default; opting into a kernel TUN
682        // interface (which needs root) must be explicit.
683        let control: ts_control::Config = (&Config::default()).into();
684        assert_eq!(control.transport_mode, ts_control::TransportMode::Netstack);
685    }
686
687    /// The WireGuard listen port defaults to `None` (OS-chosen ephemeral, today's behavior) and
688    /// crosses the control boundary unchanged. A daemon that wants Go's `--port 41641` sets it
689    /// explicitly; the engine never pins a port by default.
690    #[test]
691    fn from_config_default_wireguard_listen_port_is_none() {
692        let cfg = Config::default();
693        assert_eq!(cfg.wireguard_listen_port, None);
694        let control: ts_control::Config = (&cfg).into();
695        assert_eq!(control.wireguard_listen_port, None);
696    }
697
698    #[test]
699    fn from_config_default_has_no_exit_proxy() {
700        let control: ts_control::Config = (&Config::default()).into();
701        assert!(control.exit_proxy.is_none());
702        assert!(!control.forward_exit_egress);
703    }
704
705    /// Persistent keepalive is **on by default at 25s** — this is the idle-wedge fix's safe default
706    /// for the relayed case (an idle DERP-relayed session would otherwise age out and wedge). The
707    /// default mirrors `ts_control::DEFAULT_PERSISTENT_KEEPALIVE` and crosses the control boundary.
708    #[test]
709    fn from_config_default_enables_persistent_keepalive_25s() {
710        let cfg = Config::default();
711        assert_eq!(
712            cfg.persistent_keepalive_interval,
713            Some(std::time::Duration::from_secs(25))
714        );
715        let control: ts_control::Config = (&cfg).into();
716        assert_eq!(
717            control.persistent_keepalive_interval,
718            Some(ts_control::DEFAULT_PERSISTENT_KEEPALIVE)
719        );
720    }
721
722    #[test]
723    fn wif_fields_default_none() {
724        // Workload-identity-federation config is SaaS-only and opt-in: a default config never
725        // carries an auth key or any OAuth/OIDC federation material.
726        let cfg = Config::default();
727        assert!(cfg.auth_key.is_none());
728        assert!(cfg.client_id.is_none());
729        assert!(cfg.client_secret.is_none());
730        assert!(cfg.id_token.is_none());
731        assert!(cfg.audience.is_none());
732    }
733
734    #[test]
735    fn from_config_default_is_ipv4_only() {
736        // The IPv6-off posture is the safe default: enabling overlay IPv6 must be an explicit opt-in.
737        let control: ts_control::Config = (&Config::default()).into();
738        assert!(!control.enable_ipv6);
739    }
740
741    /// `use_tun` is a chainable builder that sets `transport_mode` to `Tun(TunConfig { name, mtu })`,
742    /// and the selection threads through to the control config. Also exercises the facade re-exports
743    /// `tailscale::TransportMode` / `tailscale::TunConfig` by naming them without the `ts_control::`
744    /// path (the whole point of the re-export — a downstream crate can use only the facade).
745    #[test]
746    fn use_tun_builder_sets_transport_mode() {
747        use crate::{TransportMode, TunConfig};
748
749        // Default is netstack.
750        assert_eq!(Config::default().transport_mode, TransportMode::Netstack);
751
752        let cfg = Config::default().use_tun(Some("tailscale0".to_string()), Some(1280));
753        assert_eq!(
754            cfg.transport_mode,
755            TransportMode::Tun(TunConfig {
756                name: Some("tailscale0".to_string()),
757                mtu: Some(1280),
758            })
759        );
760
761        // The selection crosses the From<&Config> boundary into the control config.
762        let control: ts_control::Config = (&cfg).into();
763        assert_eq!(
764            control.transport_mode,
765            TransportMode::Tun(TunConfig {
766                name: Some("tailscale0".to_string()),
767                mtu: Some(1280),
768            })
769        );
770    }
771}
772
773/// What to do if the key file can't be parsed.
774///
775/// Default behavior: return an error.
776#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
777pub enum BadFormatBehavior {
778    /// Return an error.
779    #[default]
780    Error,
781
782    /// Overwrite the file with a newly-generated set of keys.
783    Overwrite,
784}
785
786/// Attempt to load a file from a path. If it doesn't exist, create it with the
787/// specified default value.
788#[tracing::instrument(skip_all, fields(?bad_format_behavior, path = %path.as_ref().display()))]
789async fn load_or_init<KeyState>(
790    path: impl AsRef<Path>,
791    default: impl FnOnce() -> KeyState,
792    migrate: impl FnOnce(&KeyState) -> Option<KeyState>,
793    bad_format_behavior: BadFormatBehavior,
794) -> Result<KeyState, crate::Error>
795where
796    KeyState: serde::Serialize + serde::de::DeserializeOwned,
797{
798    let path = path.as_ref();
799
800    tokio::fs::create_dir_all(path.parent().unwrap())
801        .await
802        .map_err(|e| {
803            tracing::error!(error = %e, "creating parent dirs for key file");
804            crate::Error::KeyFileWrite
805        })?;
806
807    match tokio::fs::read(path).await {
808        Ok(contents) => match serde_json::from_slice::<KeyState>(&contents) {
809            Ok(state) => {
810                if let Some(migrated) = migrate(&state) {
811                    match try_write(path, &migrated).await {
812                        Ok(_) => {
813                            tracing::info!("migrated key file to new disco-less format");
814                            return Ok(migrated);
815                        }
816                        Err(e) => {
817                            tracing::error!(error = %e, "unable to migrate key file");
818                        }
819                    }
820                }
821
822                return Ok(state);
823            }
824            Err(e) => match bad_format_behavior {
825                BadFormatBehavior::Error => {
826                    tracing::error!(error = %e, "parsing key file");
827                    return Err(crate::Error::KeyFileRead);
828                }
829                BadFormatBehavior::Overwrite => {
830                    tracing::warn!(
831                        error = %e,
832                        config_file_contents_len = contents.len(),
833                        "failed loading version from key file, overwriting",
834                    );
835                }
836            },
837        },
838        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
839        Err(e) => {
840            tracing::error!(error = %e, path = %path.display(), "reading key file");
841            return Err(crate::Error::KeyFileRead);
842        }
843    }
844
845    let value = default();
846    try_write(path, &value).await?;
847    Ok(value)
848}
849
850async fn try_write(
851    path: impl AsRef<Path>,
852    value: &impl serde::Serialize,
853) -> Result<(), crate::Error> {
854    tokio::fs::write(
855        path,
856        serde_json::to_vec(value).map_err(|e| {
857            tracing::error!(error = %e, "serializing key state");
858            crate::Error::KeyFileWrite
859        })?,
860    )
861    .await
862    .map_err(|e| {
863        tracing::error!(error = %e, "saving key state");
864        crate::Error::KeyFileWrite
865    })?;
866
867    Ok(())
868}