Skip to main content

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    /// Expired peers have no installed routes. A live node's own addresses (`/32` and `/128`) are
479    /// always installed so the peer itself stays reachable. Larger advertised subnet routes are
480    /// only installed when `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 self.expired {
503                return false;
504            }
505            if route.prefix_len() == 0 {
506                // Default route: installed only when this peer is the selected exit node. Both the
507                // outbound route table and the inbound source filter call this, so the exit peer
508                // may legitimately source arbitrary internet IPs on return traffic — and only it.
509                return is_selected_exit;
510            }
511            accept_routes || !self.is_subnet_route(route)
512        })
513    }
514
515    /// The capability version at and above which a peer can proxy DNS for nodes using it as an exit
516    /// node (Go `tailcfg.CapabilityVersion` `peerCanProxyDNS`, introduced 2022-01-12 at V26).
517    const PEER_CAN_PROXY_DNS: CapabilityVersion = CapabilityVersion::V26;
518
519    /// The base URL of this peer's IPv4 peerAPI DoH endpoint for exit-node DNS proxying, if it can
520    /// proxy DNS. Returns e.g. `http://100.64.0.5:8080/dns-query`.
521    ///
522    /// Mirrors Go `peerAPIBase(...)+"/dns-query"` gated by `exitNodeCanProxyDNS`: a peer can proxy
523    /// DNS when it advertises an IPv4 peerAPI port **and** either advertises the explicit
524    /// `peerapi-dns-proxy` service or is new enough ([`Node::cap`] ≥ `PEER_CAN_PROXY_DNS`). A
525    /// WireGuard-only peer never runs a peerAPI, so it returns `None` here (its exit-node DNS comes
526    /// from [`Node::exit_node_dns_resolvers`] instead).
527    ///
528    /// IPv4-only by deliberate design: the tailnet dataplane in this fork binds IPv4 only, so we
529    /// never form a peerAPI URL on the peer's IPv6 address.
530    ///
531    /// `None` for an [`expired`](Self::expired) peer — see [`Node::peerapi_addr`].
532    pub fn peerapi_doh_url(&self) -> Option<String> {
533        self.peerapi_doh_addr()
534            .map(|addr| format!("http://{addr}/dns-query"))
535    }
536
537    /// The IPv4 socket address (`<tailnet-ipv4>:<peerapi-port>`) of this peer's peerAPI DoH endpoint
538    /// for exit-node DNS proxying, if it can proxy DNS. Same gate as [`Node::peerapi_doh_url`]; this
539    /// is the form the DoH *client* dials (over the overlay netstack) when delegating recursive
540    /// resolution to a selected exit node. `SocketAddr`'s `Display` is `ip:port`, so
541    /// `peerapi_doh_url` formats to `http://<ip>:<port>/dns-query` over this.
542    pub fn peerapi_doh_addr(&self) -> Option<SocketAddr> {
543        if self.is_wireguard_only || self.expired {
544            return None;
545        }
546        let port = self.peerapi_port?;
547        if !(self.peerapi_dns_proxy || self.cap >= Self::PEER_CAN_PROXY_DNS) {
548            return None;
549        }
550        Some(SocketAddr::new(
551            IpAddr::V4(self.tailnet_address.ipv4.addr()),
552            port,
553        ))
554    }
555
556    /// The IPv4 peerAPI socket address (`<tailnet-ipv4>:<peerapi4-port>`) of this node, if it
557    /// advertises an IPv4 peerAPI. Unlike [`Node::peerapi_doh_addr`], this is **not** gated on the
558    /// DNS-proxy capability: it is the general base for any peerAPI request to this node (e.g. a
559    /// Taildrop `PUT /v0/put/<name>` upload), mirroring Go's `peerAPIBase`/`peerAPIPorts`.
560    ///
561    /// IPv4-only by this fork's deliberate design (the tailnet dataplane binds IPv4 only, so we never
562    /// form a peerAPI URL on the peer's IPv6 address). Returns `None` for a WireGuard-only peer (which
563    /// runs no peerAPI) or a peer advertising no IPv4 peerAPI port.
564    ///
565    /// Also `None` for an [`expired`](Self::expired) peer: Go refuses a peerAPI dial to one with
566    /// [`PEER_KEY_EXPIRED`](crate::PEER_KEY_EXPIRED) (`LocalBackend.pingPeerAPI`), and this is the
567    /// chokepoint every peerAPI dial in this fork resolves its destination through. Callers that
568    /// want to *report* the refusal rather than silently skip the peer should test
569    /// [`expired`](Self::expired) first.
570    pub fn peerapi_addr(&self) -> Option<SocketAddr> {
571        if self.is_wireguard_only || self.expired {
572            return None;
573        }
574        let port = self.peerapi_port?;
575        Some(SocketAddr::new(
576            IpAddr::V4(self.tailnet_address.ipv4.addr()),
577            port,
578        ))
579    }
580
581    /// The node attribute granting HTTPS (TLS cert provisioning) for this node (Go
582    /// `tailcfg.CapabilityHTTPS`). One of the two caps [`Node::can_funnel`] requires.
583    const CAP_HTTPS: &'static str = "https";
584
585    /// The node attribute granting the ability to host Funnel ingress (Go `tailcfg.NodeAttrFunnel`).
586    /// The other cap [`Node::can_funnel`] requires.
587    const NODE_ATTR_FUNNEL: &'static str = "funnel";
588
589    /// The capability URL whose `?ports=` query enumerates the ports Funnel may listen on (Go
590    /// `tailcfg.CapabilityFunnelPorts`). The allowed ports live entirely in the *key's* query
591    /// string, not the cap value.
592    const CAP_FUNNEL_PORTS: &'static str = "https://tailscale.com/cap/funnel-ports";
593
594    /// Report whether the cap map contains `cap` as a key (Go `NodeCapMap.Contains` / `HasCap`).
595    pub fn has_node_attr(&self, cap: &str) -> bool {
596        self.cap_map.contains_key(cap)
597    }
598
599    /// Report whether this node is permitted to host Tailscale Funnel ingress.
600    ///
601    /// Mirrors Go `ipn.NodeCanFunnel`: the node must advertise BOTH `CapabilityHTTPS` (`"https"`)
602    /// AND `NodeAttrFunnel` (`"funnel"`) in its cap map. Fail-closed: a missing cap denies.
603    pub fn can_funnel(&self) -> bool {
604        self.has_node_attr(Self::CAP_HTTPS) && self.has_node_attr(Self::NODE_ATTR_FUNNEL)
605    }
606
607    /// The capability control grants the **self** node when Taildrop is enabled for the tailnet (Go
608    /// `tailcfg.CapabilityFileSharing`). Gates [`Node::can_share_files`].
609    const CAP_FILE_SHARING: &'static str = "https://tailscale.com/cap/file-sharing";
610
611    /// The capability marking a **peer** as an explicit Taildrop send target even across owners (Go
612    /// `tailcfg.PeerCapabilityFileSharingTarget`). Checked by [`Node::is_file_sharing_target`].
613    const CAP_FILE_SHARING_TARGET: &'static str = "tailscale.com/cap/file-sharing-target";
614
615    /// Report whether this node may send Taildrop files — i.e. the admin has enabled file sharing for
616    /// the tailnet (Go `self.CapMap().Contains(CapabilityFileSharing)`). Applied to the **self** node
617    /// as the node-level gate in `FileTargets`; fail-closed when the cap is absent.
618    pub fn can_share_files(&self) -> bool {
619        self.has_node_attr(Self::CAP_FILE_SHARING)
620    }
621
622    /// Report whether this **peer** is an explicit Taildrop send target via ACL caps (Go
623    /// `PeerHasCap(p, PeerCapabilityFileSharingTarget)`) — the cross-owner path that lets a peer owned
624    /// by a different user still be a valid target.
625    pub fn is_file_sharing_target(&self) -> bool {
626        self.has_node_attr(Self::CAP_FILE_SHARING_TARGET)
627    }
628
629    /// The node attribute control sets on a node whose **subdomains** all resolve to the node
630    /// itself (Go `tailcfg/nodecap`'s `NodeAttrDNSSubdomainResolve`). Read by
631    /// [`Node::resolves_subdomains`].
632    const NODE_ATTR_DNS_SUBDOMAIN_RESOLVE: &'static str = "dns-subdomain-resolve";
633
634    /// Report whether every subdomain of this node's MagicDNS name resolves to this node's
635    /// addresses — `foo.<node>` and `bar.foo.<node>` alike.
636    ///
637    /// Go's resolver (`net/dns/resolver/tsdns.go`) learns the same thing two ways — a
638    /// `Config.SubdomainHosts` set of FQDNs beside its `Hosts` map, and a `SubdomainHost` predicate
639    /// on its MagicDNS host index — and on a lookup miss walks the queried name's parents,
640    /// answering from the first parent either one accepts. Here the attribute on the node *is* that
641    /// predicate, read where the parent walk finds the node.
642    ///
643    /// Being a plain per-node attribute, it needs no capability version: a node control has not set
644    /// it on is unaffected, and its subdomains stay `NXDOMAIN`.
645    pub fn resolves_subdomains(&self) -> bool {
646        self.has_node_attr(Self::NODE_ATTR_DNS_SUBDOMAIN_RESOLVE)
647    }
648
649    /// The node attribute control sets to stop the DNS forwarder re-asking a truncated upstream
650    /// answer over TCP (Go `tailcfg/nodecap`'s `NodeAttrDNSForwarderDisableTCPRetries`, surfaced in
651    /// `control/controlknobs` as `Knobs.DisableDNSForwarderTCPRetries`). Read by
652    /// [`Node::disable_dns_forwarder_tcp_retries`].
653    const NODE_ATTR_DNS_FORWARDER_DISABLE_TCP_RETRIES: &'static str =
654        "dns-forwarder-disable-tcp-retries";
655
656    /// Report whether control has told this node **not** to retry a truncated forwarded DNS answer
657    /// over TCP.
658    ///
659    /// The retry is on by default and this attribute is its *off* switch — so, unlike every other
660    /// attribute here, the fail-closed reading is the one that ignores it: a node control has not
661    /// set it on keeps retrying, which is what a stub resolver on this node needs for a name whose
662    /// answer does not fit a datagram. Go reads it the same way round
663    /// (`skipTCP := skipTCPRetry() || (f.controlKnobs != nil &&
664    /// f.controlKnobs.DisableDNSForwarderTCPRetries.Load())`, net/dns/resolver/forwarder.go).
665    ///
666    /// Upstream dates a client's understanding of the attribute to capability version 75
667    /// ([`ts_capabilityversion::CapabilityVersion::V75`]), which this tree's `CURRENT` is well
668    /// above, so control will send it to this node when the tailnet sets it.
669    pub fn disable_dns_forwarder_tcp_retries(&self) -> bool {
670        self.has_node_attr(Self::NODE_ATTR_DNS_FORWARDER_DISABLE_TCP_RETRIES)
671    }
672
673    /// The node attribute by which control asks this node to keep its periodic STUN sweep running
674    /// even while the datapath is idle (Go `tailcfg/nodecap`'s `NodeAttrDebugForceBackgroundSTUN`,
675    /// surfaced in `control/controlknobs` as `Knobs.ForceBackgroundSTUN`). Read by
676    /// [`force_background_stun`](Self::force_background_stun).
677    const NODE_ATTR_DEBUG_FORCE_BACKGROUND_STUN: &'static str = "debug-always-stun";
678
679    /// Report whether control has asked this node to keep STUNning in the background regardless of
680    /// datapath activity.
681    ///
682    /// This is the single override on the idle stop condition in Go magicsock's
683    /// `shouldDoPeriodicReSTUNLocked`: once the datapath has been idle longer than the session-active
684    /// timeout the periodic sweep stops, *unless* `c.controlKnobs.ForceBackgroundSTUN` is set, in
685    /// which case it keeps running. It overrides nothing else — a node with no peers still does not
686    /// STUN, with or without the attribute, because that arm returns before the idle arm is reached.
687    ///
688    /// Read off the **self** node's cap map, like every other control knob. Absent (the normal case)
689    /// means "let the idle stop apply", which is the quiet default; the attribute is a debugging
690    /// escape hatch control sets deliberately, so there is nothing to fail closed to here.
691    pub fn force_background_stun(&self) -> bool {
692        self.has_node_attr(Self::NODE_ATTR_DEBUG_FORCE_BACKGROUND_STUN)
693    }
694
695    /// The node attribute by which control asks this node to collapse its per-peer CGNAT host
696    /// routes into the single `100.64.0.0/10` (Go `tailcfg/nodecap`'s `OneCGNATEnable`).
697    ///
698    /// Note the query string: the key is the literal `one-cgnat?v=true`, not `one-cgnat`. The
699    /// attribute is a tri-state carried as two mutually exclusive keys rather than as a key with a
700    /// value, so the lookup is on the whole literal.
701    const NODE_ATTR_ONE_CGNAT_ENABLE: &'static str = "one-cgnat?v=true";
702
703    /// The node attribute by which control asks this node NOT to force the collapsed route (Go
704    /// `tailcfg/nodecap`'s `OneCGNATDisable`): one host route per peer until the consumer's own
705    /// peer-count ceiling, which still applies. The other half of
706    /// [`NODE_ATTR_ONE_CGNAT_ENABLE`](Self::NODE_ATTR_ONE_CGNAT_ENABLE)'s tri-state.
707    ///
708    /// It declines the **forced** collapse only. It is not a licence for an unbounded host route
709    /// table: the consumer's own peer-count ceiling still applies above it — see
710    /// [`Node::one_cgnat`](Self::one_cgnat).
711    const NODE_ATTR_ONE_CGNAT_DISABLE: &'static str = "one-cgnat?v=false";
712
713    /// Control's tri-state instruction about collapsing this node's per-peer CGNAT host routes
714    /// into the single `100.64.0.0/10`, read off the **self** node's cap map.
715    ///
716    /// Mirrors Go `ipn/ipnlocal`'s read of `nodecap.OneCGNATEnable` / `nodecap.OneCGNATDisable`
717    /// into `controlknobs.Knobs.OneCGNAT`, which is an `opt.Bool` and not a `bool` precisely so the
718    /// third state exists:
719    ///
720    /// * `Some(true)` — `one-cgnat?v=true`: collapse as soon as there is more than one peer route.
721    /// * `Some(false)` — `one-cgnat?v=false`: do not force the collapse, whatever the platform
722    ///   would default to. It is NOT "never collapse": the consumer's peer-count ceiling (Go
723    ///   `net/routemanager`'s `cgnatThreshold`) still collapses a large enough tailnet.
724    /// * `None` — neither attribute present: control has no opinion, and the consumer's platform
725    ///   default decides (Go `ipn/ipnlocal`'s `shouldUseOneCGNATRoute`), under the same ceiling.
726    ///
727    /// What the third state does NOT do is switch the consumer's peer-count ceiling off. Upstream
728    /// resolves this `opt.Bool` down to a plain `bool` before the route manager sees it, and
729    /// `RouteManager.cgnatThreshold()` then picks between `1` and `cgnatThreshold` (`10_000`) —
730    /// so a node holding `one-cgnat?v=false` still collapses once it has more CGNAT peer routes
731    /// than the ceiling. The tri-state chooses whether to collapse *early*, not whether a host
732    /// route table may grow without bound.
733    ///
734    /// A node holding BOTH attributes reads as `Some(true)`: the enabling attribute is checked
735    /// first and wins. Control setting both is a policy conflict rather than a state upstream
736    /// specifies, and collapsing is the safe way to break the tie — the `/10` is a superset of the
737    /// `/32`s it replaces, so no peer becomes unreachable, whereas honouring the disabling
738    /// attribute on a tailnet large enough for control to have set the enabling one would keep a
739    /// per-peer route table far past the point where it is worth programming.
740    pub fn one_cgnat(&self) -> Option<bool> {
741        if self.has_node_attr(Self::NODE_ATTR_ONE_CGNAT_ENABLE) {
742            Some(true)
743        } else if self.has_node_attr(Self::NODE_ATTR_ONE_CGNAT_DISABLE) {
744            Some(false)
745        } else {
746            None
747        }
748    }
749
750    /// The node attribute by which control asks this node to stop processing netmap updates through
751    /// the delta (incremental) path (Go `tailcfg/nodecap`'s `DisableDeltaUpdates`, read into
752    /// `controlknobs.Knobs.DisableDeltaUpdates`). Read off the **self** node's cap map by
753    /// [`delta_updates_disabled`](Self::delta_updates_disabled).
754    ///
755    /// Upstream documents the intent on the knob itself: the client "should not process updates via
756    /// the delta update mechanism and should instead treat all netmap changes as 'full' ones as
757    /// tailscaled did in 1.48.x and earlier". It is control's escape hatch for a delta-encoding bug
758    /// on *either* side of the map protocol — control emitting bad patches, or a client applying
759    /// them wrongly — without waiting for a client release to ship.
760    const NODE_ATTR_DISABLE_DELTA_UPDATES: &'static str = "disable-delta-updates";
761
762    /// Report whether control has asked this node to decline the incremental netmap path and treat
763    /// every netmap change as a full one.
764    ///
765    /// Mirrors the first statement of Go `control/controlclient/map.go`'s `tryHandleIncrementally`:
766    /// `if ms.controlKnobs != nil && ms.controlKnobs.DisableDeltaUpdates.Load() { return false }`.
767    /// Returning `false` there does **not** reject the response and does not drop the mutations it
768    /// carries — it declines the incremental arm so the full netmap rebuild handles the very same
769    /// response. A consumer of this method owes the same shape: fall back, never drop.
770    ///
771    /// Absent attribute ⇒ `false` ⇒ the delta path, which is the default and the overwhelmingly
772    /// common case. Being a plain per-node attribute it needs no capability version: a node control
773    /// has not set it on is unaffected.
774    pub fn delta_updates_disabled(&self) -> bool {
775        self.has_node_attr(Self::NODE_ATTR_DISABLE_DELTA_UPDATES)
776    }
777
778    /// The node attribute by which control tells this node to stop sending disco heartbeats to its
779    /// peers (Go `tailcfg/nodecap`'s `SilentDisco`, read into `controlknobs.Knobs.SilentDisco` and
780    /// handed to magicsock by `ipn/ipnlocal`'s `b.MagicConn().SetSilentDisco(...)`). Read off the
781    /// **self** node's cap map by [`silent_disco`](Self::silent_disco).
782    ///
783    /// Upstream's own summary of the attribute is one sentence — it "makes the client suppress
784    /// disco heartbeats to its peers" — and the node it is set on is the node that goes quiet, so
785    /// it is the *self* node's cap map that decides, never the peer's.
786    const NODE_ATTR_SILENT_DISCO: &'static str = "silent-disco";
787
788    /// Report whether control has asked this node to stop heartbeating its peers' confirmed direct
789    /// paths.
790    ///
791    /// Mirrors Go magicsock `Conn.debugFlagsLocked`'s `heartbeatDisabled`, which the netmap push
792    /// (`endpoint.updateFromNode`) stamps onto every endpoint. With it set, the periodic
793    /// keep-the-best-path-alive ping is not sent — and, as the compensating half, an inbound packet
794    /// from the current best address extends that path's trust directly, because with no heartbeat
795    /// there is nothing else keeping it trusted.
796    ///
797    /// Absent attribute ⇒ `false` ⇒ the existing heartbeat cadence, which is the default and the
798    /// overwhelmingly common case. Being a plain per-node attribute it needs no capability version:
799    /// a node control has not set it on is unaffected.
800    ///
801    /// Go additionally ORs in a `TS_DEBUG_ENABLE_SILENT_DISCO` envknob at the same place. This tree
802    /// has no envknob layer at all, so the control attribute is the whole input here.
803    pub fn silent_disco(&self) -> bool {
804        self.has_node_attr(Self::NODE_ATTR_SILENT_DISCO)
805    }
806
807    /// The node attribute by which control tells this node that the network it sits on tolerates
808    /// nothing but TCP on port 443 (Go `tailcfg/nodecap`'s `OnlyTCP443`, read off `nm.SelfNode` in
809    /// `ipn/ipnlocal` and handed to magicsock by `b.MagicConn().SetOnlyTCP443(...)`). Read off the
810    /// **self** node's cap map by [`only_tcp_443`](Self::only_tcp_443).
811    ///
812    /// Upstream's own words: the client "should not attempt to generate any outbound traffic that
813    /// isn't TCP on port 443", which "thus implies all traffic is over DERP". The node it is set on
814    /// is the node that goes quiet, so it is the *self* node's cap map that decides, never the
815    /// peer's.
816    const NODE_ATTR_ONLY_TCP_443: &'static str = "only-tcp-443";
817
818    /// Report whether control has told this node to emit nothing but TCP/443 — no UDP at all, so
819    /// every tailnet packet rides DERP.
820    ///
821    /// This is the single predicate behind all of upstream's TCP-443-only behaviour: magicsock's
822    /// UDP send chokepoint refuses silently, netcheck's UDP (STUN) probes refuse with
823    /// `errors.ErrUnsupported`, the portmapper is disabled and the peer-relay client is switched
824    /// off. Consumers in this tree read it here rather than each re-deriving it from the cap map.
825    ///
826    /// Absent attribute ⇒ `false` ⇒ UDP as before, which is the default and the overwhelmingly
827    /// common case. Being a plain per-node attribute it needs no capability version: a node control
828    /// has not set it on is unaffected. It is read live off each netmap, never latched at start-up,
829    /// because upstream's setter (`Conn.SetOnlyTCP443`) is live — control withdrawing the attribute
830    /// has to restore UDP without a restart.
831    pub fn only_tcp_443(&self) -> bool {
832        self.has_node_attr(Self::NODE_ATTR_ONLY_TCP_443)
833    }
834
835    /// Report whether `wanted_port` is allowed for Funnel on this node.
836    ///
837    /// Mirrors Go `ipn.CheckFunnelPort`: scan the cap-map keys for one prefixed by
838    /// `Node::CAP_FUNNEL_PORTS`, URL-parse that key, read its `ports` query parameter, and match
839    /// `wanted_port` against the comma-separated list of single ports and `first-last` ranges. The
840    /// port list lives in the *key*, never the value. Fail-closed: no matching cap, an empty or
841    /// unparseable `ports` query, or a key whose non-query part isn't exactly the funnel-ports URL
842    /// all deny.
843    pub fn check_funnel_port(&self, wanted_port: u16) -> bool {
844        // Extract the `ports=` list from the first cap-map key that is the funnel-ports URL with a
845        // non-empty `ports` query. Returns `None` (deny) if the key is unparseable, the query is
846        // missing/empty, or the URL (sans query) isn't exactly the funnel-ports cap.
847        let parse_attr = |attr: &str| -> Option<String> {
848            let mut url = url::Url::parse(attr).ok()?;
849            let ports = url
850                .query_pairs()
851                .find(|(k, _)| k == "ports")
852                .map(|(_, v)| v.into_owned())?;
853            if ports.is_empty() {
854                return None;
855            }
856            url.set_query(None);
857            // Go compares `u.String()` against the bare cap; `url`'s serializer keeps a trailing
858            // `/` only if present in the input, and the funnel-ports cap has none, so a direct
859            // string compare matches Go's behavior.
860            if url.as_str() != Self::CAP_FUNNEL_PORTS {
861                return None;
862            }
863            Some(ports)
864        };
865
866        let Some(ports_str) = self
867            .cap_map
868            .keys()
869            .filter(|attr| attr.starts_with(Self::CAP_FUNNEL_PORTS))
870            .find_map(|attr| parse_attr(attr))
871        else {
872            return false;
873        };
874
875        let wanted = wanted_port.to_string();
876        for ps in ports_str.split(',') {
877            if ps.is_empty() {
878                continue;
879            }
880            match ps.split_once('-') {
881                None => {
882                    if ps == wanted {
883                        return true;
884                    }
885                }
886                Some((first, last)) => {
887                    let (Ok(fp), Ok(lp)) = (first.parse::<u16>(), last.parse::<u16>()) else {
888                        continue;
889                    };
890                    if fp <= wanted_port && wanted_port <= lp {
891                        return true;
892                    }
893                }
894            }
895        }
896        false
897    }
898
899    /// Report whether this node is permitted to host Tailscale VIP services.
900    ///
901    /// Mirrors the Go grant model: possession of the `service-host`
902    /// ([`ts_control_serde::NODE_ATTR_SERVICE_HOST`]) node-capability **and** at least one assigned
903    /// VIP address. Go additionally requires the host to be tagged
904    /// (`ErrUntaggedServiceHost`); that tag gate is enforced at
905    /// `Device::listen_service` using [`Node::tags`]. Fail-closed: no cap
906    /// or no assigned VIP denies.
907    pub fn is_service_host(&self) -> bool {
908        self.has_node_attr(ts_control_serde::NODE_ATTR_SERVICE_HOST)
909            && !self.service_vips.is_empty()
910    }
911
912    /// The control-assigned VIP addresses for one named service (`svc:<label>`), or an empty slice
913    /// if this node does not host that service. This is the exact per-service mapping (so a
914    /// multi-service co-host binds the right VIP for each service).
915    pub fn service_addresses_for(&self, service: &str) -> &[IpAddr] {
916        self.service_vips
917            .get(service)
918            .map(Vec::as_slice)
919            .unwrap_or(&[])
920    }
921
922    /// The flattened, deduplicated set of every VIP address this node hosts across all services.
923    /// Used to widen the netstack's accepted-address set so any hosted-service listener is
924    /// reachable. Per-service binding uses [`Node::service_addresses_for`] instead.
925    pub fn service_addresses(&self) -> Vec<IpAddr> {
926        let mut seen = alloc::collections::BTreeSet::new();
927        let mut out = Vec::new();
928        for addr in self.service_vips.values().flatten() {
929            if seen.insert(*addr) {
930                out.push(*addr);
931            }
932        }
933        out
934    }
935
936    /// The VIP services control has told this node it can *reach* (`tailcfg.ServiceDetails`),
937    /// parsed from every `services/<id>`
938    /// ([`ts_control_serde::NODE_ATTR_PREFIX_SERVICES`]) node-capability value, in capability-key
939    /// order.
940    ///
941    /// This is the consuming half of the VIP services model, and the counterpart to
942    /// [`Node::service_vips`], which is what this node *hosts*. Each entry carries the service's
943    /// canonical name, its VIP addresses, the ports it accepts and the client application actions
944    /// available on those ports. The capability key's suffix is an opaque server-chosen id, so the
945    /// name comes from `ServiceDetails::name` and never from the key.
946    ///
947    /// Fail-closed per value, never per netmap: a capability value that does not parse as a
948    /// `ServiceDetails` is skipped and the remaining services are still returned — the same
949    /// treatment the `service-host` parser gives a malformed `tailcfg.ServiceIPMappings` value. An
950    /// carrying a *type* this build does not know is not a parse failure: it decodes and is
951    /// returned, matching Go's rule that clients ignore actions they do not recognize. Use
952    /// `ts_control_serde::ServiceDetails::known_actions` to walk only the recognized ones.
953    pub fn visible_services(&self) -> Vec<ts_control_serde::ServiceDetails<'_>> {
954        self.cap_map
955            .iter()
956            .filter(|(key, _)| key.starts_with(ts_control_serde::NODE_ATTR_PREFIX_SERVICES))
957            .flat_map(|(_, values)| values.iter())
958            .filter_map(|raw| serde_json::from_str(raw).ok())
959            .collect()
960    }
961}
962
963/// Validate a Tailscale VIP service name (`tailcfg.ServiceName.Validate`): it must carry the
964/// `svc:` prefix ([`ts_control_serde::SERVICE_NAME_PREFIX`]) followed by a valid DNS label
965/// (1–63 chars, ASCII alphanumeric or `-`, not starting/ending with `-`). Returns the bare label on
966/// success. Fail-closed: anything malformed is rejected so a listener can never bind for a bogus
967/// service name.
968pub fn validate_service_name(name: &str) -> Option<&str> {
969    let label = name.strip_prefix(ts_control_serde::SERVICE_NAME_PREFIX)?;
970    if label.is_empty() || label.len() > 63 {
971        return None;
972    }
973    if label.starts_with('-') || label.ends_with('-') {
974        return None;
975    }
976    if label
977        .bytes()
978        .all(|b| b.is_ascii_alphanumeric() || b == b'-')
979    {
980        Some(label)
981    } else {
982        None
983    }
984}
985
986/// Parse the per-service VIP map this node hosts from the `service-host` node-capability value(s).
987/// Each value is the raw JSON text of a [`ts_control_serde::ServiceIpMappings`] object (svc-name ->
988/// VIP IPs); unparseable values are skipped (fail-closed: a malformed mapping contributes no VIPs).
989/// Per-service IP lists are deduplicated, source order otherwise preserved.
990fn service_vips_from_cap_map(
991    cap_map: &NodeCapMap,
992) -> alloc::collections::BTreeMap<String, Vec<IpAddr>> {
993    let mut out: alloc::collections::BTreeMap<String, Vec<IpAddr>> =
994        alloc::collections::BTreeMap::new();
995    let Some(values) = cap_map.get(ts_control_serde::NODE_ATTR_SERVICE_HOST) else {
996        return out;
997    };
998
999    for raw in values {
1000        let Ok(mappings) = serde_json::from_str::<ts_control_serde::ServiceIpMappings>(raw) else {
1001            continue;
1002        };
1003        for (name, addrs) in &mappings.0 {
1004            let entry = out.entry((*name).to_string()).or_default();
1005            for addr in addrs {
1006                if !entry.contains(addr) {
1007                    entry.push(*addr);
1008                }
1009            }
1010        }
1011    }
1012    out
1013}
1014
1015/// Collect a wire ([`ts_control_serde`]) node cap map into an owned [`NodeCapMap`].
1016///
1017/// Keys are copied as owned strings; each value's raw JSON text is preserved verbatim. The wire map
1018/// borrows from the decode buffer, so an owned copy is required to outlive it on the domain
1019/// [`Node`].
1020fn cap_map_from_serde(wire: &ts_nodecapability::Map<'_>) -> NodeCapMap {
1021    wire.iter()
1022        .map(|(&key, values)| {
1023            let owned_values = values.0.iter().map(|v| v.get().to_owned()).collect();
1024            (key.to_owned(), owned_values)
1025        })
1026        .collect()
1027}
1028
1029/// Extract the advertised IPv4 peerAPI port and whether the explicit `peerapi-dns-proxy` service is
1030/// advertised, from a peer's `HostInfo.Services` list.
1031fn peerapi_from_services(
1032    services: Option<&[ts_control_serde::Service<'_>]>,
1033) -> (Option<u16>, bool) {
1034    use ts_control_serde::ServiceProto;
1035
1036    let Some(services) = services else {
1037        return (None, false);
1038    };
1039    let mut port = None;
1040    let mut dns_proxy = false;
1041    for svc in services {
1042        match svc.proto {
1043            ServiceProto::PeerApi4 => port = Some(svc.port),
1044            ServiceProto::PeerApiDnsProxy => dns_proxy = true,
1045            _ => {}
1046        }
1047    }
1048    (port, dns_proxy)
1049}
1050
1051/// Addresses for a node within a tailnet.
1052#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1053pub struct TailnetAddress {
1054    /// The IPv4 address of the node in the tailnet.
1055    pub ipv4: ipnet::Ipv4Net,
1056    /// The IPv6 address of the node in the tailnet.
1057    pub ipv6: ipnet::Ipv6Net,
1058}
1059
1060impl TailnetAddress {
1061    /// Report whether `addr` matches either address in this [`TailnetAddress`].
1062    pub fn contains(&self, addr: IpAddr) -> bool {
1063        match addr {
1064            IpAddr::V4(a) => self.ipv4.addr() == a,
1065            IpAddr::V6(a) => self.ipv6.addr() == a,
1066        }
1067    }
1068}
1069
1070impl From<&ts_control_serde::Node<'_>> for Node {
1071    fn from(value: &ts_control_serde::Node) -> Self {
1072        let fqdn_without_trailing_dot = value.name.strip_suffix('.').unwrap_or(&value.name);
1073
1074        let (hostname, tailnet) = match fqdn_without_trailing_dot.split_once('.') {
1075            Some((hostname, tailnet)) => (hostname, Some(tailnet.to_owned())),
1076            None => (fqdn_without_trailing_dot, None),
1077        };
1078
1079        let (peerapi_port, peerapi_dns_proxy) =
1080            peerapi_from_services(value.host_info.services.as_deref());
1081
1082        let cap_map = cap_map_from_serde(&value.cap_map);
1083        let service_vips = service_vips_from_cap_map(&cap_map);
1084
1085        // `addresses` is a variable-length `Vec<IpNet>` on the wire (Go `[]netip.Prefix`), not a
1086        // fixed (v4, v6) pair: an IPv6-off tailnet assigns only a v4 prefix. The whole list is kept
1087        // verbatim on `Node::addresses` (Go's `Node.Addresses`, which `IsRouter` tests routes
1088        // against); `tailnet_address` is the identity projection. Pick the first of each
1089        // family. The v4 prefix is the node's tailnet identity (always present on a normal node);
1090        // if somehow absent we fall back to the unspecified `0.0.0.0/32` rather than panicking.
1091        // The v6 prefix is optional — when the tailnet is IPv4-only there is none, and the overlay
1092        // never reads `ipv6` in that mode (gated on `enable_ipv6`); we synthesize the unspecified
1093        // `::/128` placeholder so the domain `TailnetAddress` stays infallible.
1094        let ipv4 = value
1095            .addresses
1096            .iter()
1097            .find_map(|p| match p {
1098                ipnet::IpNet::V4(n) => Some(*n),
1099                ipnet::IpNet::V6(_) => None,
1100            })
1101            .unwrap_or_else(|| ipnet::Ipv4Net::new(core::net::Ipv4Addr::UNSPECIFIED, 32).unwrap());
1102        let ipv6 = value
1103            .addresses
1104            .iter()
1105            .find_map(|p| match p {
1106                ipnet::IpNet::V6(n) => Some(*n),
1107                ipnet::IpNet::V4(_) => None,
1108            })
1109            .unwrap_or_else(|| ipnet::Ipv6Net::new(core::net::Ipv6Addr::UNSPECIFIED, 128).unwrap());
1110
1111        Self {
1112            id: value.id,
1113            stable_id: StableId(value.stable_id.0.to_string()),
1114
1115            hostname: hostname.to_owned(),
1116            user_id: value.user,
1117            tailnet,
1118
1119            tags: value
1120                .tags
1121                .as_ref()
1122                .map(|x| x.iter().map(|x| x.to_string()).collect())
1123                .unwrap_or_default(),
1124
1125            addresses: value.addresses.clone(),
1126            tailnet_address: TailnetAddress { ipv4, ipv6 },
1127            node_key: value.key,
1128            node_key_expiry: value.key_expiry,
1129            // Control's own verdict, carried verbatim; `ExpiryManager` only ever raises it.
1130            expired: value.expired,
1131            online: value.online,
1132            last_seen: value.last_seen,
1133            key_signature: value.key_signature.to_vec(),
1134            machine_key: value.machine,
1135            disco_key: value.disco_key,
1136
1137            unsigned_peer_api_only: value.unsigned_peer_api_only,
1138
1139            // Per capver-112, `AllowedIPs` null/absent means "same as `addresses`". Fall back to the
1140            // node's own assigned prefixes verbatim (whatever families the wire carried), not a
1141            // synthesized v4+v6 pair.
1142            //
1143            // `UnsignedPeerAPIOnly` clamps the result back to `addresses` whatever control sent,
1144            // mirroring Go's `upgradeNode` (`control/controlclient/map.go`): such a node is outside
1145            // tailnet lock's coverage, so a possibly-malicious control server must not be able to
1146            // grant it network access by handing it advertised routes (in the limit, `0.0.0.0/0`).
1147            // Unconditional, exactly as upstream — it does not depend on tailnet lock being
1148            // enabled here.
1149            accepted_routes: if value.unsigned_peer_api_only {
1150                value.addresses.clone()
1151            } else {
1152                value
1153                    .allowed_ips
1154                    .clone()
1155                    .unwrap_or_else(|| value.addresses.clone())
1156            },
1157            underlay_addresses: value.endpoints.clone(),
1158
1159            // legacy_derp_string is still in practical use as of 3/2026
1160            #[allow(deprecated)]
1161            derp_region: value
1162                .home_derp
1163                .or(value.legacy_derp_string)
1164                .or_else(|| value.host_info.net_info.as_ref()?.preferred_derp)
1165                .map(|x| ts_derp::RegionId(x.into())),
1166
1167            cap: value.cap,
1168            cap_map,
1169            peerapi_port,
1170            peerapi_dns_proxy,
1171            is_wireguard_only: value.is_wireguard_only,
1172            exit_node_dns_resolvers: value
1173                .exit_node_dns_resolvers
1174                .iter()
1175                .filter_map(Resolver::from_serde)
1176                .collect(),
1177            peer_relay: value.host_info.peer_relay,
1178            // Project the advertised SSH host keys (Go `Hostinfo.SSHHostKeys`), mapping the
1179            // borrowed `Option<Vec<&str>>` to owned `Vec<String>`; absent ⇒ empty (never
1180            // fabricated), matching how `services`/`peer_relay` above are projected from host_info.
1181            ssh_host_keys: value
1182                .host_info
1183                .ssh_host_keys
1184                .as_ref()
1185                .map(|keys| keys.iter().map(|k| k.to_string()).collect())
1186                .unwrap_or_default(),
1187            service_vips,
1188        }
1189    }
1190}
1191
1192/// An incremental update to a single already-known peer [`Node`], carried in
1193/// [`MapResponse::peers_changed_patch`][ts_control_serde::MapResponse::peers_changed_patch].
1194///
1195/// Control sends a patch (rather than a full node in `peers_changed`) when only a peer's
1196/// reachability changes mid-session — most importantly its UDP `endpoints`
1197/// and home [`derp_region`][PeerChange::derp_region] when an idle peer re-establishes connectivity.
1198/// Every field is `Option`: a patch sets only the fields it carries and leaves the rest of the
1199/// target node unchanged (see `PeerTracker::apply_peer_update` for the merge). Owned counterpart
1200/// of the borrow-bound [`ts_control_serde::PeerChange`]; the fields that map onto a domain
1201/// [`Node`] field are retained, including control's `online`/`last_seen` liveness deltas — the
1202/// dominant channel by which peer online transitions are delivered (see [`Node::online`]).
1203#[derive(Debug, Clone, PartialEq, Eq)]
1204pub struct PeerChange {
1205    /// The [`Node::id`] of the peer being mutated. If no peer with this id is in the current
1206    /// netmap, the patch is ignored (the wire contract — a patch never creates a node).
1207    pub id: Id,
1208    /// If `Some`, the peer's new home DERP region.
1209    pub derp_region: Option<ts_derp::RegionId>,
1210    /// If `Some`, the peer's new advertised capability version.
1211    pub cap: Option<CapabilityVersion>,
1212    /// If `Some`, the peer's new capability map (replaces the prior map wholesale).
1213    pub cap_map: Option<NodeCapMap>,
1214    /// If `Some`, the peer's new UDP underlay endpoints (`Endpoints` in Go; replaces the prior
1215    /// set). This is the field that lets magicsock re-handshake a peer that moved.
1216    pub underlay_addresses: Option<Vec<SocketAddr>>,
1217    /// If `Some`, the peer's new WireGuard public key (key rotation).
1218    pub node_key: Option<NodePublicKey>,
1219    /// If `Some`, the marshalled TKA signature over the new node key. Re-verified at the
1220    /// peer-trust chokepoint when tailnet-lock enforcement is active.
1221    pub key_signature: Option<Vec<u8>>,
1222    /// If `Some`, the peer's new disco public key.
1223    pub disco_key: Option<DiscoPublicKey>,
1224    /// If `Some`, the peer's new node-key expiry (`KeyExpiry` in Go). Maps to
1225    /// [`Node::node_key_expiry`]; carried so an expiry-only patch isn't lost until the next full
1226    /// resync.
1227    pub node_key_expiry: Option<DateTime<Utc>>,
1228    /// If `Some`, the peer's new online status (`PeerChange.Online`). `None` here means "this patch
1229    /// did not touch online", **not** "offline" — the merge sets [`Node::online`] only when present.
1230    pub online: Option<bool>,
1231    /// If `Some`, the peer's new last-seen time (`PeerChange.LastSeen`). Maps to [`Node::last_seen`].
1232    pub last_seen: Option<DateTime<Utc>>,
1233}
1234
1235impl From<&ts_control_serde::PeerChange<'_>> for PeerChange {
1236    fn from(value: &ts_control_serde::PeerChange) -> Self {
1237        Self {
1238            id: value.node_id,
1239            derp_region: value.derp_region.map(|x| ts_derp::RegionId(x.into())),
1240            cap: value.cap,
1241            cap_map: value.cap_map.as_ref().map(cap_map_from_serde),
1242            underlay_addresses: value.endpoints.clone(),
1243            node_key: value.key,
1244            key_signature: value.key_signature.map(|s| s.to_vec()),
1245            disco_key: value.disco_key,
1246            node_key_expiry: value.key_expiry,
1247            online: value.online,
1248            last_seen: value.last_seen,
1249        }
1250    }
1251}
1252
1253/// Identity of the user that owns a [`Node`], resolved from the netmap's `UserProfiles` table
1254/// (Go `tailcfg.UserProfile`). Owned counterpart of the borrow-bound
1255/// [`ts_control_serde::UserProfile`]. Keyed by [`UserProfile::id`] (== [`Node::user_id`]).
1256///
1257/// Mostly display-friendly text ([`login_name`](Self::login_name),
1258/// [`display_name`](Self::display_name)), plus [`groups`](Self::groups) — the one attribute here an
1259/// embedder can *authorise* on, because it is the one a node cannot re-derive from anything else
1260/// control sends.
1261#[derive(Debug, Clone, PartialEq, Eq)]
1262pub struct UserProfile {
1263    /// The integer id of the Tailscale user this profile describes (matches [`Node::user_id`]).
1264    pub id: ts_control_serde::UserId,
1265    /// An email-ish login name for display (e.g. `alice@example.com` / `alice@github`). May be
1266    /// empty if control sent none.
1267    pub login_name: String,
1268    /// The user's display name (e.g. `Alice Smith`), if the IdP provided one.
1269    pub display_name: Option<String>,
1270    /// The groups that contain this user and that the coordination server was configured to report
1271    /// to this node (Go `tailcfg.UserProfile.Groups`): SCIM groups (e.g.
1272    /// `engineering@example.com`) or tailnet-policy group names (e.g. `group:eng`).
1273    ///
1274    /// Carried in the order control sent it (control sorts it when it loads the profile from
1275    /// storage). **Empty** when control reported no groups — including every control server older
1276    /// than the field, which omits it entirely. An empty list therefore means "control told this
1277    /// node nothing", not "this user is in no group": treat it as no grant, never as a denial you
1278    /// can act on.
1279    pub groups: Vec<String>,
1280}
1281
1282impl From<&ts_control_serde::UserProfile<'_>> for UserProfile {
1283    fn from(value: &ts_control_serde::UserProfile) -> Self {
1284        Self {
1285            id: value.id,
1286            login_name: value.login_name.to_string(),
1287            display_name: value.display_name.as_deref().map(str::to_string),
1288            groups: value.groups.iter().map(|g| g.to_string()).collect(),
1289        }
1290    }
1291}
1292
1293impl UserProfile {
1294    /// The best human-facing label for this user: the login name when present, else the display
1295    /// name, else `None`. This is what a `WhoIs` surfaces as the owning user.
1296    pub fn best_label(&self) -> Option<String> {
1297        if !self.login_name.is_empty() {
1298            Some(self.login_name.clone())
1299        } else {
1300            self.display_name.clone()
1301        }
1302    }
1303}
1304
1305#[cfg(test)]
1306pub(crate) mod tests {
1307    use super::*;
1308
1309    /// The wire `Node.User` id must be carried onto the domain `Node.user_id` by the `From` impl
1310    /// (the field the runtime joins against the netmap `UserProfiles` table for `WhoIs.user`).
1311    /// Guards against the `From` impl wiring the wrong serde field or dropping it.
1312    #[test]
1313    fn from_wire_node_carries_user_id() {
1314        let mut wire = ts_control_serde::Node {
1315            user: 4242,
1316            ..Default::default()
1317        };
1318        wire.name = "host.tail.ts.net.".into();
1319        let domain: Node = (&wire).into();
1320        assert_eq!(domain.user_id, 4242);
1321
1322        // Default (no owner / tagged node) stays 0.
1323        let tagged = ts_control_serde::Node::default();
1324        assert_eq!(Node::from(&tagged).user_id, 0);
1325    }
1326
1327    /// The wire `Hostinfo.sshHostKeys` must be projected onto the domain `Node.ssh_host_keys`
1328    /// (the field `tailscale ssh` reads via `StatusNode` to pin a peer's host key). Present →
1329    /// carried verbatim; absent → empty (never fabricated).
1330    #[test]
1331    fn from_wire_node_carries_ssh_host_keys() {
1332        let wire = ts_control_serde::Node {
1333            host_info: ts_control_serde::HostInfo {
1334                ssh_host_keys: Some(vec![
1335                    "ssh-ed25519 AAAAC3Nz host",
1336                    "ecdsa-sha2-nistp256 AAAAE2Vj host",
1337                ]),
1338                ..Default::default()
1339            },
1340            ..Default::default()
1341        };
1342        let domain: Node = (&wire).into();
1343        assert_eq!(
1344            domain.ssh_host_keys,
1345            vec![
1346                "ssh-ed25519 AAAAC3Nz host".to_string(),
1347                "ecdsa-sha2-nistp256 AAAAE2Vj host".to_string(),
1348            ]
1349        );
1350
1351        // Absent on the wire → empty Vec, not fabricated.
1352        let bare = ts_control_serde::Node::default();
1353        assert!(Node::from(&bare).ssh_host_keys.is_empty());
1354    }
1355
1356    /// A node from an **IPv4-only** tailnet (IPv6-off control plane / Headscale) carries a
1357    /// single-element `addresses` list. This used to fail deserialization ("invalid length 1,
1358    /// expected a tuple of size 2") when `addresses` was a fixed 2-tuple; it must now parse and
1359    /// derive the v4 identity, with the unused v6 a synthesized placeholder.
1360    #[test]
1361    fn from_wire_node_ipv4_only_addresses() {
1362        let wire = ts_control_serde::Node {
1363            addresses: vec!["100.64.0.5/32".parse().unwrap()],
1364            ..Default::default()
1365        };
1366        let domain: Node = (&wire).into();
1367        assert_eq!(
1368            domain.tailnet_address.ipv4,
1369            "100.64.0.5/32".parse().unwrap()
1370        );
1371        // No v6 on the wire → unspecified placeholder (never read in IPv4-only mode).
1372        assert_eq!(
1373            domain.tailnet_address.ipv6,
1374            ipnet::Ipv6Net::new(core::net::Ipv6Addr::UNSPECIFIED, 128).unwrap()
1375        );
1376        // AllowedIPs absent → falls back to the node's own assigned prefixes (just the v4 here).
1377        assert_eq!(
1378            domain.accepted_routes,
1379            vec!["100.64.0.5/32".parse::<ipnet::IpNet>().unwrap()]
1380        );
1381    }
1382
1383    /// A dual-stack node carries both families (any order); the domain picks the first of each.
1384    #[test]
1385    fn from_wire_node_dual_stack_addresses() {
1386        let wire = ts_control_serde::Node {
1387            addresses: vec![
1388                "100.64.0.7/32".parse().unwrap(),
1389                "fd7a:115c:a1e0::7/128".parse().unwrap(),
1390            ],
1391            ..Default::default()
1392        };
1393        let domain: Node = (&wire).into();
1394        assert_eq!(
1395            domain.tailnet_address.ipv4,
1396            "100.64.0.7/32".parse().unwrap()
1397        );
1398        assert_eq!(
1399            domain.tailnet_address.ipv6,
1400            "fd7a:115c:a1e0::7/128".parse().unwrap()
1401        );
1402    }
1403
1404    /// A wire peer that owns `100.64.0.9/32` and is handed `route` plus the default route in its
1405    /// `AllowedIPs`. `unsigned` sets `UnsignedPeerAPIOnly`; everything else is identical between
1406    /// the two, so the only variable in the test below is that flag.
1407    fn wire_peer_advertising(
1408        stable_id: &'static str,
1409        route: &str,
1410        unsigned: bool,
1411    ) -> ts_control_serde::Node<'static> {
1412        ts_control_serde::Node {
1413            stable_id: ts_control_serde::StableNodeId(stable_id),
1414            addresses: vec!["100.64.0.9/32".parse().unwrap()],
1415            allowed_ips: Some(vec![
1416                "100.64.0.9/32".parse().unwrap(),
1417                route.parse().unwrap(),
1418                "0.0.0.0/0".parse().unwrap(),
1419            ]),
1420            unsigned_peer_api_only: unsigned,
1421            ..Default::default()
1422        }
1423    }
1424
1425    /// `UnsignedPeerAPIOnly` must clamp a peer's accepted routes back to its own addresses, so a
1426    /// control server cannot grant an unsigned (lock-exempt) peer network access via advertised
1427    /// routes. Mirrors Go's `upgradeNode` in `control/controlclient/map.go`.
1428    ///
1429    /// The signed peer is the control: it advertises the **same** route and the same default route,
1430    /// and keeps both. Without it this test would still pass if the `From` impl simply dropped every
1431    /// advertised route.
1432    #[test]
1433    fn from_wire_unsigned_peer_api_only_clamps_routes_to_own_addresses() {
1434        let own: ipnet::IpNet = "100.64.0.9/32".parse().unwrap();
1435        let subnet: ipnet::IpNet = "192.0.2.0/24".parse().unwrap();
1436        let default_route: ipnet::IpNet = "0.0.0.0/0".parse().unwrap();
1437
1438        let unsigned: Node = (&wire_peer_advertising("nUnsigned", "192.0.2.0/24", true)).into();
1439        let signed: Node = (&wire_peer_advertising("nSigned", "192.0.2.0/24", false)).into();
1440
1441        // The flag is carried onto the domain node, not silently dropped.
1442        assert!(unsigned.unsigned_peer_api_only);
1443        assert!(!signed.unsigned_peer_api_only);
1444
1445        // Unsigned: clamped to its own addresses. The advertised subnet and the default route are
1446        // both gone, whatever control sent.
1447        assert_eq!(unsigned.accepted_routes, vec![own]);
1448
1449        // Signed: the identical advertisement survives verbatim.
1450        assert_eq!(
1451            signed.accepted_routes,
1452            vec![own, subnet, default_route],
1453            "the clamp must be specific to UnsignedPeerAPIOnly, not a blanket route drop"
1454        );
1455
1456        // Consequences the rest of the fork reads. `is_router` reports the unsigned peer routes
1457        // nothing but itself...
1458        assert!(!unsigned.is_router());
1459        assert!(signed.is_router());
1460
1461        // ...and no route-install policy can resurrect the advertisement: even with
1462        // `--accept-routes` on AND the peer selected as the exit node — the most permissive input
1463        // `routes_to_install` accepts — the unsigned peer yields only its own address.
1464        let installed: Vec<_> = unsigned
1465            .routes_to_install(true, Some(&unsigned.stable_id))
1466            .copied()
1467            .collect();
1468        assert_eq!(installed, vec![own]);
1469
1470        // The same permissive inputs against the signed peer do install the subnet and the /0,
1471        // proving the difference is the flag and not the policy arguments.
1472        let installed_signed: Vec<_> = signed
1473            .routes_to_install(true, Some(&signed.stable_id))
1474            .copied()
1475            .collect();
1476        assert_eq!(installed_signed, vec![own, subnet, default_route]);
1477    }
1478
1479    /// The wire default (`UnsignedPeerAPIOnly` absent) must leave `AllowedIPs` untouched, including
1480    /// the capver-112 "null AllowedIPs means the node's own addresses" fallback. Guards against the
1481    /// clamp being applied on the wrong branch.
1482    #[test]
1483    fn from_wire_default_is_not_clamped() {
1484        let wire = ts_control_serde::Node {
1485            addresses: vec!["100.64.0.9/32".parse().unwrap()],
1486            allowed_ips: Some(vec!["198.51.100.0/24".parse().unwrap()]),
1487            ..Default::default()
1488        };
1489        assert!(!wire.unsigned_peer_api_only);
1490        let domain: Node = (&wire).into();
1491        assert_eq!(
1492            domain.accepted_routes,
1493            vec!["198.51.100.0/24".parse::<ipnet::IpNet>().unwrap()]
1494        );
1495    }
1496
1497    /// An unsigned peer with **no** `AllowedIPs` on the wire still lands on its own addresses (the
1498    /// clamp and the capver-112 fallback agree), and a multi-prefix unsigned peer keeps *all* of
1499    /// its assigned prefixes — the clamp is to `Addresses`, not to the v4/v6 identity pair.
1500    #[test]
1501    fn from_wire_unsigned_peer_clamp_keeps_every_assigned_prefix() {
1502        let wire = ts_control_serde::Node {
1503            addresses: vec![
1504                "100.64.0.9/32".parse().unwrap(),
1505                "fd7a:115c:a1e0::9/128".parse().unwrap(),
1506            ],
1507            allowed_ips: None,
1508            unsigned_peer_api_only: true,
1509            ..Default::default()
1510        };
1511        let domain: Node = (&wire).into();
1512        assert_eq!(
1513            domain.accepted_routes,
1514            vec![
1515                "100.64.0.9/32".parse::<ipnet::IpNet>().unwrap(),
1516                "fd7a:115c:a1e0::9/128".parse::<ipnet::IpNet>().unwrap(),
1517            ]
1518        );
1519        assert!(!domain.is_router());
1520    }
1521
1522    /// The deserialization regression itself: a MapResponse-style Node JSON with a 1-element
1523    /// `Addresses` array must parse (this is the exact shape the dev-Headscale sends).
1524    #[test]
1525    fn deserialize_node_with_single_address() {
1526        let json = r#"{
1527            "ID": 1,
1528            "StableID": "n1",
1529            "Name": "host.tail.ts.net.",
1530            "User": 1,
1531            "Addresses": ["100.64.0.9/32"],
1532            "Key": "nodekey:0000000000000000000000000000000000000000000000000000000000000000",
1533            "Machine": null,
1534            "DiscoKey": null,
1535            "AllowedIPs": null,
1536            "Endpoints": []
1537        }"#;
1538        let wire: ts_control_serde::Node = serde_json::from_str(json).expect("1-addr node parses");
1539        assert_eq!(wire.addresses.len(), 1);
1540        let domain: Node = (&wire).into();
1541        assert_eq!(
1542            domain.tailnet_address.ipv4,
1543            "100.64.0.9/32".parse().unwrap()
1544        );
1545    }
1546
1547    /// The consume side end to end: a MapResponse-shaped wire Node carrying a
1548    /// `services/<opaque-id>` capability decodes into the domain node, and the service — including
1549    /// an action type this build has never heard of — is surfaced rather than dropping the node.
1550    #[test]
1551    fn visible_services_parse_from_the_services_cap() {
1552        let json = r#"{
1553            "ID": 1,
1554            "StableID": "n1",
1555            "Name": "host.tail.ts.net.",
1556            "User": 1,
1557            "Addresses": ["100.64.0.9/32"],
1558            "Key": "nodekey:0000000000000000000000000000000000000000000000000000000000000000",
1559            "Machine": null,
1560            "DiscoKey": null,
1561            "AllowedIPs": null,
1562            "Endpoints": [],
1563            "CapMap": {
1564                "services/01HQ": [{
1565                    "Name": "svc:postgres",
1566                    "DisplayName": "Analytics DB",
1567                    "Addrs": ["100.65.32.7"],
1568                    "Ports": ["tcp:5432", "tcp:22"],
1569                    "Actions": [
1570                        {
1571                            "Type": "postgresql",
1572                            "Port": 5432,
1573                            "Attributes": {
1574                                "tailscale.com/cap/resource-name": "metrics"
1575                            }
1576                        },
1577                        { "Type": "ftp", "Port": 22 }
1578                    ]
1579                }],
1580                "services/01HR": [{ "Name": "svc:web", "Ports": ["tcp:80"] }]
1581            }
1582        }"#;
1583        let wire: ts_control_serde::Node =
1584            serde_json::from_str(json).expect("a node advertising services parses");
1585        let domain: Node = (&wire).into();
1586
1587        let services = domain.visible_services();
1588        assert_eq!(services.len(), 2);
1589
1590        let pg = &services[0];
1591        assert_eq!(pg.name.0, "svc:postgres");
1592        assert_eq!(pg.display_name, "Analytics DB");
1593        assert_eq!(pg.addrs, vec!["100.65.32.7".parse::<IpAddr>().unwrap()]);
1594        assert_eq!(pg.ports.len(), 2);
1595        // Both actions survive decoding, the unrecognized slug included.
1596        assert_eq!(pg.actions.len(), 2);
1597        assert_eq!(
1598            pg.actions[0].action_type,
1599            ts_control_serde::SERVICE_ACTION_TYPE_POSTGRESQL
1600        );
1601        assert_eq!(
1602            pg.actions[0].attributes[ts_control_serde::SERVICE_ACTION_ATTRIBUTE_RESOURCE_NAME]
1603                .get(),
1604            r#""metrics""#
1605        );
1606        assert!(!pg.actions[1].action_type.is_known());
1607        // …but only the recognized one is offered to a caller about to act on it.
1608        assert_eq!(pg.known_actions().count(), 1);
1609
1610        assert_eq!(services[1].name.0, "svc:web");
1611        assert!(services[1].actions.is_empty());
1612
1613        // The services cap is a consume-side grant only: it does not make this a service host.
1614        assert!(!domain.is_service_host());
1615    }
1616
1617    /// A `services/` value that is not a `ServiceDetails` at all costs only itself: the other
1618    /// services still come back, and the node is still usable.
1619    #[test]
1620    fn visible_services_skips_an_unparseable_value() {
1621        let mut n = node("peer", Some("ts.net"));
1622        n.cap_map.insert(
1623            "services/broken".to_string(),
1624            vec!["\"not an object\"".to_string()],
1625        );
1626        n.cap_map.insert(
1627            "services/good".to_string(),
1628            vec![r#"{"Name":"svc:web","Ports":["tcp:80"]}"#.to_string()],
1629        );
1630
1631        let services = n.visible_services();
1632        assert_eq!(services.len(), 1);
1633        assert_eq!(services[0].name.0, "svc:web");
1634    }
1635
1636    /// No services cap at all is the common case and yields nothing, not an error.
1637    #[test]
1638    fn visible_services_empty_without_the_cap() {
1639        let n = node("peer", Some("ts.net"));
1640        assert!(n.visible_services().is_empty());
1641    }
1642
1643    #[test]
1644    fn key_expiry_semantics() {
1645        let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
1646        let past: DateTime<Utc> = "2020-01-01T00:00:00Z".parse().unwrap();
1647        let future: DateTime<Utc> = "2099-01-01T00:00:00Z".parse().unwrap();
1648
1649        let mut n = node("h", Some("t.ts.net"));
1650
1651        // No expiry set => never expired (Go zero-value semantics).
1652        n.node_key_expiry = None;
1653        assert!(!n.key_expired(now));
1654        assert_eq!(n.key_expiry(), None);
1655
1656        // Future expiry => not yet expired.
1657        n.node_key_expiry = Some(future);
1658        assert!(!n.key_expired(now));
1659        assert_eq!(n.key_expiry(), Some(future));
1660
1661        // Past expiry => expired.
1662        n.node_key_expiry = Some(past);
1663        assert!(n.key_expired(now));
1664    }
1665
1666    #[test]
1667    fn key_expiry_unix_agrees_with_chrono() {
1668        // The chrono-free variants (`key_expired_at_unix` / `key_expiry_unix`) must agree with the
1669        // chrono variants for the same none/future/past cases (Unix seconds of the same instants).
1670        let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
1671        let past: DateTime<Utc> = "2020-01-01T00:00:00Z".parse().unwrap();
1672        let future: DateTime<Utc> = "2099-01-01T00:00:00Z".parse().unwrap();
1673        let now_unix = now.timestamp();
1674
1675        let mut n = node("h", Some("t.ts.net"));
1676
1677        // No expiry => never expired; the unix accessor reports `None`.
1678        n.node_key_expiry = None;
1679        assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
1680        assert!(!n.key_expired_at_unix(now_unix));
1681        assert_eq!(n.key_expiry_unix(), None);
1682
1683        // Future expiry => not yet expired; unix accessor matches the chrono timestamp.
1684        n.node_key_expiry = Some(future);
1685        assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
1686        assert!(!n.key_expired_at_unix(now_unix));
1687        assert_eq!(n.key_expiry_unix(), Some(future.timestamp()));
1688
1689        // Past expiry => expired; unix accessor matches the chrono timestamp.
1690        n.node_key_expiry = Some(past);
1691        assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
1692        assert!(n.key_expired_at_unix(now_unix));
1693        assert_eq!(n.key_expiry_unix(), Some(past.timestamp()));
1694    }
1695
1696    #[test]
1697    fn key_expiry_boundary_is_not_expired() {
1698        // A key whose expiry exactly equals `now` is NOT expired: the code uses strict `<`, matching
1699        // Go's `Before`. Both the chrono and chrono-free variants must agree at the boundary.
1700        let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
1701        let now_unix = now.timestamp();
1702
1703        let mut n = node("h", Some("t.ts.net"));
1704        n.node_key_expiry = Some(now);
1705
1706        assert!(!n.key_expired(now));
1707        assert!(!n.key_expired_at_unix(now_unix));
1708    }
1709
1710    #[test]
1711    fn is_peer_relay_returns_field() {
1712        let mut n = node("h", Some("t.ts.net"));
1713
1714        n.peer_relay = true;
1715        assert!(n.is_peer_relay());
1716
1717        n.peer_relay = false;
1718        assert!(!n.is_peer_relay());
1719    }
1720
1721    /// A minimal well-formed peer, shared with the `expiry` module's tests so both reason about
1722    /// the same node shape.
1723    pub(crate) fn test_node() -> Node {
1724        node("h", Some("t.ts.net"))
1725    }
1726
1727    fn node(hostname: &str, tailnet: Option<&str>) -> Node {
1728        Node {
1729            id: 1,
1730            stable_id: StableId("n1".to_string()),
1731            hostname: hostname.to_string(),
1732            user_id: 0,
1733            tailnet: tailnet.map(str::to_string),
1734            tags: vec![],
1735            addresses: vec![
1736                "100.64.0.1/32".parse().unwrap(),
1737                "fd7a::1/128".parse().unwrap(),
1738            ],
1739            tailnet_address: TailnetAddress {
1740                ipv4: "100.64.0.1/32".parse().unwrap(),
1741                ipv6: "fd7a::1/128".parse().unwrap(),
1742            },
1743            node_key: [0u8; 32].into(),
1744            node_key_expiry: None,
1745            expired: false,
1746            online: None,
1747            last_seen: None,
1748            key_signature: vec![],
1749            machine_key: None,
1750            disco_key: None,
1751            accepted_routes: vec![],
1752            underlay_addresses: vec![],
1753            derp_region: None,
1754            cap: CapabilityVersion::default(),
1755            cap_map: NodeCapMap::new(),
1756            peerapi_port: None,
1757            peerapi_dns_proxy: false,
1758            is_wireguard_only: false,
1759            exit_node_dns_resolvers: vec![],
1760            peer_relay: false,
1761            ssh_host_keys: vec![],
1762            service_vips: Default::default(),
1763            unsigned_peer_api_only: false,
1764        }
1765    }
1766
1767    #[test]
1768    fn matches_name_is_case_and_trailing_dot_insensitive() {
1769        let n = node("MyHost", Some("tail-scale.ts.net"));
1770
1771        // bare hostname, any case
1772        assert!(n.matches_name("myhost"));
1773        assert!(n.matches_name("MYHOST"));
1774        assert!(n.matches_name("MyHost"));
1775
1776        // fqdn, any case, with and without trailing dot
1777        assert!(n.matches_name("myhost.tail-scale.ts.net"));
1778        assert!(n.matches_name("MYHOST.TAIL-SCALE.TS.NET"));
1779        assert!(n.matches_name("myhost.tail-scale.ts.net."));
1780        assert!(n.matches_name("MyHost.Tail-Scale.TS.NET."));
1781
1782        // wrong host / wrong tailnet must not match
1783        assert!(!n.matches_name("other"));
1784        assert!(!n.matches_name("myhost.other.ts.net"));
1785    }
1786
1787    #[test]
1788    fn matches_name_no_tailnet() {
1789        let n = node("solo", None);
1790        assert!(n.matches_name("solo"));
1791        assert!(n.matches_name("SOLO."));
1792        assert!(!n.matches_name("solo.ts.net"));
1793    }
1794
1795    #[test]
1796    fn is_tailscale_ip_ranges() {
1797        // CGNAT v4
1798        assert!(is_tailscale_ip("100.64.0.1".parse().unwrap()));
1799        assert!(is_tailscale_ip("100.127.255.254".parse().unwrap()));
1800        // ChromeOS carve-out is excluded
1801        assert!(!is_tailscale_ip("100.115.92.5".parse().unwrap()));
1802        // outside CGNAT
1803        assert!(!is_tailscale_ip("10.0.0.1".parse().unwrap()));
1804        assert!(!is_tailscale_ip("100.128.0.1".parse().unwrap()));
1805        // Tailscale ULA v6
1806        assert!(is_tailscale_ip("fd7a:115c:a1e0::1".parse().unwrap()));
1807        assert!(!is_tailscale_ip("fd00::1".parse().unwrap()));
1808    }
1809
1810    /// Taildrop SSRF guard (defense-in-depth). `Device::send_file` rejects an upload destination
1811    /// unless `is_tailscale_ip(peer.peerapi_addr().ip())` holds. `Device::send_file` itself needs a
1812    /// live runtime (it goes through `self.channel()`), so it can't be unit-tested here; instead we
1813    /// test the exact composition the guard relies on — `is_tailscale_ip ∘ peerapi_addr` — against a
1814    /// `Node` whose `tailnet_address.ipv4` has been corrupted to a non-CGNAT (public) address. A
1815    /// well-formed peer always has a CGNAT 100.64.0.0/10 address, but the guard exists to catch a
1816    /// malformed/hostile node; this proves it would reject one.
1817    #[test]
1818    fn taildrop_ssrf_guard_rejects_non_cgnat_peerapi_addr() {
1819        let mut n = node("evil", Some("ts.net"));
1820        // Corrupt the peer to a public, non-CGNAT address and advertise a peerAPI port so
1821        // `peerapi_addr` returns `Some(_)`.
1822        n.tailnet_address.ipv4 = "1.2.3.4/32".parse().unwrap();
1823        n.peerapi_port = Some(443);
1824
1825        let addr = n
1826            .peerapi_addr()
1827            .expect("peerapi_addr yields Some with a port set");
1828        assert_eq!(addr.ip(), Ipv4Addr::new(1, 2, 3, 4));
1829        // The guard `if !is_tailscale_ip(dst.ip()) { return Err(BadRequest) }` WOULD reject this.
1830        assert!(
1831            !is_tailscale_ip(addr.ip()),
1832            "SSRF guard must reject a peer whose peerAPI addr is not a Tailscale CGNAT IP"
1833        );
1834
1835        // Conversely, a well-formed CGNAT peer passes the guard.
1836        let mut good = node("friend", Some("ts.net"));
1837        good.peerapi_port = Some(443);
1838        let good_addr = good.peerapi_addr().expect("peerapi_addr yields Some");
1839        assert!(is_tailscale_ip(good_addr.ip()));
1840    }
1841
1842    /// Ported from upstream's `TestNodeIsRouter` (`tailcfg/tailcfg_test.go`, `8d830599b`): a node
1843    /// is a router exactly when its `AllowedIPs` reach past its own `Addresses`. The absent case
1844    /// (a plain node advertising only its own addresses) is asserted alongside the present one,
1845    /// since "no routes besides my own" is the answer that must not drift.
1846    #[test]
1847    fn is_router_reports_routes_beyond_own_addresses() {
1848        let v4: ipnet::Ipv4Net = "100.64.0.1/32".parse().unwrap();
1849        let v6: ipnet::Ipv6Net = "fd7a:115c:a1e0::1/128".parse().unwrap();
1850        let self4 = ipnet::IpNet::V4(v4);
1851        let self6 = ipnet::IpNet::V6(v6);
1852
1853        let cases: &[(&str, Vec<ipnet::IpNet>, bool)] = &[
1854            ("empty", vec![], false),
1855            ("plain-ipv4", vec![self4], false),
1856            ("plain-ipv6", vec![self6], false),
1857            ("plain-ipv4-ipv6", vec![self4, self6], false),
1858            ("duplicates", vec![self4, self4], false),
1859            (
1860                "exit-node-ipv4",
1861                vec![self4, "0.0.0.0/0".parse().unwrap()],
1862                true,
1863            ),
1864            ("exit-node-ipv6", vec![self6, "::/0".parse().unwrap()], true),
1865            (
1866                "exit-node-ipv4-ipv6",
1867                vec![
1868                    self4,
1869                    self6,
1870                    "0.0.0.0/0".parse().unwrap(),
1871                    "::/0".parse().unwrap(),
1872                ],
1873                true,
1874            ),
1875            (
1876                "subnet-router-ipv4",
1877                vec![self4, "192.0.2.0/24".parse().unwrap()],
1878                true,
1879            ),
1880            (
1881                "subnet-router-ipv6",
1882                vec![self6, "2001:db8::/32".parse().unwrap()],
1883                true,
1884            ),
1885            (
1886                "subnet-router-ipv4-ipv6",
1887                vec![
1888                    self4,
1889                    self6,
1890                    "192.0.2.0/24".parse().unwrap(),
1891                    "2001:db8::/32".parse().unwrap(),
1892                ],
1893                true,
1894            ),
1895            // Go's `IsRouter` has no Tailscale-range exception: another peer's /32 is still a
1896            // routed address. This is where it parts ways with `is_subnet_route`.
1897            (
1898                "other-tailnet-host",
1899                vec![self4, "100.64.5.5/32".parse().unwrap()],
1900                true,
1901            ),
1902        ];
1903
1904        for (name, allowed, want) in cases {
1905            let mut n = node("host", Some("ts.net"));
1906            n.addresses = vec![self4, self6];
1907            n.tailnet_address = TailnetAddress { ipv4: v4, ipv6: v6 };
1908            n.accepted_routes = allowed.clone();
1909            assert_eq!(n.is_router(), *want, "{name}");
1910        }
1911    }
1912
1913    /// Go's `IsRouter` tests each `AllowedIPs` prefix against the node's **whole** `Addresses`
1914    /// slice, so every prefix control assigned is "its own". The wire field is a variable-length
1915    /// list, not a v4/v6 pair, so a tailnet may hand a node more than one prefix of a family; such
1916    /// a node must not be reported as a router on account of the extra one — which comparing only
1917    /// against the first-of-family `tailnet_address` pair does. Runs through the production `From`
1918    /// impl so the retention of the full list is pinned along with the predicate.
1919    #[test]
1920    fn is_router_tests_every_assigned_address_not_only_the_first_of_each_family() {
1921        let second4: ipnet::IpNet = "100.64.0.9/32".parse().unwrap();
1922        let second6: ipnet::IpNet = "fd7a:115c:a1e0::9/128".parse().unwrap();
1923        let wire = ts_control_serde::Node {
1924            addresses: vec![
1925                "100.64.0.1/32".parse().unwrap(),
1926                second4,
1927                "fd7a:115c:a1e0::1/128".parse().unwrap(),
1928                second6,
1929            ],
1930            ..Default::default()
1931        };
1932        let domain: Node = (&wire).into();
1933
1934        // The identity projection is still the first prefix of each family...
1935        assert_eq!(
1936            domain.tailnet_address.ipv4,
1937            "100.64.0.1/32".parse().unwrap()
1938        );
1939        // ...but every assigned prefix is retained, and (AllowedIPs absent ⇒ routes are exactly
1940        // the addresses) none of them makes the node a router.
1941        assert_eq!(domain.addresses, wire.addresses);
1942        assert!(
1943            !domain.is_router(),
1944            "a node whose routes are exactly its own assigned prefixes is not a router"
1945        );
1946
1947        // Either second-of-family address on its own is still not a routed prefix.
1948        for extra in [second4, second6] {
1949            let mut n = domain.clone();
1950            n.accepted_routes = vec![extra];
1951            assert!(
1952                !n.is_router(),
1953                "{extra} is one of this node's own addresses"
1954            );
1955        }
1956
1957        // The predicate still fires for a route that does reach past every assigned address.
1958        let mut router = domain.clone();
1959        router.accepted_routes.push("192.0.2.0/24".parse().unwrap());
1960        assert!(router.is_router(), "a real subnet route makes it a router");
1961    }
1962
1963    #[test]
1964    fn is_subnet_route_distinguishes_self_from_subnet() {
1965        let n = node("host", Some("ts.net"));
1966
1967        // The node's own /32 and /128 are self-addresses, not subnet routes.
1968        assert!(!n.is_subnet_route(&"100.64.0.1/32".parse().unwrap()));
1969        assert!(!n.is_subnet_route(&"fd7a::1/128".parse().unwrap()));
1970        // A different single Tailscale IP is still a self-address (Tailscale-assigned host).
1971        assert!(!n.is_subnet_route(&"100.64.5.5/32".parse().unwrap()));
1972        // A LAN /24 the node advertises is a subnet route.
1973        assert!(n.is_subnet_route(&"192.168.1.0/24".parse().unwrap()));
1974        // A single non-Tailscale host IP counts as a subnet route.
1975        assert!(n.is_subnet_route(&"8.8.8.8/32".parse().unwrap()));
1976        // The default route is treated as a subnet route.
1977        assert!(n.is_subnet_route(&"0.0.0.0/0".parse().unwrap()));
1978        assert!(n.is_subnet_route(&"::/0".parse().unwrap()));
1979    }
1980
1981    #[test]
1982    fn routes_to_install_gates_subnets_on_accept_routes() {
1983        let mut n = node("host", Some("ts.net"));
1984        let self4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
1985        let self6: ipnet::IpNet = "fd7a::1/128".parse().unwrap();
1986        let subnet: ipnet::IpNet = "192.168.1.0/24".parse().unwrap();
1987        n.accepted_routes = vec![self4, self6, subnet];
1988
1989        // accept_routes off: only the self addresses are installed.
1990        let off: Vec<_> = n.routes_to_install(false, None).copied().collect();
1991        assert_eq!(off, vec![self4, self6]);
1992
1993        // accept_routes on: the advertised subnet is installed too.
1994        let on: Vec<_> = n.routes_to_install(true, None).copied().collect();
1995        assert_eq!(on, vec![self4, self6, subnet]);
1996    }
1997
1998    #[test]
1999    fn routes_to_install_default_route_only_for_selected_exit_node() {
2000        let mut n = node("host", Some("ts.net"));
2001        n.stable_id = StableId("exit1".to_string());
2002        let self4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
2003        let default4: ipnet::IpNet = "0.0.0.0/0".parse().unwrap();
2004        let default6: ipnet::IpNet = "::/0".parse().unwrap();
2005        n.accepted_routes = vec![self4, default4, default6];
2006
2007        // No exit node selected: default routes are excluded even with accept_routes on
2008        // (fail-closed — internet-bound traffic has no overlay route and is dropped).
2009        let none_off: Vec<_> = n.routes_to_install(false, None).copied().collect();
2010        assert_eq!(none_off, vec![self4]);
2011        let none_on: Vec<_> = n.routes_to_install(true, None).copied().collect();
2012        assert_eq!(none_on, vec![self4]);
2013
2014        // A *different* peer selected as exit node: this peer still gets no default route.
2015        let other = StableId("exit2".to_string());
2016        let other_sel: Vec<_> = n.routes_to_install(false, Some(&other)).copied().collect();
2017        assert_eq!(other_sel, vec![self4]);
2018
2019        // This peer selected as the exit node: its default routes are installed.
2020        let me = StableId("exit1".to_string());
2021        let sel: Vec<_> = n.routes_to_install(false, Some(&me)).copied().collect();
2022        assert_eq!(sel, vec![self4, default4, default6]);
2023    }
2024
2025    #[test]
2026    fn routes_to_install_excludes_expired_peers_including_selected_exit() {
2027        let mut n = node("host", Some("ts.net"));
2028        n.accepted_routes = [
2029            "100.64.0.1/32",
2030            "fd7a::1/128",
2031            "192.168.1.0/24",
2032            "0.0.0.0/0",
2033            "::/0",
2034        ]
2035        .into_iter()
2036        .map(|route| route.parse().unwrap())
2037        .collect();
2038        n.expired = true;
2039        for accept_routes in [false, true] {
2040            for selected in [None, Some(&n.stable_id)] {
2041                assert_eq!(n.routes_to_install(accept_routes, selected).count(), 0);
2042            }
2043        }
2044        n.expired = false;
2045        assert_eq!(n.routes_to_install(true, Some(&n.stable_id)).count(), 5);
2046    }
2047
2048    fn exit_node_with(id: &str, ipv4: &str, hostname: &str, tailnet: Option<&str>) -> Node {
2049        let mut n = node(hostname, tailnet);
2050        n.stable_id = StableId(id.to_string());
2051        n.tailnet_address.ipv4 = format!("{ipv4}/32").parse().unwrap();
2052        n
2053    }
2054
2055    #[test]
2056    fn exit_node_selector_resolves_by_id_ip_and_name() {
2057        let a = exit_node_with("nA", "100.64.0.5", "alpha", Some("ts.net"));
2058        let b = exit_node_with("nB", "100.64.0.6", "beta", Some("ts.net"));
2059        let peers = [a, b];
2060        let it = || peers.iter();
2061
2062        // By stable id.
2063        assert_eq!(
2064            ExitNodeSelector::StableId(StableId("nB".into())).resolve(it()),
2065            Some(StableId("nB".into()))
2066        );
2067        // By tailnet IP.
2068        assert_eq!(
2069            ExitNodeSelector::Ip("100.64.0.5".parse().unwrap()).resolve(it()),
2070            Some(StableId("nA".into()))
2071        );
2072        // By MagicDNS name (fqdn, case-insensitive).
2073        assert_eq!(
2074            ExitNodeSelector::Name("BETA.ts.net".into()).resolve(it()),
2075            Some(StableId("nB".into()))
2076        );
2077        // By bare hostname.
2078        assert_eq!(
2079            ExitNodeSelector::Name("alpha".into()).resolve(it()),
2080            Some(StableId("nA".into()))
2081        );
2082        // Unresolvable selector => None (fail-closed at the call site).
2083        assert_eq!(
2084            ExitNodeSelector::Ip("100.64.0.99".parse().unwrap()).resolve(it()),
2085            None
2086        );
2087        assert_eq!(ExitNodeSelector::Name("ghost".into()).resolve(it()), None);
2088    }
2089
2090    #[test]
2091    fn exit_node_selector_resolution_is_deterministic_on_ties() {
2092        // Two peers sharing a name (transient netmap state): the smallest stable id wins, so the
2093        // outbound table and inbound source filter — which resolve independently — agree.
2094        let a = exit_node_with("nZ", "100.64.0.5", "dup", Some("ts.net"));
2095        let b = exit_node_with("nA", "100.64.0.6", "dup", Some("ts.net"));
2096        let peers = [a, b];
2097
2098        assert_eq!(
2099            ExitNodeSelector::Name("dup".into()).resolve(peers.iter()),
2100            Some(StableId("nA".into())),
2101            "smallest stable id wins the tie"
2102        );
2103        // Order of iteration must not change the result.
2104        assert_eq!(
2105            ExitNodeSelector::Name("dup".into()).resolve(peers.iter().rev()),
2106            Some(StableId("nA".into()))
2107        );
2108    }
2109
2110    #[test]
2111    fn peerapi_doh_url_requires_port_and_capability() {
2112        let mut n = node("exit", Some("ts.net"));
2113        n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
2114
2115        // No peerAPI port advertised: cannot proxy DNS.
2116        n.peerapi_port = None;
2117        n.cap = CapabilityVersion::V130;
2118        assert_eq!(n.peerapi_doh_url(), None);
2119
2120        // Port advertised but capability too old and no explicit service: cannot proxy.
2121        n.peerapi_port = Some(8080);
2122        n.cap = CapabilityVersion::V25;
2123        n.peerapi_dns_proxy = false;
2124        assert_eq!(n.peerapi_doh_url(), None);
2125
2126        // Port + new-enough capability: yields the DoH URL on the IPv4 address.
2127        n.cap = CapabilityVersion::V26;
2128        assert_eq!(
2129            n.peerapi_doh_url().as_deref(),
2130            Some("http://100.64.0.5:8080/dns-query")
2131        );
2132
2133        // Port + explicit peerapi-dns-proxy service, even with an old capability.
2134        n.cap = CapabilityVersion::V25;
2135        n.peerapi_dns_proxy = true;
2136        assert_eq!(
2137            n.peerapi_doh_url().as_deref(),
2138            Some("http://100.64.0.5:8080/dns-query")
2139        );
2140
2141        // WireGuard-only peers never run a peerAPI: no DoH URL even with a port.
2142        n.is_wireguard_only = true;
2143        assert_eq!(n.peerapi_doh_url(), None);
2144    }
2145
2146    #[test]
2147    fn peerapi_doh_addr_matches_url_gate() {
2148        let mut n = node("exit", Some("ts.net"));
2149        n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
2150        n.peerapi_port = Some(8080);
2151        n.cap = CapabilityVersion::V26;
2152
2153        // The addr form the DoH client dials is the same gated endpoint as the URL.
2154        assert_eq!(
2155            n.peerapi_doh_addr(),
2156            Some("100.64.0.5:8080".parse().unwrap())
2157        );
2158        // And it composes into exactly the URL form.
2159        assert_eq!(
2160            n.peerapi_doh_url().as_deref(),
2161            Some("http://100.64.0.5:8080/dns-query")
2162        );
2163
2164        // Gated off the same way: no port => no addr.
2165        n.peerapi_port = None;
2166        assert_eq!(n.peerapi_doh_addr(), None);
2167    }
2168
2169    #[test]
2170    fn peerapi_addr_returns_addr_when_advertised() {
2171        let mut n = node("peer", Some("ts.net"));
2172        n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
2173        n.peerapi_port = Some(8089);
2174
2175        // Not gated on the DNS-proxy capability: a plain advertised peerAPI port is enough.
2176        assert_eq!(n.peerapi_addr(), Some("100.64.0.5:8089".parse().unwrap()));
2177    }
2178
2179    #[test]
2180    fn peerapi_addr_none_when_no_port() {
2181        let mut n = node("peer", Some("ts.net"));
2182        n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
2183        n.peerapi_port = None;
2184
2185        assert_eq!(n.peerapi_addr(), None);
2186    }
2187
2188    #[test]
2189    fn peerapi_addr_none_for_wireguard_only() {
2190        let mut n = node("peer", Some("ts.net"));
2191        n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
2192        n.peerapi_port = Some(8089);
2193        n.is_wireguard_only = true;
2194
2195        // WireGuard-only peers run no peerAPI, even with a port set.
2196        assert_eq!(n.peerapi_addr(), None);
2197    }
2198
2199    #[test]
2200    fn can_share_files_gated_on_self_capability() {
2201        let mut n = node("self", Some("ts.net"));
2202        assert!(
2203            !n.can_share_files(),
2204            "no cap → file sharing not enabled (fail-closed)"
2205        );
2206        n.cap_map
2207            .insert("https://tailscale.com/cap/file-sharing".to_string(), vec![]);
2208        assert!(n.can_share_files(), "the file-sharing cap enables it");
2209    }
2210
2211    #[test]
2212    fn is_file_sharing_target_gated_on_peer_capability() {
2213        let mut n = node("peer", Some("ts.net"));
2214        assert!(
2215            !n.is_file_sharing_target(),
2216            "no cap → not an explicit target"
2217        );
2218        n.cap_map
2219            .insert("tailscale.com/cap/file-sharing-target".to_string(), vec![]);
2220        assert!(
2221            n.is_file_sharing_target(),
2222            "the file-sharing-target cap marks a cross-owner target"
2223        );
2224    }
2225
2226    #[test]
2227    fn resolves_subdomains_gated_on_the_node_attribute() {
2228        let mut n = node("peer", Some("ts.net"));
2229        assert!(
2230            !n.resolves_subdomains(),
2231            "no attribute → not a subdomain host: control has to opt the node in"
2232        );
2233        n.cap_map
2234            .insert("dns-subdomain-resolve".to_string(), vec![]);
2235        assert!(
2236            n.resolves_subdomains(),
2237            "the dns-subdomain-resolve attribute makes this node a subdomain host"
2238        );
2239    }
2240
2241    /// The DNS forwarder's TCP retry is ON by default, so the attribute has to be read as the *off*
2242    /// switch it is: absent means retry. Getting the polarity backwards would silently disable the
2243    /// retry on every tailnet that never set the attribute — the failure the retry exists to remove.
2244    #[test]
2245    fn dns_forwarder_tcp_retries_disabled_only_by_the_node_attribute() {
2246        let mut n = node("peer", Some("ts.net"));
2247        assert!(
2248            !n.disable_dns_forwarder_tcp_retries(),
2249            "no attribute → the retry stays on: this is the off switch, not the on switch"
2250        );
2251        n.cap_map
2252            .insert("dns-forwarder-disable-tcp-retries".to_string(), vec![]);
2253        assert!(
2254            n.disable_dns_forwarder_tcp_retries(),
2255            "the dns-forwarder-disable-tcp-retries attribute turns the TCP retry off"
2256        );
2257    }
2258
2259    /// The debug-always-stun attribute is control's only way to keep the periodic STUN sweep running
2260    /// once the datapath has gone idle, so its key has to be the literal Go sends
2261    /// (`NodeAttrDebugForceBackgroundSTUN`). Absent is the quiet default.
2262    #[test]
2263    fn force_background_stun_gated_on_the_node_attribute() {
2264        let mut n = node("self", Some("ts.net"));
2265        assert!(
2266            !n.force_background_stun(),
2267            "no attribute → the idle stop applies, which is the quiet default"
2268        );
2269        n.cap_map.insert("debug-always-stun".to_string(), vec![]);
2270        assert!(
2271            n.force_background_stun(),
2272            "the debug-always-stun attribute keeps the background sweep running"
2273        );
2274    }
2275
2276    #[test]
2277    fn one_cgnat_is_a_tri_state_read_off_the_query_string_keys() {
2278        let mut n = node("self", Some("ts.net"));
2279        assert_eq!(
2280            n.one_cgnat(),
2281            None,
2282            "neither attribute → control has no opinion, the platform default decides"
2283        );
2284
2285        // The key carries a query string; the bare `one-cgnat` is not the attribute and must not
2286        // be mistaken for either half of the tri-state.
2287        n.cap_map.insert("one-cgnat".to_string(), vec![]);
2288        assert_eq!(
2289            n.one_cgnat(),
2290            None,
2291            "a bare `one-cgnat` key is not one of the two attributes control sets"
2292        );
2293
2294        n.cap_map.insert("one-cgnat?v=false".to_string(), vec![]);
2295        assert_eq!(
2296            n.one_cgnat(),
2297            Some(false),
2298            "`one-cgnat?v=false` declines the forced collapse"
2299        );
2300
2301        n.cap_map.insert("one-cgnat?v=true".to_string(), vec![]);
2302        assert_eq!(
2303            n.one_cgnat(),
2304            Some(true),
2305            "a node holding both attributes collapses: the enabling attribute is checked first"
2306        );
2307
2308        n.cap_map.remove("one-cgnat?v=false");
2309        assert_eq!(
2310            n.one_cgnat(),
2311            Some(true),
2312            "`one-cgnat?v=true` alone collapses"
2313        );
2314    }
2315
2316    #[test]
2317    fn delta_updates_disabled_reads_the_disable_delta_updates_attribute() {
2318        let mut n = node("self", Some("ts.net"));
2319        assert!(
2320            !n.delta_updates_disabled(),
2321            "absent attribute → the incremental path, which is the default"
2322        );
2323
2324        // The attribute is the bare key; the presence of the key is the whole signal, and its
2325        // value (control sends an empty one) is never read.
2326        n.cap_map
2327            .insert("disable-delta-updates".to_string(), vec![]);
2328        assert!(
2329            n.delta_updates_disabled(),
2330            "control granted the attribute → decline the incremental path"
2331        );
2332
2333        n.cap_map.remove("disable-delta-updates");
2334        assert!(
2335            !n.delta_updates_disabled(),
2336            "control withdrawing the attribute returns the node to the incremental path"
2337        );
2338    }
2339
2340    #[test]
2341    fn silent_disco_reads_the_silent_disco_attribute() {
2342        let mut n = node("self", Some("ts.net"));
2343        assert!(
2344            !n.silent_disco(),
2345            "absent attribute → keep heartbeating, which is the default"
2346        );
2347
2348        // The attribute is the bare key; the presence of the key is the whole signal, and its
2349        // value (control sends an empty one) is never read.
2350        n.cap_map.insert("silent-disco".to_string(), vec![]);
2351        assert!(
2352            n.silent_disco(),
2353            "control granted the attribute → stop heartbeating peers"
2354        );
2355
2356        n.cap_map.remove("silent-disco");
2357        assert!(
2358            !n.silent_disco(),
2359            "control withdrawing the attribute returns the node to the heartbeat cadence"
2360        );
2361    }
2362
2363    /// Control's `only-tcp-443` attribute is read off the self node's cap map, and — because
2364    /// upstream's `SetOnlyTCP443` is live rather than start-time — withdrawing it reads back as
2365    /// `false` on the very next netmap.
2366    #[test]
2367    fn only_tcp_443_reads_the_only_tcp_443_attribute() {
2368        let mut n = node("self", Some("ts.net"));
2369        assert!(
2370            !n.only_tcp_443(),
2371            "absent attribute → UDP as before, which is the default"
2372        );
2373
2374        // The attribute is the bare key; the presence of the key is the whole signal, and its
2375        // value (control sends an empty one) is never read.
2376        n.cap_map.insert("only-tcp-443".to_string(), vec![]);
2377        assert!(
2378            n.only_tcp_443(),
2379            "control granted the attribute → this node emits nothing but TCP/443"
2380        );
2381
2382        n.cap_map.remove("only-tcp-443");
2383        assert!(
2384            !n.only_tcp_443(),
2385            "control withdrawing the attribute must restore UDP, with no restart"
2386        );
2387    }
2388
2389    #[test]
2390    fn peerapi_from_services_extracts_v4_port_and_dns_proxy_flag() {
2391        use ts_control_serde::{Service, ServiceProto};
2392
2393        let services = [
2394            Service {
2395                proto: ServiceProto::PeerApi4,
2396                port: 8080,
2397                description: "peerapi".into(),
2398            },
2399            Service {
2400                proto: ServiceProto::PeerApi6,
2401                port: 9090,
2402                description: "peerapi6".into(),
2403            },
2404            Service {
2405                proto: ServiceProto::PeerApiDnsProxy,
2406                port: 1,
2407                description: "dns".into(),
2408            },
2409        ];
2410        let (port, dns_proxy) = peerapi_from_services(Some(&services));
2411        assert_eq!(port, Some(8080), "only the IPv4 peerAPI port is taken");
2412        assert!(dns_proxy);
2413
2414        // No services at all.
2415        assert_eq!(peerapi_from_services(None), (None, false));
2416    }
2417
2418    #[test]
2419    fn exit_node_selector_parses_ip_vs_name() {
2420        assert_eq!(
2421            "100.64.0.5".parse::<ExitNodeSelector>().unwrap(),
2422            ExitNodeSelector::Ip("100.64.0.5".parse().unwrap())
2423        );
2424        assert_eq!(
2425            "fd7a::5".parse::<ExitNodeSelector>().unwrap(),
2426            ExitNodeSelector::Ip("fd7a::5".parse().unwrap())
2427        );
2428        assert_eq!(
2429            "my-exit.ts.net".parse::<ExitNodeSelector>().unwrap(),
2430            ExitNodeSelector::Name("my-exit.ts.net".into())
2431        );
2432    }
2433}