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