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