ts_control/node.rs
1//! The parsed domain [`Node`] model: a tailnet node decoded from the wire (`tailcfg.Node`).
2//!
3//! [`Node`] is the owned, validated form the rest of the fork reasons about (addresses, keys, caps,
4//! accepted routes, peerAPI/VIP services), built from the borrow-bound `ts_control_serde::Node` via
5//! the [`From`] impl. It also carries the route/exit-node/funnel predicates ([`Node::is_subnet_route`],
6//! [`Node::routes_to_install`], [`Node::can_funnel`]) and the [`ExitNodeSelector`] resolution.
7//!
8//! Fail-closed: route, funnel, and service-host gates all deny on a missing/malformed input.
9
10use core::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
11use std::collections::BTreeMap;
12
13use chrono::{DateTime, Utc};
14use ts_capabilityversion::CapabilityVersion;
15use ts_keys::{DiscoPublicKey, MachinePublicKey, NodePublicKey};
16
17use crate::dns::Resolver;
18
19/// An owned node-capability map (`Node.CapMap` in Go: `map[NodeCapability][]RawMessage`).
20///
21/// Keys are capability names or URLs (e.g. `"funnel"`, `"https"`, or
22/// `"https://tailscale.com/cap/funnel-ports?ports=443,8443"`); values are the raw JSON-encoded
23/// argument blobs for that capability (often empty). Stored *owned* because the wire form
24/// ([`ts_control_serde::Node::cap_map`]) borrows from the decode buffer, whereas the domain
25/// [`Node`] outlives it. Funnel gating only inspects the keys (see [`Node::can_funnel`] and
26/// [`Node::check_funnel_port`]); the values are retained for capabilities that carry argument data.
27pub type NodeCapMap = BTreeMap<String, Vec<String>>;
28
29/// Whether `addr` falls in a range Tailscale assigns to nodes: the CGNAT range for IPv4
30/// (`100.64.0.0/10`, excluding the ChromeOS VM carve-out `100.115.92.0/23`) and the Tailscale
31/// ULA for IPv6 (`fd7a:115c:a1e0::/48`).
32///
33/// Mirrors `tsaddr.IsTailscaleIP` in the Go client. Used to tell a peer's own node addresses
34/// (always single Tailscale IPs) apart from the larger subnet routes it advertises.
35pub fn is_tailscale_ip(addr: IpAddr) -> bool {
36 match addr {
37 IpAddr::V4(v4) => {
38 let cgnat = ipnet::Ipv4Net::new(Ipv4Addr::new(100, 64, 0, 0), 10).unwrap();
39 let chromeos = ipnet::Ipv4Net::new(Ipv4Addr::new(100, 115, 92, 0), 23).unwrap();
40 cgnat.contains(&v4) && !chromeos.contains(&v4)
41 }
42 IpAddr::V6(v6) => {
43 let ula = ipnet::Ipv6Net::new(Ipv6Addr::new(0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0), 48)
44 .unwrap();
45 ula.contains(&v6)
46 }
47 }
48}
49
50/// The unique id of a node.
51pub type Id = i64;
52
53/// The stable ID of a node.
54#[derive(
55 Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
56)]
57pub struct StableId(pub String);
58
59/// How this node selects which peer to use as its exit node (`--exit-node` in the Go client).
60///
61/// Mirrors the Go client's `--exit-node`, which accepts a tailnet IP, a MagicDNS name, or a stable
62/// node ID, and resolves it to a `StableNodeID` (`resolveExitNodeIPLocked`). We keep the selector
63/// *unresolved* and re-run [`ExitNodeSelector::resolve`] against the live peer set on every route
64/// rebuild, so an IP- or name-based selection follows the peer as the netmap changes (e.g. the
65/// exit node re-registers under a new stable id).
66///
67/// A selector can be parsed from a string with [`str::parse`]/[`FromStr`](core::str::FromStr),
68/// auto-detecting the variant the way the Go CLI's `--exit-node` does: a value that parses as an IP
69/// address becomes [`ExitNodeSelector::Ip`], anything else becomes [`ExitNodeSelector::Name`].
70/// Stable-id selection is available only by constructing [`ExitNodeSelector::StableId`] directly
71/// (it is not auto-detected, since a stable id is otherwise indistinguishable from a hostname).
72#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
73pub enum ExitNodeSelector {
74 /// Select the peer with this exact stable node id.
75 StableId(StableId),
76 /// Select the peer whose tailnet address is this IP.
77 Ip(IpAddr),
78 /// Select the peer matching this bare hostname or MagicDNS name (case-insensitive, optional
79 /// trailing dot), as per [`Node::matches_name`].
80 Name(String),
81}
82
83impl core::str::FromStr for ExitNodeSelector {
84 type Err = core::convert::Infallible;
85
86 /// Parse a selector from a string, auto-detecting IP vs. name (matching the Go CLI's
87 /// `--exit-node`). Parsing never fails: a non-IP string is taken as a MagicDNS name.
88 fn from_str(s: &str) -> Result<Self, Self::Err> {
89 Ok(match s.parse::<IpAddr>() {
90 Ok(ip) => ExitNodeSelector::Ip(ip),
91 Err(_) => ExitNodeSelector::Name(s.to_owned()),
92 })
93 }
94}
95
96impl ExitNodeSelector {
97 /// Resolve this selector to the stable id of the matching peer, if any, given the current set
98 /// of peers.
99 ///
100 /// Resolution is **deterministic**: if a selector somehow matches more than one peer (e.g. two
101 /// peers sharing a MagicDNS name during a transient netmap state), the peer with the smallest
102 /// [`StableId`] is chosen. This matters because both the outbound route table and the inbound
103 /// source filter resolve independently; a deterministic tiebreak guarantees they pick the
104 /// *same* peer, preserving the cryptokey-routing coupling that prevents source-spoofing.
105 ///
106 /// Returns `None` when no peer matches (a stale/typo'd selector). Callers treat `None` as
107 /// fail-closed: no peer is granted a default route, so internet-bound traffic is dropped.
108 pub fn resolve<'a>(&self, peers: impl Iterator<Item = &'a Node>) -> Option<StableId> {
109 peers
110 .filter(|node| match self {
111 ExitNodeSelector::StableId(id) => &node.stable_id == id,
112 ExitNodeSelector::Ip(ip) => node.tailnet_address.contains(*ip),
113 ExitNodeSelector::Name(name) => node.matches_name(name),
114 })
115 .map(|node| &node.stable_id)
116 .min()
117 .cloned()
118 }
119}
120
121/// A node in a tailnet.
122#[derive(Debug, Clone, PartialEq, Eq, Hash)]
123pub struct Node {
124 /// The node's id.
125 pub id: Id,
126 /// The node's stable id.
127 pub stable_id: StableId,
128
129 /// This node's hostname.
130 pub hostname: String,
131
132 /// The integer id of the user that owns this node (`Node.User` in Go). `0` when control sends
133 /// no owner (e.g. tagged/ACL nodes have no human owner). Join against the netmap's
134 /// `UserProfiles` table (accumulated by the runtime's peer tracker) to resolve a login/display
135 /// name — see the runtime `WhoIs` lookup.
136 pub user_id: ts_control_serde::UserId,
137
138 /// The tailnet this node belongs to.
139 pub tailnet: Option<String>,
140
141 /// The tags assigned to this node.
142 pub tags: Vec<String>,
143
144 /// Every prefix control assigned this node (`tailcfg.Node.Addresses`), in wire order.
145 ///
146 /// Normally one IPv4 `/32` and one IPv6 `/128`, but the wire field is a variable-length list:
147 /// an IPv6-off tailnet assigns only the v4 prefix, and nothing in the protocol stops control
148 /// assigning more than one prefix of a family.
149 ///
150 /// [`tailnet_address`](Self::tailnet_address) is the *identity* projection of this list — the
151 /// first prefix of each family — and is what the overlay, MagicDNS and exit-node selection
152 /// reason about. The whole list is retained because [`is_router`](Self::is_router) has to ask
153 /// "is this prefix one of my own?" of **all** of them, exactly as Go's `tailcfg.Node.IsRouter`
154 /// does. Keep the two consistent when building a `Node` by hand.
155 pub addresses: Vec<ipnet::IpNet>,
156
157 /// The address of the node in the tailnet: the first prefix of each family in
158 /// [`addresses`](Self::addresses), with an unspecified placeholder for a family the tailnet
159 /// does not assign.
160 pub tailnet_address: TailnetAddress,
161
162 /// The node's [`NodePublicKey`].
163 pub node_key: NodePublicKey,
164 /// The node key's expiration.
165 pub node_key_expiry: Option<DateTime<Utc>>,
166
167 /// Whether this node's key is expired (`tailcfg.Node.Expired`).
168 ///
169 /// Two writers, exactly as upstream. Control may send it on the wire, and the client sets it
170 /// itself — only ever `false` → `true` — when
171 /// [`node_key_expiry`](Self::node_key_expiry) has passed, so the decision is made against a
172 /// clock corrected for control skew rather than the raw local one. See
173 /// [`ExpiryManager::flag_expired_peer`](crate::ExpiryManager::flag_expired_peer), which is what
174 /// sets it and which also clears this node's endpoints and home DERP and breaks its
175 /// [`node_key`](Self::node_key).
176 ///
177 /// An expired peer is **kept** in the netmap, not dropped: that is what lets a caller answer
178 /// [`PEER_KEY_EXPIRED`](crate::PEER_KEY_EXPIRED) rather than "no such peer". Distinct from
179 /// [`key_expired`](Self::key_expired), which recomputes the answer from the raw local clock and
180 /// is what the **self**-node re-auth decision reads.
181 pub expired: bool,
182
183 /// Whether control reports this node currently connected to the coordination server
184 /// (`tailcfg.Node.Online`, a tri-state `*bool`). `None` = unknown / no permission to know /
185 /// never been online — **do not collapse to `false`** (that would fabricate an offline status
186 /// control never asserted). Updated by full nodes AND by the delta channels (a
187 /// [`PeerChange::online`], or the `MapResponse.online_change` map).
188 pub online: Option<bool>,
189 /// When control last saw this node online (`tailcfg.Node.LastSeen`). Per Go, only meaningful
190 /// while `online` is not `Some(true)` ("not updated when Online is true"). `None` = unknown /
191 /// never online.
192 pub last_seen: Option<DateTime<Utc>>,
193
194 /// Marshalled TKA node-key signature (`tailcfg.Node.KeySignature`); empty when control sends
195 /// none. Verified against a TKA `Authority` at the peer-trust chokepoint WHEN tailnet-lock
196 /// enforcement is active.
197 pub key_signature: Vec<u8>,
198
199 /// The node's [`MachinePublicKey`], if known.
200 pub machine_key: Option<MachinePublicKey>,
201 /// The node's [`DiscoPublicKey`], if known.
202 pub disco_key: Option<DiscoPublicKey>,
203
204 /// Whether control marked this node as peerAPI-only and outside tailnet lock's coverage
205 /// (`tailcfg.Node.UnsignedPeerAPIOnly`).
206 ///
207 /// Such a node carries no node-key signature. Upstream Go treats that as deliberate: it exempts
208 /// the node from tailnet-lock verification and, in exchange, gives it **no network access** —
209 /// only this node's peerAPI.
210 ///
211 /// **This fork does not implement that admission exemption yet.** While a tailnet-lock authority
212 /// with a **non-empty trusted-key set** is active, the runtime's peer-admission gate
213 /// (`ts_runtime`'s `PeerTracker::tka_snapshot_admits`) drops *every* peer with an empty
214 /// [`key_signature`](Self::key_signature), this flag included — so such a peer is not admitted
215 /// to the peer db at all and gets no peerAPI access either. That is stricter than Go (the safe
216 /// direction); the carve-out is tracked as a parity gap in `docs/PARITY_ROADMAP.md`.
217 ///
218 /// The trusted-key qualifier is not hypothetical hedging, it names the one case where the gate
219 /// does not enforce: an authority whose trusted-key set is *empty* admits every peer, signed or
220 /// not. A verified chain can never produce that state (genesis rejects an empty key set and the
221 /// last key cannot be removed), so it means a `ts_tka` invariant was violated — and the gate
222 /// prefers admitting everyone (logged at `error!`) over black-holing the whole netmap. In that
223 /// state this flag changes nothing either, because nothing is being enforced against.
224 ///
225 /// The *routes* half of upstream's treatment **is** implemented here. Because the node is
226 /// outside the lock, a (possibly malicious) control server must not be able to grant it
227 /// network access via advertised routes, so [`accepted_routes`](Self::accepted_routes) is
228 /// clamped to the node's own [`addresses`](Self::addresses) when this is set. See the `From`
229 /// impl on this type, which mirrors Go's `upgradeNode` in `control/controlclient/map.go`.
230 ///
231 /// The clamp is **unconditional** — it does not depend on tailnet lock being enabled locally,
232 /// because the point is that an unsigned peer is by definition outside the lock's coverage.
233 ///
234 /// The clamp alone is not enough, because it closes only one of the two doors control has.
235 /// Control can leave the node's `AllowedIPs` at its own addresses — which the clamp permits,
236 /// those *are* its addresses — and write those same addresses into the **packet filter** as an
237 /// allowed source instead. Upstream's answer is to reject the filter outright: a filter that
238 /// grants an unsigned peer network access is treated as invalid ("the server is either broken
239 /// or malicious") and ignored wholesale. That is
240 /// [`ts_packetfilter::permits_unlocked_nodes`], driven by `ts_runtime`'s packet-filter updater
241 /// on every netmap that moves either the filter or the peer set — Go's
242 /// `packetFilterPermitsUnlockedNodes` / `nodeBackend.unlockedNodesPermitted` in
243 /// `ipn/ipnlocal/local.go`.
244 ///
245 /// The **capability** half is still unported: there is no per-peer capability map in this
246 /// domain model, only the node-attribute [`cap_map`](Self::cap_map). When it is ported, note
247 /// that upstream does **not** withhold every capability. `capsAllowedForUnsignedPeer`
248 /// (`ipn/ipnlocal/node_backend.go`) keeps `tailcfg.PeerCapabilityIngress` when the peer has it
249 /// and drops the rest, on upstream's own reasoning that "Tailscale Funnel ingress nodes are
250 /// unsigned by design, and the capability only permits ingress requests over the PeerAPI, which
251 /// unsigned peers can already reach". Withholding it too would refuse Funnel ingress from real
252 /// Tailscale nodes, so the carve-out travels with the port rather than after it.
253 pub unsigned_peer_api_only: bool,
254
255 /// The routes this node accepts traffic for.
256 ///
257 /// Clamped to [`addresses`](Self::addresses) when
258 /// [`unsigned_peer_api_only`](Self::unsigned_peer_api_only) is set.
259 pub accepted_routes: Vec<ipnet::IpNet>,
260 /// The underlay addresses this node is reachable on (`Endpoints` in Go).
261 pub underlay_addresses: Vec<SocketAddr>,
262
263 /// The node's advertised SSH host public keys, in known_hosts format (Go
264 /// `tailcfg.Hostinfo.SSHHostKeys`, surfaced by tsnet as `ipnstate.PeerStatus.SSH_HostKeys`).
265 /// Used by `tailscale ssh` to pin a peer's host key (TOFU). Empty when control advertised none
266 /// (the wire `Hostinfo.sshHostKeys` was absent), never fabricated. Projected from
267 /// [`ts_control_serde::HostInfo::ssh_host_keys`].
268 pub ssh_host_keys: Vec<String>,
269
270 /// The DERP region for this node, if known.
271 pub derp_region: Option<ts_derp::RegionId>,
272
273 /// This node's advertised capability version (`Node.Cap` in Go). Old control servers may not
274 /// send it, in which case it defaults to [`CapabilityVersion::default`]. Used to gate features
275 /// that require a minimum peer capability, e.g. exit-node DNS proxying (`peerCanProxyDNS`).
276 pub cap: CapabilityVersion,
277
278 /// This node's capability map (`Node.CapMap` in Go). Keys are capability names/URLs; values are
279 /// the raw JSON argument blobs (often empty). Threaded from the wire
280 /// ([`ts_control_serde::Node::cap_map`]) as an owned copy. Used to gate node-level features such
281 /// as Funnel ingress ([`Node::can_funnel`], [`Node::check_funnel_port`]).
282 pub cap_map: NodeCapMap,
283
284 /// The peerAPI port this node advertises over IPv4 (`peerapi4` service), if any.
285 ///
286 /// Derived from `HostInfo.Services`. `None` means the peer advertises no IPv4 peerAPI, so it
287 /// cannot be reached for peerAPI DoH (DNS-over-HTTPS) exit-node delegation.
288 pub peerapi_port: Option<u16>,
289
290 /// Whether this peer advertises the `peerapi-dns-proxy` service (Go `PeerAPIDNSProxy`),
291 /// indicating it will proxy DNS lookups for other nodes when used as an exit node.
292 pub peerapi_dns_proxy: bool,
293
294 /// Whether this is a non-Tailscale WireGuard-only peer (`IsWireGuardOnly` in Go). Such peers
295 /// cannot run a peerAPI DoH server, so exit-node DNS for them comes from
296 /// [`Node::exit_node_dns_resolvers`] instead.
297 pub is_wireguard_only: bool,
298
299 /// DNS resolvers to use when this WireGuard-only peer is selected as an exit node
300 /// (`ExitNodeDNSResolvers` in Go). Only meaningful when [`Node::is_wireguard_only`] is set.
301 /// Encrypted-transport resolvers are dropped (see `Resolver::from_serde`).
302 pub exit_node_dns_resolvers: Vec<Resolver>,
303
304 /// Whether this node advertises itself as a **peer relay** (Go `Hostinfo.PeerRelay`): it runs a
305 /// UDP relay server other peers can allocate relay endpoints on. This fork is a relay client
306 /// only and never sets this for itself; it is parsed off peers so a relay candidate can be
307 /// recognized. Actually *using* a relay path (the Geneve data path + allocation handshake) is
308 /// not yet implemented — see the crate docs.
309 pub peer_relay: bool,
310
311 /// Per-service virtual IP addresses of the Tailscale VIP services this node *hosts*, keyed by
312 /// `svc:<label>` service name. Parsed from the `service-host`
313 /// ([`ts_control_serde::NODE_ATTR_SERVICE_HOST`]) node-capability value
314 /// (`tailcfg.ServiceIPMappings`). These VIPs are control-assigned and also injected into the
315 /// node's `AllowedIPs`; the application netstack must accept packets for them so a
316 /// `Device::listen_service`-bound listener can answer. Empty when the
317 /// node hosts no VIP services (the common case). Per-service IP lists are deduplicated, source
318 /// order otherwise preserved. Use [`Node::service_addresses`] for the flattened set (netstack
319 /// accept list) and [`Node::service_addresses_for`] for a specific service's VIPs.
320 pub service_vips: alloc::collections::BTreeMap<String, Vec<IpAddr>>,
321}
322
323impl Node {
324 /// The fully-qualified domain name of the node.
325 ///
326 /// This is a string of the form `$HOST.$TAILNET_DOMAIN.`. For tailnets controlled by
327 /// Tailscale's control plane, this usually means `$HOST.tail1234.ts.net.`
328 ///
329 /// The `trailing_dot` parameter specifies whether to include the trailing dot in the
330 /// fqdn. This is included by the definition of FQDN, and is the way the Go codebase
331 /// formats this field, but the parameter is included to allow turning it off for use
332 /// in contexts that expect it to be absent.
333 pub fn fqdn(&self, trailing_dot: bool) -> String {
334 let dot = if trailing_dot { "." } else { "" };
335 match &self.tailnet {
336 Some(tailnet) => format!("{}.{tailnet}{dot}", self.hostname),
337 None => format!("{}{dot}", self.hostname),
338 }
339 }
340
341 /// Whether this node's key has expired as of `now`, mirroring Go's
342 /// `netmap.NetworkMap.SelfKeyExpiry` + the `!expiry.IsZero() && expiry.Before(now)` check in
343 /// `ipnlocal`. A node with no expiry ([`Node::node_key_expiry`] is `None`, the Go "zero value =
344 /// does not expire") is never expired.
345 ///
346 /// Like Go, this fork is **reactive**: it reports expiry rather than auto-rotating in the
347 /// background (Go transitions to `NeedsLogin` on expiry and re-registers via stored auth-key or
348 /// interactive login). A caller observing `true` should re-register
349 /// (`crate::tokio::register`) — supplying `RegisterRequest::old_node_key` (the prior key) and
350 /// a fresh `node_key` when rotating the key, or the same key to merely refresh.
351 pub fn key_expired(&self, now: DateTime<Utc>) -> bool {
352 match self.node_key_expiry {
353 None => false,
354 Some(expiry) => expiry < now,
355 }
356 }
357
358 /// The instant this node's key expires (`Node.KeyExpiry` in Go), or `None` if it never expires.
359 /// A caller can schedule a re-evaluation/re-auth at this time.
360 pub fn key_expiry(&self) -> Option<DateTime<Utc>> {
361 self.node_key_expiry
362 }
363
364 /// Whether this node advertises itself as a peer relay (Go `Hostinfo.PeerRelay`): it runs a UDP
365 /// relay server other peers may allocate relay endpoints on. Recognizing a relay candidate;
366 /// actually traversing a relay path is not yet implemented in this fork.
367 pub fn is_peer_relay(&self) -> bool {
368 self.peer_relay
369 }
370
371 /// The key-expiry instant as **Unix seconds**, or `None` if the key never expires. Provided for
372 /// callers (e.g. the root crate) that don't depend on `chrono`.
373 pub fn key_expiry_unix(&self) -> Option<i64> {
374 self.node_key_expiry.map(|t| t.timestamp())
375 }
376
377 /// Whether the key has expired as of `now_unix_secs` (Unix seconds). Equivalent to
378 /// [`key_expired`](Self::key_expired) for `chrono`-free callers. A key with no expiry is never
379 /// expired.
380 pub fn key_expired_at_unix(&self, now_unix_secs: i64) -> bool {
381 match self.key_expiry_unix() {
382 None => false,
383 Some(expiry) => expiry < now_unix_secs,
384 }
385 }
386
387 /// The fully-qualified domain name of the node, only returning `Some` if the tailnet
388 /// component is present.
389 ///
390 /// See [`Node::fqdn`].
391 pub fn fqdn_opt(&self, trailing_dot: bool) -> Option<String> {
392 let dot = if trailing_dot { "." } else { "" };
393 let tailnet = self.tailnet.as_deref()?;
394
395 Some(format!("{}.{tailnet}{dot}", self.hostname))
396 }
397
398 /// Report whether this node matches the given `name`.
399 ///
400 /// `name` is checked for equality with both this node's bare hostname and its fqdn. A
401 /// trailing `.` may be present. Matching is case-insensitive (DNS names are
402 /// case-insensitive), so this agrees with the canonicalized MagicDNS-name index used for
403 /// peer lookups.
404 pub fn matches_name(&self, name: &str) -> bool {
405 // Strip an optional trailing root dot, then chop our `.tailnet` suffix off the end (if it
406 // matches, case-insensitively) and compare the remainder to our hostname. If the tailnet
407 // suffix doesn't match, the final case-insensitive compare against our bare hostname fails
408 // naturally; if `name` was just the hostname, nothing is chopped and we compare directly.
409
410 let name = name.strip_suffix('.').unwrap_or(name);
411
412 let name = if let Some(tailnet) = &self.tailnet {
413 name.get(name.len().saturating_sub(tailnet.len())..)
414 .filter(|suffix| suffix.eq_ignore_ascii_case(tailnet))
415 .and_then(|_| name.get(..name.len() - tailnet.len()))
416 .and_then(|name| name.strip_suffix('.'))
417 .unwrap_or(name)
418 } else {
419 name
420 };
421
422 name.eq_ignore_ascii_case(&self.hostname)
423 }
424
425 /// Report whether this node is a **router**: it routes addresses besides its own. An exit
426 /// node, a subnet router and an app connector are all routers.
427 ///
428 /// Mirrors Go's `tailcfg.Node.IsRouter` (`tailcfg/tailcfg.go`, added upstream in `8d830599b`),
429 /// which is `true` when any prefix in `AllowedIPs` is not also one of the node's own
430 /// `Addresses`. It is a *derived predicate*, not a wire field: control sends nothing new for
431 /// it, so there is no interop surface here and no capability version to gate on.
432 ///
433 /// Deliberately **not** [`Node::is_subnet_route`] folded over [`Node::accepted_routes`]. That
434 /// predicate also excuses any single Tailscale-range IP (`100.64.0.0/10` /
435 /// `fd7a:115c:a1e0::/48`) so route installation never mistakes another peer's address for an
436 /// advertised subnet; Go's `IsRouter` makes no such exception — a `/32` that is not *this*
437 /// node's own address still makes it a router. The two must stay separate.
438 ///
439 /// The comparison is against [`Node::addresses`] — *every* prefix control assigned this node,
440 /// as Go's `slices.Contains(n.Addresses, r)` is — and not against the first-prefix-per-family
441 /// pair in [`Node::tailnet_address`]. A node control handed two prefixes of one family would
442 /// otherwise have the second read as a routed address and be misreported as a router.
443 pub fn is_router(&self) -> bool {
444 self.accepted_routes
445 .iter()
446 .any(|route| !self.addresses.contains(route))
447 }
448
449 /// Report whether `route` is an advertised *subnet* route (as opposed to one of this node's
450 /// own tailnet addresses).
451 ///
452 /// Mirrors `cidrIsSubnet` in the Go client (`wgengine/wgcfg/nmcfg/nmcfg.go`). A route is *not*
453 /// a subnet route (i.e. it's a self-address) when it is a single host IP that is either a
454 /// Tailscale-assigned IP or exactly one of this node's [`TailnetAddress`] addresses. Everything
455 /// else — multi-IP CIDRs, and single IPs outside the Tailscale ranges — is a subnet route.
456 ///
457 /// The default route (`0.0.0.0/0` / `::/0`) is treated as a subnet route here; exit-node
458 /// handling is a separate concern.
459 pub fn is_subnet_route(&self, route: &ipnet::IpNet) -> bool {
460 let host_prefix = match route {
461 ipnet::IpNet::V4(_) => 32,
462 ipnet::IpNet::V6(_) => 128,
463 };
464
465 if route.prefix_len() != host_prefix {
466 // Any multi-IP CIDR (including the default route) is a subnet route.
467 return true;
468 }
469
470 let addr = route.addr();
471 !(is_tailscale_ip(addr) || self.tailnet_address.contains(addr))
472 }
473
474 /// The routes that should be installed for this peer, given whether this node accepts
475 /// advertised subnet routes (`--accept-routes` / `RouteAll` in the Go client) and which peer
476 /// (if any) is the selected exit node (`--exit-node` / `ExitNodeID` in the Go client).
477 ///
478 /// This node's own addresses (the peer's `/32` and `/128`) are always installed so the peer
479 /// itself stays reachable. Larger advertised subnet routes are only installed when
480 /// `accept_routes` is set; otherwise they are dropped (fail-closed). The same filtered set
481 /// governs both outbound routing to the peer and inbound source validation, exactly as
482 /// WireGuard cryptokey routing couples them in the Go client.
483 ///
484 /// The default route (`0.0.0.0/0` / `::/0`) is installed *only* for the peer whose
485 /// [`StableId`] equals `exit_node`, mirroring `nmcfg.go`'s `if allowedIP.Bits()==0 &&
486 /// peer.StableID()!=exitNode { skip }`. Exit-node use is gated behind this separate, explicit
487 /// preference (`ExitNodeID`, not `RouteAll`): conflating the two would let enabling
488 /// subnet-route acceptance silently route every packet through any peer advertising a default
489 /// route — unacceptable for a fail-closed privacy posture. When `exit_node` is `None` (the
490 /// default) no peer ever receives a `/0`, so internet-bound traffic has no overlay route and is
491 /// dropped by the userspace netstack (fail-closed, no leak). Longest-prefix-match means a peer
492 /// selected as the exit node still loses more-specific destinations to other peers; only
493 /// residual default-route traffic egresses through it.
494 pub fn routes_to_install<'a>(
495 &'a self,
496 accept_routes: bool,
497 exit_node: Option<&StableId>,
498 ) -> impl Iterator<Item = &'a ipnet::IpNet> + 'a {
499 // Computed eagerly so the returned iterator doesn't borrow `exit_node`.
500 let is_selected_exit = exit_node == Some(&self.stable_id);
501 self.accepted_routes.iter().filter(move |route| {
502 if route.prefix_len() == 0 {
503 // Default route: installed only when this peer is the selected exit node. Both the
504 // outbound route table and the inbound source filter call this, so the exit peer
505 // may legitimately source arbitrary internet IPs on return traffic — and only it.
506 return is_selected_exit;
507 }
508 accept_routes || !self.is_subnet_route(route)
509 })
510 }
511
512 /// The capability version at and above which a peer can proxy DNS for nodes using it as an exit
513 /// node (Go `tailcfg.CapabilityVersion` `peerCanProxyDNS`, introduced 2022-01-12 at V26).
514 const PEER_CAN_PROXY_DNS: CapabilityVersion = CapabilityVersion::V26;
515
516 /// The base URL of this peer's IPv4 peerAPI DoH endpoint for exit-node DNS proxying, if it can
517 /// proxy DNS. Returns e.g. `http://100.64.0.5:8080/dns-query`.
518 ///
519 /// Mirrors Go `peerAPIBase(...)+"/dns-query"` gated by `exitNodeCanProxyDNS`: a peer can proxy
520 /// DNS when it advertises an IPv4 peerAPI port **and** either advertises the explicit
521 /// `peerapi-dns-proxy` service or is new enough ([`Node::cap`] ≥ `PEER_CAN_PROXY_DNS`). A
522 /// WireGuard-only peer never runs a peerAPI, so it returns `None` here (its exit-node DNS comes
523 /// from [`Node::exit_node_dns_resolvers`] instead).
524 ///
525 /// IPv4-only by deliberate design: the tailnet dataplane in this fork binds IPv4 only, so we
526 /// never form a peerAPI URL on the peer's IPv6 address.
527 ///
528 /// `None` for an [`expired`](Self::expired) peer — see [`Node::peerapi_addr`].
529 pub fn peerapi_doh_url(&self) -> Option<String> {
530 self.peerapi_doh_addr()
531 .map(|addr| format!("http://{addr}/dns-query"))
532 }
533
534 /// The IPv4 socket address (`<tailnet-ipv4>:<peerapi-port>`) of this peer's peerAPI DoH endpoint
535 /// for exit-node DNS proxying, if it can proxy DNS. Same gate as [`Node::peerapi_doh_url`]; this
536 /// is the form the DoH *client* dials (over the overlay netstack) when delegating recursive
537 /// resolution to a selected exit node. `SocketAddr`'s `Display` is `ip:port`, so
538 /// `peerapi_doh_url` formats to `http://<ip>:<port>/dns-query` over this.
539 pub fn peerapi_doh_addr(&self) -> Option<SocketAddr> {
540 if self.is_wireguard_only || self.expired {
541 return None;
542 }
543 let port = self.peerapi_port?;
544 if !(self.peerapi_dns_proxy || self.cap >= Self::PEER_CAN_PROXY_DNS) {
545 return None;
546 }
547 Some(SocketAddr::new(
548 IpAddr::V4(self.tailnet_address.ipv4.addr()),
549 port,
550 ))
551 }
552
553 /// The IPv4 peerAPI socket address (`<tailnet-ipv4>:<peerapi4-port>`) of this node, if it
554 /// advertises an IPv4 peerAPI. Unlike [`Node::peerapi_doh_addr`], this is **not** gated on the
555 /// DNS-proxy capability: it is the general base for any peerAPI request to this node (e.g. a
556 /// Taildrop `PUT /v0/put/<name>` upload), mirroring Go's `peerAPIBase`/`peerAPIPorts`.
557 ///
558 /// IPv4-only by this fork's deliberate design (the tailnet dataplane binds IPv4 only, so we never
559 /// form a peerAPI URL on the peer's IPv6 address). Returns `None` for a WireGuard-only peer (which
560 /// runs no peerAPI) or a peer advertising no IPv4 peerAPI port.
561 ///
562 /// Also `None` for an [`expired`](Self::expired) peer: Go refuses a peerAPI dial to one with
563 /// [`PEER_KEY_EXPIRED`](crate::PEER_KEY_EXPIRED) (`LocalBackend.pingPeerAPI`), and this is the
564 /// chokepoint every peerAPI dial in this fork resolves its destination through. Callers that
565 /// want to *report* the refusal rather than silently skip the peer should test
566 /// [`expired`](Self::expired) first.
567 pub fn peerapi_addr(&self) -> Option<SocketAddr> {
568 if self.is_wireguard_only || self.expired {
569 return None;
570 }
571 let port = self.peerapi_port?;
572 Some(SocketAddr::new(
573 IpAddr::V4(self.tailnet_address.ipv4.addr()),
574 port,
575 ))
576 }
577
578 /// The node attribute granting HTTPS (TLS cert provisioning) for this node (Go
579 /// `tailcfg.CapabilityHTTPS`). One of the two caps [`Node::can_funnel`] requires.
580 const CAP_HTTPS: &'static str = "https";
581
582 /// The node attribute granting the ability to host Funnel ingress (Go `tailcfg.NodeAttrFunnel`).
583 /// The other cap [`Node::can_funnel`] requires.
584 const NODE_ATTR_FUNNEL: &'static str = "funnel";
585
586 /// The capability URL whose `?ports=` query enumerates the ports Funnel may listen on (Go
587 /// `tailcfg.CapabilityFunnelPorts`). The allowed ports live entirely in the *key's* query
588 /// string, not the cap value.
589 const CAP_FUNNEL_PORTS: &'static str = "https://tailscale.com/cap/funnel-ports";
590
591 /// Report whether the cap map contains `cap` as a key (Go `NodeCapMap.Contains` / `HasCap`).
592 pub fn has_node_attr(&self, cap: &str) -> bool {
593 self.cap_map.contains_key(cap)
594 }
595
596 /// Report whether this node is permitted to host Tailscale Funnel ingress.
597 ///
598 /// Mirrors Go `ipn.NodeCanFunnel`: the node must advertise BOTH `CapabilityHTTPS` (`"https"`)
599 /// AND `NodeAttrFunnel` (`"funnel"`) in its cap map. Fail-closed: a missing cap denies.
600 pub fn can_funnel(&self) -> bool {
601 self.has_node_attr(Self::CAP_HTTPS) && self.has_node_attr(Self::NODE_ATTR_FUNNEL)
602 }
603
604 /// The capability control grants the **self** node when Taildrop is enabled for the tailnet (Go
605 /// `tailcfg.CapabilityFileSharing`). Gates [`Node::can_share_files`].
606 const CAP_FILE_SHARING: &'static str = "https://tailscale.com/cap/file-sharing";
607
608 /// The capability marking a **peer** as an explicit Taildrop send target even across owners (Go
609 /// `tailcfg.PeerCapabilityFileSharingTarget`). Checked by [`Node::is_file_sharing_target`].
610 const CAP_FILE_SHARING_TARGET: &'static str = "tailscale.com/cap/file-sharing-target";
611
612 /// Report whether this node may send Taildrop files — i.e. the admin has enabled file sharing for
613 /// the tailnet (Go `self.CapMap().Contains(CapabilityFileSharing)`). Applied to the **self** node
614 /// as the node-level gate in `FileTargets`; fail-closed when the cap is absent.
615 pub fn can_share_files(&self) -> bool {
616 self.has_node_attr(Self::CAP_FILE_SHARING)
617 }
618
619 /// Report whether this **peer** is an explicit Taildrop send target via ACL caps (Go
620 /// `PeerHasCap(p, PeerCapabilityFileSharingTarget)`) — the cross-owner path that lets a peer owned
621 /// by a different user still be a valid target.
622 pub fn is_file_sharing_target(&self) -> bool {
623 self.has_node_attr(Self::CAP_FILE_SHARING_TARGET)
624 }
625
626 /// The node attribute control sets on a node whose **subdomains** all resolve to the node
627 /// itself (Go `tailcfg/nodecap`'s `NodeAttrDNSSubdomainResolve`). Read by
628 /// [`Node::resolves_subdomains`].
629 const NODE_ATTR_DNS_SUBDOMAIN_RESOLVE: &'static str = "dns-subdomain-resolve";
630
631 /// Report whether every subdomain of this node's MagicDNS name resolves to this node's
632 /// addresses — `foo.<node>` and `bar.foo.<node>` alike.
633 ///
634 /// Go's resolver (`net/dns/resolver/tsdns.go`) learns the same thing two ways — a
635 /// `Config.SubdomainHosts` set of FQDNs beside its `Hosts` map, and a `SubdomainHost` predicate
636 /// on its MagicDNS host index — and on a lookup miss walks the queried name's parents,
637 /// answering from the first parent either one accepts. Here the attribute on the node *is* that
638 /// predicate, read where the parent walk finds the node.
639 ///
640 /// Being a plain per-node attribute, it needs no capability version: a node control has not set
641 /// it on is unaffected, and its subdomains stay `NXDOMAIN`.
642 pub fn resolves_subdomains(&self) -> bool {
643 self.has_node_attr(Self::NODE_ATTR_DNS_SUBDOMAIN_RESOLVE)
644 }
645
646 /// The node attribute control sets to stop the DNS forwarder re-asking a truncated upstream
647 /// answer over TCP (Go `tailcfg/nodecap`'s `NodeAttrDNSForwarderDisableTCPRetries`, surfaced in
648 /// `control/controlknobs` as `Knobs.DisableDNSForwarderTCPRetries`). Read by
649 /// [`Node::disable_dns_forwarder_tcp_retries`].
650 const NODE_ATTR_DNS_FORWARDER_DISABLE_TCP_RETRIES: &'static str =
651 "dns-forwarder-disable-tcp-retries";
652
653 /// Report whether control has told this node **not** to retry a truncated forwarded DNS answer
654 /// over TCP.
655 ///
656 /// The retry is on by default and this attribute is its *off* switch — so, unlike every other
657 /// attribute here, the fail-closed reading is the one that ignores it: a node control has not
658 /// set it on keeps retrying, which is what a stub resolver on this node needs for a name whose
659 /// answer does not fit a datagram. Go reads it the same way round
660 /// (`skipTCP := skipTCPRetry() || (f.controlKnobs != nil &&
661 /// f.controlKnobs.DisableDNSForwarderTCPRetries.Load())`, net/dns/resolver/forwarder.go).
662 ///
663 /// Upstream dates a client's understanding of the attribute to capability version 75
664 /// ([`ts_capabilityversion::CapabilityVersion::V75`]), which this tree's `CURRENT` is well
665 /// above, so control will send it to this node when the tailnet sets it.
666 pub fn disable_dns_forwarder_tcp_retries(&self) -> bool {
667 self.has_node_attr(Self::NODE_ATTR_DNS_FORWARDER_DISABLE_TCP_RETRIES)
668 }
669
670 /// The node attribute by which control asks this node to keep its periodic STUN sweep running
671 /// even while the datapath is idle (Go `tailcfg/nodecap`'s `NodeAttrDebugForceBackgroundSTUN`,
672 /// surfaced in `control/controlknobs` as `Knobs.ForceBackgroundSTUN`). Read by
673 /// [`force_background_stun`](Self::force_background_stun).
674 const NODE_ATTR_DEBUG_FORCE_BACKGROUND_STUN: &'static str = "debug-always-stun";
675
676 /// Report whether control has asked this node to keep STUNning in the background regardless of
677 /// datapath activity.
678 ///
679 /// This is the single override on the idle stop condition in Go magicsock's
680 /// `shouldDoPeriodicReSTUNLocked`: once the datapath has been idle longer than the session-active
681 /// timeout the periodic sweep stops, *unless* `c.controlKnobs.ForceBackgroundSTUN` is set, in
682 /// which case it keeps running. It overrides nothing else — a node with no peers still does not
683 /// STUN, with or without the attribute, because that arm returns before the idle arm is reached.
684 ///
685 /// Read off the **self** node's cap map, like every other control knob. Absent (the normal case)
686 /// means "let the idle stop apply", which is the quiet default; the attribute is a debugging
687 /// escape hatch control sets deliberately, so there is nothing to fail closed to here.
688 pub fn force_background_stun(&self) -> bool {
689 self.has_node_attr(Self::NODE_ATTR_DEBUG_FORCE_BACKGROUND_STUN)
690 }
691
692 /// The node attribute by which control asks this node to collapse its per-peer CGNAT host
693 /// routes into the single `100.64.0.0/10` (Go `tailcfg/nodecap`'s `OneCGNATEnable`).
694 ///
695 /// Note the query string: the key is the literal `one-cgnat?v=true`, not `one-cgnat`. The
696 /// attribute is a tri-state carried as two mutually exclusive keys rather than as a key with a
697 /// value, so the lookup is on the whole literal.
698 const NODE_ATTR_ONE_CGNAT_ENABLE: &'static str = "one-cgnat?v=true";
699
700 /// The node attribute by which control asks this node to keep one host route **per peer** no
701 /// matter how many peers there are (Go `tailcfg/nodecap`'s `OneCGNATDisable`). The other half
702 /// of [`NODE_ATTR_ONE_CGNAT_ENABLE`](Self::NODE_ATTR_ONE_CGNAT_ENABLE)'s tri-state.
703 const NODE_ATTR_ONE_CGNAT_DISABLE: &'static str = "one-cgnat?v=false";
704
705 /// Control's tri-state instruction about collapsing this node's per-peer CGNAT host routes
706 /// into the single `100.64.0.0/10`, read off the **self** node's cap map.
707 ///
708 /// Mirrors Go `ipn/ipnlocal`'s read of `nodecap.OneCGNATEnable` / `nodecap.OneCGNATDisable`
709 /// into `controlknobs.Knobs.OneCGNAT`, which is an `opt.Bool` and not a `bool` precisely so the
710 /// third state exists:
711 ///
712 /// * `Some(true)` — `one-cgnat?v=true`: always collapse.
713 /// * `Some(false)` — `one-cgnat?v=false`: never collapse, one `/32` per peer however many
714 /// peers there are.
715 /// * `None` — neither attribute present: control has no opinion, and the consumer's own
716 /// peer-count threshold decides (Go `net/routemanager`'s `cgnatThreshold`).
717 ///
718 /// A node holding BOTH attributes reads as `Some(true)`: the enabling attribute is checked
719 /// first and wins. Control setting both is a policy conflict rather than a state upstream
720 /// specifies, and collapsing is the safe way to break the tie — the `/10` is a superset of the
721 /// `/32`s it replaces, so no peer becomes unreachable, whereas honouring the disabling
722 /// attribute on a tailnet large enough for control to have set the enabling one is exactly the
723 /// unbounded host route table the threshold exists to prevent.
724 pub fn one_cgnat(&self) -> Option<bool> {
725 if self.has_node_attr(Self::NODE_ATTR_ONE_CGNAT_ENABLE) {
726 Some(true)
727 } else if self.has_node_attr(Self::NODE_ATTR_ONE_CGNAT_DISABLE) {
728 Some(false)
729 } else {
730 None
731 }
732 }
733
734 /// The node attribute by which control asks this node to stop processing netmap updates through
735 /// the delta (incremental) path (Go `tailcfg/nodecap`'s `DisableDeltaUpdates`, read into
736 /// `controlknobs.Knobs.DisableDeltaUpdates`). Read off the **self** node's cap map by
737 /// [`delta_updates_disabled`](Self::delta_updates_disabled).
738 ///
739 /// Upstream documents the intent on the knob itself: the client "should not process updates via
740 /// the delta update mechanism and should instead treat all netmap changes as 'full' ones as
741 /// tailscaled did in 1.48.x and earlier". It is control's escape hatch for a delta-encoding bug
742 /// on *either* side of the map protocol — control emitting bad patches, or a client applying
743 /// them wrongly — without waiting for a client release to ship.
744 const NODE_ATTR_DISABLE_DELTA_UPDATES: &'static str = "disable-delta-updates";
745
746 /// Report whether control has asked this node to decline the incremental netmap path and treat
747 /// every netmap change as a full one.
748 ///
749 /// Mirrors the first statement of Go `control/controlclient/map.go`'s `tryHandleIncrementally`:
750 /// `if ms.controlKnobs != nil && ms.controlKnobs.DisableDeltaUpdates.Load() { return false }`.
751 /// Returning `false` there does **not** reject the response and does not drop the mutations it
752 /// carries — it declines the incremental arm so the full netmap rebuild handles the very same
753 /// response. A consumer of this method owes the same shape: fall back, never drop.
754 ///
755 /// Absent attribute ⇒ `false` ⇒ the delta path, which is the default and the overwhelmingly
756 /// common case. Being a plain per-node attribute it needs no capability version: a node control
757 /// has not set it on is unaffected.
758 pub fn delta_updates_disabled(&self) -> bool {
759 self.has_node_attr(Self::NODE_ATTR_DISABLE_DELTA_UPDATES)
760 }
761
762 /// The node attribute by which control tells this node to stop sending disco heartbeats to its
763 /// peers (Go `tailcfg/nodecap`'s `SilentDisco`, read into `controlknobs.Knobs.SilentDisco` and
764 /// handed to magicsock by `ipn/ipnlocal`'s `b.MagicConn().SetSilentDisco(...)`). Read off the
765 /// **self** node's cap map by [`silent_disco`](Self::silent_disco).
766 ///
767 /// Upstream's own summary of the attribute is one sentence — it "makes the client suppress
768 /// disco heartbeats to its peers" — and the node it is set on is the node that goes quiet, so
769 /// it is the *self* node's cap map that decides, never the peer's.
770 const NODE_ATTR_SILENT_DISCO: &'static str = "silent-disco";
771
772 /// Report whether control has asked this node to stop heartbeating its peers' confirmed direct
773 /// paths.
774 ///
775 /// Mirrors Go magicsock `Conn.debugFlagsLocked`'s `heartbeatDisabled`, which the netmap push
776 /// (`endpoint.updateFromNode`) stamps onto every endpoint. With it set, the periodic
777 /// keep-the-best-path-alive ping is not sent — and, as the compensating half, an inbound packet
778 /// from the current best address extends that path's trust directly, because with no heartbeat
779 /// there is nothing else keeping it trusted.
780 ///
781 /// Absent attribute ⇒ `false` ⇒ the existing heartbeat cadence, which is the default and the
782 /// overwhelmingly common case. Being a plain per-node attribute it needs no capability version:
783 /// a node control has not set it on is unaffected.
784 ///
785 /// Go additionally ORs in a `TS_DEBUG_ENABLE_SILENT_DISCO` envknob at the same place. This tree
786 /// has no envknob layer at all, so the control attribute is the whole input here.
787 pub fn silent_disco(&self) -> bool {
788 self.has_node_attr(Self::NODE_ATTR_SILENT_DISCO)
789 }
790
791 /// Report whether `wanted_port` is allowed for Funnel on this node.
792 ///
793 /// Mirrors Go `ipn.CheckFunnelPort`: scan the cap-map keys for one prefixed by
794 /// `Node::CAP_FUNNEL_PORTS`, URL-parse that key, read its `ports` query parameter, and match
795 /// `wanted_port` against the comma-separated list of single ports and `first-last` ranges. The
796 /// port list lives in the *key*, never the value. Fail-closed: no matching cap, an empty or
797 /// unparseable `ports` query, or a key whose non-query part isn't exactly the funnel-ports URL
798 /// all deny.
799 pub fn check_funnel_port(&self, wanted_port: u16) -> bool {
800 // Extract the `ports=` list from the first cap-map key that is the funnel-ports URL with a
801 // non-empty `ports` query. Returns `None` (deny) if the key is unparseable, the query is
802 // missing/empty, or the URL (sans query) isn't exactly the funnel-ports cap.
803 let parse_attr = |attr: &str| -> Option<String> {
804 let mut url = url::Url::parse(attr).ok()?;
805 let ports = url
806 .query_pairs()
807 .find(|(k, _)| k == "ports")
808 .map(|(_, v)| v.into_owned())?;
809 if ports.is_empty() {
810 return None;
811 }
812 url.set_query(None);
813 // Go compares `u.String()` against the bare cap; `url`'s serializer keeps a trailing
814 // `/` only if present in the input, and the funnel-ports cap has none, so a direct
815 // string compare matches Go's behavior.
816 if url.as_str() != Self::CAP_FUNNEL_PORTS {
817 return None;
818 }
819 Some(ports)
820 };
821
822 let Some(ports_str) = self
823 .cap_map
824 .keys()
825 .filter(|attr| attr.starts_with(Self::CAP_FUNNEL_PORTS))
826 .find_map(|attr| parse_attr(attr))
827 else {
828 return false;
829 };
830
831 let wanted = wanted_port.to_string();
832 for ps in ports_str.split(',') {
833 if ps.is_empty() {
834 continue;
835 }
836 match ps.split_once('-') {
837 None => {
838 if ps == wanted {
839 return true;
840 }
841 }
842 Some((first, last)) => {
843 let (Ok(fp), Ok(lp)) = (first.parse::<u16>(), last.parse::<u16>()) else {
844 continue;
845 };
846 if fp <= wanted_port && wanted_port <= lp {
847 return true;
848 }
849 }
850 }
851 }
852 false
853 }
854
855 /// Report whether this node is permitted to host Tailscale VIP services.
856 ///
857 /// Mirrors the Go grant model: possession of the `service-host`
858 /// ([`ts_control_serde::NODE_ATTR_SERVICE_HOST`]) node-capability **and** at least one assigned
859 /// VIP address. Go additionally requires the host to be tagged
860 /// (`ErrUntaggedServiceHost`); that tag gate is enforced at
861 /// `Device::listen_service` using [`Node::tags`]. Fail-closed: no cap
862 /// or no assigned VIP denies.
863 pub fn is_service_host(&self) -> bool {
864 self.has_node_attr(ts_control_serde::NODE_ATTR_SERVICE_HOST)
865 && !self.service_vips.is_empty()
866 }
867
868 /// The control-assigned VIP addresses for one named service (`svc:<label>`), or an empty slice
869 /// if this node does not host that service. This is the exact per-service mapping (so a
870 /// multi-service co-host binds the right VIP for each service).
871 pub fn service_addresses_for(&self, service: &str) -> &[IpAddr] {
872 self.service_vips
873 .get(service)
874 .map(Vec::as_slice)
875 .unwrap_or(&[])
876 }
877
878 /// The flattened, deduplicated set of every VIP address this node hosts across all services.
879 /// Used to widen the netstack's accepted-address set so any hosted-service listener is
880 /// reachable. Per-service binding uses [`Node::service_addresses_for`] instead.
881 pub fn service_addresses(&self) -> Vec<IpAddr> {
882 let mut seen = alloc::collections::BTreeSet::new();
883 let mut out = Vec::new();
884 for addr in self.service_vips.values().flatten() {
885 if seen.insert(*addr) {
886 out.push(*addr);
887 }
888 }
889 out
890 }
891}
892
893/// Validate a Tailscale VIP service name (`tailcfg.ServiceName.Validate`): it must carry the
894/// `svc:` prefix ([`ts_control_serde::SERVICE_NAME_PREFIX`]) followed by a valid DNS label
895/// (1–63 chars, ASCII alphanumeric or `-`, not starting/ending with `-`). Returns the bare label on
896/// success. Fail-closed: anything malformed is rejected so a listener can never bind for a bogus
897/// service name.
898pub fn validate_service_name(name: &str) -> Option<&str> {
899 let label = name.strip_prefix(ts_control_serde::SERVICE_NAME_PREFIX)?;
900 if label.is_empty() || label.len() > 63 {
901 return None;
902 }
903 if label.starts_with('-') || label.ends_with('-') {
904 return None;
905 }
906 if label
907 .bytes()
908 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
909 {
910 Some(label)
911 } else {
912 None
913 }
914}
915
916/// Parse the per-service VIP map this node hosts from the `service-host` node-capability value(s).
917/// Each value is the raw JSON text of a [`ts_control_serde::ServiceIpMappings`] object (svc-name ->
918/// VIP IPs); unparseable values are skipped (fail-closed: a malformed mapping contributes no VIPs).
919/// Per-service IP lists are deduplicated, source order otherwise preserved.
920fn service_vips_from_cap_map(
921 cap_map: &NodeCapMap,
922) -> alloc::collections::BTreeMap<String, Vec<IpAddr>> {
923 let mut out: alloc::collections::BTreeMap<String, Vec<IpAddr>> =
924 alloc::collections::BTreeMap::new();
925 let Some(values) = cap_map.get(ts_control_serde::NODE_ATTR_SERVICE_HOST) else {
926 return out;
927 };
928
929 for raw in values {
930 let Ok(mappings) = serde_json::from_str::<ts_control_serde::ServiceIpMappings>(raw) else {
931 continue;
932 };
933 for (name, addrs) in &mappings.0 {
934 let entry = out.entry((*name).to_string()).or_default();
935 for addr in addrs {
936 if !entry.contains(addr) {
937 entry.push(*addr);
938 }
939 }
940 }
941 }
942 out
943}
944
945/// Collect a wire ([`ts_control_serde`]) node cap map into an owned [`NodeCapMap`].
946///
947/// Keys are copied as owned strings; each value's raw JSON text is preserved verbatim. The wire map
948/// borrows from the decode buffer, so an owned copy is required to outlive it on the domain
949/// [`Node`].
950fn cap_map_from_serde(wire: &ts_nodecapability::Map<'_>) -> NodeCapMap {
951 wire.iter()
952 .map(|(&key, values)| {
953 let owned_values = values.0.iter().map(|v| v.get().to_owned()).collect();
954 (key.to_owned(), owned_values)
955 })
956 .collect()
957}
958
959/// Extract the advertised IPv4 peerAPI port and whether the explicit `peerapi-dns-proxy` service is
960/// advertised, from a peer's `HostInfo.Services` list.
961fn peerapi_from_services(
962 services: Option<&[ts_control_serde::Service<'_>]>,
963) -> (Option<u16>, bool) {
964 use ts_control_serde::ServiceProto;
965
966 let Some(services) = services else {
967 return (None, false);
968 };
969 let mut port = None;
970 let mut dns_proxy = false;
971 for svc in services {
972 match svc.proto {
973 ServiceProto::PeerApi4 => port = Some(svc.port),
974 ServiceProto::PeerApiDnsProxy => dns_proxy = true,
975 _ => {}
976 }
977 }
978 (port, dns_proxy)
979}
980
981/// Addresses for a node within a tailnet.
982#[derive(Debug, Clone, PartialEq, Eq, Hash)]
983pub struct TailnetAddress {
984 /// The IPv4 address of the node in the tailnet.
985 pub ipv4: ipnet::Ipv4Net,
986 /// The IPv6 address of the node in the tailnet.
987 pub ipv6: ipnet::Ipv6Net,
988}
989
990impl TailnetAddress {
991 /// Report whether `addr` matches either address in this [`TailnetAddress`].
992 pub fn contains(&self, addr: IpAddr) -> bool {
993 match addr {
994 IpAddr::V4(a) => self.ipv4.addr() == a,
995 IpAddr::V6(a) => self.ipv6.addr() == a,
996 }
997 }
998}
999
1000impl From<&ts_control_serde::Node<'_>> for Node {
1001 fn from(value: &ts_control_serde::Node) -> Self {
1002 let fqdn_without_trailing_dot = value.name.strip_suffix('.').unwrap_or(&value.name);
1003
1004 let (hostname, tailnet) = match fqdn_without_trailing_dot.split_once('.') {
1005 Some((hostname, tailnet)) => (hostname, Some(tailnet.to_owned())),
1006 None => (fqdn_without_trailing_dot, None),
1007 };
1008
1009 let (peerapi_port, peerapi_dns_proxy) =
1010 peerapi_from_services(value.host_info.services.as_deref());
1011
1012 let cap_map = cap_map_from_serde(&value.cap_map);
1013 let service_vips = service_vips_from_cap_map(&cap_map);
1014
1015 // `addresses` is a variable-length `Vec<IpNet>` on the wire (Go `[]netip.Prefix`), not a
1016 // fixed (v4, v6) pair: an IPv6-off tailnet assigns only a v4 prefix. The whole list is kept
1017 // verbatim on `Node::addresses` (Go's `Node.Addresses`, which `IsRouter` tests routes
1018 // against); `tailnet_address` is the identity projection. Pick the first of each
1019 // family. The v4 prefix is the node's tailnet identity (always present on a normal node);
1020 // if somehow absent we fall back to the unspecified `0.0.0.0/32` rather than panicking.
1021 // The v6 prefix is optional — when the tailnet is IPv4-only there is none, and the overlay
1022 // never reads `ipv6` in that mode (gated on `enable_ipv6`); we synthesize the unspecified
1023 // `::/128` placeholder so the domain `TailnetAddress` stays infallible.
1024 let ipv4 = value
1025 .addresses
1026 .iter()
1027 .find_map(|p| match p {
1028 ipnet::IpNet::V4(n) => Some(*n),
1029 ipnet::IpNet::V6(_) => None,
1030 })
1031 .unwrap_or_else(|| ipnet::Ipv4Net::new(core::net::Ipv4Addr::UNSPECIFIED, 32).unwrap());
1032 let ipv6 = value
1033 .addresses
1034 .iter()
1035 .find_map(|p| match p {
1036 ipnet::IpNet::V6(n) => Some(*n),
1037 ipnet::IpNet::V4(_) => None,
1038 })
1039 .unwrap_or_else(|| ipnet::Ipv6Net::new(core::net::Ipv6Addr::UNSPECIFIED, 128).unwrap());
1040
1041 Self {
1042 id: value.id,
1043 stable_id: StableId(value.stable_id.0.to_string()),
1044
1045 hostname: hostname.to_owned(),
1046 user_id: value.user,
1047 tailnet,
1048
1049 tags: value
1050 .tags
1051 .as_ref()
1052 .map(|x| x.iter().map(|x| x.to_string()).collect())
1053 .unwrap_or_default(),
1054
1055 addresses: value.addresses.clone(),
1056 tailnet_address: TailnetAddress { ipv4, ipv6 },
1057 node_key: value.key,
1058 node_key_expiry: value.key_expiry,
1059 // Control's own verdict, carried verbatim; `ExpiryManager` only ever raises it.
1060 expired: value.expired,
1061 online: value.online,
1062 last_seen: value.last_seen,
1063 key_signature: value.key_signature.to_vec(),
1064 machine_key: value.machine,
1065 disco_key: value.disco_key,
1066
1067 unsigned_peer_api_only: value.unsigned_peer_api_only,
1068
1069 // Per capver-112, `AllowedIPs` null/absent means "same as `addresses`". Fall back to the
1070 // node's own assigned prefixes verbatim (whatever families the wire carried), not a
1071 // synthesized v4+v6 pair.
1072 //
1073 // `UnsignedPeerAPIOnly` clamps the result back to `addresses` whatever control sent,
1074 // mirroring Go's `upgradeNode` (`control/controlclient/map.go`): such a node is outside
1075 // tailnet lock's coverage, so a possibly-malicious control server must not be able to
1076 // grant it network access by handing it advertised routes (in the limit, `0.0.0.0/0`).
1077 // Unconditional, exactly as upstream — it does not depend on tailnet lock being
1078 // enabled here.
1079 accepted_routes: if value.unsigned_peer_api_only {
1080 value.addresses.clone()
1081 } else {
1082 value
1083 .allowed_ips
1084 .clone()
1085 .unwrap_or_else(|| value.addresses.clone())
1086 },
1087 underlay_addresses: value.endpoints.clone(),
1088
1089 // legacy_derp_string is still in practical use as of 3/2026
1090 #[allow(deprecated)]
1091 derp_region: value
1092 .home_derp
1093 .or(value.legacy_derp_string)
1094 .or_else(|| value.host_info.net_info.as_ref()?.preferred_derp)
1095 .map(|x| ts_derp::RegionId(x.into())),
1096
1097 cap: value.cap,
1098 cap_map,
1099 peerapi_port,
1100 peerapi_dns_proxy,
1101 is_wireguard_only: value.is_wireguard_only,
1102 exit_node_dns_resolvers: value
1103 .exit_node_dns_resolvers
1104 .iter()
1105 .filter_map(Resolver::from_serde)
1106 .collect(),
1107 peer_relay: value.host_info.peer_relay,
1108 // Project the advertised SSH host keys (Go `Hostinfo.SSHHostKeys`), mapping the
1109 // borrowed `Option<Vec<&str>>` to owned `Vec<String>`; absent ⇒ empty (never
1110 // fabricated), matching how `services`/`peer_relay` above are projected from host_info.
1111 ssh_host_keys: value
1112 .host_info
1113 .ssh_host_keys
1114 .as_ref()
1115 .map(|keys| keys.iter().map(|k| k.to_string()).collect())
1116 .unwrap_or_default(),
1117 service_vips,
1118 }
1119 }
1120}
1121
1122/// An incremental update to a single already-known peer [`Node`], carried in
1123/// [`MapResponse::peers_changed_patch`][ts_control_serde::MapResponse::peers_changed_patch].
1124///
1125/// Control sends a patch (rather than a full node in `peers_changed`) when only a peer's
1126/// reachability changes mid-session — most importantly its UDP `endpoints`
1127/// and home [`derp_region`][PeerChange::derp_region] when an idle peer re-establishes connectivity.
1128/// Every field is `Option`: a patch sets only the fields it carries and leaves the rest of the
1129/// target node unchanged (see `PeerTracker::apply_peer_update` for the merge). Owned counterpart
1130/// of the borrow-bound [`ts_control_serde::PeerChange`]; the fields that map onto a domain
1131/// [`Node`] field are retained, including control's `online`/`last_seen` liveness deltas — the
1132/// dominant channel by which peer online transitions are delivered (see [`Node::online`]).
1133#[derive(Debug, Clone, PartialEq, Eq)]
1134pub struct PeerChange {
1135 /// The [`Node::id`] of the peer being mutated. If no peer with this id is in the current
1136 /// netmap, the patch is ignored (the wire contract — a patch never creates a node).
1137 pub id: Id,
1138 /// If `Some`, the peer's new home DERP region.
1139 pub derp_region: Option<ts_derp::RegionId>,
1140 /// If `Some`, the peer's new advertised capability version.
1141 pub cap: Option<CapabilityVersion>,
1142 /// If `Some`, the peer's new capability map (replaces the prior map wholesale).
1143 pub cap_map: Option<NodeCapMap>,
1144 /// If `Some`, the peer's new UDP underlay endpoints (`Endpoints` in Go; replaces the prior
1145 /// set). This is the field that lets magicsock re-handshake a peer that moved.
1146 pub underlay_addresses: Option<Vec<SocketAddr>>,
1147 /// If `Some`, the peer's new WireGuard public key (key rotation).
1148 pub node_key: Option<NodePublicKey>,
1149 /// If `Some`, the marshalled TKA signature over the new node key. Re-verified at the
1150 /// peer-trust chokepoint when tailnet-lock enforcement is active.
1151 pub key_signature: Option<Vec<u8>>,
1152 /// If `Some`, the peer's new disco public key.
1153 pub disco_key: Option<DiscoPublicKey>,
1154 /// If `Some`, the peer's new node-key expiry (`KeyExpiry` in Go). Maps to
1155 /// [`Node::node_key_expiry`]; carried so an expiry-only patch isn't lost until the next full
1156 /// resync.
1157 pub node_key_expiry: Option<DateTime<Utc>>,
1158 /// If `Some`, the peer's new online status (`PeerChange.Online`). `None` here means "this patch
1159 /// did not touch online", **not** "offline" — the merge sets [`Node::online`] only when present.
1160 pub online: Option<bool>,
1161 /// If `Some`, the peer's new last-seen time (`PeerChange.LastSeen`). Maps to [`Node::last_seen`].
1162 pub last_seen: Option<DateTime<Utc>>,
1163}
1164
1165impl From<&ts_control_serde::PeerChange<'_>> for PeerChange {
1166 fn from(value: &ts_control_serde::PeerChange) -> Self {
1167 Self {
1168 id: value.node_id,
1169 derp_region: value.derp_region.map(|x| ts_derp::RegionId(x.into())),
1170 cap: value.cap,
1171 cap_map: value.cap_map.as_ref().map(cap_map_from_serde),
1172 underlay_addresses: value.endpoints.clone(),
1173 node_key: value.key,
1174 key_signature: value.key_signature.map(|s| s.to_vec()),
1175 disco_key: value.disco_key,
1176 node_key_expiry: value.key_expiry,
1177 online: value.online,
1178 last_seen: value.last_seen,
1179 }
1180 }
1181}
1182
1183/// Identity of the user that owns a [`Node`], resolved from the netmap's `UserProfiles` table
1184/// (Go `tailcfg.UserProfile`). Owned counterpart of the borrow-bound
1185/// [`ts_control_serde::UserProfile`]. Keyed by [`UserProfile::id`] (== [`Node::user_id`]).
1186///
1187/// Mostly display-friendly text ([`login_name`](Self::login_name),
1188/// [`display_name`](Self::display_name)), plus [`groups`](Self::groups) — the one attribute here an
1189/// embedder can *authorise* on, because it is the one a node cannot re-derive from anything else
1190/// control sends.
1191#[derive(Debug, Clone, PartialEq, Eq)]
1192pub struct UserProfile {
1193 /// The integer id of the Tailscale user this profile describes (matches [`Node::user_id`]).
1194 pub id: ts_control_serde::UserId,
1195 /// An email-ish login name for display (e.g. `alice@example.com` / `alice@github`). May be
1196 /// empty if control sent none.
1197 pub login_name: String,
1198 /// The user's display name (e.g. `Alice Smith`), if the IdP provided one.
1199 pub display_name: Option<String>,
1200 /// The groups that contain this user and that the coordination server was configured to report
1201 /// to this node (Go `tailcfg.UserProfile.Groups`): SCIM groups (e.g.
1202 /// `engineering@example.com`) or tailnet-policy group names (e.g. `group:eng`).
1203 ///
1204 /// Carried in the order control sent it (control sorts it when it loads the profile from
1205 /// storage). **Empty** when control reported no groups — including every control server older
1206 /// than the field, which omits it entirely. An empty list therefore means "control told this
1207 /// node nothing", not "this user is in no group": treat it as no grant, never as a denial you
1208 /// can act on.
1209 pub groups: Vec<String>,
1210}
1211
1212impl From<&ts_control_serde::UserProfile<'_>> for UserProfile {
1213 fn from(value: &ts_control_serde::UserProfile) -> Self {
1214 Self {
1215 id: value.id,
1216 login_name: value.login_name.to_string(),
1217 display_name: value.display_name.as_deref().map(str::to_string),
1218 groups: value.groups.iter().map(|g| g.to_string()).collect(),
1219 }
1220 }
1221}
1222
1223impl UserProfile {
1224 /// The best human-facing label for this user: the login name when present, else the display
1225 /// name, else `None`. This is what a `WhoIs` surfaces as the owning user.
1226 pub fn best_label(&self) -> Option<String> {
1227 if !self.login_name.is_empty() {
1228 Some(self.login_name.clone())
1229 } else {
1230 self.display_name.clone()
1231 }
1232 }
1233}
1234
1235#[cfg(test)]
1236pub(crate) mod tests {
1237 use super::*;
1238
1239 /// The wire `Node.User` id must be carried onto the domain `Node.user_id` by the `From` impl
1240 /// (the field the runtime joins against the netmap `UserProfiles` table for `WhoIs.user`).
1241 /// Guards against the `From` impl wiring the wrong serde field or dropping it.
1242 #[test]
1243 fn from_wire_node_carries_user_id() {
1244 let mut wire = ts_control_serde::Node {
1245 user: 4242,
1246 ..Default::default()
1247 };
1248 wire.name = "host.tail.ts.net.".into();
1249 let domain: Node = (&wire).into();
1250 assert_eq!(domain.user_id, 4242);
1251
1252 // Default (no owner / tagged node) stays 0.
1253 let tagged = ts_control_serde::Node::default();
1254 assert_eq!(Node::from(&tagged).user_id, 0);
1255 }
1256
1257 /// The wire `Hostinfo.sshHostKeys` must be projected onto the domain `Node.ssh_host_keys`
1258 /// (the field `tailscale ssh` reads via `StatusNode` to pin a peer's host key). Present →
1259 /// carried verbatim; absent → empty (never fabricated).
1260 #[test]
1261 fn from_wire_node_carries_ssh_host_keys() {
1262 let wire = ts_control_serde::Node {
1263 host_info: ts_control_serde::HostInfo {
1264 ssh_host_keys: Some(vec![
1265 "ssh-ed25519 AAAAC3Nz host",
1266 "ecdsa-sha2-nistp256 AAAAE2Vj host",
1267 ]),
1268 ..Default::default()
1269 },
1270 ..Default::default()
1271 };
1272 let domain: Node = (&wire).into();
1273 assert_eq!(
1274 domain.ssh_host_keys,
1275 vec![
1276 "ssh-ed25519 AAAAC3Nz host".to_string(),
1277 "ecdsa-sha2-nistp256 AAAAE2Vj host".to_string(),
1278 ]
1279 );
1280
1281 // Absent on the wire → empty Vec, not fabricated.
1282 let bare = ts_control_serde::Node::default();
1283 assert!(Node::from(&bare).ssh_host_keys.is_empty());
1284 }
1285
1286 /// A node from an **IPv4-only** tailnet (IPv6-off control plane / Headscale) carries a
1287 /// single-element `addresses` list. This used to fail deserialization ("invalid length 1,
1288 /// expected a tuple of size 2") when `addresses` was a fixed 2-tuple; it must now parse and
1289 /// derive the v4 identity, with the unused v6 a synthesized placeholder.
1290 #[test]
1291 fn from_wire_node_ipv4_only_addresses() {
1292 let wire = ts_control_serde::Node {
1293 addresses: vec!["100.64.0.5/32".parse().unwrap()],
1294 ..Default::default()
1295 };
1296 let domain: Node = (&wire).into();
1297 assert_eq!(
1298 domain.tailnet_address.ipv4,
1299 "100.64.0.5/32".parse().unwrap()
1300 );
1301 // No v6 on the wire → unspecified placeholder (never read in IPv4-only mode).
1302 assert_eq!(
1303 domain.tailnet_address.ipv6,
1304 ipnet::Ipv6Net::new(core::net::Ipv6Addr::UNSPECIFIED, 128).unwrap()
1305 );
1306 // AllowedIPs absent → falls back to the node's own assigned prefixes (just the v4 here).
1307 assert_eq!(
1308 domain.accepted_routes,
1309 vec!["100.64.0.5/32".parse::<ipnet::IpNet>().unwrap()]
1310 );
1311 }
1312
1313 /// A dual-stack node carries both families (any order); the domain picks the first of each.
1314 #[test]
1315 fn from_wire_node_dual_stack_addresses() {
1316 let wire = ts_control_serde::Node {
1317 addresses: vec![
1318 "100.64.0.7/32".parse().unwrap(),
1319 "fd7a:115c:a1e0::7/128".parse().unwrap(),
1320 ],
1321 ..Default::default()
1322 };
1323 let domain: Node = (&wire).into();
1324 assert_eq!(
1325 domain.tailnet_address.ipv4,
1326 "100.64.0.7/32".parse().unwrap()
1327 );
1328 assert_eq!(
1329 domain.tailnet_address.ipv6,
1330 "fd7a:115c:a1e0::7/128".parse().unwrap()
1331 );
1332 }
1333
1334 /// A wire peer that owns `100.64.0.9/32` and is handed `route` plus the default route in its
1335 /// `AllowedIPs`. `unsigned` sets `UnsignedPeerAPIOnly`; everything else is identical between
1336 /// the two, so the only variable in the test below is that flag.
1337 fn wire_peer_advertising(
1338 stable_id: &'static str,
1339 route: &str,
1340 unsigned: bool,
1341 ) -> ts_control_serde::Node<'static> {
1342 ts_control_serde::Node {
1343 stable_id: ts_control_serde::StableNodeId(stable_id),
1344 addresses: vec!["100.64.0.9/32".parse().unwrap()],
1345 allowed_ips: Some(vec![
1346 "100.64.0.9/32".parse().unwrap(),
1347 route.parse().unwrap(),
1348 "0.0.0.0/0".parse().unwrap(),
1349 ]),
1350 unsigned_peer_api_only: unsigned,
1351 ..Default::default()
1352 }
1353 }
1354
1355 /// `UnsignedPeerAPIOnly` must clamp a peer's accepted routes back to its own addresses, so a
1356 /// control server cannot grant an unsigned (lock-exempt) peer network access via advertised
1357 /// routes. Mirrors Go's `upgradeNode` in `control/controlclient/map.go`.
1358 ///
1359 /// The signed peer is the control: it advertises the **same** route and the same default route,
1360 /// and keeps both. Without it this test would still pass if the `From` impl simply dropped every
1361 /// advertised route.
1362 #[test]
1363 fn from_wire_unsigned_peer_api_only_clamps_routes_to_own_addresses() {
1364 let own: ipnet::IpNet = "100.64.0.9/32".parse().unwrap();
1365 let subnet: ipnet::IpNet = "192.0.2.0/24".parse().unwrap();
1366 let default_route: ipnet::IpNet = "0.0.0.0/0".parse().unwrap();
1367
1368 let unsigned: Node = (&wire_peer_advertising("nUnsigned", "192.0.2.0/24", true)).into();
1369 let signed: Node = (&wire_peer_advertising("nSigned", "192.0.2.0/24", false)).into();
1370
1371 // The flag is carried onto the domain node, not silently dropped.
1372 assert!(unsigned.unsigned_peer_api_only);
1373 assert!(!signed.unsigned_peer_api_only);
1374
1375 // Unsigned: clamped to its own addresses. The advertised subnet and the default route are
1376 // both gone, whatever control sent.
1377 assert_eq!(unsigned.accepted_routes, vec![own]);
1378
1379 // Signed: the identical advertisement survives verbatim.
1380 assert_eq!(
1381 signed.accepted_routes,
1382 vec![own, subnet, default_route],
1383 "the clamp must be specific to UnsignedPeerAPIOnly, not a blanket route drop"
1384 );
1385
1386 // Consequences the rest of the fork reads. `is_router` reports the unsigned peer routes
1387 // nothing but itself...
1388 assert!(!unsigned.is_router());
1389 assert!(signed.is_router());
1390
1391 // ...and no route-install policy can resurrect the advertisement: even with
1392 // `--accept-routes` on AND the peer selected as the exit node — the most permissive input
1393 // `routes_to_install` accepts — the unsigned peer yields only its own address.
1394 let installed: Vec<_> = unsigned
1395 .routes_to_install(true, Some(&unsigned.stable_id))
1396 .copied()
1397 .collect();
1398 assert_eq!(installed, vec![own]);
1399
1400 // The same permissive inputs against the signed peer do install the subnet and the /0,
1401 // proving the difference is the flag and not the policy arguments.
1402 let installed_signed: Vec<_> = signed
1403 .routes_to_install(true, Some(&signed.stable_id))
1404 .copied()
1405 .collect();
1406 assert_eq!(installed_signed, vec![own, subnet, default_route]);
1407 }
1408
1409 /// The wire default (`UnsignedPeerAPIOnly` absent) must leave `AllowedIPs` untouched, including
1410 /// the capver-112 "null AllowedIPs means the node's own addresses" fallback. Guards against the
1411 /// clamp being applied on the wrong branch.
1412 #[test]
1413 fn from_wire_default_is_not_clamped() {
1414 let wire = ts_control_serde::Node {
1415 addresses: vec!["100.64.0.9/32".parse().unwrap()],
1416 allowed_ips: Some(vec!["198.51.100.0/24".parse().unwrap()]),
1417 ..Default::default()
1418 };
1419 assert!(!wire.unsigned_peer_api_only);
1420 let domain: Node = (&wire).into();
1421 assert_eq!(
1422 domain.accepted_routes,
1423 vec!["198.51.100.0/24".parse::<ipnet::IpNet>().unwrap()]
1424 );
1425 }
1426
1427 /// An unsigned peer with **no** `AllowedIPs` on the wire still lands on its own addresses (the
1428 /// clamp and the capver-112 fallback agree), and a multi-prefix unsigned peer keeps *all* of
1429 /// its assigned prefixes — the clamp is to `Addresses`, not to the v4/v6 identity pair.
1430 #[test]
1431 fn from_wire_unsigned_peer_clamp_keeps_every_assigned_prefix() {
1432 let wire = ts_control_serde::Node {
1433 addresses: vec![
1434 "100.64.0.9/32".parse().unwrap(),
1435 "fd7a:115c:a1e0::9/128".parse().unwrap(),
1436 ],
1437 allowed_ips: None,
1438 unsigned_peer_api_only: true,
1439 ..Default::default()
1440 };
1441 let domain: Node = (&wire).into();
1442 assert_eq!(
1443 domain.accepted_routes,
1444 vec![
1445 "100.64.0.9/32".parse::<ipnet::IpNet>().unwrap(),
1446 "fd7a:115c:a1e0::9/128".parse::<ipnet::IpNet>().unwrap(),
1447 ]
1448 );
1449 assert!(!domain.is_router());
1450 }
1451
1452 /// The deserialization regression itself: a MapResponse-style Node JSON with a 1-element
1453 /// `Addresses` array must parse (this is the exact shape the dev-Headscale sends).
1454 #[test]
1455 fn deserialize_node_with_single_address() {
1456 let json = r#"{
1457 "ID": 1,
1458 "StableID": "n1",
1459 "Name": "host.tail.ts.net.",
1460 "User": 1,
1461 "Addresses": ["100.64.0.9/32"],
1462 "Key": "nodekey:0000000000000000000000000000000000000000000000000000000000000000",
1463 "Machine": null,
1464 "DiscoKey": null,
1465 "AllowedIPs": null,
1466 "Endpoints": []
1467 }"#;
1468 let wire: ts_control_serde::Node = serde_json::from_str(json).expect("1-addr node parses");
1469 assert_eq!(wire.addresses.len(), 1);
1470 let domain: Node = (&wire).into();
1471 assert_eq!(
1472 domain.tailnet_address.ipv4,
1473 "100.64.0.9/32".parse().unwrap()
1474 );
1475 }
1476
1477 #[test]
1478 fn key_expiry_semantics() {
1479 let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
1480 let past: DateTime<Utc> = "2020-01-01T00:00:00Z".parse().unwrap();
1481 let future: DateTime<Utc> = "2099-01-01T00:00:00Z".parse().unwrap();
1482
1483 let mut n = node("h", Some("t.ts.net"));
1484
1485 // No expiry set => never expired (Go zero-value semantics).
1486 n.node_key_expiry = None;
1487 assert!(!n.key_expired(now));
1488 assert_eq!(n.key_expiry(), None);
1489
1490 // Future expiry => not yet expired.
1491 n.node_key_expiry = Some(future);
1492 assert!(!n.key_expired(now));
1493 assert_eq!(n.key_expiry(), Some(future));
1494
1495 // Past expiry => expired.
1496 n.node_key_expiry = Some(past);
1497 assert!(n.key_expired(now));
1498 }
1499
1500 #[test]
1501 fn key_expiry_unix_agrees_with_chrono() {
1502 // The chrono-free variants (`key_expired_at_unix` / `key_expiry_unix`) must agree with the
1503 // chrono variants for the same none/future/past cases (Unix seconds of the same instants).
1504 let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
1505 let past: DateTime<Utc> = "2020-01-01T00:00:00Z".parse().unwrap();
1506 let future: DateTime<Utc> = "2099-01-01T00:00:00Z".parse().unwrap();
1507 let now_unix = now.timestamp();
1508
1509 let mut n = node("h", Some("t.ts.net"));
1510
1511 // No expiry => never expired; the unix accessor reports `None`.
1512 n.node_key_expiry = None;
1513 assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
1514 assert!(!n.key_expired_at_unix(now_unix));
1515 assert_eq!(n.key_expiry_unix(), None);
1516
1517 // Future expiry => not yet expired; unix accessor matches the chrono timestamp.
1518 n.node_key_expiry = Some(future);
1519 assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
1520 assert!(!n.key_expired_at_unix(now_unix));
1521 assert_eq!(n.key_expiry_unix(), Some(future.timestamp()));
1522
1523 // Past expiry => expired; unix accessor matches the chrono timestamp.
1524 n.node_key_expiry = Some(past);
1525 assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
1526 assert!(n.key_expired_at_unix(now_unix));
1527 assert_eq!(n.key_expiry_unix(), Some(past.timestamp()));
1528 }
1529
1530 #[test]
1531 fn key_expiry_boundary_is_not_expired() {
1532 // A key whose expiry exactly equals `now` is NOT expired: the code uses strict `<`, matching
1533 // Go's `Before`. Both the chrono and chrono-free variants must agree at the boundary.
1534 let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
1535 let now_unix = now.timestamp();
1536
1537 let mut n = node("h", Some("t.ts.net"));
1538 n.node_key_expiry = Some(now);
1539
1540 assert!(!n.key_expired(now));
1541 assert!(!n.key_expired_at_unix(now_unix));
1542 }
1543
1544 #[test]
1545 fn is_peer_relay_returns_field() {
1546 let mut n = node("h", Some("t.ts.net"));
1547
1548 n.peer_relay = true;
1549 assert!(n.is_peer_relay());
1550
1551 n.peer_relay = false;
1552 assert!(!n.is_peer_relay());
1553 }
1554
1555 /// A minimal well-formed peer, shared with the `expiry` module's tests so both reason about
1556 /// the same node shape.
1557 pub(crate) fn test_node() -> Node {
1558 node("h", Some("t.ts.net"))
1559 }
1560
1561 fn node(hostname: &str, tailnet: Option<&str>) -> Node {
1562 Node {
1563 id: 1,
1564 stable_id: StableId("n1".to_string()),
1565 hostname: hostname.to_string(),
1566 user_id: 0,
1567 tailnet: tailnet.map(str::to_string),
1568 tags: vec![],
1569 addresses: vec![
1570 "100.64.0.1/32".parse().unwrap(),
1571 "fd7a::1/128".parse().unwrap(),
1572 ],
1573 tailnet_address: TailnetAddress {
1574 ipv4: "100.64.0.1/32".parse().unwrap(),
1575 ipv6: "fd7a::1/128".parse().unwrap(),
1576 },
1577 node_key: [0u8; 32].into(),
1578 node_key_expiry: None,
1579 expired: false,
1580 online: None,
1581 last_seen: None,
1582 key_signature: vec![],
1583 machine_key: None,
1584 disco_key: None,
1585 accepted_routes: vec![],
1586 underlay_addresses: vec![],
1587 derp_region: None,
1588 cap: CapabilityVersion::default(),
1589 cap_map: NodeCapMap::new(),
1590 peerapi_port: None,
1591 peerapi_dns_proxy: false,
1592 is_wireguard_only: false,
1593 exit_node_dns_resolvers: vec![],
1594 peer_relay: false,
1595 ssh_host_keys: vec![],
1596 service_vips: Default::default(),
1597 unsigned_peer_api_only: false,
1598 }
1599 }
1600
1601 #[test]
1602 fn matches_name_is_case_and_trailing_dot_insensitive() {
1603 let n = node("MyHost", Some("tail-scale.ts.net"));
1604
1605 // bare hostname, any case
1606 assert!(n.matches_name("myhost"));
1607 assert!(n.matches_name("MYHOST"));
1608 assert!(n.matches_name("MyHost"));
1609
1610 // fqdn, any case, with and without trailing dot
1611 assert!(n.matches_name("myhost.tail-scale.ts.net"));
1612 assert!(n.matches_name("MYHOST.TAIL-SCALE.TS.NET"));
1613 assert!(n.matches_name("myhost.tail-scale.ts.net."));
1614 assert!(n.matches_name("MyHost.Tail-Scale.TS.NET."));
1615
1616 // wrong host / wrong tailnet must not match
1617 assert!(!n.matches_name("other"));
1618 assert!(!n.matches_name("myhost.other.ts.net"));
1619 }
1620
1621 #[test]
1622 fn matches_name_no_tailnet() {
1623 let n = node("solo", None);
1624 assert!(n.matches_name("solo"));
1625 assert!(n.matches_name("SOLO."));
1626 assert!(!n.matches_name("solo.ts.net"));
1627 }
1628
1629 #[test]
1630 fn is_tailscale_ip_ranges() {
1631 // CGNAT v4
1632 assert!(is_tailscale_ip("100.64.0.1".parse().unwrap()));
1633 assert!(is_tailscale_ip("100.127.255.254".parse().unwrap()));
1634 // ChromeOS carve-out is excluded
1635 assert!(!is_tailscale_ip("100.115.92.5".parse().unwrap()));
1636 // outside CGNAT
1637 assert!(!is_tailscale_ip("10.0.0.1".parse().unwrap()));
1638 assert!(!is_tailscale_ip("100.128.0.1".parse().unwrap()));
1639 // Tailscale ULA v6
1640 assert!(is_tailscale_ip("fd7a:115c:a1e0::1".parse().unwrap()));
1641 assert!(!is_tailscale_ip("fd00::1".parse().unwrap()));
1642 }
1643
1644 /// Taildrop SSRF guard (defense-in-depth). `Device::send_file` rejects an upload destination
1645 /// unless `is_tailscale_ip(peer.peerapi_addr().ip())` holds. `Device::send_file` itself needs a
1646 /// live runtime (it goes through `self.channel()`), so it can't be unit-tested here; instead we
1647 /// test the exact composition the guard relies on — `is_tailscale_ip ∘ peerapi_addr` — against a
1648 /// `Node` whose `tailnet_address.ipv4` has been corrupted to a non-CGNAT (public) address. A
1649 /// well-formed peer always has a CGNAT 100.64.0.0/10 address, but the guard exists to catch a
1650 /// malformed/hostile node; this proves it would reject one.
1651 #[test]
1652 fn taildrop_ssrf_guard_rejects_non_cgnat_peerapi_addr() {
1653 let mut n = node("evil", Some("ts.net"));
1654 // Corrupt the peer to a public, non-CGNAT address and advertise a peerAPI port so
1655 // `peerapi_addr` returns `Some(_)`.
1656 n.tailnet_address.ipv4 = "1.2.3.4/32".parse().unwrap();
1657 n.peerapi_port = Some(443);
1658
1659 let addr = n
1660 .peerapi_addr()
1661 .expect("peerapi_addr yields Some with a port set");
1662 assert_eq!(addr.ip(), Ipv4Addr::new(1, 2, 3, 4));
1663 // The guard `if !is_tailscale_ip(dst.ip()) { return Err(BadRequest) }` WOULD reject this.
1664 assert!(
1665 !is_tailscale_ip(addr.ip()),
1666 "SSRF guard must reject a peer whose peerAPI addr is not a Tailscale CGNAT IP"
1667 );
1668
1669 // Conversely, a well-formed CGNAT peer passes the guard.
1670 let mut good = node("friend", Some("ts.net"));
1671 good.peerapi_port = Some(443);
1672 let good_addr = good.peerapi_addr().expect("peerapi_addr yields Some");
1673 assert!(is_tailscale_ip(good_addr.ip()));
1674 }
1675
1676 /// Ported from upstream's `TestNodeIsRouter` (`tailcfg/tailcfg_test.go`, `8d830599b`): a node
1677 /// is a router exactly when its `AllowedIPs` reach past its own `Addresses`. The absent case
1678 /// (a plain node advertising only its own addresses) is asserted alongside the present one,
1679 /// since "no routes besides my own" is the answer that must not drift.
1680 #[test]
1681 fn is_router_reports_routes_beyond_own_addresses() {
1682 let v4: ipnet::Ipv4Net = "100.64.0.1/32".parse().unwrap();
1683 let v6: ipnet::Ipv6Net = "fd7a:115c:a1e0::1/128".parse().unwrap();
1684 let self4 = ipnet::IpNet::V4(v4);
1685 let self6 = ipnet::IpNet::V6(v6);
1686
1687 let cases: &[(&str, Vec<ipnet::IpNet>, bool)] = &[
1688 ("empty", vec![], false),
1689 ("plain-ipv4", vec![self4], false),
1690 ("plain-ipv6", vec![self6], false),
1691 ("plain-ipv4-ipv6", vec![self4, self6], false),
1692 ("duplicates", vec![self4, self4], false),
1693 (
1694 "exit-node-ipv4",
1695 vec![self4, "0.0.0.0/0".parse().unwrap()],
1696 true,
1697 ),
1698 ("exit-node-ipv6", vec![self6, "::/0".parse().unwrap()], true),
1699 (
1700 "exit-node-ipv4-ipv6",
1701 vec![
1702 self4,
1703 self6,
1704 "0.0.0.0/0".parse().unwrap(),
1705 "::/0".parse().unwrap(),
1706 ],
1707 true,
1708 ),
1709 (
1710 "subnet-router-ipv4",
1711 vec![self4, "192.0.2.0/24".parse().unwrap()],
1712 true,
1713 ),
1714 (
1715 "subnet-router-ipv6",
1716 vec![self6, "2001:db8::/32".parse().unwrap()],
1717 true,
1718 ),
1719 (
1720 "subnet-router-ipv4-ipv6",
1721 vec![
1722 self4,
1723 self6,
1724 "192.0.2.0/24".parse().unwrap(),
1725 "2001:db8::/32".parse().unwrap(),
1726 ],
1727 true,
1728 ),
1729 // Go's `IsRouter` has no Tailscale-range exception: another peer's /32 is still a
1730 // routed address. This is where it parts ways with `is_subnet_route`.
1731 (
1732 "other-tailnet-host",
1733 vec![self4, "100.64.5.5/32".parse().unwrap()],
1734 true,
1735 ),
1736 ];
1737
1738 for (name, allowed, want) in cases {
1739 let mut n = node("host", Some("ts.net"));
1740 n.addresses = vec![self4, self6];
1741 n.tailnet_address = TailnetAddress { ipv4: v4, ipv6: v6 };
1742 n.accepted_routes = allowed.clone();
1743 assert_eq!(n.is_router(), *want, "{name}");
1744 }
1745 }
1746
1747 /// Go's `IsRouter` tests each `AllowedIPs` prefix against the node's **whole** `Addresses`
1748 /// slice, so every prefix control assigned is "its own". The wire field is a variable-length
1749 /// list, not a v4/v6 pair, so a tailnet may hand a node more than one prefix of a family; such
1750 /// a node must not be reported as a router on account of the extra one — which comparing only
1751 /// against the first-of-family `tailnet_address` pair does. Runs through the production `From`
1752 /// impl so the retention of the full list is pinned along with the predicate.
1753 #[test]
1754 fn is_router_tests_every_assigned_address_not_only_the_first_of_each_family() {
1755 let second4: ipnet::IpNet = "100.64.0.9/32".parse().unwrap();
1756 let second6: ipnet::IpNet = "fd7a:115c:a1e0::9/128".parse().unwrap();
1757 let wire = ts_control_serde::Node {
1758 addresses: vec![
1759 "100.64.0.1/32".parse().unwrap(),
1760 second4,
1761 "fd7a:115c:a1e0::1/128".parse().unwrap(),
1762 second6,
1763 ],
1764 ..Default::default()
1765 };
1766 let domain: Node = (&wire).into();
1767
1768 // The identity projection is still the first prefix of each family...
1769 assert_eq!(
1770 domain.tailnet_address.ipv4,
1771 "100.64.0.1/32".parse().unwrap()
1772 );
1773 // ...but every assigned prefix is retained, and (AllowedIPs absent ⇒ routes are exactly
1774 // the addresses) none of them makes the node a router.
1775 assert_eq!(domain.addresses, wire.addresses);
1776 assert!(
1777 !domain.is_router(),
1778 "a node whose routes are exactly its own assigned prefixes is not a router"
1779 );
1780
1781 // Either second-of-family address on its own is still not a routed prefix.
1782 for extra in [second4, second6] {
1783 let mut n = domain.clone();
1784 n.accepted_routes = vec![extra];
1785 assert!(
1786 !n.is_router(),
1787 "{extra} is one of this node's own addresses"
1788 );
1789 }
1790
1791 // The predicate still fires for a route that does reach past every assigned address.
1792 let mut router = domain.clone();
1793 router.accepted_routes.push("192.0.2.0/24".parse().unwrap());
1794 assert!(router.is_router(), "a real subnet route makes it a router");
1795 }
1796
1797 #[test]
1798 fn is_subnet_route_distinguishes_self_from_subnet() {
1799 let n = node("host", Some("ts.net"));
1800
1801 // The node's own /32 and /128 are self-addresses, not subnet routes.
1802 assert!(!n.is_subnet_route(&"100.64.0.1/32".parse().unwrap()));
1803 assert!(!n.is_subnet_route(&"fd7a::1/128".parse().unwrap()));
1804 // A different single Tailscale IP is still a self-address (Tailscale-assigned host).
1805 assert!(!n.is_subnet_route(&"100.64.5.5/32".parse().unwrap()));
1806 // A LAN /24 the node advertises is a subnet route.
1807 assert!(n.is_subnet_route(&"192.168.1.0/24".parse().unwrap()));
1808 // A single non-Tailscale host IP counts as a subnet route.
1809 assert!(n.is_subnet_route(&"8.8.8.8/32".parse().unwrap()));
1810 // The default route is treated as a subnet route.
1811 assert!(n.is_subnet_route(&"0.0.0.0/0".parse().unwrap()));
1812 assert!(n.is_subnet_route(&"::/0".parse().unwrap()));
1813 }
1814
1815 #[test]
1816 fn routes_to_install_gates_subnets_on_accept_routes() {
1817 let mut n = node("host", Some("ts.net"));
1818 let self4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
1819 let self6: ipnet::IpNet = "fd7a::1/128".parse().unwrap();
1820 let subnet: ipnet::IpNet = "192.168.1.0/24".parse().unwrap();
1821 n.accepted_routes = vec![self4, self6, subnet];
1822
1823 // accept_routes off: only the self addresses are installed.
1824 let off: Vec<_> = n.routes_to_install(false, None).copied().collect();
1825 assert_eq!(off, vec![self4, self6]);
1826
1827 // accept_routes on: the advertised subnet is installed too.
1828 let on: Vec<_> = n.routes_to_install(true, None).copied().collect();
1829 assert_eq!(on, vec![self4, self6, subnet]);
1830 }
1831
1832 #[test]
1833 fn routes_to_install_default_route_only_for_selected_exit_node() {
1834 let mut n = node("host", Some("ts.net"));
1835 n.stable_id = StableId("exit1".to_string());
1836 let self4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
1837 let default4: ipnet::IpNet = "0.0.0.0/0".parse().unwrap();
1838 let default6: ipnet::IpNet = "::/0".parse().unwrap();
1839 n.accepted_routes = vec![self4, default4, default6];
1840
1841 // No exit node selected: default routes are excluded even with accept_routes on
1842 // (fail-closed — internet-bound traffic has no overlay route and is dropped).
1843 let none_off: Vec<_> = n.routes_to_install(false, None).copied().collect();
1844 assert_eq!(none_off, vec![self4]);
1845 let none_on: Vec<_> = n.routes_to_install(true, None).copied().collect();
1846 assert_eq!(none_on, vec![self4]);
1847
1848 // A *different* peer selected as exit node: this peer still gets no default route.
1849 let other = StableId("exit2".to_string());
1850 let other_sel: Vec<_> = n.routes_to_install(false, Some(&other)).copied().collect();
1851 assert_eq!(other_sel, vec![self4]);
1852
1853 // This peer selected as the exit node: its default routes are installed.
1854 let me = StableId("exit1".to_string());
1855 let sel: Vec<_> = n.routes_to_install(false, Some(&me)).copied().collect();
1856 assert_eq!(sel, vec![self4, default4, default6]);
1857 }
1858
1859 fn exit_node_with(id: &str, ipv4: &str, hostname: &str, tailnet: Option<&str>) -> Node {
1860 let mut n = node(hostname, tailnet);
1861 n.stable_id = StableId(id.to_string());
1862 n.tailnet_address.ipv4 = format!("{ipv4}/32").parse().unwrap();
1863 n
1864 }
1865
1866 #[test]
1867 fn exit_node_selector_resolves_by_id_ip_and_name() {
1868 let a = exit_node_with("nA", "100.64.0.5", "alpha", Some("ts.net"));
1869 let b = exit_node_with("nB", "100.64.0.6", "beta", Some("ts.net"));
1870 let peers = [a, b];
1871 let it = || peers.iter();
1872
1873 // By stable id.
1874 assert_eq!(
1875 ExitNodeSelector::StableId(StableId("nB".into())).resolve(it()),
1876 Some(StableId("nB".into()))
1877 );
1878 // By tailnet IP.
1879 assert_eq!(
1880 ExitNodeSelector::Ip("100.64.0.5".parse().unwrap()).resolve(it()),
1881 Some(StableId("nA".into()))
1882 );
1883 // By MagicDNS name (fqdn, case-insensitive).
1884 assert_eq!(
1885 ExitNodeSelector::Name("BETA.ts.net".into()).resolve(it()),
1886 Some(StableId("nB".into()))
1887 );
1888 // By bare hostname.
1889 assert_eq!(
1890 ExitNodeSelector::Name("alpha".into()).resolve(it()),
1891 Some(StableId("nA".into()))
1892 );
1893 // Unresolvable selector => None (fail-closed at the call site).
1894 assert_eq!(
1895 ExitNodeSelector::Ip("100.64.0.99".parse().unwrap()).resolve(it()),
1896 None
1897 );
1898 assert_eq!(ExitNodeSelector::Name("ghost".into()).resolve(it()), None);
1899 }
1900
1901 #[test]
1902 fn exit_node_selector_resolution_is_deterministic_on_ties() {
1903 // Two peers sharing a name (transient netmap state): the smallest stable id wins, so the
1904 // outbound table and inbound source filter — which resolve independently — agree.
1905 let a = exit_node_with("nZ", "100.64.0.5", "dup", Some("ts.net"));
1906 let b = exit_node_with("nA", "100.64.0.6", "dup", Some("ts.net"));
1907 let peers = [a, b];
1908
1909 assert_eq!(
1910 ExitNodeSelector::Name("dup".into()).resolve(peers.iter()),
1911 Some(StableId("nA".into())),
1912 "smallest stable id wins the tie"
1913 );
1914 // Order of iteration must not change the result.
1915 assert_eq!(
1916 ExitNodeSelector::Name("dup".into()).resolve(peers.iter().rev()),
1917 Some(StableId("nA".into()))
1918 );
1919 }
1920
1921 #[test]
1922 fn peerapi_doh_url_requires_port_and_capability() {
1923 let mut n = node("exit", Some("ts.net"));
1924 n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
1925
1926 // No peerAPI port advertised: cannot proxy DNS.
1927 n.peerapi_port = None;
1928 n.cap = CapabilityVersion::V130;
1929 assert_eq!(n.peerapi_doh_url(), None);
1930
1931 // Port advertised but capability too old and no explicit service: cannot proxy.
1932 n.peerapi_port = Some(8080);
1933 n.cap = CapabilityVersion::V25;
1934 n.peerapi_dns_proxy = false;
1935 assert_eq!(n.peerapi_doh_url(), None);
1936
1937 // Port + new-enough capability: yields the DoH URL on the IPv4 address.
1938 n.cap = CapabilityVersion::V26;
1939 assert_eq!(
1940 n.peerapi_doh_url().as_deref(),
1941 Some("http://100.64.0.5:8080/dns-query")
1942 );
1943
1944 // Port + explicit peerapi-dns-proxy service, even with an old capability.
1945 n.cap = CapabilityVersion::V25;
1946 n.peerapi_dns_proxy = true;
1947 assert_eq!(
1948 n.peerapi_doh_url().as_deref(),
1949 Some("http://100.64.0.5:8080/dns-query")
1950 );
1951
1952 // WireGuard-only peers never run a peerAPI: no DoH URL even with a port.
1953 n.is_wireguard_only = true;
1954 assert_eq!(n.peerapi_doh_url(), None);
1955 }
1956
1957 #[test]
1958 fn peerapi_doh_addr_matches_url_gate() {
1959 let mut n = node("exit", Some("ts.net"));
1960 n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
1961 n.peerapi_port = Some(8080);
1962 n.cap = CapabilityVersion::V26;
1963
1964 // The addr form the DoH client dials is the same gated endpoint as the URL.
1965 assert_eq!(
1966 n.peerapi_doh_addr(),
1967 Some("100.64.0.5:8080".parse().unwrap())
1968 );
1969 // And it composes into exactly the URL form.
1970 assert_eq!(
1971 n.peerapi_doh_url().as_deref(),
1972 Some("http://100.64.0.5:8080/dns-query")
1973 );
1974
1975 // Gated off the same way: no port => no addr.
1976 n.peerapi_port = None;
1977 assert_eq!(n.peerapi_doh_addr(), None);
1978 }
1979
1980 #[test]
1981 fn peerapi_addr_returns_addr_when_advertised() {
1982 let mut n = node("peer", Some("ts.net"));
1983 n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
1984 n.peerapi_port = Some(8089);
1985
1986 // Not gated on the DNS-proxy capability: a plain advertised peerAPI port is enough.
1987 assert_eq!(n.peerapi_addr(), Some("100.64.0.5:8089".parse().unwrap()));
1988 }
1989
1990 #[test]
1991 fn peerapi_addr_none_when_no_port() {
1992 let mut n = node("peer", Some("ts.net"));
1993 n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
1994 n.peerapi_port = None;
1995
1996 assert_eq!(n.peerapi_addr(), None);
1997 }
1998
1999 #[test]
2000 fn peerapi_addr_none_for_wireguard_only() {
2001 let mut n = node("peer", Some("ts.net"));
2002 n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
2003 n.peerapi_port = Some(8089);
2004 n.is_wireguard_only = true;
2005
2006 // WireGuard-only peers run no peerAPI, even with a port set.
2007 assert_eq!(n.peerapi_addr(), None);
2008 }
2009
2010 #[test]
2011 fn can_share_files_gated_on_self_capability() {
2012 let mut n = node("self", Some("ts.net"));
2013 assert!(
2014 !n.can_share_files(),
2015 "no cap → file sharing not enabled (fail-closed)"
2016 );
2017 n.cap_map
2018 .insert("https://tailscale.com/cap/file-sharing".to_string(), vec![]);
2019 assert!(n.can_share_files(), "the file-sharing cap enables it");
2020 }
2021
2022 #[test]
2023 fn is_file_sharing_target_gated_on_peer_capability() {
2024 let mut n = node("peer", Some("ts.net"));
2025 assert!(
2026 !n.is_file_sharing_target(),
2027 "no cap → not an explicit target"
2028 );
2029 n.cap_map
2030 .insert("tailscale.com/cap/file-sharing-target".to_string(), vec![]);
2031 assert!(
2032 n.is_file_sharing_target(),
2033 "the file-sharing-target cap marks a cross-owner target"
2034 );
2035 }
2036
2037 #[test]
2038 fn resolves_subdomains_gated_on_the_node_attribute() {
2039 let mut n = node("peer", Some("ts.net"));
2040 assert!(
2041 !n.resolves_subdomains(),
2042 "no attribute → not a subdomain host: control has to opt the node in"
2043 );
2044 n.cap_map
2045 .insert("dns-subdomain-resolve".to_string(), vec![]);
2046 assert!(
2047 n.resolves_subdomains(),
2048 "the dns-subdomain-resolve attribute makes this node a subdomain host"
2049 );
2050 }
2051
2052 /// The DNS forwarder's TCP retry is ON by default, so the attribute has to be read as the *off*
2053 /// switch it is: absent means retry. Getting the polarity backwards would silently disable the
2054 /// retry on every tailnet that never set the attribute — the failure the retry exists to remove.
2055 #[test]
2056 fn dns_forwarder_tcp_retries_disabled_only_by_the_node_attribute() {
2057 let mut n = node("peer", Some("ts.net"));
2058 assert!(
2059 !n.disable_dns_forwarder_tcp_retries(),
2060 "no attribute → the retry stays on: this is the off switch, not the on switch"
2061 );
2062 n.cap_map
2063 .insert("dns-forwarder-disable-tcp-retries".to_string(), vec![]);
2064 assert!(
2065 n.disable_dns_forwarder_tcp_retries(),
2066 "the dns-forwarder-disable-tcp-retries attribute turns the TCP retry off"
2067 );
2068 }
2069
2070 /// The debug-always-stun attribute is control's only way to keep the periodic STUN sweep running
2071 /// once the datapath has gone idle, so its key has to be the literal Go sends
2072 /// (`NodeAttrDebugForceBackgroundSTUN`). Absent is the quiet default.
2073 #[test]
2074 fn force_background_stun_gated_on_the_node_attribute() {
2075 let mut n = node("self", Some("ts.net"));
2076 assert!(
2077 !n.force_background_stun(),
2078 "no attribute → the idle stop applies, which is the quiet default"
2079 );
2080 n.cap_map.insert("debug-always-stun".to_string(), vec![]);
2081 assert!(
2082 n.force_background_stun(),
2083 "the debug-always-stun attribute keeps the background sweep running"
2084 );
2085 }
2086
2087 #[test]
2088 fn one_cgnat_is_a_tri_state_read_off_the_query_string_keys() {
2089 let mut n = node("self", Some("ts.net"));
2090 assert_eq!(
2091 n.one_cgnat(),
2092 None,
2093 "neither attribute → control has no opinion, the threshold decides"
2094 );
2095
2096 // The key carries a query string; the bare `one-cgnat` is not the attribute and must not
2097 // be mistaken for either half of the tri-state.
2098 n.cap_map.insert("one-cgnat".to_string(), vec![]);
2099 assert_eq!(
2100 n.one_cgnat(),
2101 None,
2102 "a bare `one-cgnat` key is not one of the two attributes control sets"
2103 );
2104
2105 n.cap_map.insert("one-cgnat?v=false".to_string(), vec![]);
2106 assert_eq!(
2107 n.one_cgnat(),
2108 Some(false),
2109 "`one-cgnat?v=false` forces one route per peer"
2110 );
2111
2112 n.cap_map.insert("one-cgnat?v=true".to_string(), vec![]);
2113 assert_eq!(
2114 n.one_cgnat(),
2115 Some(true),
2116 "a node holding both attributes collapses: the enabling attribute is checked first"
2117 );
2118
2119 n.cap_map.remove("one-cgnat?v=false");
2120 assert_eq!(
2121 n.one_cgnat(),
2122 Some(true),
2123 "`one-cgnat?v=true` alone collapses"
2124 );
2125 }
2126
2127 #[test]
2128 fn delta_updates_disabled_reads_the_disable_delta_updates_attribute() {
2129 let mut n = node("self", Some("ts.net"));
2130 assert!(
2131 !n.delta_updates_disabled(),
2132 "absent attribute → the incremental path, which is the default"
2133 );
2134
2135 // The attribute is the bare key; the presence of the key is the whole signal, and its
2136 // value (control sends an empty one) is never read.
2137 n.cap_map
2138 .insert("disable-delta-updates".to_string(), vec![]);
2139 assert!(
2140 n.delta_updates_disabled(),
2141 "control granted the attribute → decline the incremental path"
2142 );
2143
2144 n.cap_map.remove("disable-delta-updates");
2145 assert!(
2146 !n.delta_updates_disabled(),
2147 "control withdrawing the attribute returns the node to the incremental path"
2148 );
2149 }
2150
2151 #[test]
2152 fn silent_disco_reads_the_silent_disco_attribute() {
2153 let mut n = node("self", Some("ts.net"));
2154 assert!(
2155 !n.silent_disco(),
2156 "absent attribute → keep heartbeating, which is the default"
2157 );
2158
2159 // The attribute is the bare key; the presence of the key is the whole signal, and its
2160 // value (control sends an empty one) is never read.
2161 n.cap_map.insert("silent-disco".to_string(), vec![]);
2162 assert!(
2163 n.silent_disco(),
2164 "control granted the attribute → stop heartbeating peers"
2165 );
2166
2167 n.cap_map.remove("silent-disco");
2168 assert!(
2169 !n.silent_disco(),
2170 "control withdrawing the attribute returns the node to the heartbeat cadence"
2171 );
2172 }
2173
2174 #[test]
2175 fn peerapi_from_services_extracts_v4_port_and_dns_proxy_flag() {
2176 use ts_control_serde::{Service, ServiceProto};
2177
2178 let services = [
2179 Service {
2180 proto: ServiceProto::PeerApi4,
2181 port: 8080,
2182 description: "peerapi".into(),
2183 },
2184 Service {
2185 proto: ServiceProto::PeerApi6,
2186 port: 9090,
2187 description: "peerapi6".into(),
2188 },
2189 Service {
2190 proto: ServiceProto::PeerApiDnsProxy,
2191 port: 1,
2192 description: "dns".into(),
2193 },
2194 ];
2195 let (port, dns_proxy) = peerapi_from_services(Some(&services));
2196 assert_eq!(port, Some(8080), "only the IPv4 peerAPI port is taken");
2197 assert!(dns_proxy);
2198
2199 // No services at all.
2200 assert_eq!(peerapi_from_services(None), (None, false));
2201 }
2202
2203 #[test]
2204 fn exit_node_selector_parses_ip_vs_name() {
2205 assert_eq!(
2206 "100.64.0.5".parse::<ExitNodeSelector>().unwrap(),
2207 ExitNodeSelector::Ip("100.64.0.5".parse().unwrap())
2208 );
2209 assert_eq!(
2210 "fd7a::5".parse::<ExitNodeSelector>().unwrap(),
2211 ExitNodeSelector::Ip("fd7a::5".parse().unwrap())
2212 );
2213 assert_eq!(
2214 "my-exit.ts.net".parse::<ExitNodeSelector>().unwrap(),
2215 ExitNodeSelector::Name("my-exit.ts.net".into())
2216 );
2217 }
2218}