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 accept (and route traffic to) subnet routes advertised by peers.
62    ///
63    /// This is the equivalent of `tailscale up --accept-routes`. Defaults to `false`: only each
64    /// peer's own tailnet address is reachable. Set to `true` to use peers that act as subnet
65    /// routers, so traffic destined for an advertised subnet egresses via the advertising peer.
66    pub accept_routes: bool,
67
68    /// The peer to route internet-bound traffic through (exit node).
69    ///
70    /// This is the equivalent of `tailscale up --exit-node`. The peer may be named by stable node
71    /// ID, tailnet IP, or MagicDNS name via [`ExitNodeSelector`](crate::ExitNodeSelector) (a bare
72    /// IP or name can be parsed with `selector.parse()`). Defaults to `None`: internet-bound
73    /// traffic has no overlay route and is dropped (fail-closed). When set to a peer that
74    /// advertises a default route, all traffic not matching a more-specific route egresses through
75    /// that peer. The selection is re-resolved as the netmap changes.
76    pub exit_node: Option<ts_control::ExitNodeSelector>,
77
78    /// Subnet routes to advertise as a subnet router.
79    ///
80    /// This is the equivalent of `tailscale up --advertise-routes`. Defaults to empty: this node
81    /// advertises no routes. Each prefix is sent to the control server in `HostInfo.RoutableIPs`;
82    /// once the route is approved, peers with `accept_routes` may send traffic for that subnet
83    /// through this node. Only IPv4 prefixes are advertised — IPv6 prefixes are dropped to uphold
84    /// the IPv6-off posture (we never forward IPv6, so advertising it would be a black hole).
85    pub advertise_routes: Vec<ipnet::IpNet>,
86
87    /// Whether to advertise this node as an exit node.
88    ///
89    /// This is the equivalent of `tailscale up --advertise-exit-node`. Defaults to `false`. When
90    /// `true`, the default route `0.0.0.0/0` is advertised so that, once approved, other peers may
91    /// route their internet-bound traffic out through this node's real origin IP. Because that
92    /// means *other* peers' traffic egresses via our IP, it is strictly opt-in. `::/0` is never
93    /// advertised (IPv6-off).
94    pub advertise_exit_node: bool,
95
96    /// TCP ports the inbound forwarder accepts and splices to real OS sockets, for every advertised
97    /// route ([`advertise_routes`](Config::advertise_routes) / [`advertise_exit_node`](Config::advertise_exit_node)).
98    ///
99    /// Acting as a subnet router or exit node means inbound overlay flows to advertised
100    /// destinations are dialed out as real OS connections (mirroring Go `tsnet`'s forwarders). The
101    /// underlying netstack has no all-port accept mode, so the set of forwarded ports is explicit
102    /// rather than the full 1–65535 range. Defaults to empty: a node may advertise routes but
103    /// forward nothing until ports are configured (fail-closed — nothing is dialed).
104    pub forward_tcp_ports: Vec<u16>,
105
106    /// UDP ports the inbound forwarder accepts and splices to real OS sockets, for every advertised
107    /// route. See [`forward_tcp_ports`](Config::forward_tcp_ports); defaults to empty.
108    pub forward_udp_ports: Vec<u16>,
109
110    /// Forward **all** TCP/UDP ports (1–65535) on every advertised route, like a Go subnet router.
111    ///
112    /// This is the equivalent of a `tailscale up --advertise-routes` node forwarding every port,
113    /// instead of the explicit [`forward_tcp_ports`](Config::forward_tcp_ports) /
114    /// [`forward_udp_ports`](Config::forward_udp_ports) sets. When `true`, those explicit sets are
115    /// ignored and the forwarder runs an on-demand per-port listener manager. Anti-leak is
116    /// unchanged: every flow still routes through the same dialer chokepoint, so
117    /// [`forward_exit_egress`](Config::forward_exit_egress) still governs exit-node egress. Defaults
118    /// to `false`.
119    pub forward_all_ports: bool,
120
121    /// Whether exit-node (`0.0.0.0/0`) inbound flows are actually egressed via **this host's real
122    /// origin IP**.
123    ///
124    /// Anti-leak opt-in, separate from [`advertise_exit_node`](Config::advertise_exit_node):
125    /// advertising the default route only offers this node as an exit to control; it does not by
126    /// itself egress a peer's internet-bound traffic. Defaults to `false` (fail-closed): the
127    /// forwarder structurally refuses exit-node egress, dropping `0.0.0.0/0` flows at dial time
128    /// rather than leaking them out our real IP. Set to `true` only on a node whose real IP *is* the
129    /// intended egress (e.g. a residential exit), never on a host whose IP must stay hidden (e.g. a
130    /// cloud VPS). Subnet routes are dialed identically regardless of this flag.
131    pub forward_exit_egress: bool,
132
133    /// Optional upstream proxy that exit-node egress is routed through, so the node egresses via
134    /// the proxy's IP rather than its own origin IP.
135    ///
136    /// This is a **product capability beyond strict Go `tsnet` parity**: it lets a cloud exit node
137    /// route the traffic it egresses through a residential proxy provider configured by the
138    /// deployer, so the cloud host's real IP never appears upstream. Only consulted when
139    /// [`forward_exit_egress`](Config::forward_exit_egress) is `true`. When `Some`, the forwarder is
140    /// wired with a SOCKS5 / HTTP `CONNECT` proxy dialer that **fails closed** — any proxy connect
141    /// or handshake failure drops the flow rather than dialing direct, so the real IP never leaks.
142    /// When `None` (the default) and exit egress is enabled, egress uses this host's real IP. See
143    /// the proxy-egress section of the repo's `AGENTS.md`/`CLAUDE.md`.
144    pub exit_proxy: Option<ExitProxyConfig>,
145
146    /// Per-direction TCP send/receive buffer size (bytes) for the userspace netstack, or `None` to
147    /// use the netstack default (256 KiB per direction, ~512 KiB per socket).
148    ///
149    /// The underlying smoltcp stack has no TCP window auto-tuning, so this value is the hard cap on
150    /// a single flow's bandwidth-delay product: at an 80 ms RTT a 16 KiB window throttles a flow to
151    /// ~1.6 Mbps, which visibly slows large model-API responses even at 1x. Each socket allocates
152    /// this size for both its rx and tx buffer, so a socket consumes ~2× this value. The default
153    /// (256 KiB) suits high-RTT links carrying a few large flows; lower it on memory-constrained
154    /// deployments running many concurrent sockets. Applies to both the application and forwarder
155    /// netstacks.
156    pub tcp_buffer_size: Option<usize>,
157
158    /// Whether to enable IPv6 **on the tailnet overlay** (peer-to-peer reachability over the node's
159    /// Tailscale IPv6 address). Defaults to `false`: the node is IPv4-only on the overlay.
160    ///
161    /// This is an opt-in for general embedders that want Go `tsnet`-style dual-stack overlay
162    /// reachability. It is deliberately **off by default** to preserve this fork's sacred anti-leak
163    /// posture: its primary deployment is a privacy proxy / cloud exit node where IPv6 is disabled
164    /// everywhere to prevent tunnel-bypass IP leakage. When `false`, behavior is byte-for-byte the
165    /// historical IPv4-only path: the underlay binds `0.0.0.0:0`, IPv6 candidates/STUN are refused,
166    /// the netstack is handed no IPv6 overlay address, and MagicDNS answers AAAA as NODATA.
167    ///
168    /// **This flag governs only the overlay.** It has NO effect on the exit-node / forwarder egress
169    /// path: exit and subnet egress to the public internet stays hardcoded IPv4 in `ts_forwarder`
170    /// regardless of this flag, so the residential-proxy / real-origin-IP isolation invariant can
171    /// never be weakened by enabling overlay IPv6. On a host with IPv6 disabled at the kernel, the
172    /// dual-stack overlay bind simply fails and the node stays inert on IPv6 rather than panicking.
173    pub enable_ipv6: bool,
174
175    /// How this node's **application** overlay data path is realized.
176    ///
177    /// Defaults to [`TransportMode::Netstack`](ts_control::TransportMode::Netstack), the userspace
178    /// smoltcp netstack used by the fork's primary unprivileged proxy / exit-node deployment.
179    /// [`TransportMode::Tun`](ts_control::TransportMode::Tun) instead routes the node's overlay
180    /// packets through a real kernel TUN interface (for embedders that want the host OS networking
181    /// stack to see the tailnet directly); it requires privileges (root / `CAP_NET_ADMIN`) and a
182    /// platform with TUN support. This governs only the application data path — never the
183    /// exit-node / forwarder egress path, which keeps its own IPv4-only userspace netstack.
184    pub transport_mode: ts_control::TransportMode,
185
186    /// Whether to ask control to wire this node up server-side for Tailscale Funnel, even when no
187    /// Funnel endpoint is currently active (Go `tsnet`'s "would like to be wired up for Funnel"
188    /// signal, `HostInfo.WireIngress`, capver 113).
189    ///
190    /// When `true`, registration and map requests set `HostInfo.WireIngress` so control provisions
191    /// the DNS / ingress records a Funnel node needs, making a later
192    /// [`Device::listen_funnel`](crate::Device::listen_funnel) (or
193    /// `serve`) session work immediately. Defaults to `false` (fail-closed): a node requests Funnel
194    /// wiring only when explicitly opted in.
195    ///
196    /// Note this fork cannot yet *terminate* public Funnel ingress — `Device::listen_funnel` is
197    /// fail-closed (no client-side ACME engine, and a self-hosted control plane provides no public
198    /// ingress relay). Setting this flag only requests server-side wiring; it does not by itself
199    /// make Funnel live.
200    pub wire_ingress: bool,
201
202    /// VIP services this node advertises that it **hosts** (`svc:<dns-label>` names), the advertise
203    /// side of Tailscale VIP services (Go `tsnet`'s `Hostinfo.ServicesHash` + c2n
204    /// `GET /vip-services`).
205    ///
206    /// Each entry is a full `svc:`-prefixed name. The valid names (each validated as a well-formed
207    /// `svc:<dns-label>`; malformed names are dropped and logged) are hashed into
208    /// `HostInfo.ServicesHash` on registration and every map request, and reported when control
209    /// fetches the hosted-service list via the c2n `/vip-services` endpoint. Defaults to empty:
210    /// advertise nothing (the hash is `""`, behavior unchanged). Actually *hosting* a service still
211    /// requires control to assign it a VIP and the node to be tagged.
212    pub advertise_services: Vec<String>,
213
214    /// Filesystem directory that received Taildrop files land in, or `None` to disable Taildrop
215    /// (the default, fail-closed).
216    ///
217    /// When `Some(dir)` **and** a peerAPI port is configured (Taildrop is served on the shared
218    /// peerAPI listener, so it needs the same bind), the runtime serves the Taildrop peerAPI route
219    /// `PUT /v0/put/<name>` and writes incoming files under `dir` (created if absent). When `None`,
220    /// no Taildrop server is run and a peer's `PUT` is refused (`403`). The embedder consumes
221    /// received files via the [`Device::taildrop_waiting_files`](crate::Device::taildrop_waiting_files)
222    /// / [`taildrop_open_file`](crate::Device::taildrop_open_file) /
223    /// [`taildrop_delete_file`](crate::Device::taildrop_delete_file) methods.
224    pub taildrop_dir: Option<std::path::PathBuf>,
225
226    /// Pre-auth key for non-interactive registration (Go `tsnet.Server.AuthKey`). When set, used as
227    /// the registration auth key. If it is an OAuth client secret (prefix `tskey-client-`) and the
228    /// `identity-federation` feature is enabled, it is exchanged for an auth key before registration.
229    /// Falls back to the `TS_AUTH_KEY` env var (see [`auth_key_from_env`]). Defaults to `None`.
230    pub auth_key: Option<String>,
231
232    /// OAuth client ID for workload-identity federation (Go `tsnet.Server.ClientID`). SaaS-only;
233    /// requires the `identity-federation` feature. With [`id_token`](Config::id_token) or
234    /// [`audience`](Config::audience), the node exchanges an IdP-issued OIDC token for a Tailscale
235    /// auth key. Defaults to `None` (`TS_CLIENT_ID` env fallback).
236    pub client_id: Option<String>,
237
238    /// OAuth client secret used to mint auth keys via OAuth (Go `tsnet.Server.ClientSecret`).
239    /// SaaS-only; requires the `identity-federation` feature. Defaults to `None` (`TS_CLIENT_SECRET`).
240    ///
241    /// Treat as **fully operator-trusted input**: a `tskey-client-…?baseURL=…` secret redirects the
242    /// credential exchange to that host, so a hostile value would exfiltrate the secret and the
243    /// minted auth key. Never source it from a less-trusted origin.
244    pub client_secret: Option<String>,
245
246    /// IdP-issued OIDC ID token to exchange with control for an auth key via workload-identity
247    /// federation (Go `tsnet.Server.IDToken`). SaaS-only; requires the `identity-federation` feature
248    /// and [`client_id`](Config::client_id). Mutually exclusive with [`audience`](Config::audience).
249    /// Defaults to `None` (`TS_ID_TOKEN`).
250    pub id_token: Option<String>,
251
252    /// Audience for requesting an OIDC ID token from the ambient workload identity (GitHub Actions /
253    /// GCP / AWS), to exchange for an auth key via workload-identity federation (Go
254    /// `tsnet.Server.Audience`). SaaS-only; requires the `identity-federation` feature +
255    /// [`client_id`](Config::client_id). Mutually exclusive with [`id_token`](Config::id_token).
256    /// Defaults to `None` (`TS_AUDIENCE`).
257    pub audience: Option<String>,
258}
259
260impl Config {
261    /// Create a new config with its [`key_state`](Config::key_state) populated from the specified key file and using
262    /// default options for other configuration.
263    ///
264    /// See [`load_key_file`] for more details and an alternative with more options for reading
265    /// the key file.
266    pub async fn default_with_key_file(p: impl AsRef<Path>) -> Result<Self, crate::Error> {
267        Ok(Config {
268            key_state: load_key_file(p, Default::default()).await?,
269            ..Default::default()
270        })
271    }
272
273    /// Construct a default config, setting certain fields from environment variables.
274    ///
275    /// The fields are only set if the corresponding environment variable is present, using
276    /// the default value otherwise.
277    ///
278    /// Loads:
279    ///
280    /// - `control_server_url` from `TS_CONTROL_URL`
281    /// - `requested_hostname` from `TS_HOSTNAME`
282    /// - `auth_key` from `TS_AUTH_KEY`
283    /// - `client_id` from `TS_CLIENT_ID`
284    /// - `client_secret` from `TS_CLIENT_SECRET`
285    /// - `id_token` from `TS_ID_TOKEN`
286    /// - `audience` from `TS_AUDIENCE`
287    pub fn default_from_env() -> Config {
288        let mut config = Config::default();
289
290        if let Ok(u) = std::env::var(CONTROL_URL_VAR) {
291            match u.parse() {
292                Ok(u) => config.control_server_url = u,
293                Err(e) => {
294                    tracing::error!(error = %e, "parsing {CONTROL_URL_VAR} (fall back to default value)");
295                }
296            }
297        };
298
299        config.requested_hostname = std::env::var(HOSTNAME_VAR).ok();
300
301        if let Some(auth_key) = auth_key_from_env() {
302            config.auth_key = Some(auth_key);
303        }
304        if let Ok(client_id) = std::env::var(CLIENT_ID_VAR) {
305            config.client_id = Some(client_id);
306        }
307        if let Ok(client_secret) = std::env::var(CLIENT_SECRET_VAR) {
308            config.client_secret = Some(client_secret);
309        }
310        if let Ok(id_token) = std::env::var(ID_TOKEN_VAR) {
311            config.id_token = Some(id_token);
312        }
313        if let Ok(audience) = std::env::var(AUDIENCE_VAR) {
314            config.audience = Some(audience);
315        }
316
317        config
318    }
319
320    /// Rotate this config's node key in place for an embedder-driven re-registration, mirroring Go's
321    /// `regen` flow: the current node key is recorded as the old key and a fresh node key is
322    /// generated. Re-create the [`Device`](crate::Device) from this config to perform the rotation;
323    /// the next registration sends the prior key as `OldNodeKey` for key continuity.
324    ///
325    /// Reactive and embedder-driven by design (you decide when to rotate, e.g. after observing
326    /// [`Device::self_key_expired`](crate::Device::self_key_expired) flip, or on a policy of your
327    /// own). This fork does not auto-rotate before expiry — neither does Go, which treats key expiry
328    /// as a deliberate periodic re-authentication checkpoint. Rotation still requires a valid auth
329    /// key, exactly like a fresh registration.
330    pub fn rotate_node_key(&mut self) {
331        self.key_state.rotate_node_key();
332    }
333}
334
335/// Load an auth key from the `TS_AUTH_KEY` environment variable.
336pub fn auth_key_from_env() -> Option<String> {
337    std::env::var(AUTHKEY_VAR).ok()
338}
339
340/// Load key state from a path on the filesystem, or create a file with a new key state if
341/// one doesn't exist.
342///
343/// The `bad_format` argument allows you to specify whether an existing file should be
344/// overwritten if the contents can't be parsed.
345pub async fn load_key_file(
346    p: impl AsRef<Path>,
347    bad_format: BadFormatBehavior,
348) -> Result<PersistState, crate::Error> {
349    let p = p.as_ref();
350
351    tracing::trace!(key_file = %p.display(), "loading key file");
352
353    let key_file = load_or_init::<KeyFile>(
354        &p,
355        Default::default,
356        |x| match x {
357            #[allow(deprecated)]
358            KeyFile::Old(old) => Some(KeyFile::New(KeyFileNew {
359                key_state: PersistState::from(&old.key_state),
360            })),
361            _ => None,
362        },
363        bad_format,
364    )
365    .await?;
366    Ok(key_file.key_state())
367}
368
369#[derive(serde::Deserialize)]
370#[serde(untagged)]
371enum KeyFile {
372    #[deprecated]
373    Old(KeyFileOld),
374    New(KeyFileNew),
375}
376
377impl KeyFile {
378    #[allow(deprecated)]
379    pub fn key_state(&self) -> PersistState {
380        match self {
381            Self::Old(old) => (&old.key_state).into(),
382            Self::New(new) => new.key_state.clone(),
383        }
384    }
385}
386
387impl Default for KeyFile {
388    fn default() -> Self {
389        KeyFile::New(KeyFileNew::default())
390    }
391}
392
393impl serde::Serialize for KeyFile {
394    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
395    where
396        S: Serializer,
397    {
398        KeyFileNew {
399            key_state: self.key_state(),
400        }
401        .serialize(serializer)
402    }
403}
404
405#[derive(serde::Deserialize, serde::Serialize, Default)]
406struct KeyFileNew {
407    key_state: PersistState,
408}
409
410#[derive(serde::Deserialize)]
411struct KeyFileOld {
412    key_state: NodeState,
413}
414
415impl From<&Config> for ts_control::Config {
416    fn from(value: &Config) -> ts_control::Config {
417        ts_control::Config {
418            client_name: value.client_name.clone(),
419            hostname: value.requested_hostname.clone(),
420            server_url: value.control_server_url.clone(),
421            tags: value.requested_tags.clone(),
422            ephemeral: value.ephemeral,
423            accept_routes: value.accept_routes,
424            exit_node: value.exit_node.clone(),
425            advertise_routes: value.advertise_routes.clone(),
426            advertise_exit_node: value.advertise_exit_node,
427            forward_tcp_ports: value.forward_tcp_ports.clone(),
428            forward_udp_ports: value.forward_udp_ports.clone(),
429            forward_all_ports: value.forward_all_ports,
430            forward_exit_egress: value.forward_exit_egress,
431            exit_proxy: value.exit_proxy.clone(),
432            tcp_buffer_size: value.tcp_buffer_size,
433            peerapi_port: None,
434            taildrop_dir: value.taildrop_dir.clone(),
435            enable_ipv6: value.enable_ipv6,
436            transport_mode: value.transport_mode.clone(),
437            wire_ingress: value.wire_ingress,
438            // A fresh runtime-local flag (default `false`): the runtime flips it when
439            // `Device::listen_funnel` starts a listener. Not derived from the embedder config.
440            ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
441            advertise_services: value.advertise_services.clone(),
442            allow_http_key_fetch: value.allow_http_key_fetch,
443        }
444    }
445}
446
447impl Default for Config {
448    fn default() -> Self {
449        Self {
450            key_state: Default::default(),
451            client_name: None,
452            control_server_url: ts_control::DEFAULT_CONTROL_SERVER.clone(),
453            allow_http_key_fetch: false,
454            requested_hostname: None,
455            requested_tags: vec![],
456            ephemeral: true,
457            accept_routes: false,
458            exit_node: None,
459            advertise_routes: vec![],
460            advertise_exit_node: false,
461            forward_tcp_ports: vec![],
462            forward_udp_ports: vec![],
463            forward_all_ports: false,
464            forward_exit_egress: false,
465            exit_proxy: None,
466            tcp_buffer_size: None,
467            enable_ipv6: false,
468            transport_mode: ts_control::TransportMode::default(),
469            wire_ingress: false,
470            advertise_services: vec![],
471            taildrop_dir: None,
472            auth_key: None,
473            client_id: None,
474            client_secret: None,
475            id_token: None,
476            audience: None,
477        }
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    // The `From<&Config> for ts_control::Config` impl hand-copies every field, so it silently
486    // drops any field a future edit forgets to add. These tests assert each dataplane field
487    // crosses the boundary, with special attention to the anti-leak ones (`forward_exit_egress`,
488    // `exit_proxy`) whose loss would change egress behavior.
489    #[test]
490    fn from_config_threads_all_dataplane_fields() {
491        let cfg = Config {
492            accept_routes: true,
493            advertise_exit_node: true,
494            forward_all_ports: true,
495            forward_exit_egress: true,
496            forward_tcp_ports: vec![80, 443],
497            forward_udp_ports: vec![53],
498            tcp_buffer_size: Some(1024 * 128),
499            enable_ipv6: true,
500            wire_ingress: true,
501            transport_mode: ts_control::TransportMode::Tun(ts_control::TunConfig {
502                name: Some("tailscale0".to_owned()),
503                mtu: Some(1280),
504            }),
505            advertise_routes: vec!["10.0.0.0/24".parse().unwrap()],
506            requested_tags: vec!["tag:exit".to_owned()],
507            advertise_services: vec!["svc:samba".to_owned()],
508            ephemeral: false,
509            exit_proxy: Some(ExitProxyConfig {
510                addr: "198.51.100.9:8080".parse().unwrap(),
511                scheme: ts_control::ExitProxyScheme::Socks5,
512                auth: Some(("u".to_owned(), "p".to_owned())),
513            }),
514            taildrop_dir: Some(std::path::PathBuf::from("/var/lib/taildrop")),
515            ..Default::default()
516        };
517
518        let control: ts_control::Config = (&cfg).into();
519
520        assert!(control.accept_routes);
521        assert!(control.advertise_exit_node);
522        assert!(control.forward_all_ports);
523        assert!(control.forward_exit_egress);
524        assert!(!control.ephemeral);
525        assert_eq!(control.forward_tcp_ports, vec![80, 443]);
526        assert_eq!(control.forward_udp_ports, vec![53]);
527        assert_eq!(control.tcp_buffer_size, Some(1024 * 128));
528        assert_eq!(control.tags, vec!["tag:exit".to_owned()]);
529        let proxy = control.exit_proxy.expect("exit_proxy crosses the boundary");
530        assert_eq!(proxy.addr, "198.51.100.9:8080".parse().unwrap());
531        assert_eq!(proxy.scheme, ts_control::ExitProxyScheme::Socks5);
532        assert_eq!(proxy.auth, Some(("u".to_owned(), "p".to_owned())));
533        assert!(control.enable_ipv6);
534        assert!(control.wire_ingress);
535        assert_eq!(control.advertise_services, vec!["svc:samba".to_owned()]);
536        assert_eq!(
537            control.taildrop_dir,
538            Some(std::path::PathBuf::from("/var/lib/taildrop"))
539        );
540        assert_eq!(
541            control.transport_mode,
542            ts_control::TransportMode::Tun(ts_control::TunConfig {
543                name: Some("tailscale0".to_owned()),
544                mtu: Some(1280),
545            })
546        );
547    }
548
549    #[test]
550    fn from_config_default_is_netstack_transport() {
551        // The unprivileged userspace netstack is the safe default; opting into a kernel TUN
552        // interface (which needs root) must be explicit.
553        let control: ts_control::Config = (&Config::default()).into();
554        assert_eq!(control.transport_mode, ts_control::TransportMode::Netstack);
555    }
556
557    #[test]
558    fn from_config_default_has_no_exit_proxy() {
559        let control: ts_control::Config = (&Config::default()).into();
560        assert!(control.exit_proxy.is_none());
561        assert!(!control.forward_exit_egress);
562    }
563
564    #[test]
565    fn wif_fields_default_none() {
566        // Workload-identity-federation config is SaaS-only and opt-in: a default config never
567        // carries an auth key or any OAuth/OIDC federation material.
568        let cfg = Config::default();
569        assert!(cfg.auth_key.is_none());
570        assert!(cfg.client_id.is_none());
571        assert!(cfg.client_secret.is_none());
572        assert!(cfg.id_token.is_none());
573        assert!(cfg.audience.is_none());
574    }
575
576    #[test]
577    fn from_config_default_is_ipv4_only() {
578        // The IPv6-off posture is the safe default: enabling overlay IPv6 must be an explicit opt-in.
579        let control: ts_control::Config = (&Config::default()).into();
580        assert!(!control.enable_ipv6);
581    }
582}
583
584/// What to do if the key file can't be parsed.
585///
586/// Default behavior: return an error.
587#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
588pub enum BadFormatBehavior {
589    /// Return an error.
590    #[default]
591    Error,
592
593    /// Overwrite the file with a newly-generated set of keys.
594    Overwrite,
595}
596
597/// Attempt to load a file from a path. If it doesn't exist, create it with the
598/// specified default value.
599#[tracing::instrument(skip_all, fields(?bad_format_behavior, path = %path.as_ref().display()))]
600async fn load_or_init<KeyState>(
601    path: impl AsRef<Path>,
602    default: impl FnOnce() -> KeyState,
603    migrate: impl FnOnce(&KeyState) -> Option<KeyState>,
604    bad_format_behavior: BadFormatBehavior,
605) -> Result<KeyState, crate::Error>
606where
607    KeyState: serde::Serialize + serde::de::DeserializeOwned,
608{
609    let path = path.as_ref();
610
611    tokio::fs::create_dir_all(path.parent().unwrap())
612        .await
613        .map_err(|e| {
614            tracing::error!(error = %e, "creating parent dirs for key file");
615            crate::Error::KeyFileWrite
616        })?;
617
618    match tokio::fs::read(path).await {
619        Ok(contents) => match serde_json::from_slice::<KeyState>(&contents) {
620            Ok(state) => {
621                if let Some(migrated) = migrate(&state) {
622                    match try_write(path, &migrated).await {
623                        Ok(_) => {
624                            tracing::info!("migrated key file to new disco-less format");
625                            return Ok(migrated);
626                        }
627                        Err(e) => {
628                            tracing::error!(error = %e, "unable to migrate key file");
629                        }
630                    }
631                }
632
633                return Ok(state);
634            }
635            Err(e) => match bad_format_behavior {
636                BadFormatBehavior::Error => {
637                    tracing::error!(error = %e, "parsing key file");
638                    return Err(crate::Error::KeyFileRead);
639                }
640                BadFormatBehavior::Overwrite => {
641                    tracing::warn!(
642                        error = %e,
643                        config_file_contents_len = contents.len(),
644                        "failed loading version from key file, overwriting",
645                    );
646                }
647            },
648        },
649        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
650        Err(e) => {
651            tracing::error!(error = %e, path = %path.display(), "reading key file");
652            return Err(crate::Error::KeyFileRead);
653        }
654    }
655
656    let value = default();
657    try_write(path, &value).await?;
658    Ok(value)
659}
660
661async fn try_write(
662    path: impl AsRef<Path>,
663    value: &impl serde::Serialize,
664) -> Result<(), crate::Error> {
665    tokio::fs::write(
666        path,
667        serde_json::to_vec(value).map_err(|e| {
668            tracing::error!(error = %e, "serializing key state");
669            crate::Error::KeyFileWrite
670        })?,
671    )
672    .await
673    .map_err(|e| {
674        tracing::error!(error = %e, "saving key state");
675        crate::Error::KeyFileWrite
676    })?;
677
678    Ok(())
679}