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    /// The address of the node in the tailnet.
145    pub tailnet_address: TailnetAddress,
146
147    /// The node's [`NodePublicKey`].
148    pub node_key: NodePublicKey,
149    /// The node key's expiration.
150    pub node_key_expiry: Option<DateTime<Utc>>,
151
152    /// Whether control reports this node currently connected to the coordination server
153    /// (`tailcfg.Node.Online`, a tri-state `*bool`). `None` = unknown / no permission to know /
154    /// never been online — **do not collapse to `false`** (that would fabricate an offline status
155    /// control never asserted). Updated by full nodes AND by the delta channels (a
156    /// [`PeerChange::online`], or the `MapResponse.online_change` map).
157    pub online: Option<bool>,
158    /// When control last saw this node online (`tailcfg.Node.LastSeen`). Per Go, only meaningful
159    /// while `online` is not `Some(true)` ("not updated when Online is true"). `None` = unknown /
160    /// never online.
161    pub last_seen: Option<DateTime<Utc>>,
162
163    /// Marshalled TKA node-key signature (`tailcfg.Node.KeySignature`); empty when control sends
164    /// none. Verified against a TKA `Authority` at the peer-trust chokepoint WHEN tailnet-lock
165    /// enforcement is active.
166    pub key_signature: Vec<u8>,
167
168    /// The node's [`MachinePublicKey`], if known.
169    pub machine_key: Option<MachinePublicKey>,
170    /// The node's [`DiscoPublicKey`], if known.
171    pub disco_key: Option<DiscoPublicKey>,
172
173    /// The routes this node accepts traffic for.
174    pub accepted_routes: Vec<ipnet::IpNet>,
175    /// The underlay addresses this node is reachable on (`Endpoints` in Go).
176    pub underlay_addresses: Vec<SocketAddr>,
177
178    /// The node's advertised SSH host public keys, in known_hosts format (Go
179    /// `tailcfg.Hostinfo.SSHHostKeys`, surfaced by tsnet as `ipnstate.PeerStatus.SSH_HostKeys`).
180    /// Used by `tailscale ssh` to pin a peer's host key (TOFU). Empty when control advertised none
181    /// (the wire `Hostinfo.sshHostKeys` was absent), never fabricated. Projected from
182    /// [`ts_control_serde::HostInfo::ssh_host_keys`].
183    pub ssh_host_keys: Vec<String>,
184
185    /// The DERP region for this node, if known.
186    pub derp_region: Option<ts_derp::RegionId>,
187
188    /// This node's advertised capability version (`Node.Cap` in Go). Old control servers may not
189    /// send it, in which case it defaults to [`CapabilityVersion::default`]. Used to gate features
190    /// that require a minimum peer capability, e.g. exit-node DNS proxying (`peerCanProxyDNS`).
191    pub cap: CapabilityVersion,
192
193    /// This node's capability map (`Node.CapMap` in Go). Keys are capability names/URLs; values are
194    /// the raw JSON argument blobs (often empty). Threaded from the wire
195    /// ([`ts_control_serde::Node::cap_map`]) as an owned copy. Used to gate node-level features such
196    /// as Funnel ingress ([`Node::can_funnel`], [`Node::check_funnel_port`]).
197    pub cap_map: NodeCapMap,
198
199    /// The peerAPI port this node advertises over IPv4 (`peerapi4` service), if any.
200    ///
201    /// Derived from `HostInfo.Services`. `None` means the peer advertises no IPv4 peerAPI, so it
202    /// cannot be reached for peerAPI DoH (DNS-over-HTTPS) exit-node delegation.
203    pub peerapi_port: Option<u16>,
204
205    /// Whether this peer advertises the `peerapi-dns-proxy` service (Go `PeerAPIDNSProxy`),
206    /// indicating it will proxy DNS lookups for other nodes when used as an exit node.
207    pub peerapi_dns_proxy: bool,
208
209    /// Whether this is a non-Tailscale WireGuard-only peer (`IsWireGuardOnly` in Go). Such peers
210    /// cannot run a peerAPI DoH server, so exit-node DNS for them comes from
211    /// [`Node::exit_node_dns_resolvers`] instead.
212    pub is_wireguard_only: bool,
213
214    /// DNS resolvers to use when this WireGuard-only peer is selected as an exit node
215    /// (`ExitNodeDNSResolvers` in Go). Only meaningful when [`Node::is_wireguard_only`] is set.
216    /// Encrypted-transport resolvers are dropped (see `Resolver::from_serde`).
217    pub exit_node_dns_resolvers: Vec<Resolver>,
218
219    /// Whether this node advertises itself as a **peer relay** (Go `Hostinfo.PeerRelay`): it runs a
220    /// UDP relay server other peers can allocate relay endpoints on. This fork is a relay client
221    /// only and never sets this for itself; it is parsed off peers so a relay candidate can be
222    /// recognized. Actually *using* a relay path (the Geneve data path + allocation handshake) is
223    /// not yet implemented — see the crate docs.
224    pub peer_relay: bool,
225
226    /// Per-service virtual IP addresses of the Tailscale VIP services this node *hosts*, keyed by
227    /// `svc:<label>` service name. Parsed from the `service-host`
228    /// ([`ts_control_serde::NODE_ATTR_SERVICE_HOST`]) node-capability value
229    /// (`tailcfg.ServiceIPMappings`). These VIPs are control-assigned and also injected into the
230    /// node's `AllowedIPs`; the application netstack must accept packets for them so a
231    /// `Device::listen_service`-bound listener can answer. Empty when the
232    /// node hosts no VIP services (the common case). Per-service IP lists are deduplicated, source
233    /// order otherwise preserved. Use [`Node::service_addresses`] for the flattened set (netstack
234    /// accept list) and [`Node::service_addresses_for`] for a specific service's VIPs.
235    pub service_vips: alloc::collections::BTreeMap<String, Vec<IpAddr>>,
236}
237
238impl Node {
239    /// The fully-qualified domain name of the node.
240    ///
241    /// This is a string of the form `$HOST.$TAILNET_DOMAIN.`. For tailnets controlled by
242    /// Tailscale's control plane, this usually means `$HOST.tail1234.ts.net.`
243    ///
244    /// The `trailing_dot` parameter specifies whether to include the trailing dot in the
245    /// fqdn. This is included by the definition of FQDN, and is the way the Go codebase
246    /// formats this field, but the parameter is included to allow turning it off for use
247    /// in contexts that expect it to be absent.
248    pub fn fqdn(&self, trailing_dot: bool) -> String {
249        let dot = if trailing_dot { "." } else { "" };
250        match &self.tailnet {
251            Some(tailnet) => format!("{}.{tailnet}{dot}", self.hostname),
252            None => format!("{}{dot}", self.hostname),
253        }
254    }
255
256    /// Whether this node's key has expired as of `now`, mirroring Go's
257    /// `netmap.NetworkMap.SelfKeyExpiry` + the `!expiry.IsZero() && expiry.Before(now)` check in
258    /// `ipnlocal`. A node with no expiry ([`Node::node_key_expiry`] is `None`, the Go "zero value =
259    /// does not expire") is never expired.
260    ///
261    /// Like Go, this fork is **reactive**: it reports expiry rather than auto-rotating in the
262    /// background (Go transitions to `NeedsLogin` on expiry and re-registers via stored auth-key or
263    /// interactive login). A caller observing `true` should re-register
264    /// (`crate::tokio::register`) — supplying `RegisterRequest::old_node_key` (the prior key) and
265    /// a fresh `node_key` when rotating the key, or the same key to merely refresh.
266    pub fn key_expired(&self, now: DateTime<Utc>) -> bool {
267        match self.node_key_expiry {
268            None => false,
269            Some(expiry) => expiry < now,
270        }
271    }
272
273    /// The instant this node's key expires (`Node.KeyExpiry` in Go), or `None` if it never expires.
274    /// A caller can schedule a re-evaluation/re-auth at this time.
275    pub fn key_expiry(&self) -> Option<DateTime<Utc>> {
276        self.node_key_expiry
277    }
278
279    /// Whether this node advertises itself as a peer relay (Go `Hostinfo.PeerRelay`): it runs a UDP
280    /// relay server other peers may allocate relay endpoints on. Recognizing a relay candidate;
281    /// actually traversing a relay path is not yet implemented in this fork.
282    pub fn is_peer_relay(&self) -> bool {
283        self.peer_relay
284    }
285
286    /// The key-expiry instant as **Unix seconds**, or `None` if the key never expires. Provided for
287    /// callers (e.g. the root crate) that don't depend on `chrono`.
288    pub fn key_expiry_unix(&self) -> Option<i64> {
289        self.node_key_expiry.map(|t| t.timestamp())
290    }
291
292    /// Whether the key has expired as of `now_unix_secs` (Unix seconds). Equivalent to
293    /// [`key_expired`](Self::key_expired) for `chrono`-free callers. A key with no expiry is never
294    /// expired.
295    pub fn key_expired_at_unix(&self, now_unix_secs: i64) -> bool {
296        match self.key_expiry_unix() {
297            None => false,
298            Some(expiry) => expiry < now_unix_secs,
299        }
300    }
301
302    /// The fully-qualified domain name of the node, only returning `Some` if the tailnet
303    /// component is present.
304    ///
305    /// See [`Node::fqdn`].
306    pub fn fqdn_opt(&self, trailing_dot: bool) -> Option<String> {
307        let dot = if trailing_dot { "." } else { "" };
308        let tailnet = self.tailnet.as_deref()?;
309
310        Some(format!("{}.{tailnet}{dot}", self.hostname))
311    }
312
313    /// Report whether this node matches the given `name`.
314    ///
315    /// `name` is checked for equality with both this node's bare hostname and its fqdn. A
316    /// trailing `.` may be present. Matching is case-insensitive (DNS names are
317    /// case-insensitive), so this agrees with the canonicalized MagicDNS-name index used for
318    /// peer lookups.
319    pub fn matches_name(&self, name: &str) -> bool {
320        // Strip an optional trailing root dot, then chop our `.tailnet` suffix off the end (if it
321        // matches, case-insensitively) and compare the remainder to our hostname. If the tailnet
322        // suffix doesn't match, the final case-insensitive compare against our bare hostname fails
323        // naturally; if `name` was just the hostname, nothing is chopped and we compare directly.
324
325        let name = name.strip_suffix('.').unwrap_or(name);
326
327        let name = if let Some(tailnet) = &self.tailnet {
328            name.get(name.len().saturating_sub(tailnet.len())..)
329                .filter(|suffix| suffix.eq_ignore_ascii_case(tailnet))
330                .and_then(|_| name.get(..name.len() - tailnet.len()))
331                .and_then(|name| name.strip_suffix('.'))
332                .unwrap_or(name)
333        } else {
334            name
335        };
336
337        name.eq_ignore_ascii_case(&self.hostname)
338    }
339
340    /// Report whether `route` is an advertised *subnet* route (as opposed to one of this node's
341    /// own tailnet addresses).
342    ///
343    /// Mirrors `cidrIsSubnet` in the Go client (`wgengine/wgcfg/nmcfg/nmcfg.go`). A route is *not*
344    /// a subnet route (i.e. it's a self-address) when it is a single host IP that is either a
345    /// Tailscale-assigned IP or exactly one of this node's [`TailnetAddress`] addresses. Everything
346    /// else — multi-IP CIDRs, and single IPs outside the Tailscale ranges — is a subnet route.
347    ///
348    /// The default route (`0.0.0.0/0` / `::/0`) is treated as a subnet route here; exit-node
349    /// handling is a separate concern.
350    pub fn is_subnet_route(&self, route: &ipnet::IpNet) -> bool {
351        let host_prefix = match route {
352            ipnet::IpNet::V4(_) => 32,
353            ipnet::IpNet::V6(_) => 128,
354        };
355
356        if route.prefix_len() != host_prefix {
357            // Any multi-IP CIDR (including the default route) is a subnet route.
358            return true;
359        }
360
361        let addr = route.addr();
362        !(is_tailscale_ip(addr) || self.tailnet_address.contains(addr))
363    }
364
365    /// The routes that should be installed for this peer, given whether this node accepts
366    /// advertised subnet routes (`--accept-routes` / `RouteAll` in the Go client) and which peer
367    /// (if any) is the selected exit node (`--exit-node` / `ExitNodeID` in the Go client).
368    ///
369    /// This node's own addresses (the peer's `/32` and `/128`) are always installed so the peer
370    /// itself stays reachable. Larger advertised subnet routes are only installed when
371    /// `accept_routes` is set; otherwise they are dropped (fail-closed). The same filtered set
372    /// governs both outbound routing to the peer and inbound source validation, exactly as
373    /// WireGuard cryptokey routing couples them in the Go client.
374    ///
375    /// The default route (`0.0.0.0/0` / `::/0`) is installed *only* for the peer whose
376    /// [`StableId`] equals `exit_node`, mirroring `nmcfg.go`'s `if allowedIP.Bits()==0 &&
377    /// peer.StableID()!=exitNode { skip }`. Exit-node use is gated behind this separate, explicit
378    /// preference (`ExitNodeID`, not `RouteAll`): conflating the two would let enabling
379    /// subnet-route acceptance silently route every packet through any peer advertising a default
380    /// route — unacceptable for a fail-closed privacy posture. When `exit_node` is `None` (the
381    /// default) no peer ever receives a `/0`, so internet-bound traffic has no overlay route and is
382    /// dropped by the userspace netstack (fail-closed, no leak). Longest-prefix-match means a peer
383    /// selected as the exit node still loses more-specific destinations to other peers; only
384    /// residual default-route traffic egresses through it.
385    pub fn routes_to_install<'a>(
386        &'a self,
387        accept_routes: bool,
388        exit_node: Option<&StableId>,
389    ) -> impl Iterator<Item = &'a ipnet::IpNet> + 'a {
390        // Computed eagerly so the returned iterator doesn't borrow `exit_node`.
391        let is_selected_exit = exit_node == Some(&self.stable_id);
392        self.accepted_routes.iter().filter(move |route| {
393            if route.prefix_len() == 0 {
394                // Default route: installed only when this peer is the selected exit node. Both the
395                // outbound route table and the inbound source filter call this, so the exit peer
396                // may legitimately source arbitrary internet IPs on return traffic — and only it.
397                return is_selected_exit;
398            }
399            accept_routes || !self.is_subnet_route(route)
400        })
401    }
402
403    /// The capability version at and above which a peer can proxy DNS for nodes using it as an exit
404    /// node (Go `tailcfg.CapabilityVersion` `peerCanProxyDNS`, introduced 2022-01-12 at V26).
405    const PEER_CAN_PROXY_DNS: CapabilityVersion = CapabilityVersion::V26;
406
407    /// The base URL of this peer's IPv4 peerAPI DoH endpoint for exit-node DNS proxying, if it can
408    /// proxy DNS. Returns e.g. `http://100.64.0.5:8080/dns-query`.
409    ///
410    /// Mirrors Go `peerAPIBase(...)+"/dns-query"` gated by `exitNodeCanProxyDNS`: a peer can proxy
411    /// DNS when it advertises an IPv4 peerAPI port **and** either advertises the explicit
412    /// `peerapi-dns-proxy` service or is new enough ([`Node::cap`] ≥ `PEER_CAN_PROXY_DNS`). A
413    /// WireGuard-only peer never runs a peerAPI, so it returns `None` here (its exit-node DNS comes
414    /// from [`Node::exit_node_dns_resolvers`] instead).
415    ///
416    /// IPv4-only by deliberate design: the tailnet dataplane in this fork binds IPv4 only, so we
417    /// never form a peerAPI URL on the peer's IPv6 address.
418    pub fn peerapi_doh_url(&self) -> Option<String> {
419        self.peerapi_doh_addr()
420            .map(|addr| format!("http://{addr}/dns-query"))
421    }
422
423    /// The IPv4 socket address (`<tailnet-ipv4>:<peerapi-port>`) of this peer's peerAPI DoH endpoint
424    /// for exit-node DNS proxying, if it can proxy DNS. Same gate as [`Node::peerapi_doh_url`]; this
425    /// is the form the DoH *client* dials (over the overlay netstack) when delegating recursive
426    /// resolution to a selected exit node. `SocketAddr`'s `Display` is `ip:port`, so
427    /// `peerapi_doh_url` formats to `http://<ip>:<port>/dns-query` over this.
428    pub fn peerapi_doh_addr(&self) -> Option<SocketAddr> {
429        if self.is_wireguard_only {
430            return None;
431        }
432        let port = self.peerapi_port?;
433        if !(self.peerapi_dns_proxy || self.cap >= Self::PEER_CAN_PROXY_DNS) {
434            return None;
435        }
436        Some(SocketAddr::new(
437            IpAddr::V4(self.tailnet_address.ipv4.addr()),
438            port,
439        ))
440    }
441
442    /// The IPv4 peerAPI socket address (`<tailnet-ipv4>:<peerapi4-port>`) of this node, if it
443    /// advertises an IPv4 peerAPI. Unlike [`Node::peerapi_doh_addr`], this is **not** gated on the
444    /// DNS-proxy capability: it is the general base for any peerAPI request to this node (e.g. a
445    /// Taildrop `PUT /v0/put/<name>` upload), mirroring Go's `peerAPIBase`/`peerAPIPorts`.
446    ///
447    /// IPv4-only by this fork's deliberate design (the tailnet dataplane binds IPv4 only, so we never
448    /// form a peerAPI URL on the peer's IPv6 address). Returns `None` for a WireGuard-only peer (which
449    /// runs no peerAPI) or a peer advertising no IPv4 peerAPI port.
450    pub fn peerapi_addr(&self) -> Option<SocketAddr> {
451        if self.is_wireguard_only {
452            return None;
453        }
454        let port = self.peerapi_port?;
455        Some(SocketAddr::new(
456            IpAddr::V4(self.tailnet_address.ipv4.addr()),
457            port,
458        ))
459    }
460
461    /// The node attribute granting HTTPS (TLS cert provisioning) for this node (Go
462    /// `tailcfg.CapabilityHTTPS`). One of the two caps [`Node::can_funnel`] requires.
463    const CAP_HTTPS: &'static str = "https";
464
465    /// The node attribute granting the ability to host Funnel ingress (Go `tailcfg.NodeAttrFunnel`).
466    /// The other cap [`Node::can_funnel`] requires.
467    const NODE_ATTR_FUNNEL: &'static str = "funnel";
468
469    /// The capability URL whose `?ports=` query enumerates the ports Funnel may listen on (Go
470    /// `tailcfg.CapabilityFunnelPorts`). The allowed ports live entirely in the *key's* query
471    /// string, not the cap value.
472    const CAP_FUNNEL_PORTS: &'static str = "https://tailscale.com/cap/funnel-ports";
473
474    /// Report whether the cap map contains `cap` as a key (Go `NodeCapMap.Contains` / `HasCap`).
475    pub fn has_node_attr(&self, cap: &str) -> bool {
476        self.cap_map.contains_key(cap)
477    }
478
479    /// Report whether this node is permitted to host Tailscale Funnel ingress.
480    ///
481    /// Mirrors Go `ipn.NodeCanFunnel`: the node must advertise BOTH `CapabilityHTTPS` (`"https"`)
482    /// AND `NodeAttrFunnel` (`"funnel"`) in its cap map. Fail-closed: a missing cap denies.
483    pub fn can_funnel(&self) -> bool {
484        self.has_node_attr(Self::CAP_HTTPS) && self.has_node_attr(Self::NODE_ATTR_FUNNEL)
485    }
486
487    /// The capability control grants the **self** node when Taildrop is enabled for the tailnet (Go
488    /// `tailcfg.CapabilityFileSharing`). Gates [`Node::can_share_files`].
489    const CAP_FILE_SHARING: &'static str = "https://tailscale.com/cap/file-sharing";
490
491    /// The capability marking a **peer** as an explicit Taildrop send target even across owners (Go
492    /// `tailcfg.PeerCapabilityFileSharingTarget`). Checked by [`Node::is_file_sharing_target`].
493    const CAP_FILE_SHARING_TARGET: &'static str = "tailscale.com/cap/file-sharing-target";
494
495    /// Report whether this node may send Taildrop files — i.e. the admin has enabled file sharing for
496    /// the tailnet (Go `self.CapMap().Contains(CapabilityFileSharing)`). Applied to the **self** node
497    /// as the node-level gate in `FileTargets`; fail-closed when the cap is absent.
498    pub fn can_share_files(&self) -> bool {
499        self.has_node_attr(Self::CAP_FILE_SHARING)
500    }
501
502    /// Report whether this **peer** is an explicit Taildrop send target via ACL caps (Go
503    /// `PeerHasCap(p, PeerCapabilityFileSharingTarget)`) — the cross-owner path that lets a peer owned
504    /// by a different user still be a valid target.
505    pub fn is_file_sharing_target(&self) -> bool {
506        self.has_node_attr(Self::CAP_FILE_SHARING_TARGET)
507    }
508
509    /// Report whether `wanted_port` is allowed for Funnel on this node.
510    ///
511    /// Mirrors Go `ipn.CheckFunnelPort`: scan the cap-map keys for one prefixed by
512    /// `Node::CAP_FUNNEL_PORTS`, URL-parse that key, read its `ports` query parameter, and match
513    /// `wanted_port` against the comma-separated list of single ports and `first-last` ranges. The
514    /// port list lives in the *key*, never the value. Fail-closed: no matching cap, an empty or
515    /// unparseable `ports` query, or a key whose non-query part isn't exactly the funnel-ports URL
516    /// all deny.
517    pub fn check_funnel_port(&self, wanted_port: u16) -> bool {
518        // Extract the `ports=` list from the first cap-map key that is the funnel-ports URL with a
519        // non-empty `ports` query. Returns `None` (deny) if the key is unparseable, the query is
520        // missing/empty, or the URL (sans query) isn't exactly the funnel-ports cap.
521        let parse_attr = |attr: &str| -> Option<String> {
522            let mut url = url::Url::parse(attr).ok()?;
523            let ports = url
524                .query_pairs()
525                .find(|(k, _)| k == "ports")
526                .map(|(_, v)| v.into_owned())?;
527            if ports.is_empty() {
528                return None;
529            }
530            url.set_query(None);
531            // Go compares `u.String()` against the bare cap; `url`'s serializer keeps a trailing
532            // `/` only if present in the input, and the funnel-ports cap has none, so a direct
533            // string compare matches Go's behavior.
534            if url.as_str() != Self::CAP_FUNNEL_PORTS {
535                return None;
536            }
537            Some(ports)
538        };
539
540        let Some(ports_str) = self
541            .cap_map
542            .keys()
543            .filter(|attr| attr.starts_with(Self::CAP_FUNNEL_PORTS))
544            .find_map(|attr| parse_attr(attr))
545        else {
546            return false;
547        };
548
549        let wanted = wanted_port.to_string();
550        for ps in ports_str.split(',') {
551            if ps.is_empty() {
552                continue;
553            }
554            match ps.split_once('-') {
555                None => {
556                    if ps == wanted {
557                        return true;
558                    }
559                }
560                Some((first, last)) => {
561                    let (Ok(fp), Ok(lp)) = (first.parse::<u16>(), last.parse::<u16>()) else {
562                        continue;
563                    };
564                    if fp <= wanted_port && wanted_port <= lp {
565                        return true;
566                    }
567                }
568            }
569        }
570        false
571    }
572
573    /// Report whether this node is permitted to host Tailscale VIP services.
574    ///
575    /// Mirrors the Go grant model: possession of the `service-host`
576    /// ([`ts_control_serde::NODE_ATTR_SERVICE_HOST`]) node-capability **and** at least one assigned
577    /// VIP address. Go additionally requires the host to be tagged
578    /// (`ErrUntaggedServiceHost`); that tag gate is enforced at
579    /// `Device::listen_service` using [`Node::tags`]. Fail-closed: no cap
580    /// or no assigned VIP denies.
581    pub fn is_service_host(&self) -> bool {
582        self.has_node_attr(ts_control_serde::NODE_ATTR_SERVICE_HOST)
583            && !self.service_vips.is_empty()
584    }
585
586    /// The control-assigned VIP addresses for one named service (`svc:<label>`), or an empty slice
587    /// if this node does not host that service. This is the exact per-service mapping (so a
588    /// multi-service co-host binds the right VIP for each service).
589    pub fn service_addresses_for(&self, service: &str) -> &[IpAddr] {
590        self.service_vips
591            .get(service)
592            .map(Vec::as_slice)
593            .unwrap_or(&[])
594    }
595
596    /// The flattened, deduplicated set of every VIP address this node hosts across all services.
597    /// Used to widen the netstack's accepted-address set so any hosted-service listener is
598    /// reachable. Per-service binding uses [`Node::service_addresses_for`] instead.
599    pub fn service_addresses(&self) -> Vec<IpAddr> {
600        let mut seen = alloc::collections::BTreeSet::new();
601        let mut out = Vec::new();
602        for addr in self.service_vips.values().flatten() {
603            if seen.insert(*addr) {
604                out.push(*addr);
605            }
606        }
607        out
608    }
609}
610
611/// Validate a Tailscale VIP service name (`tailcfg.ServiceName.Validate`): it must carry the
612/// `svc:` prefix ([`ts_control_serde::SERVICE_NAME_PREFIX`]) followed by a valid DNS label
613/// (1–63 chars, ASCII alphanumeric or `-`, not starting/ending with `-`). Returns the bare label on
614/// success. Fail-closed: anything malformed is rejected so a listener can never bind for a bogus
615/// service name.
616pub fn validate_service_name(name: &str) -> Option<&str> {
617    let label = name.strip_prefix(ts_control_serde::SERVICE_NAME_PREFIX)?;
618    if label.is_empty() || label.len() > 63 {
619        return None;
620    }
621    if label.starts_with('-') || label.ends_with('-') {
622        return None;
623    }
624    if label
625        .bytes()
626        .all(|b| b.is_ascii_alphanumeric() || b == b'-')
627    {
628        Some(label)
629    } else {
630        None
631    }
632}
633
634/// Parse the per-service VIP map this node hosts from the `service-host` node-capability value(s).
635/// Each value is the raw JSON text of a [`ts_control_serde::ServiceIpMappings`] object (svc-name ->
636/// VIP IPs); unparseable values are skipped (fail-closed: a malformed mapping contributes no VIPs).
637/// Per-service IP lists are deduplicated, source order otherwise preserved.
638fn service_vips_from_cap_map(
639    cap_map: &NodeCapMap,
640) -> alloc::collections::BTreeMap<String, Vec<IpAddr>> {
641    let mut out: alloc::collections::BTreeMap<String, Vec<IpAddr>> =
642        alloc::collections::BTreeMap::new();
643    let Some(values) = cap_map.get(ts_control_serde::NODE_ATTR_SERVICE_HOST) else {
644        return out;
645    };
646
647    for raw in values {
648        let Ok(mappings) = serde_json::from_str::<ts_control_serde::ServiceIpMappings>(raw) else {
649            continue;
650        };
651        for (name, addrs) in &mappings.0 {
652            let entry = out.entry((*name).to_string()).or_default();
653            for addr in addrs {
654                if !entry.contains(addr) {
655                    entry.push(*addr);
656                }
657            }
658        }
659    }
660    out
661}
662
663/// Collect a wire ([`ts_control_serde`]) node cap map into an owned [`NodeCapMap`].
664///
665/// Keys are copied as owned strings; each value's raw JSON text is preserved verbatim. The wire map
666/// borrows from the decode buffer, so an owned copy is required to outlive it on the domain
667/// [`Node`].
668fn cap_map_from_serde(wire: &ts_nodecapability::Map<'_>) -> NodeCapMap {
669    wire.iter()
670        .map(|(&key, values)| {
671            let owned_values = values.0.iter().map(|v| v.get().to_owned()).collect();
672            (key.to_owned(), owned_values)
673        })
674        .collect()
675}
676
677/// Extract the advertised IPv4 peerAPI port and whether the explicit `peerapi-dns-proxy` service is
678/// advertised, from a peer's `HostInfo.Services` list.
679fn peerapi_from_services(
680    services: Option<&[ts_control_serde::Service<'_>]>,
681) -> (Option<u16>, bool) {
682    use ts_control_serde::ServiceProto;
683
684    let Some(services) = services else {
685        return (None, false);
686    };
687    let mut port = None;
688    let mut dns_proxy = false;
689    for svc in services {
690        match svc.proto {
691            ServiceProto::PeerApi4 => port = Some(svc.port),
692            ServiceProto::PeerApiDnsProxy => dns_proxy = true,
693            _ => {}
694        }
695    }
696    (port, dns_proxy)
697}
698
699/// Addresses for a node within a tailnet.
700#[derive(Debug, Clone, PartialEq, Eq, Hash)]
701pub struct TailnetAddress {
702    /// The IPv4 address of the node in the tailnet.
703    pub ipv4: ipnet::Ipv4Net,
704    /// The IPv6 address of the node in the tailnet.
705    pub ipv6: ipnet::Ipv6Net,
706}
707
708impl TailnetAddress {
709    /// Report whether `addr` matches either address in this [`TailnetAddress`].
710    pub fn contains(&self, addr: IpAddr) -> bool {
711        match addr {
712            IpAddr::V4(a) => self.ipv4.addr() == a,
713            IpAddr::V6(a) => self.ipv6.addr() == a,
714        }
715    }
716}
717
718impl From<&ts_control_serde::Node<'_>> for Node {
719    fn from(value: &ts_control_serde::Node) -> Self {
720        let fqdn_without_trailing_dot = value.name.strip_suffix('.').unwrap_or(&value.name);
721
722        let (hostname, tailnet) = match fqdn_without_trailing_dot.split_once('.') {
723            Some((hostname, tailnet)) => (hostname, Some(tailnet.to_owned())),
724            None => (fqdn_without_trailing_dot, None),
725        };
726
727        let (peerapi_port, peerapi_dns_proxy) =
728            peerapi_from_services(value.host_info.services.as_deref());
729
730        let cap_map = cap_map_from_serde(&value.cap_map);
731        let service_vips = service_vips_from_cap_map(&cap_map);
732
733        // `addresses` is a variable-length `Vec<IpNet>` on the wire (Go `[]netip.Prefix`), not a
734        // fixed (v4, v6) pair: an IPv6-off tailnet assigns only a v4 prefix. Pick the first of each
735        // family. The v4 prefix is the node's tailnet identity (always present on a normal node);
736        // if somehow absent we fall back to the unspecified `0.0.0.0/32` rather than panicking.
737        // The v6 prefix is optional — when the tailnet is IPv4-only there is none, and the overlay
738        // never reads `ipv6` in that mode (gated on `enable_ipv6`); we synthesize the unspecified
739        // `::/128` placeholder so the domain `TailnetAddress` stays infallible.
740        let ipv4 = value
741            .addresses
742            .iter()
743            .find_map(|p| match p {
744                ipnet::IpNet::V4(n) => Some(*n),
745                ipnet::IpNet::V6(_) => None,
746            })
747            .unwrap_or_else(|| ipnet::Ipv4Net::new(core::net::Ipv4Addr::UNSPECIFIED, 32).unwrap());
748        let ipv6 = value
749            .addresses
750            .iter()
751            .find_map(|p| match p {
752                ipnet::IpNet::V6(n) => Some(*n),
753                ipnet::IpNet::V4(_) => None,
754            })
755            .unwrap_or_else(|| ipnet::Ipv6Net::new(core::net::Ipv6Addr::UNSPECIFIED, 128).unwrap());
756
757        Self {
758            id: value.id,
759            stable_id: StableId(value.stable_id.0.to_string()),
760
761            hostname: hostname.to_owned(),
762            user_id: value.user,
763            tailnet,
764
765            tags: value
766                .tags
767                .as_ref()
768                .map(|x| x.iter().map(|x| x.to_string()).collect())
769                .unwrap_or_default(),
770
771            tailnet_address: TailnetAddress { ipv4, ipv6 },
772            node_key: value.key,
773            node_key_expiry: value.key_expiry,
774            online: value.online,
775            last_seen: value.last_seen,
776            key_signature: value.key_signature.to_vec(),
777            machine_key: value.machine,
778            disco_key: value.disco_key,
779
780            // Per capver-112, `AllowedIPs` null/absent means "same as `addresses`". Fall back to the
781            // node's own assigned prefixes verbatim (whatever families the wire carried), not a
782            // synthesized v4+v6 pair.
783            accepted_routes: value
784                .allowed_ips
785                .clone()
786                .unwrap_or_else(|| value.addresses.clone()),
787            underlay_addresses: value.endpoints.clone(),
788
789            // legacy_derp_string is still in practical use as of 3/2026
790            #[allow(deprecated)]
791            derp_region: value
792                .home_derp
793                .or(value.legacy_derp_string)
794                .or_else(|| value.host_info.net_info.as_ref()?.preferred_derp)
795                .map(|x| ts_derp::RegionId(x.into())),
796
797            cap: value.cap,
798            cap_map,
799            peerapi_port,
800            peerapi_dns_proxy,
801            is_wireguard_only: value.is_wireguard_only,
802            exit_node_dns_resolvers: value
803                .exit_node_dns_resolvers
804                .iter()
805                .filter_map(Resolver::from_serde)
806                .collect(),
807            peer_relay: value.host_info.peer_relay,
808            // Project the advertised SSH host keys (Go `Hostinfo.SSHHostKeys`), mapping the
809            // borrowed `Option<Vec<&str>>` to owned `Vec<String>`; absent ⇒ empty (never
810            // fabricated), matching how `services`/`peer_relay` above are projected from host_info.
811            ssh_host_keys: value
812                .host_info
813                .ssh_host_keys
814                .as_ref()
815                .map(|keys| keys.iter().map(|k| k.to_string()).collect())
816                .unwrap_or_default(),
817            service_vips,
818        }
819    }
820}
821
822/// An incremental update to a single already-known peer [`Node`], carried in
823/// [`MapResponse::peers_changed_patch`][ts_control_serde::MapResponse::peers_changed_patch].
824///
825/// Control sends a patch (rather than a full node in `peers_changed`) when only a peer's
826/// reachability changes mid-session — most importantly its UDP `endpoints`
827/// and home [`derp_region`][PeerChange::derp_region] when an idle peer re-establishes connectivity.
828/// Every field is `Option`: a patch sets only the fields it carries and leaves the rest of the
829/// target node unchanged (see `PeerTracker::apply_peer_update` for the merge). Owned counterpart
830/// of the borrow-bound [`ts_control_serde::PeerChange`]; the fields that map onto a domain
831/// [`Node`] field are retained, including control's `online`/`last_seen` liveness deltas — the
832/// dominant channel by which peer online transitions are delivered (see [`Node::online`]).
833#[derive(Debug, Clone, PartialEq, Eq)]
834pub struct PeerChange {
835    /// The [`Node::id`] of the peer being mutated. If no peer with this id is in the current
836    /// netmap, the patch is ignored (the wire contract — a patch never creates a node).
837    pub id: Id,
838    /// If `Some`, the peer's new home DERP region.
839    pub derp_region: Option<ts_derp::RegionId>,
840    /// If `Some`, the peer's new advertised capability version.
841    pub cap: Option<CapabilityVersion>,
842    /// If `Some`, the peer's new capability map (replaces the prior map wholesale).
843    pub cap_map: Option<NodeCapMap>,
844    /// If `Some`, the peer's new UDP underlay endpoints (`Endpoints` in Go; replaces the prior
845    /// set). This is the field that lets magicsock re-handshake a peer that moved.
846    pub underlay_addresses: Option<Vec<SocketAddr>>,
847    /// If `Some`, the peer's new WireGuard public key (key rotation).
848    pub node_key: Option<NodePublicKey>,
849    /// If `Some`, the marshalled TKA signature over the new node key. Re-verified at the
850    /// peer-trust chokepoint when tailnet-lock enforcement is active.
851    pub key_signature: Option<Vec<u8>>,
852    /// If `Some`, the peer's new disco public key.
853    pub disco_key: Option<DiscoPublicKey>,
854    /// If `Some`, the peer's new node-key expiry (`KeyExpiry` in Go). Maps to
855    /// [`Node::node_key_expiry`]; carried so an expiry-only patch isn't lost until the next full
856    /// resync.
857    pub node_key_expiry: Option<DateTime<Utc>>,
858    /// If `Some`, the peer's new online status (`PeerChange.Online`). `None` here means "this patch
859    /// did not touch online", **not** "offline" — the merge sets [`Node::online`] only when present.
860    pub online: Option<bool>,
861    /// If `Some`, the peer's new last-seen time (`PeerChange.LastSeen`). Maps to [`Node::last_seen`].
862    pub last_seen: Option<DateTime<Utc>>,
863}
864
865impl From<&ts_control_serde::PeerChange<'_>> for PeerChange {
866    fn from(value: &ts_control_serde::PeerChange) -> Self {
867        Self {
868            id: value.node_id,
869            derp_region: value.derp_region.map(|x| ts_derp::RegionId(x.into())),
870            cap: value.cap,
871            cap_map: value.cap_map.as_ref().map(cap_map_from_serde),
872            underlay_addresses: value.endpoints.clone(),
873            node_key: value.key,
874            key_signature: value.key_signature.map(|s| s.to_vec()),
875            disco_key: value.disco_key,
876            node_key_expiry: value.key_expiry,
877            online: value.online,
878            last_seen: value.last_seen,
879        }
880    }
881}
882
883/// Display-friendly identity for the user that owns a [`Node`], resolved from the netmap's
884/// `UserProfiles` table (Go `tailcfg.UserProfile`). Owned counterpart of the borrow-bound
885/// [`ts_control_serde::UserProfile`]. Keyed by [`UserProfile::id`] (== [`Node::user_id`]).
886#[derive(Debug, Clone, PartialEq, Eq)]
887pub struct UserProfile {
888    /// The integer id of the Tailscale user this profile describes (matches [`Node::user_id`]).
889    pub id: ts_control_serde::UserId,
890    /// An email-ish login name for display (e.g. `alice@example.com` / `alice@github`). May be
891    /// empty if control sent none.
892    pub login_name: String,
893    /// The user's display name (e.g. `Alice Smith`), if the IdP provided one.
894    pub display_name: Option<String>,
895}
896
897impl From<&ts_control_serde::UserProfile<'_>> for UserProfile {
898    fn from(value: &ts_control_serde::UserProfile) -> Self {
899        Self {
900            id: value.id,
901            login_name: value.login_name.to_string(),
902            display_name: value.display_name.as_deref().map(str::to_string),
903        }
904    }
905}
906
907impl UserProfile {
908    /// The best human-facing label for this user: the login name when present, else the display
909    /// name, else `None`. This is what a `WhoIs` surfaces as the owning user.
910    pub fn best_label(&self) -> Option<String> {
911        if !self.login_name.is_empty() {
912            Some(self.login_name.clone())
913        } else {
914            self.display_name.clone()
915        }
916    }
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922
923    /// The wire `Node.User` id must be carried onto the domain `Node.user_id` by the `From` impl
924    /// (the field the runtime joins against the netmap `UserProfiles` table for `WhoIs.user`).
925    /// Guards against the `From` impl wiring the wrong serde field or dropping it.
926    #[test]
927    fn from_wire_node_carries_user_id() {
928        let mut wire = ts_control_serde::Node {
929            user: 4242,
930            ..Default::default()
931        };
932        wire.name = "host.tail.ts.net.".into();
933        let domain: Node = (&wire).into();
934        assert_eq!(domain.user_id, 4242);
935
936        // Default (no owner / tagged node) stays 0.
937        let tagged = ts_control_serde::Node::default();
938        assert_eq!(Node::from(&tagged).user_id, 0);
939    }
940
941    /// The wire `Hostinfo.sshHostKeys` must be projected onto the domain `Node.ssh_host_keys`
942    /// (the field `tailscale ssh` reads via `StatusNode` to pin a peer's host key). Present →
943    /// carried verbatim; absent → empty (never fabricated).
944    #[test]
945    fn from_wire_node_carries_ssh_host_keys() {
946        let wire = ts_control_serde::Node {
947            host_info: ts_control_serde::HostInfo {
948                ssh_host_keys: Some(vec![
949                    "ssh-ed25519 AAAAC3Nz host",
950                    "ecdsa-sha2-nistp256 AAAAE2Vj host",
951                ]),
952                ..Default::default()
953            },
954            ..Default::default()
955        };
956        let domain: Node = (&wire).into();
957        assert_eq!(
958            domain.ssh_host_keys,
959            vec![
960                "ssh-ed25519 AAAAC3Nz host".to_string(),
961                "ecdsa-sha2-nistp256 AAAAE2Vj host".to_string(),
962            ]
963        );
964
965        // Absent on the wire → empty Vec, not fabricated.
966        let bare = ts_control_serde::Node::default();
967        assert!(Node::from(&bare).ssh_host_keys.is_empty());
968    }
969
970    /// A node from an **IPv4-only** tailnet (IPv6-off control plane / Headscale) carries a
971    /// single-element `addresses` list. This used to fail deserialization ("invalid length 1,
972    /// expected a tuple of size 2") when `addresses` was a fixed 2-tuple; it must now parse and
973    /// derive the v4 identity, with the unused v6 a synthesized placeholder.
974    #[test]
975    fn from_wire_node_ipv4_only_addresses() {
976        let wire = ts_control_serde::Node {
977            addresses: vec!["100.64.0.5/32".parse().unwrap()],
978            ..Default::default()
979        };
980        let domain: Node = (&wire).into();
981        assert_eq!(
982            domain.tailnet_address.ipv4,
983            "100.64.0.5/32".parse().unwrap()
984        );
985        // No v6 on the wire → unspecified placeholder (never read in IPv4-only mode).
986        assert_eq!(
987            domain.tailnet_address.ipv6,
988            ipnet::Ipv6Net::new(core::net::Ipv6Addr::UNSPECIFIED, 128).unwrap()
989        );
990        // AllowedIPs absent → falls back to the node's own assigned prefixes (just the v4 here).
991        assert_eq!(
992            domain.accepted_routes,
993            vec!["100.64.0.5/32".parse::<ipnet::IpNet>().unwrap()]
994        );
995    }
996
997    /// A dual-stack node carries both families (any order); the domain picks the first of each.
998    #[test]
999    fn from_wire_node_dual_stack_addresses() {
1000        let wire = ts_control_serde::Node {
1001            addresses: vec![
1002                "100.64.0.7/32".parse().unwrap(),
1003                "fd7a:115c:a1e0::7/128".parse().unwrap(),
1004            ],
1005            ..Default::default()
1006        };
1007        let domain: Node = (&wire).into();
1008        assert_eq!(
1009            domain.tailnet_address.ipv4,
1010            "100.64.0.7/32".parse().unwrap()
1011        );
1012        assert_eq!(
1013            domain.tailnet_address.ipv6,
1014            "fd7a:115c:a1e0::7/128".parse().unwrap()
1015        );
1016    }
1017
1018    /// The deserialization regression itself: a MapResponse-style Node JSON with a 1-element
1019    /// `Addresses` array must parse (this is the exact shape the dev-Headscale sends).
1020    #[test]
1021    fn deserialize_node_with_single_address() {
1022        let json = r#"{
1023            "ID": 1,
1024            "StableID": "n1",
1025            "Name": "host.tail.ts.net.",
1026            "User": 1,
1027            "Addresses": ["100.64.0.9/32"],
1028            "Key": "nodekey:0000000000000000000000000000000000000000000000000000000000000000",
1029            "Machine": null,
1030            "DiscoKey": null,
1031            "AllowedIPs": null,
1032            "Endpoints": []
1033        }"#;
1034        let wire: ts_control_serde::Node = serde_json::from_str(json).expect("1-addr node parses");
1035        assert_eq!(wire.addresses.len(), 1);
1036        let domain: Node = (&wire).into();
1037        assert_eq!(
1038            domain.tailnet_address.ipv4,
1039            "100.64.0.9/32".parse().unwrap()
1040        );
1041    }
1042
1043    #[test]
1044    fn key_expiry_semantics() {
1045        let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
1046        let past: DateTime<Utc> = "2020-01-01T00:00:00Z".parse().unwrap();
1047        let future: DateTime<Utc> = "2099-01-01T00:00:00Z".parse().unwrap();
1048
1049        let mut n = node("h", Some("t.ts.net"));
1050
1051        // No expiry set => never expired (Go zero-value semantics).
1052        n.node_key_expiry = None;
1053        assert!(!n.key_expired(now));
1054        assert_eq!(n.key_expiry(), None);
1055
1056        // Future expiry => not yet expired.
1057        n.node_key_expiry = Some(future);
1058        assert!(!n.key_expired(now));
1059        assert_eq!(n.key_expiry(), Some(future));
1060
1061        // Past expiry => expired.
1062        n.node_key_expiry = Some(past);
1063        assert!(n.key_expired(now));
1064    }
1065
1066    #[test]
1067    fn key_expiry_unix_agrees_with_chrono() {
1068        // The chrono-free variants (`key_expired_at_unix` / `key_expiry_unix`) must agree with the
1069        // chrono variants for the same none/future/past cases (Unix seconds of the same instants).
1070        let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
1071        let past: DateTime<Utc> = "2020-01-01T00:00:00Z".parse().unwrap();
1072        let future: DateTime<Utc> = "2099-01-01T00:00:00Z".parse().unwrap();
1073        let now_unix = now.timestamp();
1074
1075        let mut n = node("h", Some("t.ts.net"));
1076
1077        // No expiry => never expired; the unix accessor reports `None`.
1078        n.node_key_expiry = None;
1079        assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
1080        assert!(!n.key_expired_at_unix(now_unix));
1081        assert_eq!(n.key_expiry_unix(), None);
1082
1083        // Future expiry => not yet expired; unix accessor matches the chrono timestamp.
1084        n.node_key_expiry = Some(future);
1085        assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
1086        assert!(!n.key_expired_at_unix(now_unix));
1087        assert_eq!(n.key_expiry_unix(), Some(future.timestamp()));
1088
1089        // Past expiry => expired; unix accessor matches the chrono timestamp.
1090        n.node_key_expiry = Some(past);
1091        assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
1092        assert!(n.key_expired_at_unix(now_unix));
1093        assert_eq!(n.key_expiry_unix(), Some(past.timestamp()));
1094    }
1095
1096    #[test]
1097    fn key_expiry_boundary_is_not_expired() {
1098        // A key whose expiry exactly equals `now` is NOT expired: the code uses strict `<`, matching
1099        // Go's `Before`. Both the chrono and chrono-free variants must agree at the boundary.
1100        let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
1101        let now_unix = now.timestamp();
1102
1103        let mut n = node("h", Some("t.ts.net"));
1104        n.node_key_expiry = Some(now);
1105
1106        assert!(!n.key_expired(now));
1107        assert!(!n.key_expired_at_unix(now_unix));
1108    }
1109
1110    #[test]
1111    fn is_peer_relay_returns_field() {
1112        let mut n = node("h", Some("t.ts.net"));
1113
1114        n.peer_relay = true;
1115        assert!(n.is_peer_relay());
1116
1117        n.peer_relay = false;
1118        assert!(!n.is_peer_relay());
1119    }
1120
1121    fn node(hostname: &str, tailnet: Option<&str>) -> Node {
1122        Node {
1123            id: 1,
1124            stable_id: StableId("n1".to_string()),
1125            hostname: hostname.to_string(),
1126            user_id: 0,
1127            tailnet: tailnet.map(str::to_string),
1128            tags: vec![],
1129            tailnet_address: TailnetAddress {
1130                ipv4: "100.64.0.1/32".parse().unwrap(),
1131                ipv6: "fd7a::1/128".parse().unwrap(),
1132            },
1133            node_key: [0u8; 32].into(),
1134            node_key_expiry: None,
1135            online: None,
1136            last_seen: None,
1137            key_signature: vec![],
1138            machine_key: None,
1139            disco_key: None,
1140            accepted_routes: vec![],
1141            underlay_addresses: vec![],
1142            derp_region: None,
1143            cap: CapabilityVersion::default(),
1144            cap_map: NodeCapMap::new(),
1145            peerapi_port: None,
1146            peerapi_dns_proxy: false,
1147            is_wireguard_only: false,
1148            exit_node_dns_resolvers: vec![],
1149            peer_relay: false,
1150            ssh_host_keys: vec![],
1151            service_vips: Default::default(),
1152        }
1153    }
1154
1155    #[test]
1156    fn matches_name_is_case_and_trailing_dot_insensitive() {
1157        let n = node("MyHost", Some("tail-scale.ts.net"));
1158
1159        // bare hostname, any case
1160        assert!(n.matches_name("myhost"));
1161        assert!(n.matches_name("MYHOST"));
1162        assert!(n.matches_name("MyHost"));
1163
1164        // fqdn, any case, with and without trailing dot
1165        assert!(n.matches_name("myhost.tail-scale.ts.net"));
1166        assert!(n.matches_name("MYHOST.TAIL-SCALE.TS.NET"));
1167        assert!(n.matches_name("myhost.tail-scale.ts.net."));
1168        assert!(n.matches_name("MyHost.Tail-Scale.TS.NET."));
1169
1170        // wrong host / wrong tailnet must not match
1171        assert!(!n.matches_name("other"));
1172        assert!(!n.matches_name("myhost.other.ts.net"));
1173    }
1174
1175    #[test]
1176    fn matches_name_no_tailnet() {
1177        let n = node("solo", None);
1178        assert!(n.matches_name("solo"));
1179        assert!(n.matches_name("SOLO."));
1180        assert!(!n.matches_name("solo.ts.net"));
1181    }
1182
1183    #[test]
1184    fn is_tailscale_ip_ranges() {
1185        // CGNAT v4
1186        assert!(is_tailscale_ip("100.64.0.1".parse().unwrap()));
1187        assert!(is_tailscale_ip("100.127.255.254".parse().unwrap()));
1188        // ChromeOS carve-out is excluded
1189        assert!(!is_tailscale_ip("100.115.92.5".parse().unwrap()));
1190        // outside CGNAT
1191        assert!(!is_tailscale_ip("10.0.0.1".parse().unwrap()));
1192        assert!(!is_tailscale_ip("100.128.0.1".parse().unwrap()));
1193        // Tailscale ULA v6
1194        assert!(is_tailscale_ip("fd7a:115c:a1e0::1".parse().unwrap()));
1195        assert!(!is_tailscale_ip("fd00::1".parse().unwrap()));
1196    }
1197
1198    /// Taildrop SSRF guard (defense-in-depth). `Device::send_file` rejects an upload destination
1199    /// unless `is_tailscale_ip(peer.peerapi_addr().ip())` holds. `Device::send_file` itself needs a
1200    /// live runtime (it goes through `self.channel()`), so it can't be unit-tested here; instead we
1201    /// test the exact composition the guard relies on — `is_tailscale_ip ∘ peerapi_addr` — against a
1202    /// `Node` whose `tailnet_address.ipv4` has been corrupted to a non-CGNAT (public) address. A
1203    /// well-formed peer always has a CGNAT 100.64.0.0/10 address, but the guard exists to catch a
1204    /// malformed/hostile node; this proves it would reject one.
1205    #[test]
1206    fn taildrop_ssrf_guard_rejects_non_cgnat_peerapi_addr() {
1207        let mut n = node("evil", Some("ts.net"));
1208        // Corrupt the peer to a public, non-CGNAT address and advertise a peerAPI port so
1209        // `peerapi_addr` returns `Some(_)`.
1210        n.tailnet_address.ipv4 = "1.2.3.4/32".parse().unwrap();
1211        n.peerapi_port = Some(443);
1212
1213        let addr = n
1214            .peerapi_addr()
1215            .expect("peerapi_addr yields Some with a port set");
1216        assert_eq!(addr.ip(), Ipv4Addr::new(1, 2, 3, 4));
1217        // The guard `if !is_tailscale_ip(dst.ip()) { return Err(BadRequest) }` WOULD reject this.
1218        assert!(
1219            !is_tailscale_ip(addr.ip()),
1220            "SSRF guard must reject a peer whose peerAPI addr is not a Tailscale CGNAT IP"
1221        );
1222
1223        // Conversely, a well-formed CGNAT peer passes the guard.
1224        let mut good = node("friend", Some("ts.net"));
1225        good.peerapi_port = Some(443);
1226        let good_addr = good.peerapi_addr().expect("peerapi_addr yields Some");
1227        assert!(is_tailscale_ip(good_addr.ip()));
1228    }
1229
1230    #[test]
1231    fn is_subnet_route_distinguishes_self_from_subnet() {
1232        let n = node("host", Some("ts.net"));
1233
1234        // The node's own /32 and /128 are self-addresses, not subnet routes.
1235        assert!(!n.is_subnet_route(&"100.64.0.1/32".parse().unwrap()));
1236        assert!(!n.is_subnet_route(&"fd7a::1/128".parse().unwrap()));
1237        // A different single Tailscale IP is still a self-address (Tailscale-assigned host).
1238        assert!(!n.is_subnet_route(&"100.64.5.5/32".parse().unwrap()));
1239        // A LAN /24 the node advertises is a subnet route.
1240        assert!(n.is_subnet_route(&"192.168.1.0/24".parse().unwrap()));
1241        // A single non-Tailscale host IP counts as a subnet route.
1242        assert!(n.is_subnet_route(&"8.8.8.8/32".parse().unwrap()));
1243        // The default route is treated as a subnet route.
1244        assert!(n.is_subnet_route(&"0.0.0.0/0".parse().unwrap()));
1245        assert!(n.is_subnet_route(&"::/0".parse().unwrap()));
1246    }
1247
1248    #[test]
1249    fn routes_to_install_gates_subnets_on_accept_routes() {
1250        let mut n = node("host", Some("ts.net"));
1251        let self4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
1252        let self6: ipnet::IpNet = "fd7a::1/128".parse().unwrap();
1253        let subnet: ipnet::IpNet = "192.168.1.0/24".parse().unwrap();
1254        n.accepted_routes = vec![self4, self6, subnet];
1255
1256        // accept_routes off: only the self addresses are installed.
1257        let off: Vec<_> = n.routes_to_install(false, None).copied().collect();
1258        assert_eq!(off, vec![self4, self6]);
1259
1260        // accept_routes on: the advertised subnet is installed too.
1261        let on: Vec<_> = n.routes_to_install(true, None).copied().collect();
1262        assert_eq!(on, vec![self4, self6, subnet]);
1263    }
1264
1265    #[test]
1266    fn routes_to_install_default_route_only_for_selected_exit_node() {
1267        let mut n = node("host", Some("ts.net"));
1268        n.stable_id = StableId("exit1".to_string());
1269        let self4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
1270        let default4: ipnet::IpNet = "0.0.0.0/0".parse().unwrap();
1271        let default6: ipnet::IpNet = "::/0".parse().unwrap();
1272        n.accepted_routes = vec![self4, default4, default6];
1273
1274        // No exit node selected: default routes are excluded even with accept_routes on
1275        // (fail-closed — internet-bound traffic has no overlay route and is dropped).
1276        let none_off: Vec<_> = n.routes_to_install(false, None).copied().collect();
1277        assert_eq!(none_off, vec![self4]);
1278        let none_on: Vec<_> = n.routes_to_install(true, None).copied().collect();
1279        assert_eq!(none_on, vec![self4]);
1280
1281        // A *different* peer selected as exit node: this peer still gets no default route.
1282        let other = StableId("exit2".to_string());
1283        let other_sel: Vec<_> = n.routes_to_install(false, Some(&other)).copied().collect();
1284        assert_eq!(other_sel, vec![self4]);
1285
1286        // This peer selected as the exit node: its default routes are installed.
1287        let me = StableId("exit1".to_string());
1288        let sel: Vec<_> = n.routes_to_install(false, Some(&me)).copied().collect();
1289        assert_eq!(sel, vec![self4, default4, default6]);
1290    }
1291
1292    fn exit_node_with(id: &str, ipv4: &str, hostname: &str, tailnet: Option<&str>) -> Node {
1293        let mut n = node(hostname, tailnet);
1294        n.stable_id = StableId(id.to_string());
1295        n.tailnet_address.ipv4 = format!("{ipv4}/32").parse().unwrap();
1296        n
1297    }
1298
1299    #[test]
1300    fn exit_node_selector_resolves_by_id_ip_and_name() {
1301        let a = exit_node_with("nA", "100.64.0.5", "alpha", Some("ts.net"));
1302        let b = exit_node_with("nB", "100.64.0.6", "beta", Some("ts.net"));
1303        let peers = [a, b];
1304        let it = || peers.iter();
1305
1306        // By stable id.
1307        assert_eq!(
1308            ExitNodeSelector::StableId(StableId("nB".into())).resolve(it()),
1309            Some(StableId("nB".into()))
1310        );
1311        // By tailnet IP.
1312        assert_eq!(
1313            ExitNodeSelector::Ip("100.64.0.5".parse().unwrap()).resolve(it()),
1314            Some(StableId("nA".into()))
1315        );
1316        // By MagicDNS name (fqdn, case-insensitive).
1317        assert_eq!(
1318            ExitNodeSelector::Name("BETA.ts.net".into()).resolve(it()),
1319            Some(StableId("nB".into()))
1320        );
1321        // By bare hostname.
1322        assert_eq!(
1323            ExitNodeSelector::Name("alpha".into()).resolve(it()),
1324            Some(StableId("nA".into()))
1325        );
1326        // Unresolvable selector => None (fail-closed at the call site).
1327        assert_eq!(
1328            ExitNodeSelector::Ip("100.64.0.99".parse().unwrap()).resolve(it()),
1329            None
1330        );
1331        assert_eq!(ExitNodeSelector::Name("ghost".into()).resolve(it()), None);
1332    }
1333
1334    #[test]
1335    fn exit_node_selector_resolution_is_deterministic_on_ties() {
1336        // Two peers sharing a name (transient netmap state): the smallest stable id wins, so the
1337        // outbound table and inbound source filter — which resolve independently — agree.
1338        let a = exit_node_with("nZ", "100.64.0.5", "dup", Some("ts.net"));
1339        let b = exit_node_with("nA", "100.64.0.6", "dup", Some("ts.net"));
1340        let peers = [a, b];
1341
1342        assert_eq!(
1343            ExitNodeSelector::Name("dup".into()).resolve(peers.iter()),
1344            Some(StableId("nA".into())),
1345            "smallest stable id wins the tie"
1346        );
1347        // Order of iteration must not change the result.
1348        assert_eq!(
1349            ExitNodeSelector::Name("dup".into()).resolve(peers.iter().rev()),
1350            Some(StableId("nA".into()))
1351        );
1352    }
1353
1354    #[test]
1355    fn peerapi_doh_url_requires_port_and_capability() {
1356        let mut n = node("exit", Some("ts.net"));
1357        n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
1358
1359        // No peerAPI port advertised: cannot proxy DNS.
1360        n.peerapi_port = None;
1361        n.cap = CapabilityVersion::V130;
1362        assert_eq!(n.peerapi_doh_url(), None);
1363
1364        // Port advertised but capability too old and no explicit service: cannot proxy.
1365        n.peerapi_port = Some(8080);
1366        n.cap = CapabilityVersion::V25;
1367        n.peerapi_dns_proxy = false;
1368        assert_eq!(n.peerapi_doh_url(), None);
1369
1370        // Port + new-enough capability: yields the DoH URL on the IPv4 address.
1371        n.cap = CapabilityVersion::V26;
1372        assert_eq!(
1373            n.peerapi_doh_url().as_deref(),
1374            Some("http://100.64.0.5:8080/dns-query")
1375        );
1376
1377        // Port + explicit peerapi-dns-proxy service, even with an old capability.
1378        n.cap = CapabilityVersion::V25;
1379        n.peerapi_dns_proxy = true;
1380        assert_eq!(
1381            n.peerapi_doh_url().as_deref(),
1382            Some("http://100.64.0.5:8080/dns-query")
1383        );
1384
1385        // WireGuard-only peers never run a peerAPI: no DoH URL even with a port.
1386        n.is_wireguard_only = true;
1387        assert_eq!(n.peerapi_doh_url(), None);
1388    }
1389
1390    #[test]
1391    fn peerapi_doh_addr_matches_url_gate() {
1392        let mut n = node("exit", Some("ts.net"));
1393        n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
1394        n.peerapi_port = Some(8080);
1395        n.cap = CapabilityVersion::V26;
1396
1397        // The addr form the DoH client dials is the same gated endpoint as the URL.
1398        assert_eq!(
1399            n.peerapi_doh_addr(),
1400            Some("100.64.0.5:8080".parse().unwrap())
1401        );
1402        // And it composes into exactly the URL form.
1403        assert_eq!(
1404            n.peerapi_doh_url().as_deref(),
1405            Some("http://100.64.0.5:8080/dns-query")
1406        );
1407
1408        // Gated off the same way: no port => no addr.
1409        n.peerapi_port = None;
1410        assert_eq!(n.peerapi_doh_addr(), None);
1411    }
1412
1413    #[test]
1414    fn peerapi_addr_returns_addr_when_advertised() {
1415        let mut n = node("peer", Some("ts.net"));
1416        n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
1417        n.peerapi_port = Some(8089);
1418
1419        // Not gated on the DNS-proxy capability: a plain advertised peerAPI port is enough.
1420        assert_eq!(n.peerapi_addr(), Some("100.64.0.5:8089".parse().unwrap()));
1421    }
1422
1423    #[test]
1424    fn peerapi_addr_none_when_no_port() {
1425        let mut n = node("peer", Some("ts.net"));
1426        n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
1427        n.peerapi_port = None;
1428
1429        assert_eq!(n.peerapi_addr(), None);
1430    }
1431
1432    #[test]
1433    fn peerapi_addr_none_for_wireguard_only() {
1434        let mut n = node("peer", Some("ts.net"));
1435        n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
1436        n.peerapi_port = Some(8089);
1437        n.is_wireguard_only = true;
1438
1439        // WireGuard-only peers run no peerAPI, even with a port set.
1440        assert_eq!(n.peerapi_addr(), None);
1441    }
1442
1443    #[test]
1444    fn can_share_files_gated_on_self_capability() {
1445        let mut n = node("self", Some("ts.net"));
1446        assert!(
1447            !n.can_share_files(),
1448            "no cap → file sharing not enabled (fail-closed)"
1449        );
1450        n.cap_map
1451            .insert("https://tailscale.com/cap/file-sharing".to_string(), vec![]);
1452        assert!(n.can_share_files(), "the file-sharing cap enables it");
1453    }
1454
1455    #[test]
1456    fn is_file_sharing_target_gated_on_peer_capability() {
1457        let mut n = node("peer", Some("ts.net"));
1458        assert!(
1459            !n.is_file_sharing_target(),
1460            "no cap → not an explicit target"
1461        );
1462        n.cap_map
1463            .insert("tailscale.com/cap/file-sharing-target".to_string(), vec![]);
1464        assert!(
1465            n.is_file_sharing_target(),
1466            "the file-sharing-target cap marks a cross-owner target"
1467        );
1468    }
1469
1470    #[test]
1471    fn peerapi_from_services_extracts_v4_port_and_dns_proxy_flag() {
1472        use ts_control_serde::{Service, ServiceProto};
1473
1474        let services = [
1475            Service {
1476                proto: ServiceProto::PeerApi4,
1477                port: 8080,
1478                description: "peerapi".into(),
1479            },
1480            Service {
1481                proto: ServiceProto::PeerApi6,
1482                port: 9090,
1483                description: "peerapi6".into(),
1484            },
1485            Service {
1486                proto: ServiceProto::PeerApiDnsProxy,
1487                port: 1,
1488                description: "dns".into(),
1489            },
1490        ];
1491        let (port, dns_proxy) = peerapi_from_services(Some(&services));
1492        assert_eq!(port, Some(8080), "only the IPv4 peerAPI port is taken");
1493        assert!(dns_proxy);
1494
1495        // No services at all.
1496        assert_eq!(peerapi_from_services(None), (None, false));
1497    }
1498
1499    #[test]
1500    fn exit_node_selector_parses_ip_vs_name() {
1501        assert_eq!(
1502            "100.64.0.5".parse::<ExitNodeSelector>().unwrap(),
1503            ExitNodeSelector::Ip("100.64.0.5".parse().unwrap())
1504        );
1505        assert_eq!(
1506            "fd7a::5".parse::<ExitNodeSelector>().unwrap(),
1507            ExitNodeSelector::Ip("fd7a::5".parse().unwrap())
1508        );
1509        assert_eq!(
1510            "my-exit.ts.net".parse::<ExitNodeSelector>().unwrap(),
1511            ExitNodeSelector::Name("my-exit.ts.net".into())
1512        );
1513    }
1514}