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