1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
use core::net::{IpAddr, SocketAddr};
use chrono::{DateTime, Utc};
use ts_keys::{DiscoPublicKey, MachinePublicKey, NodePublicKey};
/// The unique id of a node.
pub type Id = i64;
/// The stable ID of a node.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct StableId(pub String);
/// A node in a tailnet.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Node {
/// The node's id.
pub id: Id,
/// The node's stable id.
pub stable_id: StableId,
/// This node's hostname.
pub hostname: String,
/// The tailnet this node belongs to.
pub tailnet: Option<String>,
/// The tags assigned to this node.
pub tags: Vec<String>,
/// The address of the node in the tailnet.
pub tailnet_address: TailnetAddress,
/// The node's [`NodePublicKey`].
pub node_key: NodePublicKey,
/// The node key's expiration.
pub node_key_expiry: Option<DateTime<Utc>>,
/// The node's [`MachinePublicKey`], if known.
pub machine_key: Option<MachinePublicKey>,
/// The node's [`DiscoPublicKey`], if known.
pub disco_key: Option<DiscoPublicKey>,
/// The routes this node accepts traffic for.
pub accepted_routes: Vec<ipnet::IpNet>,
/// The underlay addresses this node is reachable on (`Endpoints` in Go).
pub underlay_addresses: Vec<SocketAddr>,
/// The DERP region for this node, if known.
pub derp_region: Option<ts_transport_derp::RegionId>,
}
impl Node {
/// The fully-qualified domain name of the node.
///
/// This is a string of the form `$HOST.$TAILNET_DOMAIN.`. For tailnets controlled by
/// Tailscale's control plane, this usually means `$HOST.tail1234.ts.net.`
///
/// The `trailing_dot` parameter specifies whether to include the trailing dot in the
/// fqdn. This is included by the definition of FQDN, and is the way the Go codebase
/// formats this field, but the parameter is included to allow turning it off for use
/// in contexts that expect it to be absent.
pub fn fqdn(&self, trailing_dot: bool) -> String {
let dot = if trailing_dot { "." } else { "" };
match &self.tailnet {
Some(tailnet) => format!("{}.{tailnet}{dot}", self.hostname),
None => format!("{}{dot}", self.hostname),
}
}
/// Report whether this node matches the given `name`.
///
/// `name` is checked for equality with both this node's bare hostname and its fqdn. A
/// trailing `.` may be present.
pub fn matches_name(&self, name: &str) -> bool {
// This approach is taken to avoid allocating a buffer just for the sake of making this
// comparison: try to chop `.tailnet.` off of the end of `name` and compare the
// remainder to our hostname. If `.tailnet.` doesn't match `name`, we'll end up comparing
// our hostname to `hostname.other_tailnet.`, which won't succeed. If `name` was just the
// hostname, nothing will have been chopped, so the comparison will still be hostname-to-
// hostname.
let name = name.strip_suffix('.').unwrap_or(name);
let name = if let Some(tailnet) = &self.tailnet {
name.strip_suffix(tailnet.as_str())
.and_then(|name| name.strip_suffix('.'))
.unwrap_or(name)
} else {
name
};
name == self.hostname
}
}
/// Addresses for a node within a tailnet.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TailnetAddress {
/// The IPv4 address of the node in the tailnet.
pub ipv4: ipnet::Ipv4Net,
/// The IPv6 address of the node in the tailnet.
pub ipv6: ipnet::Ipv6Net,
}
impl TailnetAddress {
/// Report whether `addr` matches either address in this [`TailnetAddress`].
pub fn contains(&self, addr: IpAddr) -> bool {
match addr {
IpAddr::V4(a) => self.ipv4.addr() == a,
IpAddr::V6(a) => self.ipv6.addr() == a,
}
}
}
impl From<&ts_control_serde::Node<'_>> for Node {
fn from(value: &ts_control_serde::Node) -> Self {
let fqdn_without_trailing_dot = value.name.strip_suffix('.').unwrap_or(value.name);
let (hostname, tailnet) = match fqdn_without_trailing_dot.split_once('.') {
Some((hostname, tailnet)) => (hostname, Some(tailnet.to_owned())),
None => (fqdn_without_trailing_dot, None),
};
Self {
id: value.id,
stable_id: StableId(value.stable_id.0.to_string()),
hostname: hostname.to_owned(),
tailnet,
tags: value
.tags
.as_ref()
.map(|x| x.iter().map(|x| x.to_string()).collect())
.unwrap_or_default(),
tailnet_address: TailnetAddress {
ipv4: value.addresses.0,
ipv6: value.addresses.1,
},
node_key: value.key,
node_key_expiry: value.key_expiry,
machine_key: value.machine,
disco_key: value.disco_key,
accepted_routes: value
.allowed_ips
.clone()
.unwrap_or_else(|| vec![value.addresses.0.into(), value.addresses.1.into()]),
underlay_addresses: value.endpoints.clone(),
// legacy_derp_string is still in practical use as of 3/2026
#[allow(deprecated)]
derp_region: value
.home_derp
.or(value.legacy_derp_string)
.or_else(|| value.host_info.net_info.as_ref()?.preferred_derp)
.map(|x| ts_transport_derp::RegionId(x.into())),
}
}
}