Skip to main content

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    /// Directory the last full network map is cached in, or `None` (the default) to never persist
330    /// one.
331    ///
332    /// Unlike the other dataplane fields on this struct, `ts_control` **does** read this one: the
333    /// map-poll loop builds a [`NetmapCache`](crate::NetmapCache) from it and the control runner
334    /// replays what it finds there on a cold start, before control has answered. Setting it only
335    /// makes caching *possible* — nothing is written unless control also grants the node
336    /// `cache-network-maps` and withholds `disable-cache-network-maps` (Go
337    /// `nodecap.CacheNetworkMaps` / `DisableCacheNetworkMaps`), and a netmap that withdraws the
338    /// grant deletes whatever was cached.
339    ///
340    /// The cached frame is sensitive — it is the tailnet's peer list with their public keys and
341    /// endpoints, the DNS configuration and the packet filter — so the directory is created `0700`
342    /// and the file `0600` on Unix. Point it somewhere only this node's user can read. The `tsnet`
343    /// facade sets it to `<Server::dir>/netmap-cache` when a state directory is configured.
344    #[serde(default)]
345    pub netmap_cache_dir: Option<std::path::PathBuf>,
346
347    /// Per-direction TCP send/receive buffer size (bytes) for the userspace netstack, or `None` to
348    /// use the netstack default (256 KiB per direction, ~512 KiB per socket).
349    ///
350    /// smoltcp has no window auto-tuning, so this is the hard cap on a single flow's
351    /// bandwidth-delay product; raising it helps large model-API responses on high-RTT links, at
352    /// the cost of more memory per concurrent socket (each socket allocates this size for both rx
353    /// and tx). Like the other dataplane fields, this is a client-side preference not read inside
354    /// `ts_control`; it is carried here only to be threaded into the runtime's netstack
355    /// configuration.
356    #[serde(default)]
357    pub tcp_buffer_size: Option<usize>,
358
359    /// Whether IPv6 is enabled on the tailnet overlay. Defaults to `false` (IPv4-only).
360    ///
361    /// Like the other dataplane fields, this is a client-side preference not read inside
362    /// `ts_control`; it is carried here only to be threaded into the runtime's underlay socket,
363    /// disco candidate filter, netstack address assignment, and MagicDNS AAAA handling. It governs
364    /// only the overlay and never the exit-node / forwarder egress path, which stays IPv4-only
365    /// regardless to uphold the real-origin-IP isolation invariant.
366    #[serde(default)]
367    pub enable_ipv6: bool,
368
369    /// Whether the runtime runs an internal OS network-link monitor that auto-re-binds + re-probes
370    /// connectivity on a link change (Wi-Fi switch, sleep/wake, default-route change). Defaults to
371    /// `false` (no monitor — the embedder drives `Device::rebind` itself).
372    ///
373    /// Like the other dataplane fields, this is a client-side preference not read inside
374    /// `ts_control`; it is carried here only to be threaded into the runtime, which (when set, and
375    /// when built with the `network-monitor` feature) spawns a `NetmonSupervisor`. It is off by
376    /// default to preserve the fork's pure-engine posture (it is an engine, not a daemon): with it
377    /// off, the runtime starts zero monitor threads/sockets and behaves byte-for-byte as before.
378    #[serde(default)]
379    pub network_monitor: bool,
380
381    /// The fixed UDP port magicsock binds for WireGuard + disco, or `None` for an OS-chosen
382    /// ephemeral port (Go `tailscaled --port` / `ListenPort`). Defaults to `None`.
383    ///
384    /// Like the other dataplane fields, this is a client-side preference not read inside
385    /// `ts_control`; it is carried here only to be threaded into the runtime's *initial* underlay
386    /// socket bind. `None` binds `0.0.0.0:0` (ephemeral, today's behavior); `Some(p)` pins port `p`
387    /// with an ephemeral fallback if it is already taken (a port collision never fails bring-up).
388    /// Governs only the bound port, never the bind family — the IPv4-only-by-default, fail-closed
389    /// underlay posture is unchanged.
390    #[serde(default)]
391    pub wireguard_listen_port: Option<u16>,
392
393    /// WireGuard persistent-keepalive interval applied to every peer, or `None` to disable persistent
394    /// keepalives (`PersistentKeepalive`; Tailscale uses 25s).
395    ///
396    /// When `Some(interval)`, each peer emits an empty authenticated keepalive every `interval` of
397    /// outbound silence, holding the (typically DERP-relayed) path/NAT mapping warm so an idle
398    /// session doesn't age past expiry and wedge the next dial — the failure this fork's primary
399    /// userspace-netstack deployment hits, where the relay is the only path to a peer. Unlike the
400    /// reactive WireGuard §6.5 keepalive (armed only by inbound traffic), this re-arms unconditionally
401    /// and fires on a fully idle tunnel; the empty packet does not advance the session's
402    /// rotation/expiry timers, so a genuinely dead peer is still detected. Defaults to `Some(25s)`
403    /// ([`DEFAULT_PERSISTENT_KEEPALIVE`]). Like the other dataplane fields it is not read inside
404    /// `ts_control`; it is carried here only to be threaded into the runtime's dataplane actor.
405    #[serde(default = "default_persistent_keepalive")]
406    pub persistent_keepalive_interval: Option<std::time::Duration>,
407
408    /// How the application overlay data path is realized: userspace netstack (default) or a real
409    /// kernel TUN interface. See [`TransportMode`].
410    ///
411    /// Like the other dataplane fields, this is a client-side preference not read inside
412    /// `ts_control`; it is carried here only to be threaded into the runtime, which builds either a
413    /// netstack actor or a TUN transport from it. `ts_control` must not depend on `ts_transport_tun`.
414    #[serde(default)]
415    pub transport_mode: TransportMode,
416
417    /// Whether to ask control to wire this node up server-side for Tailscale Funnel
418    /// (`HostInfo.WireIngress`, the capver-113 client→control Funnel signal), even when no Funnel
419    /// endpoint is currently active.
420    ///
421    /// Unlike the dataplane fields above, this one *is* read inside `ts_control`: it sets
422    /// `HostInfo.WireIngress` on registration and the streaming map request, asking control to
423    /// provision the DNS / ingress records a Funnel node needs so a later `serve`/funnel session
424    /// works immediately. It mirrors Go `tsnet`'s "would like to be wired up for Funnel" signal.
425    ///
426    /// This fork cannot yet *terminate* public Funnel ingress — [`crate::listen_funnel`] is
427    /// fail-closed (no client-side ACME engine, and a self-hosted control plane provides no public
428    /// ingress relay). So `HostInfo.IngressEnabled` (Funnel endpoints actually live) is never set;
429    /// only `WireIngress` is, and only when this flag is `true`. Defaults to `false` (fail-closed):
430    /// a node requests Funnel wiring only when explicitly opted in.
431    #[serde(default)]
432    pub wire_ingress: bool,
433
434    /// Live signal that this node currently has an active Funnel ingress listener
435    /// (`Device::listen_funnel` was called and its listener is up), driving `HostInfo.IngressEnabled`
436    /// on the streaming map request.
437    ///
438    /// Unlike [`wire_ingress`](Self::wire_ingress) (a static "please provision Funnel records" hint),
439    /// this is a *dynamic* flag: the runtime flips it `true` when a funnel listener starts serving and
440    /// back to `false` when it stops, so the next map request advertises `IngressEnabled` accordingly
441    /// (Go sets `HostInfo.IngressEnabled` only while Funnel endpoints are actually live, and
442    /// `IngressEnabled` implies `WireIngress`). Shared (`Arc`) with the runtime so the device can flip
443    /// it without rebuilding the config. Defaults to a fresh `false` (fail-closed: no live endpoint).
444    /// Not serialized — it is process-local runtime state, not persisted configuration.
445    #[serde(skip, default)]
446    pub ingress_active: std::sync::Arc<std::sync::atomic::AtomicBool>,
447
448    /// VIP services this node advertises that it **hosts** (`svc:<dns-label>` names), the
449    /// advertise side of Tailscale VIP services (Go `tsnet`'s `Hostinfo.ServicesHash` +
450    /// c2n `GET /vip-services`).
451    ///
452    /// Each entry is a full `svc:`-prefixed service name. This field *is* read inside `ts_control`:
453    /// the valid names ([`validate_service_name`](crate::validate_service_name) is applied
454    /// fail-closed; malformed names are dropped and logged) are hashed into `HostInfo.ServicesHash`
455    /// on every map request, and answered when control fetches the list via the c2n
456    /// `/vip-services` endpoint. Defaults to empty: with no entries the hash is `""` and behavior is
457    /// byte-for-byte the historical non-advertising path. Hosting a service additionally requires
458    /// control to assign it a VIP and the node to be tagged (the *consume* side, unchanged here).
459    #[serde(default)]
460    pub advertise_services: Vec<String>,
461
462    /// Whether to advertise this node as an **app connector** (Go `Prefs.AppConnector.Advertise` /
463    /// `tailscale set --advertise-connector`). When `true`, this *is* read inside `ts_control`: it
464    /// sets `HostInfo.AppConnector = Some(true)` on registration and every map request, mirroring Go's
465    /// `applyPrefsToHostinfoLocked` (`hi.AppConnector.Set(prefs.AppConnector().Advertise)`).
466    ///
467    /// Advertising the bool is the **faithful engine minimum** — exactly the boundary Go draws. The
468    /// actual app-connector *data path* (control pushing the connector's domain routes, the 4via6
469    /// domain→route mapping, the per-domain DNS observation that learns target IPs) is a separate
470    /// subsystem this fork does not implement; advertising the capability without that data path is
471    /// identical in effect to Go advertising it before control has assigned any domains. Defaults to
472    /// `false` (fail-closed): a node offers itself as an app connector only when explicitly opted in.
473    #[serde(default)]
474    pub advertise_app_connector: bool,
475
476    /// Whether this node opts in to control-console-triggered auto-updates (Go
477    /// `Prefs.AutoUpdate.Apply` / `tailscale set --auto-update`). When `Some(true)`, this *is* read
478    /// inside `ts_control`: it sets `HostInfo.AllowsUpdate = true` on registration and every map
479    /// request, mirroring Go's `applyPrefsToHostinfoLocked`
480    /// (`hi.AllowsUpdate = … || prefs.AutoUpdate().Apply.EqualBool(true)`), so the admin console knows
481    /// the node accepts remote update triggers.
482    ///
483    /// Advertising the bool is the faithful engine minimum: this fork runs **no updater** (it is an
484    /// embeddable engine, not a packaged daemon), so it never *applies* an update — the actual
485    /// self-update machinery is a daemon/OS-package concern. `Some(false)` and `None` both leave
486    /// `AllowsUpdate` at its default `false` (the node advertises it does not accept remote updates);
487    /// the tri-state mirrors Go's `opt.Bool` (unset vs explicitly-off vs on). Defaults to `None`.
488    #[serde(default)]
489    pub auto_update_apply: Option<bool>,
490
491    /// Whether this node's (hypothetical) background updater should *check* for available updates
492    /// (Go `Prefs.AutoUpdate.Check`). **Carried pref only — not read inside `ts_control` and never
493    /// sent to control.** In Go this gates a purely local background update-check loop in the daemon;
494    /// it is not part of `Hostinfo` and never crosses the control wire, so storing it is the faithful
495    /// mirror of tsnet state. This fork has no updater (engine, not daemon), so the pref is carried
496    /// for a downstream daemon to consult and has no effect inside the engine. Defaults to `false`.
497    #[serde(default)]
498    pub auto_update_check: bool,
499
500    /// The OS username permitted to operate this node over the local API (Go `Prefs.OperatorUser` /
501    /// `tailscale set --operator`). **Carried pref only — not read inside `ts_control` and never sent
502    /// to control.** In Go this is purely a daemon-side LocalAPI authorization check (which Unix uid
503    /// may drive the daemon without root); it never touches the control protocol. Storing it is the
504    /// faithful mirror of tsnet state — a downstream daemon that exposes a local API consults it; the
505    /// engine itself has no local API to gate. Defaults to `None` (no operator delegated).
506    #[serde(default)]
507    pub operator_user: Option<String>,
508
509    /// A local display label for this node's profile (Go `Prefs.ProfileName`, set by
510    /// `tailscale switch`/profile management). **Carried pref only — not read inside `ts_control` and
511    /// never sent to control.** In Go this is a client-local cosmetic name for the login profile; it
512    /// is never advertised in `Hostinfo` (distinct from the `Hostinfo.Hostname` the node requests).
513    /// Storing it faithfully mirrors tsnet state for a downstream daemon's profile UI; the engine
514    /// makes no use of it. Defaults to `None`.
515    #[serde(default)]
516    pub node_nickname: Option<String>,
517
518    /// Whether device posture identity collection is enabled (Go `Prefs.PostureChecking` /
519    /// `tailscale set --posture-checking`). **Carried pref only — not read inside `ts_control` and
520    /// never sent to control.**
521    ///
522    /// There is deliberately **no `Hostinfo.PostureChecking` field to wire it to**: posture is a
523    /// control-to-node (c2n) *pull* mechanism — control requests posture attributes (serial numbers,
524    /// etc.) from the node on demand — which this fork does not implement. Storing the pref is
525    /// therefore the faithful mirror: with no c2n posture responder, control simply never pulls
526    /// posture identity, which is byte-for-byte identical to the posture-disabled case. A downstream
527    /// daemon that implements the c2n posture endpoint consults this pref to decide whether to answer.
528    /// Defaults to `false` (fail-closed: no posture identity collected).
529    #[serde(default)]
530    pub posture_checking: bool,
531
532    /// Whether this node runs a local web client (Go `Prefs.RunWebClient` /
533    /// `tailscale set --webclient`). **Carried pref only — not read inside `ts_control` and never
534    /// sent to control.** In Go this gates a daemon-hosted local web-client HTTP server (the
535    /// device-management web UI on `100.x:5252`); it is a separate subsystem, not advertised in
536    /// `Hostinfo`. This fork has no web-client server, so storing the pref faithfully mirrors tsnet
537    /// state for a downstream daemon that does; the engine never acts on it. Defaults to `false`.
538    #[serde(default)]
539    pub run_web_client: bool,
540
541    /// Whether a peer using this node as an exit node may also reach this node's **local LAN**
542    /// (Go `Prefs.ExitNodeAllowLANAccess` / `tailscale set --exit-node-allow-lan-access`).
543    /// **Carried pref only for now — not read inside `ts_control` and never sent to control.**
544    ///
545    /// In Go this is an **OS-router route-shaping** flag: when acting as an exit node it controls
546    /// whether the host router excludes the local LAN ranges from the routes pulled through the
547    /// tunnel. On a platform with no host router it has "no effect" — and this fork's default data
548    /// path is the userspace netstack with no host-route layer, so there is nothing to shape today.
549    /// The pref is stored so a downstream daemon (or a future host-route layer in this engine) can
550    /// consume it; until such a layer exists it is inert. It is never advertised to control. Defaults
551    /// to `false`.
552    #[serde(default)]
553    pub exit_node_allow_lan_access: bool,
554
555    /// Whether to automatically re-authenticate (rotate the node key + re-register with the stored
556    /// auth key, Go `doLogin`) when control reports this node's node key has expired, instead of
557    /// going terminally offline.
558    ///
559    /// Defaults to `true`: an auth-key-registered node whose key expires recovers itself without
560    /// human intervention — the common reusable-auth-key case (a persistent exit node / subnet
561    /// router) self-heals. Set to `false` for the most conservative posture (the historical behavior:
562    /// an expired key surfaces the terminal "expired" state and the node stays offline until
563    /// re-paired). Auto-reauth is additionally gated at runtime on a usable auth key being retained
564    /// and Tailnet Lock NOT being enforced (a rotation on a locked tailnet would install an unsigned
565    /// key); see the runtime's `expiry_action`. A one-shot auth key (already consumed by the first
566    /// registration) cannot re-register and degrades to the terminal state regardless of this flag.
567    ///
568    /// Like the client-preference fields, this is **not read inside `ts_control`**: it is carried for
569    /// transport only and consulted by the runtime's self-node expiry handler.
570    #[serde(default = "default_true")]
571    pub reauth_on_expiry: bool,
572
573    /// Allow fetching the control server's machine public key (`GET /key`) over plain **http** when
574    /// the [`server_url`](Config::server_url) is itself `http://`.
575    ///
576    /// By default (`false`) the `/key` fetch is always upgraded to `https`, even when the control
577    /// URL is `http://` — matching Tailscale's posture that the unauthenticated key bootstrap must
578    /// be TLS-protected. That upgrade makes registration **fail** against a control plane that only
579    /// serves plain http (e.g. a self-hosted Headscale exposed over a `http://host:port` LAN
580    /// endpoint / NodePort with no TLS), even though the rest of the control connection already
581    /// honors the `http` scheme. Set this to `true` for such a deployment to fetch `/key` over the
582    /// same `http` scheme as the control URL.
583    ///
584    /// Security: only enable this when you control both ends and the control plane is reachable
585    /// over a trusted network path — an on-path attacker could otherwise substitute the control
586    /// key. It has no effect when `server_url` is `https://` (the fetch stays https regardless).
587    /// Fail-closed default is `false`.
588    #[serde(default)]
589    pub allow_http_key_fetch: bool,
590
591    /// Whether the control plane may invoke this node's LocalAPI through the c2n
592    /// `/remoteapi/localapi/*` proxy (Go `ipn.Prefs.RemoteConfig`, consumed by
593    /// `feature/remoteconfig`'s `handleC2NRemoteAPI`).
594    ///
595    /// Tailscale's default posture is per-feature *double* opt-in: the tailnet admin can ask for
596    /// something server-side, but the local machine owner still consents to each individual
597    /// setting. This pref is the one deliberate exception — a single client-side "I trust the
598    /// tailnet admin" switch. While it is `true`, control can invoke any LocalAPI endpoint this
599    /// node serves with no further local consent, which upstream grants as read **and** write.
600    /// Appropriate when the tailnet admin owns the machine (a corporate fleet device); not
601    /// appropriate on a personal or BYOD device. Defaults to `false` (fail-closed): the c2n
602    /// proxy answers `403 remote config not enabled by local machine` until it is set.
603    #[serde(default)]
604    pub remote_config: bool,
605
606    /// This node's LocalAPI, for the c2n `/remoteapi/localapi/*` proxy to dispatch into
607    /// (Go `localapi.NewHandler(...)` inside `handleC2NRemoteAPI`).
608    ///
609    /// `None` — the default — means this node was built with no LocalAPI at all, so the c2n prefix
610    /// handler is not registered and `/remoteapi/localapi/*` takes the responder's
611    /// `unknown c2n path` `400` like any other unregistered path. That is exactly what upstream
612    /// does when the `remoteconfig` feature is omitted from the build: the `init` that calls
613    /// `ipnlocal.RegisterC2NPrefix` never runs, so no prefix matches and `handleC2N` falls through.
614    /// The `tsnet` facade installs its in-process LocalAPI here when it builds the device.
615    ///
616    /// Not serialized — it is a live in-process handler, not persisted configuration, so it is
617    /// `None` after a serde round-trip.
618    #[serde(skip, default)]
619    pub local_api: Option<std::sync::Arc<dyn LocalApi>>,
620}
621
622/// This node's LocalAPI, as reachable by the control plane through the c2n
623/// `/remoteapi/localapi/*` proxy (Go `ipn/localapi.Handler`, invoked by `feature/remoteconfig`'s
624/// `handleC2NRemoteAPI`).
625///
626/// Implemented outside this crate — `ts_control` routes the c2n request and enforces upstream's
627/// refusals, but the LocalAPI surface itself lives above the control client (in this tree, the
628/// `tsnet` facade's one-route server). Install an implementation on
629/// [`Config::local_api`](Config::local_api) to register the prefix route.
630///
631/// The request handed to [`serve`](LocalApi::serve) is already authorized: upstream builds the
632/// proxied handler with `Actor: ipnauth.Self` and `PermitRead`/`PermitWrite` set, and leaves
633/// `RequiredPassword` empty, so the loopback listener's Basic-auth and anti-DNS-rebinding gates do
634/// not apply to it. The [`Config::remote_config`](Config::remote_config) pref is the whole of the
635/// authorization decision, and `ts_control` has already checked it before calling.
636pub trait LocalApi: Send + Sync {
637    /// Serve one LocalAPI request, returning the complete HTTP/1.1 response to hand back to
638    /// control.
639    ///
640    /// `method` is the c2n request's HTTP method. `target` is its request target *after* the
641    /// `/remoteapi` prefix strip, so it always begins `/localapi/` and may still carry a query
642    /// string. `body` is the c2n request body, verbatim.
643    fn serve<'a>(
644        &'a self,
645        method: &'a str,
646        target: &'a str,
647        body: &'a str,
648    ) -> core::pin::Pin<alloc::boxed::Box<dyn core::future::Future<Output = String> + Send + 'a>>;
649}
650
651impl Config {
652    /// Get the full client name as a string.
653    ///
654    /// This takes the form `tailscale-rs ({client_name})`, where the parenthetical is only
655    /// provided if self.client_name is set.
656    pub fn format_client_name(&self) -> String {
657        let mut full_name = "tailscale-rs".to_owned();
658        if let Some(client_name) = &self.client_name {
659            full_name.push_str(&format!(" ({client_name})"));
660        }
661
662        full_name
663    }
664
665    /// Compute the set of IP prefixes to advertise in `HostInfo.RoutableIPs`, combining
666    /// [`advertise_routes`](Config::advertise_routes) with the exit-node default route when
667    /// [`advertise_exit_node`](Config::advertise_exit_node) is set.
668    ///
669    /// IPv6 prefixes are filtered out (IPv6-off posture): we never forward IPv6, so advertising an
670    /// IPv6 route would create a black hole. The exit-node default route is therefore `0.0.0.0/0`
671    /// only, never `::/0`. The result is deduplicated and order-preserving; an empty result means
672    /// "advertise nothing", and callers omit the wire field entirely.
673    pub fn advertised_routes(&self) -> Vec<ipnet::IpNet> {
674        let mut routes: Vec<ipnet::IpNet> = Vec::new();
675        let mut push_unique = |net: ipnet::IpNet| {
676            if !routes.contains(&net) {
677                routes.push(net);
678            }
679        };
680
681        for net in &self.advertise_routes {
682            // IPv6-off: drop v6 prefixes so we never advertise a route we won't forward.
683            if matches!(net, ipnet::IpNet::V4(_)) {
684                push_unique(*net);
685            } else {
686                tracing::warn!(prefix = %net, "dropping IPv6 advertise_routes prefix (IPv6-off posture)");
687            }
688        }
689
690        if self.advertise_exit_node {
691            let default_v4 = ipnet::IpNet::V4(
692                ipnet::Ipv4Net::new(core::net::Ipv4Addr::UNSPECIFIED, 0)
693                    .expect("0.0.0.0/0 is a valid prefix"),
694            );
695            push_unique(default_v4);
696        }
697
698        routes
699    }
700
701    /// The services to advertise in `HostInfo.Services`, derived from
702    /// [`peerapi_port`](Config::peerapi_port).
703    ///
704    /// When a peerAPI port is configured, we advertise the `peerapi4` service at that port plus the
705    /// `peerapi-dns-proxy` service (whose advertised port is always `1`, matching the Go client's
706    /// quirk) so peers learn they can delegate exit-node DNS to us. When `None`, the result is empty
707    /// and callers omit the `HostInfo.Services` wire field entirely (advertise no services). IPv6
708    /// peerAPI (`peerapi6`) is never advertised, per the IPv6-off posture.
709    pub fn advertised_services(&self) -> Vec<ts_control_serde::Service<'static>> {
710        use ts_control_serde::{Service, ServiceProto};
711
712        let Some(port) = self.peerapi_port else {
713            return Vec::new();
714        };
715
716        vec![
717            Service {
718                proto: ServiceProto::PeerApi4,
719                port,
720                description: "tailscale-rs".into(),
721            },
722            Service {
723                // Go quirk: the peerapi-dns-proxy service always advertises port 1.
724                proto: ServiceProto::PeerApiDnsProxy,
725                port: 1,
726                description: "tailscale-rs".into(),
727            },
728        ]
729    }
730
731    /// The validated set of VIP services this node advertises that it hosts, derived from
732    /// [`advertise_services`](Config::advertise_services).
733    ///
734    /// Each configured name is validated with
735    /// [`validate_service_name`](crate::validate_service_name) (fail-closed: a name that is not a
736    /// well-formed `svc:<dns-label>` is dropped with a warning, never advertised). Each surviving
737    /// service is advertised on **all ports** (a single `0/0..=65535`
738    /// [`ProtoPortRange`](ts_control_serde::ProtoPortRange), matching
739    /// Go's default `ServicePortRange()` when no explicit ports are configured) and marked active.
740    /// The result is the canonical input to both [`services_hash`] and the c2n `/vip-services`
741    /// response. An empty config yields an empty `Vec` (advertise nothing — the hash is `""`).
742    pub fn advertised_vip_services(&self) -> Vec<ts_control_serde::VipServiceOwned> {
743        use ts_control_serde::{ProtoPortRange, VipServiceOwned};
744
745        self.advertise_services
746            .iter()
747            .filter_map(|name| {
748                if crate::validate_service_name(name).is_none() {
749                    tracing::warn!(
750                        service = %name,
751                        "dropping invalid advertise_services name (expected svc:<dns-label>)"
752                    );
753                    return None;
754                }
755                Some(VipServiceOwned {
756                    name: name.clone(),
757                    // All ports: proto 0 (all protocols), full 0..=65535 span — Go's default
758                    // ServicePortRange() for a service with no explicit port restriction.
759                    ports: vec![ProtoPortRange {
760                        proto: 0,
761                        first: 0,
762                        last: 65535,
763                    }],
764                    active: true,
765                })
766            })
767            .collect()
768    }
769}
770
771/// Compute the `HostInfo.ServicesHash` for a node's advertised VIP services, mirroring Go's
772/// `vipServiceHash`.
773///
774/// The services are sorted by name, serialized to canonical (whitespace-free) JSON as a
775/// [`ts_control_serde::VipServiceOwned`] list, SHA-256'd, and hex-encoded. An empty list hashes to
776/// the empty string `""` (the "no services advertised" sentinel, which omits/clears the wire
777/// field). The hash is byte-stable and order-independent: the same set in any input order yields the
778/// same value, so control reliably refetches only on a genuine change.
779///
780/// Uses `ring`'s SHA-256 (the same crypto backend the rest of the stack links — no aws-lc-rs /
781/// openssl is introduced).
782pub fn services_hash(services: &[ts_control_serde::VipServiceOwned]) -> String {
783    if services.is_empty() {
784        return String::new();
785    }
786
787    let mut sorted = services.to_vec();
788    sorted.sort_by(|a, b| a.name.cmp(&b.name));
789
790    // Canonical, whitespace-free JSON so the digest is byte-stable across builds.
791    let json = serde_json::to_vec(&sorted).expect("VipServiceOwned list always serializes");
792    let digest = ring::digest::digest(&ring::digest::SHA256, &json);
793
794    let mut hex = String::with_capacity(digest.as_ref().len() * 2);
795    for byte in digest.as_ref() {
796        hex.push_str(&format!("{byte:02x}"));
797    }
798    hex
799}
800
801impl Debug for Config {
802    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
803        f.debug_struct("Config")
804            .field("hostname", &self.hostname)
805            .field("server_url", &self.server_url.as_str())
806            .field("client_name", &self.client_name)
807            .finish()
808    }
809}
810
811impl Default for Config {
812    fn default() -> Self {
813        Self {
814            server_url: DEFAULT_CONTROL_SERVER.clone(),
815            hostname: gethostname::gethostname().into_string().ok(),
816            client_name: None,
817            tags: Default::default(),
818            ephemeral: default_ephemeral(),
819            accept_routes: false,
820            accept_dns: default_true(),
821            exit_node: None,
822            advertise_routes: Vec::new(),
823            advertise_exit_node: false,
824            forward_tcp_ports: Vec::new(),
825            forward_udp_ports: Vec::new(),
826            forward_all_ports: false,
827            forward_exit_egress: false,
828            block_incoming: false,
829            exit_proxy: None,
830            peerapi_port: None,
831            taildrop_dir: None,
832            netmap_cache_dir: None,
833            tcp_buffer_size: None,
834            enable_ipv6: false,
835            network_monitor: false,
836            wireguard_listen_port: None,
837            persistent_keepalive_interval: default_persistent_keepalive(),
838            transport_mode: TransportMode::default(),
839            wire_ingress: false,
840            ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
841            advertise_services: Vec::new(),
842            advertise_app_connector: false,
843            auto_update_apply: None,
844            auto_update_check: false,
845            operator_user: None,
846            node_nickname: None,
847            posture_checking: false,
848            run_web_client: false,
849            exit_node_allow_lan_access: false,
850            remote_config: false,
851            local_api: None,
852            reauth_on_expiry: default_true(),
853            allow_http_key_fetch: false,
854        }
855    }
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    fn v4(s: &str) -> ipnet::IpNet {
863        ipnet::IpNet::V4(s.parse().unwrap())
864    }
865
866    fn v6(s: &str) -> ipnet::IpNet {
867        ipnet::IpNet::V6(s.parse().unwrap())
868    }
869
870    #[test]
871    fn default_advertises_nothing() {
872        let cfg = Config::default();
873        assert!(cfg.advertised_routes().is_empty());
874    }
875
876    #[test]
877    fn advertises_v4_subnet_routes() {
878        let cfg = Config {
879            advertise_routes: vec![v4("10.0.0.0/24"), v4("192.168.1.0/24")],
880            ..Default::default()
881        };
882        assert_eq!(
883            cfg.advertised_routes(),
884            vec![v4("10.0.0.0/24"), v4("192.168.1.0/24")]
885        );
886    }
887
888    #[test]
889    fn exit_node_adds_default_v4_route() {
890        let cfg = Config {
891            advertise_exit_node: true,
892            ..Default::default()
893        };
894        assert_eq!(cfg.advertised_routes(), vec![v4("0.0.0.0/0")]);
895    }
896
897    #[test]
898    fn v6_prefixes_are_dropped() {
899        let cfg = Config {
900            advertise_routes: vec![v4("10.0.0.0/24"), v6("fd00::/64")],
901            ..Default::default()
902        };
903        // IPv6-off: only the v4 prefix survives.
904        assert_eq!(cfg.advertised_routes(), vec![v4("10.0.0.0/24")]);
905    }
906
907    #[test]
908    fn exit_node_never_advertises_v6_default() {
909        let cfg = Config {
910            advertise_routes: vec![v6("::/0")],
911            advertise_exit_node: true,
912            ..Default::default()
913        };
914        // ::/0 is dropped; only the v4 default route is advertised.
915        assert_eq!(cfg.advertised_routes(), vec![v4("0.0.0.0/0")]);
916    }
917
918    #[test]
919    fn default_is_ephemeral() {
920        // Preserves the historical hardcoded behavior; persistent nodes must opt out explicitly.
921        assert!(Config::default().ephemeral);
922    }
923
924    #[test]
925    fn ephemeral_deserializes_default_true_when_absent() {
926        // A config that predates the field still registers ephemeral.
927        let cfg: Config = serde_json::from_str(r#"{"server_url":"https://example.com/"}"#).unwrap();
928        assert!(cfg.ephemeral);
929    }
930
931    #[test]
932    fn ephemeral_can_be_disabled_for_persistent_nodes() {
933        let cfg: Config =
934            serde_json::from_str(r#"{"server_url":"https://example.com/","ephemeral":false}"#)
935                .unwrap();
936        assert!(!cfg.ephemeral);
937    }
938
939    #[test]
940    fn tags_default_empty_and_deserialize() {
941        let cfg: Config =
942            serde_json::from_str(r#"{"server_url":"https://example.com/","tags":["tag:exit"]}"#)
943                .unwrap();
944        assert_eq!(cfg.tags, vec!["tag:exit".to_owned()]);
945        assert!(Config::default().tags.is_empty());
946    }
947
948    #[test]
949    fn advertises_no_services_without_peerapi_port() {
950        // Fail-closed default: no peerAPI port means no services advertised.
951        assert!(Config::default().advertised_services().is_empty());
952    }
953
954    #[test]
955    fn advertises_peerapi4_and_dns_proxy_when_port_set() {
956        use ts_control_serde::ServiceProto;
957
958        let cfg = Config {
959            peerapi_port: Some(8080),
960            ..Default::default()
961        };
962        let services = cfg.advertised_services();
963        assert_eq!(services.len(), 2);
964
965        // peerapi4 carries the real bind port.
966        assert_eq!(services[0].proto, ServiceProto::PeerApi4);
967        assert_eq!(services[0].port, 8080);
968
969        // peerapi-dns-proxy always advertises port 1 (Go quirk).
970        assert_eq!(services[1].proto, ServiceProto::PeerApiDnsProxy);
971        assert_eq!(services[1].port, 1);
972    }
973
974    #[test]
975    fn peerapi_port_deserializes_default_none() {
976        let cfg: Config = serde_json::from_str(r#"{"server_url":"https://example.com/"}"#).unwrap();
977        assert_eq!(cfg.peerapi_port, None);
978    }
979
980    #[test]
981    fn advertise_services_default_empty() {
982        assert!(Config::default().advertise_services.is_empty());
983        assert!(Config::default().advertised_vip_services().is_empty());
984    }
985
986    #[test]
987    fn advertise_services_deserializes() {
988        let cfg: Config = serde_json::from_str(
989            r#"{"server_url":"https://example.com/","advertise_services":["svc:samba"]}"#,
990        )
991        .unwrap();
992        assert_eq!(cfg.advertise_services, vec!["svc:samba".to_owned()]);
993    }
994
995    #[test]
996    fn advertised_vip_services_validates_and_drops_bad_names() {
997        let cfg = Config {
998            advertise_services: vec![
999                "svc:good".to_owned(),
1000                "bad-no-prefix".to_owned(),
1001                "svc:-bad-label".to_owned(),
1002            ],
1003            ..Default::default()
1004        };
1005        let svcs = cfg.advertised_vip_services();
1006        assert_eq!(svcs.len(), 1);
1007        assert_eq!(svcs[0].name, "svc:good");
1008        // All-ports default range, active.
1009        assert_eq!(svcs[0].ports.len(), 1);
1010        assert_eq!(svcs[0].ports[0].first, 0);
1011        assert_eq!(svcs[0].ports[0].last, 65535);
1012        assert!(svcs[0].active);
1013    }
1014
1015    #[test]
1016    fn services_hash_empty_is_empty_string() {
1017        assert_eq!(services_hash(&[]), "");
1018    }
1019
1020    #[test]
1021    fn services_hash_is_order_independent() {
1022        let a = Config {
1023            advertise_services: vec!["svc:a".to_owned(), "svc:b".to_owned()],
1024            ..Default::default()
1025        };
1026        let b = Config {
1027            advertise_services: vec!["svc:b".to_owned(), "svc:a".to_owned()],
1028            ..Default::default()
1029        };
1030        let ha = services_hash(&a.advertised_vip_services());
1031        let hb = services_hash(&b.advertised_vip_services());
1032        assert_eq!(ha, hb);
1033        assert!(!ha.is_empty());
1034    }
1035
1036    #[test]
1037    fn services_hash_changes_with_set() {
1038        let one = Config {
1039            advertise_services: vec!["svc:a".to_owned()],
1040            ..Default::default()
1041        };
1042        let two = Config {
1043            advertise_services: vec!["svc:a".to_owned(), "svc:b".to_owned()],
1044            ..Default::default()
1045        };
1046        assert_ne!(
1047            services_hash(&one.advertised_vip_services()),
1048            services_hash(&two.advertised_vip_services())
1049        );
1050    }
1051
1052    #[test]
1053    fn services_hash_known_answer() {
1054        // KAT: pin the hash of a single all-ports `svc:samba` so a future serialization change
1055        // (field order, whitespace) that would silently break the node's own change-detection fails
1056        // this test. The hash is a SELF-CONSISTENCY TOKEN: this node computes it, sends it in
1057        // `HostInfo.ServicesHash`, and echoes the same value in `C2NVIPServicesResponse.ServicesHash`;
1058        // control treats it as opaque and only refetches when it CHANGES — control never recomputes
1059        // it, so the node only needs to be internally consistent (it is — one `services_hash`).
1060        //
1061        // It is NOT byte-equal to Go `vipServiceHash` and is not meant to be: Go does
1062        // `json.NewEncoder(sha256).Encode(services)` which (a) appends a trailing `\n` that
1063        // `serde_json::to_vec` here does not, and (b) Go's advertise path (`vipServicesFromPrefsLocked`)
1064        // leaves `Ports` nil → `"Ports":null`, whereas this fork injects an explicit all-ports
1065        // `ProtoPortRange` → `"Ports":["*"]`. (The element form IS now Go-correct — `ProtoPortRange`
1066        // serializes as the TextMarshaler string `"*"`, not a `{Proto,First,Last}` object — which is
1067        // what moved this value from the old object-form hash.) Full Go-faithful ServicesHash is
1068        // tracked separately; benign because the token is opaque to control.
1069        let cfg = Config {
1070            advertise_services: vec!["svc:samba".to_owned()],
1071            ..Default::default()
1072        };
1073        let hash = services_hash(&cfg.advertised_vip_services());
1074        // 64 hex chars = SHA-256.
1075        assert_eq!(hash.len(), 64);
1076        assert!(hash.bytes().all(|b| b.is_ascii_hexdigit()));
1077        assert_eq!(
1078            hash,
1079            "9593a969d3df19c81e5c47a5caeca701ab60b732b99004f15aa00384d922c40c"
1080        );
1081    }
1082
1083    /// All eight up/set pref fields default off/None on a fresh `ts_control::Config`: the two
1084    /// advertise-side ones (`advertise_app_connector`, `auto_update_apply`) and the six store-only
1085    /// carried prefs. Fail-closed: a default node advertises no app-connector / auto-update and
1086    /// carries no operator/nickname/posture/webclient/LAN-access preference.
1087    #[test]
1088    fn up_set_pref_fields_default_off() {
1089        let cfg = Config::default();
1090        // Advertise-side.
1091        assert!(!cfg.advertise_app_connector);
1092        assert_eq!(cfg.auto_update_apply, None);
1093        // Store-only carried prefs.
1094        assert!(!cfg.auto_update_check);
1095        assert_eq!(cfg.operator_user, None);
1096        assert_eq!(cfg.node_nickname, None);
1097        assert!(!cfg.posture_checking);
1098        assert!(!cfg.run_web_client);
1099        assert!(!cfg.exit_node_allow_lan_access);
1100    }
1101
1102    /// End-to-end: a `Config` with `advertise_app_connector` / `auto_update_apply` set drives the
1103    /// `HostInfo.AppConnector` / `HostInfo.AllowsUpdate` wire fields through the SAME expressions the
1104    /// streaming map request (`client.rs`) and registration (`register.rs`) use. Guards that the
1105    /// advertise fields reach the wire, and that the default config omits both keys.
1106    #[test]
1107    fn advertise_prefs_drive_host_info_wire_fields() {
1108        use crate::map_request_builder::MapRequestBuilder;
1109
1110        let node_state = ts_keys::NodeState::generate();
1111
1112        // Advertising config: mirrors `.app_connector(config.advertise_app_connector)` and
1113        // `.allows_update(config.auto_update_apply == Some(true))` from client.rs.
1114        let cfg = Config {
1115            advertise_app_connector: true,
1116            auto_update_apply: Some(true),
1117            ..Default::default()
1118        };
1119        let req = MapRequestBuilder::new(&node_state)
1120            .app_connector(cfg.advertise_app_connector)
1121            .allows_update(cfg.auto_update_apply == Some(true))
1122            .build();
1123        let hi = req.host_info.unwrap();
1124        assert_eq!(hi.app_connector, Some(true));
1125        assert!(hi.allows_update);
1126        let v = serde_json::to_value(&hi).unwrap();
1127        assert_eq!(
1128            v.get("AppConnector").and_then(serde_json::Value::as_bool),
1129            Some(true)
1130        );
1131        assert_eq!(
1132            v.get("AllowsUpdate").and_then(serde_json::Value::as_bool),
1133            Some(true)
1134        );
1135
1136        // Default config (advertise off): `AppConnector` is sent as `false` (Go calls
1137        // `hi.AppConnector.Set(advertise)` unconditionally, and `.Set(false)` marshals to `false`, not
1138        // omitted), while `AllowsUpdate` (a plain `omitzero` bool) IS omitted when false. This
1139        // asymmetry matches Go's wire bytes exactly: a default node sends `AppConnector:false` but no
1140        // `AllowsUpdate` key.
1141        let cfg = Config::default();
1142        let req = MapRequestBuilder::new(&node_state)
1143            .app_connector(cfg.advertise_app_connector)
1144            .allows_update(cfg.auto_update_apply == Some(true))
1145            .build();
1146        let hi = req.host_info.unwrap();
1147        assert_eq!(hi.app_connector, Some(false));
1148        assert!(!hi.allows_update);
1149        let v = serde_json::to_value(&hi).unwrap();
1150        assert_eq!(
1151            v.get("AppConnector").and_then(serde_json::Value::as_bool),
1152            Some(false),
1153            "default node sends AppConnector:false (Go .Set(false)), not an omitted key"
1154        );
1155        assert!(
1156            v.get("AllowsUpdate").is_none(),
1157            "AllowsUpdate is an omitzero bool, omitted when false"
1158        );
1159
1160        // `auto_update_apply == Some(false)` advertises NO update (AllowsUpdate stays unset),
1161        // matching the `== Some(true)` gate.
1162        let cfg = Config {
1163            auto_update_apply: Some(false),
1164            ..Default::default()
1165        };
1166        let req = MapRequestBuilder::new(&node_state)
1167            .allows_update(cfg.auto_update_apply == Some(true))
1168            .build();
1169        assert!(!req.host_info.unwrap().allows_update);
1170    }
1171
1172    /// The pref fields deserialize from their snake_case keys (a daemon persists the config as JSON)
1173    /// and a config that predates the fields still loads with them defaulted off (the `#[serde(default)]`
1174    /// on each).
1175    #[test]
1176    fn up_set_pref_fields_deserialize_and_default_when_absent() {
1177        // Absent: defaults apply.
1178        let cfg: Config = serde_json::from_str(r#"{"server_url":"https://example.com/"}"#).unwrap();
1179        assert!(!cfg.advertise_app_connector);
1180        assert_eq!(cfg.auto_update_apply, None);
1181        assert!(!cfg.posture_checking);
1182        assert_eq!(cfg.operator_user, None);
1183
1184        // Present: parsed.
1185        let cfg: Config = serde_json::from_str(
1186            r#"{"server_url":"https://example.com/","advertise_app_connector":true,"auto_update_apply":true,"auto_update_check":true,"operator_user":"alice","node_nickname":"laptop","posture_checking":true,"run_web_client":true,"exit_node_allow_lan_access":true}"#,
1187        )
1188        .unwrap();
1189        assert!(cfg.advertise_app_connector);
1190        assert_eq!(cfg.auto_update_apply, Some(true));
1191        assert!(cfg.auto_update_check);
1192        assert_eq!(cfg.operator_user.as_deref(), Some("alice"));
1193        assert_eq!(cfg.node_nickname.as_deref(), Some("laptop"));
1194        assert!(cfg.posture_checking);
1195        assert!(cfg.run_web_client);
1196        assert!(cfg.exit_node_allow_lan_access);
1197    }
1198
1199    #[test]
1200    fn deduplicates_routes() {
1201        let cfg = Config {
1202            advertise_routes: vec![v4("0.0.0.0/0"), v4("10.0.0.0/24")],
1203            advertise_exit_node: true,
1204            ..Default::default()
1205        };
1206        // Explicit 0.0.0.0/0 plus the exit-node default route collapse to one entry.
1207        assert_eq!(
1208            cfg.advertised_routes(),
1209            vec![v4("0.0.0.0/0"), v4("10.0.0.0/24")]
1210        );
1211    }
1212}