ts_control/config.rs
1use core::fmt::Debug;
2use std::net::SocketAddr;
3
4use url::Url;
5
6lazy_static::lazy_static! {
7 /// The default [`Url`] of the control plane server (aka "coordination server").
8 pub static ref DEFAULT_CONTROL_SERVER: Url = Url::parse("https://controlplane.tailscale.com/").unwrap();
9}
10
11/// Upstream-proxy wire protocol for [`ExitProxyConfig`]. Mirrors `ts_forwarder::ProxyScheme`;
12/// kept as a separate type here because `ts_control` must not depend on `ts_forwarder` (the
13/// runtime converts between them at the boundary).
14#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15pub enum ExitProxyScheme {
16 /// SOCKS5 (RFC 1928), with optional username/password auth (RFC 1929).
17 Socks5,
18 /// HTTP `CONNECT` tunnelling, with optional `Proxy-Authorization: Basic` auth.
19 HttpConnect,
20}
21
22/// Transport-only description of an upstream proxy that exit-node egress is routed through, so a
23/// cloud exit node egresses via the proxy's (e.g. residential) IP rather than its own origin IP.
24///
25/// This is **not** read inside `ts_control`; like the other dataplane fields on [`Config`] it is
26/// carried for transport only and converted to a `ts_forwarder::ProxyConfig` by the runtime. It is
27/// only consulted when [`Config::forward_exit_egress`] is `true` (the anti-leak opt-in); on its own
28/// it changes nothing. See the proxy-egress docs in the repo's `AGENTS.md`/`CLAUDE.md`.
29#[derive(Clone, serde::Serialize, serde::Deserialize)]
30pub struct ExitProxyConfig {
31 /// Address of the upstream proxy to connect to.
32 pub addr: SocketAddr,
33 /// Wire protocol to speak to the proxy.
34 pub scheme: ExitProxyScheme,
35 /// Optional `(username, password)` credentials for proxy auth.
36 pub auth: Option<(String, String)>,
37}
38
39// Manual Debug that NEVER prints the proxy credentials, mirroring `ts_forwarder::ProxyConfig`. A
40// stray `tracing!(?cfg)` or `{:?}` must not leak the residential-proxy username/password.
41impl Debug for ExitProxyConfig {
42 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
43 f.debug_struct("ExitProxyConfig")
44 .field("addr", &self.addr)
45 .field("scheme", &self.scheme)
46 .field("auth", &self.auth.as_ref().map(|_| "<redacted>"))
47 .finish()
48 }
49}
50
51/// How the node's **application** overlay data path is realized.
52///
53/// Defaults to [`Netstack`](TransportMode::Netstack), the userspace smoltcp netstack that needs no
54/// privileges and is the right choice for the fork's primary deployment (a privacy proxy / cloud
55/// exit node running unprivileged in a container). [`Tun`](TransportMode::Tun) instead hands the
56/// node's overlay packets to a real kernel TUN interface, for embedders that want the host OS
57/// networking stack (routes, sockets, DNS) to see the tailnet directly — closer to `tailscaled`'s
58/// model than to Go `tsnet`'s in-process netstack.
59///
60/// Like the other dataplane fields this is **not read inside `ts_control`**: it is carried for
61/// transport only and converted to a `ts_transport_tun` config by the runtime at the `ts_runtime`
62/// boundary (`ts_control` must not depend on `ts_transport_tun`). The mode governs only the
63/// application data path; it never changes the exit-node / forwarder egress path, which stays its
64/// own IPv4-only userspace netstack regardless.
65#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum TransportMode {
68 /// Userspace smoltcp netstack (default). No privileges required.
69 #[default]
70 Netstack,
71 /// Real kernel TUN interface. Requires privileges (root / `CAP_NET_ADMIN` on Linux) and a
72 /// platform that supports TUN (Linux `/dev/net/tun`, macOS `utun`).
73 Tun(TunConfig),
74}
75
76/// Transport-only parameters for [`TransportMode::Tun`].
77///
78/// The node's tailnet *prefix* is deliberately absent: it is assigned by control and only known at
79/// runtime, so the runtime supplies it when it builds the real `ts_transport_tun::Config`. Only the
80/// user-choosable knobs live here.
81#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
82pub struct TunConfig {
83 /// Desired interface name (e.g. `tailscale0`). `None` lets the OS pick (e.g. `utunN` on macOS).
84 #[serde(default)]
85 pub name: Option<String>,
86
87 /// Interface MTU. `None` uses the transport's default. Tailscale's overlay MTU is 1280.
88 #[serde(default)]
89 pub mtu: Option<u16>,
90}
91
92/// Default for [`Config::ephemeral`]: `true`, matching the historical behavior of this client.
93fn default_ephemeral() -> bool {
94 true
95}
96
97/// Default for [`Config::accept_dns`]: `true`, matching Go's `NewPrefs()` (`CorpDNS: true`).
98fn default_true() -> bool {
99 true
100}
101
102/// Default WireGuard persistent-keepalive interval: 25s.
103///
104/// Matches Tailscale, which sets `PersistentKeepalive = 25` on a peer when control marks it
105/// `KeepAlive=true`. 25s sits just under the ~30s lower bound for UDP NAT/firewall mapping
106/// timeouts, so the mapping (and any DERP relay path) is refreshed before it can expire.
107pub const DEFAULT_PERSISTENT_KEEPALIVE: std::time::Duration = std::time::Duration::from_secs(25);
108
109/// Default for [`Config::persistent_keepalive_interval`]: `Some(25s)`
110/// ([`DEFAULT_PERSISTENT_KEEPALIVE`]). On by default so a relayed, idle session keeps its path warm
111/// and doesn't wedge the next dial.
112fn default_persistent_keepalive() -> Option<std::time::Duration> {
113 Some(DEFAULT_PERSISTENT_KEEPALIVE)
114}
115
116/// Configuration for the control server.
117#[derive(Clone, serde::Serialize, serde::Deserialize)]
118pub struct Config {
119 /// The URL of the control server to connect to.
120 pub server_url: Url,
121
122 /// The hostname of the current node.
123 pub hostname: Option<String>,
124
125 /// A name for this type of client.
126 ///
127 /// This will be reported to the control server in the `HostInfo.App` field.
128 pub client_name: Option<String>,
129
130 /// Tags to request from the control server (`--advertise-tags` / `AdvertiseTags` in the Go
131 /// client).
132 ///
133 /// Sent as `HostInfo.RequestTags` on registration and on every map request, so a
134 /// tag-keyed control ACL (e.g. a self-hosted control plane's route auto-approver) can match this node. Each
135 /// entry is a full tag string including the `tag:` prefix (e.g. `tag:exit`). Defaults to
136 /// empty (claim no tags); an empty set omits the wire field entirely.
137 #[serde(default)]
138 pub tags: Vec<String>,
139
140 /// Whether this node registers as *ephemeral* (`--ephemeral` / `Ephemeral` in the Go client).
141 ///
142 /// An ephemeral node is garbage-collected by the control server shortly after it
143 /// disconnects. That is the right default for short-lived clients, but a persistent exit node
144 /// or subnet router must set this to `false` or it will be GC'd out of the tailnet while
145 /// briefly offline. Defaults to `true` to match the historical behavior of this client.
146 #[serde(default = "default_ephemeral")]
147 pub ephemeral: bool,
148
149 /// Whether to accept subnet routes advertised by peers (`--accept-routes` / `RouteAll` in the
150 /// Go client).
151 ///
152 /// When `false` (the default, matching the Go client on Linux/server platforms and our
153 /// fail-closed posture), only each peer's own tailnet addresses are routed; larger advertised
154 /// subnet routes are ignored. When `true`, traffic destined for an accepted subnet egresses
155 /// via the advertising peer.
156 ///
157 /// This is a client-side preference and is not read inside `ts_control`: control always sends
158 /// the full set of advertised routes, and the runtime trims them. It is carried here only to
159 /// be threaded through to the runtime's route filter.
160 #[serde(default)]
161 pub accept_routes: bool,
162
163 /// Whether to accept the tailnet's DNS configuration (MagicDNS + the pushed resolvers/search
164 /// domains) — `--accept-dns` / the `CorpDNS` pref in the Go client. **Defaults to `true`**, matching
165 /// Go's `NewPrefs()` (`CorpDNS: true`).
166 ///
167 /// When `true`, the MagicDNS responder serves the control-pushed [`DnsConfig`](crate::DnsConfig)
168 /// (overlay-name answers + split-DNS routes + recursive forwarding). When `false`, the node
169 /// **ignores the pushed DNS config** and the responder serves nothing (every query is `REFUSED`),
170 /// mirroring Go applying an essentially-empty `dns.Config` when `CorpDNS` is off — so a node can
171 /// join the tailnet for connectivity without taking over its DNS.
172 ///
173 /// Like [`accept_routes`](Config::accept_routes), this is a client-side preference not read inside
174 /// `ts_control` (control always pushes the full `DNSConfig`; the runtime decides whether to honor
175 /// it); it is carried here only to be threaded through to the runtime's MagicDNS responder, and is
176 /// runtime-settable via `Device::set_accept_dns` (the analog of `tailscale set --accept-dns`).
177 #[serde(default = "default_true")]
178 pub accept_dns: bool,
179
180 /// Which peer (if any) to use as an exit node (`--exit-node` / `ExitNodeID` in the Go client).
181 ///
182 /// The selector may name the peer by stable id, tailnet IP, or MagicDNS name (see
183 /// [`ExitNodeSelector`](crate::ExitNodeSelector)); it is resolved against the live peer set on
184 /// every route rebuild, so an IP/name selection follows the peer across netmap changes. When
185 /// set and resolvable, the selected peer's advertised default route (`0.0.0.0/0` / `::/0`) is
186 /// installed so internet-bound traffic egresses through it. When `None` (the default) or
187 /// unresolvable, no peer receives a default route and internet-bound traffic is dropped
188 /// (fail-closed).
189 ///
190 /// Like [`accept_routes`](Config::accept_routes), this is a client-side preference not read
191 /// inside `ts_control`; it is carried here only to be threaded through to the runtime's route
192 /// filter.
193 ///
194 /// **Full-tunnel exit vs. just reaching a peer's port — leave this `None` unless you mean
195 /// full-tunnel.** Set `exit_node` *only* to route **all** internet-bound traffic through a peer
196 /// that advertises a default route (`advertise_exit_node`). To merely **reach a specific peer's
197 /// service over the tailnet** — e.g. `Device::tcp_connect` to its `100.x.y.z:1080` — you do
198 /// **not** set `exit_node` at all; direct peer dials need no exit node. Setting `exit_node` to a
199 /// peer that is only a selective CONNECT proxy (advertises no `0.0.0.0/0`) leaves egress
200 /// fail-closed and logs a warning that internet-bound traffic is dropped — which looks like a
201 /// failure but is just "that peer isn't a full-tunnel exit." If you saw that warning while only
202 /// trying to dial a peer's port, the fix is to unset `exit_node`.
203 #[serde(default)]
204 pub exit_node: Option<crate::ExitNodeSelector>,
205
206 /// Subnet routes to advertise to the control server (`--advertise-routes` / `RoutableIPs` in
207 /// the Go client).
208 ///
209 /// Unlike [`accept_routes`](Config::accept_routes)/[`exit_node`](Config::exit_node), this field
210 /// *is* read inside `ts_control`: it populates `HostInfo.RoutableIPs` on every map request so
211 /// the control server can grant this node as a subnet router. Defaults to empty (advertise
212 /// nothing — fail-closed). Only IPv4 prefixes are advertised; IPv6 prefixes are dropped to
213 /// uphold the IPv6-off posture (advertising a route we won't forward would be a black hole).
214 #[serde(default)]
215 pub advertise_routes: Vec<ipnet::IpNet>,
216
217 /// Whether to advertise this node as an exit node (`--advertise-exit-node` in the Go client).
218 ///
219 /// When `true`, the default route `0.0.0.0/0` is added to the advertised
220 /// [`routable_ips`](Config::advertise_routes) so the control server can grant this node as an
221 /// exit node, after which other peers may egress internet-bound traffic through our real IP.
222 /// Defaults to `false` (fail-closed): being an exit node means *other* peers' traffic leaves
223 /// via our real origin IP, so it must be explicit opt-in. IPv6 (`::/0`) is never advertised,
224 /// per the IPv6-off posture.
225 #[serde(default)]
226 pub advertise_exit_node: bool,
227
228 /// TCP ports the inbound forwarder accepts and splices to real OS sockets for every advertised
229 /// route (`advertise_routes` / `advertise_exit_node`).
230 ///
231 /// smoltcp has no all-port accept mode (see the `ts_forwarder` crate docs), so the forwarder
232 /// forwards a configured set of ports rather than the full 1–65535 range. Defaults to empty: a
233 /// node that advertises routes but configures no forward ports accepts inbound flows into its
234 /// dedicated forwarder netstack but forwards none of them (fail-closed — nothing is dialed).
235 #[serde(default)]
236 pub forward_tcp_ports: Vec<u16>,
237
238 /// UDP ports the inbound forwarder accepts and splices to real OS sockets for every advertised
239 /// route. See [`forward_tcp_ports`](Config::forward_tcp_ports); defaults to empty.
240 #[serde(default)]
241 pub forward_udp_ports: Vec<u16>,
242
243 /// Forward **all** TCP/UDP ports (1–65535) on every advertised route, like a Go subnet router
244 /// (`tailscale up --advertise-routes` forwards all ports), instead of the explicit
245 /// [`forward_tcp_ports`](Config::forward_tcp_ports) /
246 /// [`forward_udp_ports`](Config::forward_udp_ports) sets.
247 ///
248 /// smoltcp cannot wildcard-port-accept, so all-port mode is implemented with an on-demand
249 /// per-port listener manager driven by a raw-socket port observer on the dedicated forwarder
250 /// netstack (see the `ts_forwarder` crate docs). When `true`, the explicit port sets are
251 /// ignored. Anti-leak is unchanged: every flow still routes through the same
252 /// `RouteTable`→dialer chokepoint, so [`forward_exit_egress`](Config::forward_exit_egress) still
253 /// governs exit-node egress. Defaults to `false`.
254 #[serde(default)]
255 pub forward_all_ports: bool,
256
257 /// Whether exit-node (`0.0.0.0/0`) inbound flows are actually egressed via **this host's real
258 /// origin IP**.
259 ///
260 /// This is the anti-leak opt-in, kept separate from
261 /// [`advertise_exit_node`](Config::advertise_exit_node): advertising the default route only
262 /// makes control *offer* this node as an exit; it does not by itself egress a peer's traffic.
263 /// When `false` (the default, fail-closed), the forwarder uses a dialer that **structurally
264 /// refuses** exit-node egress — a `0.0.0.0/0` flow is dropped at dial time, never leaked out our
265 /// real IP. Set to `true` only on a node whose real IP *is* the intended egress (e.g. a
266 /// residential exit), never on a node whose host IP must stay hidden (e.g. a cloud VPS). Subnet
267 /// routes are dialed identically regardless of this flag.
268 #[serde(default)]
269 pub forward_exit_egress: bool,
270
271 /// Shields-up (Go `ipn` prefs `ShieldsUp`): when `true`, refuse all **inbound** connections from
272 /// peers that terminate on this node — the packet filter drops inbound packets aimed at this
273 /// node's own addresses. Replies to connections this node itself initiated, and forwarded
274 /// subnet/exit transit, are unaffected (the deny is scoped to self-destined packets; see
275 /// `ts_packetfilter::ShieldsUpFilter`). Transport-only client preference — `ts_control` never
276 /// reads it; the runtime's packet-filter updater consumes it. Defaults to `false`.
277 #[serde(default)]
278 pub block_incoming: bool,
279
280 /// Optional upstream proxy that exit-node egress is routed through, so the node egresses via
281 /// the proxy's IP rather than its own origin IP.
282 ///
283 /// Only consulted when [`forward_exit_egress`](Config::forward_exit_egress) is `true`. When
284 /// set, the runtime wires the forwarder with a proxy dialer (SOCKS5 / HTTP `CONNECT`) that
285 /// **fails closed** — any proxy connect or handshake failure drops the flow rather than falling
286 /// back to a direct host-IP dial, so the real origin IP never leaks. When `None` (the default)
287 /// and exit egress is enabled, egress uses this host's real IP (`HostExitDialer`).
288 ///
289 /// Like the other dataplane fields, this is a client-side preference not read inside
290 /// `ts_control`; it is carried here only to be threaded through to the runtime's dialer
291 /// selection. This is a product capability (residential-proxy egress) beyond strict tsnet
292 /// parity — see the repo's `AGENTS.md`/`CLAUDE.md`.
293 #[serde(default)]
294 pub exit_proxy: Option<ExitProxyConfig>,
295
296 /// The IPv4 peerAPI port this node binds to serve exit-node DoH (DNS-over-HTTPS) proxying for
297 /// peers that select it as their exit node (`peerapi4` + `peerapi-dns-proxy` services).
298 ///
299 /// When `Some(port)`, the runtime binds a peerAPI DoH server on this host's overlay IPv4
300 /// address at `port`, and registration / map requests advertise both the `peerapi4` service
301 /// (at `port`) and the `peerapi-dns-proxy` service (Go quirk: its advertised port is always
302 /// `1`) so peers know they can delegate DNS to us. When `None` (the default, fail-closed), no
303 /// peerAPI is run and no services are advertised — this node never offers DNS proxying.
304 ///
305 /// The DoH server always answers authoritative/overlay records (MagicDNS peer names,
306 /// `ExtraRecords`, PTR); *recursive* resolution to real upstream resolvers is gated separately
307 /// behind [`forward_exit_egress`](Config::forward_exit_egress), so a cloud exit node can serve
308 /// overlay DNS without ever exposing its real origin IP via a recursive lookup.
309 #[serde(default)]
310 pub peerapi_port: Option<u16>,
311
312 /// Filesystem directory that received Taildrop files land in, or `None` to disable Taildrop
313 /// (the default, fail-closed).
314 ///
315 /// When `Some(dir)` **and** [`peerapi_port`](Config::peerapi_port) is also set, the runtime
316 /// serves the Taildrop peerAPI route `PUT /v0/put/<name>` on the shared peerAPI listener, and
317 /// incoming files are written under `dir` (created if absent). When `None`, no Taildrop server
318 /// is run — a peer's `PUT` is refused. This is a pure on-disk destination: like the other
319 /// dataplane fields it is not read inside `ts_control`; it is carried here only to be threaded
320 /// into the runtime, which constructs the file store from it.
321 ///
322 /// Independently of the network server, the embedder consumes received files via the
323 /// `Device::taildrop_*` methods (Go exposes these over LocalAPI; this fork exposes them on the
324 /// device). With no `peerapi_port`, the store still exists for those read APIs but no peer can
325 /// deliver to it.
326 #[serde(default)]
327 pub taildrop_dir: Option<std::path::PathBuf>,
328
329 /// Per-direction TCP send/receive buffer size (bytes) for the userspace netstack, or `None` to
330 /// use the netstack default (256 KiB per direction, ~512 KiB per socket).
331 ///
332 /// smoltcp has no window auto-tuning, so this is the hard cap on a single flow's
333 /// bandwidth-delay product; raising it helps large model-API responses on high-RTT links, at
334 /// the cost of more memory per concurrent socket (each socket allocates this size for both rx
335 /// and tx). Like the other dataplane fields, this is a client-side preference not read inside
336 /// `ts_control`; it is carried here only to be threaded into the runtime's netstack
337 /// configuration.
338 #[serde(default)]
339 pub tcp_buffer_size: Option<usize>,
340
341 /// Whether IPv6 is enabled on the tailnet overlay. Defaults to `false` (IPv4-only).
342 ///
343 /// Like the other dataplane fields, this is a client-side preference not read inside
344 /// `ts_control`; it is carried here only to be threaded into the runtime's underlay socket,
345 /// disco candidate filter, netstack address assignment, and MagicDNS AAAA handling. It governs
346 /// only the overlay and never the exit-node / forwarder egress path, which stays IPv4-only
347 /// regardless to uphold the real-origin-IP isolation invariant.
348 #[serde(default)]
349 pub enable_ipv6: bool,
350
351 /// Whether the runtime runs an internal OS network-link monitor that auto-re-binds + re-probes
352 /// connectivity on a link change (Wi-Fi switch, sleep/wake, default-route change). Defaults to
353 /// `false` (no monitor — the embedder drives `Device::rebind` itself).
354 ///
355 /// Like the other dataplane fields, this is a client-side preference not read inside
356 /// `ts_control`; it is carried here only to be threaded into the runtime, which (when set, and
357 /// when built with the `network-monitor` feature) spawns a `NetmonSupervisor`. It is off by
358 /// default to preserve the fork's pure-engine posture (it is an engine, not a daemon): with it
359 /// off, the runtime starts zero monitor threads/sockets and behaves byte-for-byte as before.
360 #[serde(default)]
361 pub network_monitor: bool,
362
363 /// The fixed UDP port magicsock binds for WireGuard + disco, or `None` for an OS-chosen
364 /// ephemeral port (Go `tailscaled --port` / `ListenPort`). Defaults to `None`.
365 ///
366 /// Like the other dataplane fields, this is a client-side preference not read inside
367 /// `ts_control`; it is carried here only to be threaded into the runtime's *initial* underlay
368 /// socket bind. `None` binds `0.0.0.0:0` (ephemeral, today's behavior); `Some(p)` pins port `p`
369 /// with an ephemeral fallback if it is already taken (a port collision never fails bring-up).
370 /// Governs only the bound port, never the bind family — the IPv4-only-by-default, fail-closed
371 /// underlay posture is unchanged.
372 #[serde(default)]
373 pub wireguard_listen_port: Option<u16>,
374
375 /// WireGuard persistent-keepalive interval applied to every peer, or `None` to disable persistent
376 /// keepalives (`PersistentKeepalive`; Tailscale uses 25s).
377 ///
378 /// When `Some(interval)`, each peer emits an empty authenticated keepalive every `interval` of
379 /// outbound silence, holding the (typically DERP-relayed) path/NAT mapping warm so an idle
380 /// session doesn't age past expiry and wedge the next dial — the failure this fork's primary
381 /// userspace-netstack deployment hits, where the relay is the only path to a peer. Unlike the
382 /// reactive WireGuard §6.5 keepalive (armed only by inbound traffic), this re-arms unconditionally
383 /// and fires on a fully idle tunnel; the empty packet does not advance the session's
384 /// rotation/expiry timers, so a genuinely dead peer is still detected. Defaults to `Some(25s)`
385 /// ([`DEFAULT_PERSISTENT_KEEPALIVE`]). Like the other dataplane fields it is not read inside
386 /// `ts_control`; it is carried here only to be threaded into the runtime's dataplane actor.
387 #[serde(default = "default_persistent_keepalive")]
388 pub persistent_keepalive_interval: Option<std::time::Duration>,
389
390 /// How the application overlay data path is realized: userspace netstack (default) or a real
391 /// kernel TUN interface. See [`TransportMode`].
392 ///
393 /// Like the other dataplane fields, this is a client-side preference not read inside
394 /// `ts_control`; it is carried here only to be threaded into the runtime, which builds either a
395 /// netstack actor or a TUN transport from it. `ts_control` must not depend on `ts_transport_tun`.
396 #[serde(default)]
397 pub transport_mode: TransportMode,
398
399 /// Whether to ask control to wire this node up server-side for Tailscale Funnel
400 /// (`HostInfo.WireIngress`, the capver-113 client→control Funnel signal), even when no Funnel
401 /// endpoint is currently active.
402 ///
403 /// Unlike the dataplane fields above, this one *is* read inside `ts_control`: it sets
404 /// `HostInfo.WireIngress` on registration and the streaming map request, asking control to
405 /// provision the DNS / ingress records a Funnel node needs so a later `serve`/funnel session
406 /// works immediately. It mirrors Go `tsnet`'s "would like to be wired up for Funnel" signal.
407 ///
408 /// This fork cannot yet *terminate* public Funnel ingress — [`crate::listen_funnel`] is
409 /// fail-closed (no client-side ACME engine, and a self-hosted control plane provides no public
410 /// ingress relay). So `HostInfo.IngressEnabled` (Funnel endpoints actually live) is never set;
411 /// only `WireIngress` is, and only when this flag is `true`. Defaults to `false` (fail-closed):
412 /// a node requests Funnel wiring only when explicitly opted in.
413 #[serde(default)]
414 pub wire_ingress: bool,
415
416 /// Live signal that this node currently has an active Funnel ingress listener
417 /// (`Device::listen_funnel` was called and its listener is up), driving `HostInfo.IngressEnabled`
418 /// on the streaming map request.
419 ///
420 /// Unlike [`wire_ingress`](Self::wire_ingress) (a static "please provision Funnel records" hint),
421 /// this is a *dynamic* flag: the runtime flips it `true` when a funnel listener starts serving and
422 /// back to `false` when it stops, so the next map request advertises `IngressEnabled` accordingly
423 /// (Go sets `HostInfo.IngressEnabled` only while Funnel endpoints are actually live, and
424 /// `IngressEnabled` implies `WireIngress`). Shared (`Arc`) with the runtime so the device can flip
425 /// it without rebuilding the config. Defaults to a fresh `false` (fail-closed: no live endpoint).
426 /// Not serialized — it is process-local runtime state, not persisted configuration.
427 #[serde(skip, default)]
428 pub ingress_active: std::sync::Arc<std::sync::atomic::AtomicBool>,
429
430 /// VIP services this node advertises that it **hosts** (`svc:<dns-label>` names), the
431 /// advertise side of Tailscale VIP services (Go `tsnet`'s `Hostinfo.ServicesHash` +
432 /// c2n `GET /vip-services`).
433 ///
434 /// Each entry is a full `svc:`-prefixed service name. This field *is* read inside `ts_control`:
435 /// the valid names ([`validate_service_name`](crate::validate_service_name) is applied
436 /// fail-closed; malformed names are dropped and logged) are hashed into `HostInfo.ServicesHash`
437 /// on every map request, and answered when control fetches the list via the c2n
438 /// `/vip-services` endpoint. Defaults to empty: with no entries the hash is `""` and behavior is
439 /// byte-for-byte the historical non-advertising path. Hosting a service additionally requires
440 /// control to assign it a VIP and the node to be tagged (the *consume* side, unchanged here).
441 #[serde(default)]
442 pub advertise_services: Vec<String>,
443
444 /// Whether to automatically re-authenticate (rotate the node key + re-register with the stored
445 /// auth key, Go `doLogin`) when control reports this node's node key has expired, instead of
446 /// going terminally offline.
447 ///
448 /// Defaults to `true`: an auth-key-registered node whose key expires recovers itself without
449 /// human intervention — the common reusable-auth-key case (a persistent exit node / subnet
450 /// router) self-heals. Set to `false` for the most conservative posture (the historical behavior:
451 /// an expired key surfaces the terminal "expired" state and the node stays offline until
452 /// re-paired). Auto-reauth is additionally gated at runtime on a usable auth key being retained
453 /// and Tailnet Lock NOT being enforced (a rotation on a locked tailnet would install an unsigned
454 /// key); see the runtime's `expiry_action`. A one-shot auth key (already consumed by the first
455 /// registration) cannot re-register and degrades to the terminal state regardless of this flag.
456 ///
457 /// Like the client-preference fields, this is **not read inside `ts_control`**: it is carried for
458 /// transport only and consulted by the runtime's self-node expiry handler.
459 #[serde(default = "default_true")]
460 pub reauth_on_expiry: bool,
461
462 /// Allow fetching the control server's machine public key (`GET /key`) over plain **http** when
463 /// the [`server_url`](Config::server_url) is itself `http://`.
464 ///
465 /// By default (`false`) the `/key` fetch is always upgraded to `https`, even when the control
466 /// URL is `http://` — matching Tailscale's posture that the unauthenticated key bootstrap must
467 /// be TLS-protected. That upgrade makes registration **fail** against a control plane that only
468 /// serves plain http (e.g. a self-hosted Headscale exposed over a `http://host:port` LAN
469 /// endpoint / NodePort with no TLS), even though the rest of the control connection already
470 /// honors the `http` scheme. Set this to `true` for such a deployment to fetch `/key` over the
471 /// same `http` scheme as the control URL.
472 ///
473 /// Security: only enable this when you control both ends and the control plane is reachable
474 /// over a trusted network path — an on-path attacker could otherwise substitute the control
475 /// key. It has no effect when `server_url` is `https://` (the fetch stays https regardless).
476 /// Fail-closed default is `false`.
477 #[serde(default)]
478 pub allow_http_key_fetch: bool,
479}
480
481impl Config {
482 /// Get the full client name as a string.
483 ///
484 /// This takes the form `tailscale-rs ({client_name})`, where the parenthetical is only
485 /// provided if self.client_name is set.
486 pub fn format_client_name(&self) -> String {
487 let mut full_name = "tailscale-rs".to_owned();
488 if let Some(client_name) = &self.client_name {
489 full_name.push_str(&format!(" ({client_name})"));
490 }
491
492 full_name
493 }
494
495 /// Compute the set of IP prefixes to advertise in `HostInfo.RoutableIPs`, combining
496 /// [`advertise_routes`](Config::advertise_routes) with the exit-node default route when
497 /// [`advertise_exit_node`](Config::advertise_exit_node) is set.
498 ///
499 /// IPv6 prefixes are filtered out (IPv6-off posture): we never forward IPv6, so advertising an
500 /// IPv6 route would create a black hole. The exit-node default route is therefore `0.0.0.0/0`
501 /// only, never `::/0`. The result is deduplicated and order-preserving; an empty result means
502 /// "advertise nothing", and callers omit the wire field entirely.
503 pub fn advertised_routes(&self) -> Vec<ipnet::IpNet> {
504 let mut routes: Vec<ipnet::IpNet> = Vec::new();
505 let mut push_unique = |net: ipnet::IpNet| {
506 if !routes.contains(&net) {
507 routes.push(net);
508 }
509 };
510
511 for net in &self.advertise_routes {
512 // IPv6-off: drop v6 prefixes so we never advertise a route we won't forward.
513 if matches!(net, ipnet::IpNet::V4(_)) {
514 push_unique(*net);
515 } else {
516 tracing::warn!(prefix = %net, "dropping IPv6 advertise_routes prefix (IPv6-off posture)");
517 }
518 }
519
520 if self.advertise_exit_node {
521 let default_v4 = ipnet::IpNet::V4(
522 ipnet::Ipv4Net::new(core::net::Ipv4Addr::UNSPECIFIED, 0)
523 .expect("0.0.0.0/0 is a valid prefix"),
524 );
525 push_unique(default_v4);
526 }
527
528 routes
529 }
530
531 /// The services to advertise in `HostInfo.Services`, derived from
532 /// [`peerapi_port`](Config::peerapi_port).
533 ///
534 /// When a peerAPI port is configured, we advertise the `peerapi4` service at that port plus the
535 /// `peerapi-dns-proxy` service (whose advertised port is always `1`, matching the Go client's
536 /// quirk) so peers learn they can delegate exit-node DNS to us. When `None`, the result is empty
537 /// and callers omit the `HostInfo.Services` wire field entirely (advertise no services). IPv6
538 /// peerAPI (`peerapi6`) is never advertised, per the IPv6-off posture.
539 pub fn advertised_services(&self) -> Vec<ts_control_serde::Service<'static>> {
540 use ts_control_serde::{Service, ServiceProto};
541
542 let Some(port) = self.peerapi_port else {
543 return Vec::new();
544 };
545
546 vec![
547 Service {
548 proto: ServiceProto::PeerApi4,
549 port,
550 description: "tailscale-rs".into(),
551 },
552 Service {
553 // Go quirk: the peerapi-dns-proxy service always advertises port 1.
554 proto: ServiceProto::PeerApiDnsProxy,
555 port: 1,
556 description: "tailscale-rs".into(),
557 },
558 ]
559 }
560
561 /// The validated set of VIP services this node advertises that it hosts, derived from
562 /// [`advertise_services`](Config::advertise_services).
563 ///
564 /// Each configured name is validated with
565 /// [`validate_service_name`](crate::validate_service_name) (fail-closed: a name that is not a
566 /// well-formed `svc:<dns-label>` is dropped with a warning, never advertised). Each surviving
567 /// service is advertised on **all ports** (a single `0/0..=65535`
568 /// [`ProtoPortRange`](ts_control_serde::ProtoPortRange), matching
569 /// Go's default `ServicePortRange()` when no explicit ports are configured) and marked active.
570 /// The result is the canonical input to both [`services_hash`] and the c2n `/vip-services`
571 /// response. An empty config yields an empty `Vec` (advertise nothing — the hash is `""`).
572 pub fn advertised_vip_services(&self) -> Vec<ts_control_serde::VipServiceOwned> {
573 use ts_control_serde::{ProtoPortRange, VipServiceOwned};
574
575 self.advertise_services
576 .iter()
577 .filter_map(|name| {
578 if crate::validate_service_name(name).is_none() {
579 tracing::warn!(
580 service = %name,
581 "dropping invalid advertise_services name (expected svc:<dns-label>)"
582 );
583 return None;
584 }
585 Some(VipServiceOwned {
586 name: name.clone(),
587 // All ports: proto 0 (all protocols), full 0..=65535 span — Go's default
588 // ServicePortRange() for a service with no explicit port restriction.
589 ports: vec![ProtoPortRange {
590 proto: 0,
591 first: 0,
592 last: 65535,
593 }],
594 active: true,
595 })
596 })
597 .collect()
598 }
599}
600
601/// Compute the `HostInfo.ServicesHash` for a node's advertised VIP services, mirroring Go's
602/// `vipServiceHash`.
603///
604/// The services are sorted by name, serialized to canonical (whitespace-free) JSON as a
605/// [`ts_control_serde::VipServiceOwned`] list, SHA-256'd, and hex-encoded. An empty list hashes to
606/// the empty string `""` (the "no services advertised" sentinel, which omits/clears the wire
607/// field). The hash is byte-stable and order-independent: the same set in any input order yields the
608/// same value, so control reliably refetches only on a genuine change.
609///
610/// Uses `ring`'s SHA-256 (the same crypto backend the rest of the stack links — no aws-lc-rs /
611/// openssl is introduced).
612pub fn services_hash(services: &[ts_control_serde::VipServiceOwned]) -> String {
613 if services.is_empty() {
614 return String::new();
615 }
616
617 let mut sorted = services.to_vec();
618 sorted.sort_by(|a, b| a.name.cmp(&b.name));
619
620 // Canonical, whitespace-free JSON so the digest is byte-stable across builds.
621 let json = serde_json::to_vec(&sorted).expect("VipServiceOwned list always serializes");
622 let digest = ring::digest::digest(&ring::digest::SHA256, &json);
623
624 let mut hex = String::with_capacity(digest.as_ref().len() * 2);
625 for byte in digest.as_ref() {
626 hex.push_str(&format!("{byte:02x}"));
627 }
628 hex
629}
630
631impl Debug for Config {
632 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
633 f.debug_struct("Config")
634 .field("hostname", &self.hostname)
635 .field("server_url", &self.server_url.as_str())
636 .field("client_name", &self.client_name)
637 .finish()
638 }
639}
640
641impl Default for Config {
642 fn default() -> Self {
643 Self {
644 server_url: DEFAULT_CONTROL_SERVER.clone(),
645 hostname: gethostname::gethostname().into_string().ok(),
646 client_name: None,
647 tags: Default::default(),
648 ephemeral: default_ephemeral(),
649 accept_routes: false,
650 accept_dns: default_true(),
651 exit_node: None,
652 advertise_routes: Vec::new(),
653 advertise_exit_node: false,
654 forward_tcp_ports: Vec::new(),
655 forward_udp_ports: Vec::new(),
656 forward_all_ports: false,
657 forward_exit_egress: false,
658 block_incoming: false,
659 exit_proxy: None,
660 peerapi_port: None,
661 taildrop_dir: None,
662 tcp_buffer_size: None,
663 enable_ipv6: false,
664 network_monitor: false,
665 wireguard_listen_port: None,
666 persistent_keepalive_interval: default_persistent_keepalive(),
667 transport_mode: TransportMode::default(),
668 wire_ingress: false,
669 ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
670 advertise_services: Vec::new(),
671 reauth_on_expiry: default_true(),
672 allow_http_key_fetch: false,
673 }
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 fn v4(s: &str) -> ipnet::IpNet {
682 ipnet::IpNet::V4(s.parse().unwrap())
683 }
684
685 fn v6(s: &str) -> ipnet::IpNet {
686 ipnet::IpNet::V6(s.parse().unwrap())
687 }
688
689 #[test]
690 fn default_advertises_nothing() {
691 let cfg = Config::default();
692 assert!(cfg.advertised_routes().is_empty());
693 }
694
695 #[test]
696 fn advertises_v4_subnet_routes() {
697 let cfg = Config {
698 advertise_routes: vec![v4("10.0.0.0/24"), v4("192.168.1.0/24")],
699 ..Default::default()
700 };
701 assert_eq!(
702 cfg.advertised_routes(),
703 vec![v4("10.0.0.0/24"), v4("192.168.1.0/24")]
704 );
705 }
706
707 #[test]
708 fn exit_node_adds_default_v4_route() {
709 let cfg = Config {
710 advertise_exit_node: true,
711 ..Default::default()
712 };
713 assert_eq!(cfg.advertised_routes(), vec![v4("0.0.0.0/0")]);
714 }
715
716 #[test]
717 fn v6_prefixes_are_dropped() {
718 let cfg = Config {
719 advertise_routes: vec![v4("10.0.0.0/24"), v6("fd00::/64")],
720 ..Default::default()
721 };
722 // IPv6-off: only the v4 prefix survives.
723 assert_eq!(cfg.advertised_routes(), vec![v4("10.0.0.0/24")]);
724 }
725
726 #[test]
727 fn exit_node_never_advertises_v6_default() {
728 let cfg = Config {
729 advertise_routes: vec![v6("::/0")],
730 advertise_exit_node: true,
731 ..Default::default()
732 };
733 // ::/0 is dropped; only the v4 default route is advertised.
734 assert_eq!(cfg.advertised_routes(), vec![v4("0.0.0.0/0")]);
735 }
736
737 #[test]
738 fn default_is_ephemeral() {
739 // Preserves the historical hardcoded behavior; persistent nodes must opt out explicitly.
740 assert!(Config::default().ephemeral);
741 }
742
743 #[test]
744 fn ephemeral_deserializes_default_true_when_absent() {
745 // A config that predates the field still registers ephemeral.
746 let cfg: Config = serde_json::from_str(r#"{"server_url":"https://example.com/"}"#).unwrap();
747 assert!(cfg.ephemeral);
748 }
749
750 #[test]
751 fn ephemeral_can_be_disabled_for_persistent_nodes() {
752 let cfg: Config =
753 serde_json::from_str(r#"{"server_url":"https://example.com/","ephemeral":false}"#)
754 .unwrap();
755 assert!(!cfg.ephemeral);
756 }
757
758 #[test]
759 fn tags_default_empty_and_deserialize() {
760 let cfg: Config =
761 serde_json::from_str(r#"{"server_url":"https://example.com/","tags":["tag:exit"]}"#)
762 .unwrap();
763 assert_eq!(cfg.tags, vec!["tag:exit".to_owned()]);
764 assert!(Config::default().tags.is_empty());
765 }
766
767 #[test]
768 fn advertises_no_services_without_peerapi_port() {
769 // Fail-closed default: no peerAPI port means no services advertised.
770 assert!(Config::default().advertised_services().is_empty());
771 }
772
773 #[test]
774 fn advertises_peerapi4_and_dns_proxy_when_port_set() {
775 use ts_control_serde::ServiceProto;
776
777 let cfg = Config {
778 peerapi_port: Some(8080),
779 ..Default::default()
780 };
781 let services = cfg.advertised_services();
782 assert_eq!(services.len(), 2);
783
784 // peerapi4 carries the real bind port.
785 assert_eq!(services[0].proto, ServiceProto::PeerApi4);
786 assert_eq!(services[0].port, 8080);
787
788 // peerapi-dns-proxy always advertises port 1 (Go quirk).
789 assert_eq!(services[1].proto, ServiceProto::PeerApiDnsProxy);
790 assert_eq!(services[1].port, 1);
791 }
792
793 #[test]
794 fn peerapi_port_deserializes_default_none() {
795 let cfg: Config = serde_json::from_str(r#"{"server_url":"https://example.com/"}"#).unwrap();
796 assert_eq!(cfg.peerapi_port, None);
797 }
798
799 #[test]
800 fn advertise_services_default_empty() {
801 assert!(Config::default().advertise_services.is_empty());
802 assert!(Config::default().advertised_vip_services().is_empty());
803 }
804
805 #[test]
806 fn advertise_services_deserializes() {
807 let cfg: Config = serde_json::from_str(
808 r#"{"server_url":"https://example.com/","advertise_services":["svc:samba"]}"#,
809 )
810 .unwrap();
811 assert_eq!(cfg.advertise_services, vec!["svc:samba".to_owned()]);
812 }
813
814 #[test]
815 fn advertised_vip_services_validates_and_drops_bad_names() {
816 let cfg = Config {
817 advertise_services: vec![
818 "svc:good".to_owned(),
819 "bad-no-prefix".to_owned(),
820 "svc:-bad-label".to_owned(),
821 ],
822 ..Default::default()
823 };
824 let svcs = cfg.advertised_vip_services();
825 assert_eq!(svcs.len(), 1);
826 assert_eq!(svcs[0].name, "svc:good");
827 // All-ports default range, active.
828 assert_eq!(svcs[0].ports.len(), 1);
829 assert_eq!(svcs[0].ports[0].first, 0);
830 assert_eq!(svcs[0].ports[0].last, 65535);
831 assert!(svcs[0].active);
832 }
833
834 #[test]
835 fn services_hash_empty_is_empty_string() {
836 assert_eq!(services_hash(&[]), "");
837 }
838
839 #[test]
840 fn services_hash_is_order_independent() {
841 let a = Config {
842 advertise_services: vec!["svc:a".to_owned(), "svc:b".to_owned()],
843 ..Default::default()
844 };
845 let b = Config {
846 advertise_services: vec!["svc:b".to_owned(), "svc:a".to_owned()],
847 ..Default::default()
848 };
849 let ha = services_hash(&a.advertised_vip_services());
850 let hb = services_hash(&b.advertised_vip_services());
851 assert_eq!(ha, hb);
852 assert!(!ha.is_empty());
853 }
854
855 #[test]
856 fn services_hash_changes_with_set() {
857 let one = Config {
858 advertise_services: vec!["svc:a".to_owned()],
859 ..Default::default()
860 };
861 let two = Config {
862 advertise_services: vec!["svc:a".to_owned(), "svc:b".to_owned()],
863 ..Default::default()
864 };
865 assert_ne!(
866 services_hash(&one.advertised_vip_services()),
867 services_hash(&two.advertised_vip_services())
868 );
869 }
870
871 #[test]
872 fn services_hash_known_answer() {
873 // KAT: pin the hash of a single all-ports `svc:samba` so a future serialization change
874 // (field order, whitespace) that would silently break the node's own change-detection fails
875 // this test. The hash is a SELF-CONSISTENCY TOKEN: this node computes it, sends it in
876 // `HostInfo.ServicesHash`, and echoes the same value in `C2NVIPServicesResponse.ServicesHash`;
877 // control treats it as opaque and only refetches when it CHANGES — control never recomputes
878 // it, so the node only needs to be internally consistent (it is — one `services_hash`).
879 //
880 // It is NOT byte-equal to Go `vipServiceHash` and is not meant to be: Go does
881 // `json.NewEncoder(sha256).Encode(services)` which (a) appends a trailing `\n` that
882 // `serde_json::to_vec` here does not, and (b) Go's advertise path (`vipServicesFromPrefsLocked`)
883 // leaves `Ports` nil → `"Ports":null`, whereas this fork injects an explicit all-ports
884 // `ProtoPortRange` → `"Ports":["*"]`. (The element form IS now Go-correct — `ProtoPortRange`
885 // serializes as the TextMarshaler string `"*"`, not a `{Proto,First,Last}` object — which is
886 // what moved this value from the old object-form hash.) Full Go-faithful ServicesHash is
887 // tracked separately; benign because the token is opaque to control.
888 let cfg = Config {
889 advertise_services: vec!["svc:samba".to_owned()],
890 ..Default::default()
891 };
892 let hash = services_hash(&cfg.advertised_vip_services());
893 // 64 hex chars = SHA-256.
894 assert_eq!(hash.len(), 64);
895 assert!(hash.bytes().all(|b| b.is_ascii_hexdigit()));
896 assert_eq!(
897 hash,
898 "9593a969d3df19c81e5c47a5caeca701ab60b732b99004f15aa00384d922c40c"
899 );
900 }
901
902 #[test]
903 fn deduplicates_routes() {
904 let cfg = Config {
905 advertise_routes: vec![v4("0.0.0.0/0"), v4("10.0.0.0/24")],
906 advertise_exit_node: true,
907 ..Default::default()
908 };
909 // Explicit 0.0.0.0/0 plus the exit-node default route collapse to one entry.
910 assert_eq!(
911 cfg.advertised_routes(),
912 vec![v4("0.0.0.0/0"), v4("10.0.0.0/24")]
913 );
914 }
915}