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