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/// Configuration for the control server.
98#[derive(Clone, serde::Serialize, serde::Deserialize)]
99pub struct Config {
100    /// The URL of the control server to connect to.
101    pub server_url: Url,
102
103    /// The hostname of the current node.
104    pub hostname: Option<String>,
105
106    /// A name for this type of client.
107    ///
108    /// This will be reported to the control server in the `HostInfo.App` field.
109    pub client_name: Option<String>,
110
111    /// Tags to request from the control server (`--advertise-tags` / `AdvertiseTags` in the Go
112    /// client).
113    ///
114    /// Sent as `HostInfo.RequestTags` on registration and on every map request, so a
115    /// tag-keyed control ACL (e.g. a self-hosted control plane's route auto-approver) can match this node. Each
116    /// entry is a full tag string including the `tag:` prefix (e.g. `tag:exit`). Defaults to
117    /// empty (claim no tags); an empty set omits the wire field entirely.
118    #[serde(default)]
119    pub tags: Vec<String>,
120
121    /// Whether this node registers as *ephemeral* (`--ephemeral` / `Ephemeral` in the Go client).
122    ///
123    /// An ephemeral node is garbage-collected by the control server shortly after it
124    /// disconnects. That is the right default for short-lived clients, but a persistent exit node
125    /// or subnet router must set this to `false` or it will be GC'd out of the tailnet while
126    /// briefly offline. Defaults to `true` to match the historical behavior of this client.
127    #[serde(default = "default_ephemeral")]
128    pub ephemeral: bool,
129
130    /// Whether to accept subnet routes advertised by peers (`--accept-routes` / `RouteAll` in the
131    /// Go client).
132    ///
133    /// When `false` (the default, matching the Go client on Linux/server platforms and our
134    /// fail-closed posture), only each peer's own tailnet addresses are routed; larger advertised
135    /// subnet routes are ignored. When `true`, traffic destined for an accepted subnet egresses
136    /// via the advertising peer.
137    ///
138    /// This is a client-side preference and is not read inside `ts_control`: control always sends
139    /// the full set of advertised routes, and the runtime trims them. It is carried here only to
140    /// be threaded through to the runtime's route filter.
141    #[serde(default)]
142    pub accept_routes: bool,
143
144    /// Which peer (if any) to use as an exit node (`--exit-node` / `ExitNodeID` in the Go client).
145    ///
146    /// The selector may name the peer by stable id, tailnet IP, or MagicDNS name (see
147    /// [`ExitNodeSelector`](crate::ExitNodeSelector)); it is resolved against the live peer set on
148    /// every route rebuild, so an IP/name selection follows the peer across netmap changes. When
149    /// set and resolvable, the selected peer's advertised default route (`0.0.0.0/0` / `::/0`) is
150    /// installed so internet-bound traffic egresses through it. When `None` (the default) or
151    /// unresolvable, no peer receives a default route and internet-bound traffic is dropped
152    /// (fail-closed).
153    ///
154    /// Like [`accept_routes`](Config::accept_routes), this is a client-side preference not read
155    /// inside `ts_control`; it is carried here only to be threaded through to the runtime's route
156    /// filter.
157    #[serde(default)]
158    pub exit_node: Option<crate::ExitNodeSelector>,
159
160    /// Subnet routes to advertise to the control server (`--advertise-routes` / `RoutableIPs` in
161    /// the Go client).
162    ///
163    /// Unlike [`accept_routes`](Config::accept_routes)/[`exit_node`](Config::exit_node), this field
164    /// *is* read inside `ts_control`: it populates `HostInfo.RoutableIPs` on every map request so
165    /// the control server can grant this node as a subnet router. Defaults to empty (advertise
166    /// nothing — fail-closed). Only IPv4 prefixes are advertised; IPv6 prefixes are dropped to
167    /// uphold the IPv6-off posture (advertising a route we won't forward would be a black hole).
168    #[serde(default)]
169    pub advertise_routes: Vec<ipnet::IpNet>,
170
171    /// Whether to advertise this node as an exit node (`--advertise-exit-node` in the Go client).
172    ///
173    /// When `true`, the default route `0.0.0.0/0` is added to the advertised
174    /// [`routable_ips`](Config::advertise_routes) so the control server can grant this node as an
175    /// exit node, after which other peers may egress internet-bound traffic through our real IP.
176    /// Defaults to `false` (fail-closed): being an exit node means *other* peers' traffic leaves
177    /// via our real origin IP, so it must be explicit opt-in. IPv6 (`::/0`) is never advertised,
178    /// per the IPv6-off posture.
179    #[serde(default)]
180    pub advertise_exit_node: bool,
181
182    /// TCP ports the inbound forwarder accepts and splices to real OS sockets for every advertised
183    /// route (`advertise_routes` / `advertise_exit_node`).
184    ///
185    /// smoltcp has no all-port accept mode (see the `ts_forwarder` crate docs), so the forwarder
186    /// forwards a configured set of ports rather than the full 1–65535 range. Defaults to empty: a
187    /// node that advertises routes but configures no forward ports accepts inbound flows into its
188    /// dedicated forwarder netstack but forwards none of them (fail-closed — nothing is dialed).
189    #[serde(default)]
190    pub forward_tcp_ports: Vec<u16>,
191
192    /// UDP ports the inbound forwarder accepts and splices to real OS sockets for every advertised
193    /// route. See [`forward_tcp_ports`](Config::forward_tcp_ports); defaults to empty.
194    #[serde(default)]
195    pub forward_udp_ports: Vec<u16>,
196
197    /// Forward **all** TCP/UDP ports (1–65535) on every advertised route, like a Go subnet router
198    /// (`tailscale up --advertise-routes` forwards all ports), instead of the explicit
199    /// [`forward_tcp_ports`](Config::forward_tcp_ports) /
200    /// [`forward_udp_ports`](Config::forward_udp_ports) sets.
201    ///
202    /// smoltcp cannot wildcard-port-accept, so all-port mode is implemented with an on-demand
203    /// per-port listener manager driven by a raw-socket port observer on the dedicated forwarder
204    /// netstack (see the `ts_forwarder` crate docs). When `true`, the explicit port sets are
205    /// ignored. Anti-leak is unchanged: every flow still routes through the same
206    /// `RouteTable`→dialer chokepoint, so [`forward_exit_egress`](Config::forward_exit_egress) still
207    /// governs exit-node egress. Defaults to `false`.
208    #[serde(default)]
209    pub forward_all_ports: bool,
210
211    /// Whether exit-node (`0.0.0.0/0`) inbound flows are actually egressed via **this host's real
212    /// origin IP**.
213    ///
214    /// This is the anti-leak opt-in, kept separate from
215    /// [`advertise_exit_node`](Config::advertise_exit_node): advertising the default route only
216    /// makes control *offer* this node as an exit; it does not by itself egress a peer's traffic.
217    /// When `false` (the default, fail-closed), the forwarder uses a dialer that **structurally
218    /// refuses** exit-node egress — a `0.0.0.0/0` flow is dropped at dial time, never leaked out our
219    /// real IP. Set to `true` only on a node whose real IP *is* the intended egress (e.g. a
220    /// residential exit), never on a node whose host IP must stay hidden (e.g. a cloud VPS). Subnet
221    /// routes are dialed identically regardless of this flag.
222    #[serde(default)]
223    pub forward_exit_egress: bool,
224
225    /// Optional upstream proxy that exit-node egress is routed through, so the node egresses via
226    /// the proxy's IP rather than its own origin IP.
227    ///
228    /// Only consulted when [`forward_exit_egress`](Config::forward_exit_egress) is `true`. When
229    /// set, the runtime wires the forwarder with a proxy dialer (SOCKS5 / HTTP `CONNECT`) that
230    /// **fails closed** — any proxy connect or handshake failure drops the flow rather than falling
231    /// back to a direct host-IP dial, so the real origin IP never leaks. When `None` (the default)
232    /// and exit egress is enabled, egress uses this host's real IP (`HostExitDialer`).
233    ///
234    /// Like the other dataplane fields, this is a client-side preference not read inside
235    /// `ts_control`; it is carried here only to be threaded through to the runtime's dialer
236    /// selection. This is a product capability (residential-proxy egress) beyond strict tsnet
237    /// parity — see the repo's `AGENTS.md`/`CLAUDE.md`.
238    #[serde(default)]
239    pub exit_proxy: Option<ExitProxyConfig>,
240
241    /// The IPv4 peerAPI port this node binds to serve exit-node DoH (DNS-over-HTTPS) proxying for
242    /// peers that select it as their exit node (`peerapi4` + `peerapi-dns-proxy` services).
243    ///
244    /// When `Some(port)`, the runtime binds a peerAPI DoH server on this host's overlay IPv4
245    /// address at `port`, and registration / map requests advertise both the `peerapi4` service
246    /// (at `port`) and the `peerapi-dns-proxy` service (Go quirk: its advertised port is always
247    /// `1`) so peers know they can delegate DNS to us. When `None` (the default, fail-closed), no
248    /// peerAPI is run and no services are advertised — this node never offers DNS proxying.
249    ///
250    /// The DoH server always answers authoritative/overlay records (MagicDNS peer names,
251    /// `ExtraRecords`, PTR); *recursive* resolution to real upstream resolvers is gated separately
252    /// behind [`forward_exit_egress`](Config::forward_exit_egress), so a cloud exit node can serve
253    /// overlay DNS without ever exposing its real origin IP via a recursive lookup.
254    #[serde(default)]
255    pub peerapi_port: Option<u16>,
256
257    /// Filesystem directory that received Taildrop files land in, or `None` to disable Taildrop
258    /// (the default, fail-closed).
259    ///
260    /// When `Some(dir)` **and** [`peerapi_port`](Config::peerapi_port) is also set, the runtime
261    /// serves the Taildrop peerAPI route `PUT /v0/put/<name>` on the shared peerAPI listener, and
262    /// incoming files are written under `dir` (created if absent). When `None`, no Taildrop server
263    /// is run — a peer's `PUT` is refused. This is a pure on-disk destination: like the other
264    /// dataplane fields it is not read inside `ts_control`; it is carried here only to be threaded
265    /// into the runtime, which constructs the file store from it.
266    ///
267    /// Independently of the network server, the embedder consumes received files via the
268    /// `Device::taildrop_*` methods (Go exposes these over LocalAPI; this fork exposes them on the
269    /// device). With no `peerapi_port`, the store still exists for those read APIs but no peer can
270    /// deliver to it.
271    #[serde(default)]
272    pub taildrop_dir: Option<std::path::PathBuf>,
273
274    /// Per-direction TCP send/receive buffer size (bytes) for the userspace netstack, or `None` to
275    /// use the netstack default (256 KiB per direction, ~512 KiB per socket).
276    ///
277    /// smoltcp has no window auto-tuning, so this is the hard cap on a single flow's
278    /// bandwidth-delay product; raising it helps large model-API responses on high-RTT links, at
279    /// the cost of more memory per concurrent socket (each socket allocates this size for both rx
280    /// and tx). Like the other dataplane fields, this is a client-side preference not read inside
281    /// `ts_control`; it is carried here only to be threaded into the runtime's netstack
282    /// configuration.
283    #[serde(default)]
284    pub tcp_buffer_size: Option<usize>,
285
286    /// Whether IPv6 is enabled on the tailnet overlay. Defaults to `false` (IPv4-only).
287    ///
288    /// Like the other dataplane fields, this is a client-side preference not read inside
289    /// `ts_control`; it is carried here only to be threaded into the runtime's underlay socket,
290    /// disco candidate filter, netstack address assignment, and MagicDNS AAAA handling. It governs
291    /// only the overlay and never the exit-node / forwarder egress path, which stays IPv4-only
292    /// regardless to uphold the real-origin-IP isolation invariant.
293    #[serde(default)]
294    pub enable_ipv6: bool,
295
296    /// How the application overlay data path is realized: userspace netstack (default) or a real
297    /// kernel TUN interface. See [`TransportMode`].
298    ///
299    /// Like the other dataplane fields, this is a client-side preference not read inside
300    /// `ts_control`; it is carried here only to be threaded into the runtime, which builds either a
301    /// netstack actor or a TUN transport from it. `ts_control` must not depend on `ts_transport_tun`.
302    #[serde(default)]
303    pub transport_mode: TransportMode,
304
305    /// Whether to ask control to wire this node up server-side for Tailscale Funnel
306    /// (`HostInfo.WireIngress`, the capver-113 client→control Funnel signal), even when no Funnel
307    /// endpoint is currently active.
308    ///
309    /// Unlike the dataplane fields above, this one *is* read inside `ts_control`: it sets
310    /// `HostInfo.WireIngress` on registration and the streaming map request, asking control to
311    /// provision the DNS / ingress records a Funnel node needs so a later `serve`/funnel session
312    /// works immediately. It mirrors Go `tsnet`'s "would like to be wired up for Funnel" signal.
313    ///
314    /// This fork cannot yet *terminate* public Funnel ingress — [`crate::listen_funnel`] is
315    /// fail-closed (no client-side ACME engine, and a self-hosted control plane provides no public
316    /// ingress relay). So `HostInfo.IngressEnabled` (Funnel endpoints actually live) is never set;
317    /// only `WireIngress` is, and only when this flag is `true`. Defaults to `false` (fail-closed):
318    /// a node requests Funnel wiring only when explicitly opted in.
319    #[serde(default)]
320    pub wire_ingress: bool,
321
322    /// Live signal that this node currently has an active Funnel ingress listener
323    /// (`Device::listen_funnel` was called and its listener is up), driving `HostInfo.IngressEnabled`
324    /// on the streaming map request.
325    ///
326    /// Unlike [`wire_ingress`](Self::wire_ingress) (a static "please provision Funnel records" hint),
327    /// this is a *dynamic* flag: the runtime flips it `true` when a funnel listener starts serving and
328    /// back to `false` when it stops, so the next map request advertises `IngressEnabled` accordingly
329    /// (Go sets `HostInfo.IngressEnabled` only while Funnel endpoints are actually live, and
330    /// `IngressEnabled` implies `WireIngress`). Shared (`Arc`) with the runtime so the device can flip
331    /// it without rebuilding the config. Defaults to a fresh `false` (fail-closed: no live endpoint).
332    /// Not serialized — it is process-local runtime state, not persisted configuration.
333    #[serde(skip, default)]
334    pub ingress_active: std::sync::Arc<std::sync::atomic::AtomicBool>,
335
336    /// VIP services this node advertises that it **hosts** (`svc:<dns-label>` names), the
337    /// advertise side of Tailscale VIP services (Go `tsnet`'s `Hostinfo.ServicesHash` +
338    /// c2n `GET /vip-services`).
339    ///
340    /// Each entry is a full `svc:`-prefixed service name. This field *is* read inside `ts_control`:
341    /// the valid names ([`validate_service_name`](crate::validate_service_name) is applied
342    /// fail-closed; malformed names are dropped and logged) are hashed into `HostInfo.ServicesHash`
343    /// on every map request, and answered when control fetches the list via the c2n
344    /// `/vip-services` endpoint. Defaults to empty: with no entries the hash is `""` and behavior is
345    /// byte-for-byte the historical non-advertising path. Hosting a service additionally requires
346    /// control to assign it a VIP and the node to be tagged (the *consume* side, unchanged here).
347    #[serde(default)]
348    pub advertise_services: Vec<String>,
349
350    /// Allow fetching the control server's machine public key (`GET /key`) over plain **http** when
351    /// the [`server_url`](Config::server_url) is itself `http://`.
352    ///
353    /// By default (`false`) the `/key` fetch is always upgraded to `https`, even when the control
354    /// URL is `http://` — matching Tailscale's posture that the unauthenticated key bootstrap must
355    /// be TLS-protected. That upgrade makes registration **fail** against a control plane that only
356    /// serves plain http (e.g. a self-hosted Headscale exposed over a `http://host:port` LAN
357    /// endpoint / NodePort with no TLS), even though the rest of the control connection already
358    /// honors the `http` scheme. Set this to `true` for such a deployment to fetch `/key` over the
359    /// same `http` scheme as the control URL.
360    ///
361    /// Security: only enable this when you control both ends and the control plane is reachable
362    /// over a trusted network path — an on-path attacker could otherwise substitute the control
363    /// key. It has no effect when `server_url` is `https://` (the fetch stays https regardless).
364    /// Fail-closed default is `false`.
365    #[serde(default)]
366    pub allow_http_key_fetch: bool,
367}
368
369impl Config {
370    /// Get the full client name as a string.
371    ///
372    /// This takes the form `tailscale-rs ({client_name})`, where the parenthetical is only
373    /// provided if self.client_name is set.
374    pub fn format_client_name(&self) -> String {
375        let mut full_name = "tailscale-rs".to_owned();
376        if let Some(client_name) = &self.client_name {
377            full_name.push_str(&format!(" ({client_name})"));
378        }
379
380        full_name
381    }
382
383    /// Compute the set of IP prefixes to advertise in `HostInfo.RoutableIPs`, combining
384    /// [`advertise_routes`](Config::advertise_routes) with the exit-node default route when
385    /// [`advertise_exit_node`](Config::advertise_exit_node) is set.
386    ///
387    /// IPv6 prefixes are filtered out (IPv6-off posture): we never forward IPv6, so advertising an
388    /// IPv6 route would create a black hole. The exit-node default route is therefore `0.0.0.0/0`
389    /// only, never `::/0`. The result is deduplicated and order-preserving; an empty result means
390    /// "advertise nothing", and callers omit the wire field entirely.
391    pub fn advertised_routes(&self) -> Vec<ipnet::IpNet> {
392        let mut routes: Vec<ipnet::IpNet> = Vec::new();
393        let mut push_unique = |net: ipnet::IpNet| {
394            if !routes.contains(&net) {
395                routes.push(net);
396            }
397        };
398
399        for net in &self.advertise_routes {
400            // IPv6-off: drop v6 prefixes so we never advertise a route we won't forward.
401            if matches!(net, ipnet::IpNet::V4(_)) {
402                push_unique(*net);
403            } else {
404                tracing::warn!(prefix = %net, "dropping IPv6 advertise_routes prefix (IPv6-off posture)");
405            }
406        }
407
408        if self.advertise_exit_node {
409            let default_v4 = ipnet::IpNet::V4(
410                ipnet::Ipv4Net::new(core::net::Ipv4Addr::UNSPECIFIED, 0)
411                    .expect("0.0.0.0/0 is a valid prefix"),
412            );
413            push_unique(default_v4);
414        }
415
416        routes
417    }
418
419    /// The services to advertise in `HostInfo.Services`, derived from
420    /// [`peerapi_port`](Config::peerapi_port).
421    ///
422    /// When a peerAPI port is configured, we advertise the `peerapi4` service at that port plus the
423    /// `peerapi-dns-proxy` service (whose advertised port is always `1`, matching the Go client's
424    /// quirk) so peers learn they can delegate exit-node DNS to us. When `None`, the result is empty
425    /// and callers omit the `HostInfo.Services` wire field entirely (advertise no services). IPv6
426    /// peerAPI (`peerapi6`) is never advertised, per the IPv6-off posture.
427    pub fn advertised_services(&self) -> Vec<ts_control_serde::Service<'static>> {
428        use ts_control_serde::{Service, ServiceProto};
429
430        let Some(port) = self.peerapi_port else {
431            return Vec::new();
432        };
433
434        vec![
435            Service {
436                proto: ServiceProto::PeerApi4,
437                port,
438                description: "tailscale-rs",
439            },
440            Service {
441                // Go quirk: the peerapi-dns-proxy service always advertises port 1.
442                proto: ServiceProto::PeerApiDnsProxy,
443                port: 1,
444                description: "tailscale-rs",
445            },
446        ]
447    }
448
449    /// The validated set of VIP services this node advertises that it hosts, derived from
450    /// [`advertise_services`](Config::advertise_services).
451    ///
452    /// Each configured name is validated with
453    /// [`validate_service_name`](crate::validate_service_name) (fail-closed: a name that is not a
454    /// well-formed `svc:<dns-label>` is dropped with a warning, never advertised). Each surviving
455    /// service is advertised on **all ports** (a single `0/0..=65535`
456    /// [`ProtoPortRange`](ts_control_serde::ProtoPortRange), matching
457    /// Go's default `ServicePortRange()` when no explicit ports are configured) and marked active.
458    /// The result is the canonical input to both [`services_hash`] and the c2n `/vip-services`
459    /// response. An empty config yields an empty `Vec` (advertise nothing — the hash is `""`).
460    pub fn advertised_vip_services(&self) -> Vec<ts_control_serde::VipServiceOwned> {
461        use ts_control_serde::{ProtoPortRange, VipServiceOwned};
462
463        self.advertise_services
464            .iter()
465            .filter_map(|name| {
466                if crate::validate_service_name(name).is_none() {
467                    tracing::warn!(
468                        service = %name,
469                        "dropping invalid advertise_services name (expected svc:<dns-label>)"
470                    );
471                    return None;
472                }
473                Some(VipServiceOwned {
474                    name: name.clone(),
475                    // All ports: proto 0 (all protocols), full 0..=65535 span — Go's default
476                    // ServicePortRange() for a service with no explicit port restriction.
477                    ports: vec![ProtoPortRange {
478                        proto: 0,
479                        first: 0,
480                        last: 65535,
481                    }],
482                    active: true,
483                })
484            })
485            .collect()
486    }
487}
488
489/// Compute the `HostInfo.ServicesHash` for a node's advertised VIP services, mirroring Go's
490/// `vipServiceHash`.
491///
492/// The services are sorted by name, serialized to canonical (whitespace-free) JSON as a
493/// [`ts_control_serde::VipServiceOwned`] list, SHA-256'd, and hex-encoded. An empty list hashes to
494/// the empty string `""` (the "no services advertised" sentinel, which omits/clears the wire
495/// field). The hash is byte-stable and order-independent: the same set in any input order yields the
496/// same value, so control reliably refetches only on a genuine change.
497///
498/// Uses `ring`'s SHA-256 (the same crypto backend the rest of the stack links — no aws-lc-rs /
499/// openssl is introduced).
500pub fn services_hash(services: &[ts_control_serde::VipServiceOwned]) -> String {
501    if services.is_empty() {
502        return String::new();
503    }
504
505    let mut sorted = services.to_vec();
506    sorted.sort_by(|a, b| a.name.cmp(&b.name));
507
508    // Canonical, whitespace-free JSON so the digest is byte-stable across builds.
509    let json = serde_json::to_vec(&sorted).expect("VipServiceOwned list always serializes");
510    let digest = ring::digest::digest(&ring::digest::SHA256, &json);
511
512    let mut hex = String::with_capacity(digest.as_ref().len() * 2);
513    for byte in digest.as_ref() {
514        hex.push_str(&format!("{byte:02x}"));
515    }
516    hex
517}
518
519impl Debug for Config {
520    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
521        f.debug_struct("Config")
522            .field("hostname", &self.hostname)
523            .field("server_url", &self.server_url.as_str())
524            .field("client_name", &self.client_name)
525            .finish()
526    }
527}
528
529impl Default for Config {
530    fn default() -> Self {
531        Self {
532            server_url: DEFAULT_CONTROL_SERVER.clone(),
533            hostname: gethostname::gethostname().into_string().ok(),
534            client_name: None,
535            tags: Default::default(),
536            ephemeral: default_ephemeral(),
537            accept_routes: false,
538            exit_node: None,
539            advertise_routes: Vec::new(),
540            advertise_exit_node: false,
541            forward_tcp_ports: Vec::new(),
542            forward_udp_ports: Vec::new(),
543            forward_all_ports: false,
544            forward_exit_egress: false,
545            exit_proxy: None,
546            peerapi_port: None,
547            taildrop_dir: None,
548            tcp_buffer_size: None,
549            enable_ipv6: false,
550            transport_mode: TransportMode::default(),
551            wire_ingress: false,
552            ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
553            advertise_services: Vec::new(),
554            allow_http_key_fetch: false,
555        }
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    fn v4(s: &str) -> ipnet::IpNet {
564        ipnet::IpNet::V4(s.parse().unwrap())
565    }
566
567    fn v6(s: &str) -> ipnet::IpNet {
568        ipnet::IpNet::V6(s.parse().unwrap())
569    }
570
571    #[test]
572    fn default_advertises_nothing() {
573        let cfg = Config::default();
574        assert!(cfg.advertised_routes().is_empty());
575    }
576
577    #[test]
578    fn advertises_v4_subnet_routes() {
579        let cfg = Config {
580            advertise_routes: vec![v4("10.0.0.0/24"), v4("192.168.1.0/24")],
581            ..Default::default()
582        };
583        assert_eq!(
584            cfg.advertised_routes(),
585            vec![v4("10.0.0.0/24"), v4("192.168.1.0/24")]
586        );
587    }
588
589    #[test]
590    fn exit_node_adds_default_v4_route() {
591        let cfg = Config {
592            advertise_exit_node: true,
593            ..Default::default()
594        };
595        assert_eq!(cfg.advertised_routes(), vec![v4("0.0.0.0/0")]);
596    }
597
598    #[test]
599    fn v6_prefixes_are_dropped() {
600        let cfg = Config {
601            advertise_routes: vec![v4("10.0.0.0/24"), v6("fd00::/64")],
602            ..Default::default()
603        };
604        // IPv6-off: only the v4 prefix survives.
605        assert_eq!(cfg.advertised_routes(), vec![v4("10.0.0.0/24")]);
606    }
607
608    #[test]
609    fn exit_node_never_advertises_v6_default() {
610        let cfg = Config {
611            advertise_routes: vec![v6("::/0")],
612            advertise_exit_node: true,
613            ..Default::default()
614        };
615        // ::/0 is dropped; only the v4 default route is advertised.
616        assert_eq!(cfg.advertised_routes(), vec![v4("0.0.0.0/0")]);
617    }
618
619    #[test]
620    fn default_is_ephemeral() {
621        // Preserves the historical hardcoded behavior; persistent nodes must opt out explicitly.
622        assert!(Config::default().ephemeral);
623    }
624
625    #[test]
626    fn ephemeral_deserializes_default_true_when_absent() {
627        // A config that predates the field still registers ephemeral.
628        let cfg: Config = serde_json::from_str(r#"{"server_url":"https://example.com/"}"#).unwrap();
629        assert!(cfg.ephemeral);
630    }
631
632    #[test]
633    fn ephemeral_can_be_disabled_for_persistent_nodes() {
634        let cfg: Config =
635            serde_json::from_str(r#"{"server_url":"https://example.com/","ephemeral":false}"#)
636                .unwrap();
637        assert!(!cfg.ephemeral);
638    }
639
640    #[test]
641    fn tags_default_empty_and_deserialize() {
642        let cfg: Config =
643            serde_json::from_str(r#"{"server_url":"https://example.com/","tags":["tag:exit"]}"#)
644                .unwrap();
645        assert_eq!(cfg.tags, vec!["tag:exit".to_owned()]);
646        assert!(Config::default().tags.is_empty());
647    }
648
649    #[test]
650    fn advertises_no_services_without_peerapi_port() {
651        // Fail-closed default: no peerAPI port means no services advertised.
652        assert!(Config::default().advertised_services().is_empty());
653    }
654
655    #[test]
656    fn advertises_peerapi4_and_dns_proxy_when_port_set() {
657        use ts_control_serde::ServiceProto;
658
659        let cfg = Config {
660            peerapi_port: Some(8080),
661            ..Default::default()
662        };
663        let services = cfg.advertised_services();
664        assert_eq!(services.len(), 2);
665
666        // peerapi4 carries the real bind port.
667        assert_eq!(services[0].proto, ServiceProto::PeerApi4);
668        assert_eq!(services[0].port, 8080);
669
670        // peerapi-dns-proxy always advertises port 1 (Go quirk).
671        assert_eq!(services[1].proto, ServiceProto::PeerApiDnsProxy);
672        assert_eq!(services[1].port, 1);
673    }
674
675    #[test]
676    fn peerapi_port_deserializes_default_none() {
677        let cfg: Config = serde_json::from_str(r#"{"server_url":"https://example.com/"}"#).unwrap();
678        assert_eq!(cfg.peerapi_port, None);
679    }
680
681    #[test]
682    fn advertise_services_default_empty() {
683        assert!(Config::default().advertise_services.is_empty());
684        assert!(Config::default().advertised_vip_services().is_empty());
685    }
686
687    #[test]
688    fn advertise_services_deserializes() {
689        let cfg: Config = serde_json::from_str(
690            r#"{"server_url":"https://example.com/","advertise_services":["svc:samba"]}"#,
691        )
692        .unwrap();
693        assert_eq!(cfg.advertise_services, vec!["svc:samba".to_owned()]);
694    }
695
696    #[test]
697    fn advertised_vip_services_validates_and_drops_bad_names() {
698        let cfg = Config {
699            advertise_services: vec![
700                "svc:good".to_owned(),
701                "bad-no-prefix".to_owned(),
702                "svc:-bad-label".to_owned(),
703            ],
704            ..Default::default()
705        };
706        let svcs = cfg.advertised_vip_services();
707        assert_eq!(svcs.len(), 1);
708        assert_eq!(svcs[0].name, "svc:good");
709        // All-ports default range, active.
710        assert_eq!(svcs[0].ports.len(), 1);
711        assert_eq!(svcs[0].ports[0].first, 0);
712        assert_eq!(svcs[0].ports[0].last, 65535);
713        assert!(svcs[0].active);
714    }
715
716    #[test]
717    fn services_hash_empty_is_empty_string() {
718        assert_eq!(services_hash(&[]), "");
719    }
720
721    #[test]
722    fn services_hash_is_order_independent() {
723        let a = Config {
724            advertise_services: vec!["svc:a".to_owned(), "svc:b".to_owned()],
725            ..Default::default()
726        };
727        let b = Config {
728            advertise_services: vec!["svc:b".to_owned(), "svc:a".to_owned()],
729            ..Default::default()
730        };
731        let ha = services_hash(&a.advertised_vip_services());
732        let hb = services_hash(&b.advertised_vip_services());
733        assert_eq!(ha, hb);
734        assert!(!ha.is_empty());
735    }
736
737    #[test]
738    fn services_hash_changes_with_set() {
739        let one = Config {
740            advertise_services: vec!["svc:a".to_owned()],
741            ..Default::default()
742        };
743        let two = Config {
744            advertise_services: vec!["svc:a".to_owned(), "svc:b".to_owned()],
745            ..Default::default()
746        };
747        assert_ne!(
748            services_hash(&one.advertised_vip_services()),
749            services_hash(&two.advertised_vip_services())
750        );
751    }
752
753    #[test]
754    fn services_hash_known_answer() {
755        // KAT: pin the hash of a single all-ports `svc:samba` so a future serialization change
756        // (field order, whitespace) that would silently break control's change-detection fails
757        // this test. Computed once from this very implementation.
758        let cfg = Config {
759            advertise_services: vec!["svc:samba".to_owned()],
760            ..Default::default()
761        };
762        let hash = services_hash(&cfg.advertised_vip_services());
763        // 64 hex chars = SHA-256.
764        assert_eq!(hash.len(), 64);
765        assert!(hash.bytes().all(|b| b.is_ascii_hexdigit()));
766        assert_eq!(
767            hash,
768            "f96574bfe9f637164f5d7fff37ea169b3aa86b12e25d98f5c3b7fd049839f4e9"
769        );
770    }
771
772    #[test]
773    fn deduplicates_routes() {
774        let cfg = Config {
775            advertise_routes: vec![v4("0.0.0.0/0"), v4("10.0.0.0/24")],
776            advertise_exit_node: true,
777            ..Default::default()
778        };
779        // Explicit 0.0.0.0/0 plus the exit-node default route collapse to one entry.
780        assert_eq!(
781            cfg.advertised_routes(),
782            vec![v4("0.0.0.0/0"), v4("10.0.0.0/24")]
783        );
784    }
785}