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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
use ts_capabilityversion::CapabilityVersion;
use ts_control_serde::{Endpoint, HostInfo, MapRequest, NetInfo, Service};
/// Builder type for [`MapRequest`]s; smooths over the annoying parts of creating a request.
#[derive(Debug, Clone)]
pub struct MapRequestBuilder<'a> {
req: MapRequest<'a>,
}
impl<'a> MapRequestBuilder<'a> {
/// Create a new [`MapRequestBuilder`]. By default:
/// - [`MapRequest::keep_alive`] is `false`
/// - [`MapRequest::omit_peers`] is `true`
/// - [`MapRequest::stream`] is `false`
/// - [`MapRequest::host_info`]:
/// - [`HostInfo::hostname`] is populated from [`TailnetPeerConfig::hostname`]
/// - [`HostInfo::net_info`] is `None`, therefore:
/// - [`NetInfo::derp_latency`][crate::types::NetInfo::derp_latency] is not populated
/// - [`NetInfo::preferred_derp`][crate::types::NetInfo::preferred_derp] is not populated
pub fn new(key_state: &ts_keys::NodeState) -> Self {
Self {
req: MapRequest {
version: CapabilityVersion::CURRENT,
keep_alive: false,
omit_peers: true,
stream: false,
node_key: key_state.node_keys.public,
disco_key: key_state.disco_keys.public,
host_info: Some(HostInfo::default()),
..Default::default()
},
}
}
/// Consumes this [`MapRequestBuilder`] and returns a [`MapRequest`] with the configured
/// values.
pub fn build(self) -> MapRequest<'a> {
self.req
}
/// Set the [`MapRequest::keep_alive`] field.
pub fn keep_alive(mut self, value: bool) -> Self {
self.req.keep_alive = value;
self
}
/// Set the [`MapRequest::omit_peers`] field.
pub fn omit_peers(mut self, value: bool) -> Self {
self.req.omit_peers = value;
self
}
/// Set the [`MapRequest::stream`] field.
pub fn stream(mut self, value: bool) -> Self {
self.req.stream = value;
self
}
/// Set the [`HostInfo::hostname`] field.
pub fn hostname(mut self, hostname: &'a str) -> Self {
self.host_info_mut().hostname = Some(hostname);
self
}
/// Set the [`NetInfo::preferred_derp`] field (inside [`MapRequest::host_info`] ->
/// [`HostInfo::net_info`]).
pub fn preferred_derp(mut self, value: ts_derp::RegionId) -> Self {
self.net_info_mut().preferred_derp = Some(value.0.into());
self
}
/// Set the [`NetInfo::derp_latency`] field (inside [`MapRequest::host_info`] ->
/// [`HostInfo::net_info`]).
pub fn derp_latencies(mut self, value: impl IntoIterator<Item = (&'a str, f64)>) -> Self {
self.net_info_mut().derp_latency = Some(value.into_iter().collect());
self
}
/// Advertise the node's magicsock UDP endpoints (ip:port candidates) to the control
/// server so peers can learn where to attempt direct connections.
pub fn endpoints(mut self, endpoints: impl IntoIterator<Item = Endpoint>) -> Self {
self.req.endpoints = endpoints.into_iter().collect();
self
}
/// Advertise the set of IP prefixes this node can route (`HostInfo.RoutableIPs`), so the
/// control server can grant it as a subnet router and/or exit node. When the iterator yields
/// nothing, the field is left as `None` and omitted from the wire request (advertise nothing).
pub fn routable_ips(mut self, routes: impl IntoIterator<Item = ipnet::IpNet>) -> Self {
let routes: alloc::vec::Vec<ipnet::IpNet> = routes.into_iter().collect();
self.host_info_mut().routable_ips = (!routes.is_empty()).then_some(routes);
self
}
/// Request to reattach to a prior map session (`MapRequest::map_session_handle` +
/// `map_session_seq`), so a reconnect resumes the delta stream instead of cold-restarting.
///
/// `handle` is the opaque session handle echoed by control in the first `MapResponse` of the
/// previous session; `seq` is the last sequence number this client processed in that session.
/// Control may honor the request (sending only `seq`-greater deltas) or ignore it and start a
/// fresh session with a full netmap — either is safe. Only meaningful when
/// [`stream`](Self::stream) is `true`. An empty `handle` leaves both fields at their defaults
/// (start a new session).
pub fn map_session(mut self, handle: &'a str, seq: i64) -> Self {
self.req.map_session_handle = handle;
self.req.map_session_seq = if handle.is_empty() { 0 } else { seq };
self
}
/// Set the client application name (`HostInfo.App`) and IPN version (`HostInfo.IPNVersion`)
/// that this node reports to control, so the tailnet admin can identify the client build.
pub fn client_info(mut self, app: &'a str, ipn_version: &'a str) -> Self {
let host_info = self.host_info_mut();
host_info.app = app;
host_info.ipn_version = ipn_version;
self
}
/// Advertise the set of ACL tags this node wants to claim (`HostInfo.RequestTags`), so a
/// tag-keyed control ACL (e.g. a self-hosted control plane's route auto-approver) can match it. When the
/// iterator yields nothing, the field is left as `None` and omitted from the wire request
/// (claim no tags).
pub fn request_tags(mut self, tags: impl IntoIterator<Item = &'a str>) -> Self {
let tags: alloc::vec::Vec<&'a str> = tags.into_iter().collect();
self.host_info_mut().request_tags = (!tags.is_empty()).then_some(tags);
self
}
/// Advertise the services this node runs (`HostInfo.Services`), so peers and control can
/// discover this node's peerAPI port and whether it proxies DNS as an exit node. When the
/// iterator yields nothing, the field is left as `None` and omitted from the wire request
/// (advertise no services).
pub fn services(mut self, services: impl IntoIterator<Item = Service<'a>>) -> Self {
let services: alloc::vec::Vec<Service<'a>> = services.into_iter().collect();
self.host_info_mut().services = (!services.is_empty()).then_some(services);
self
}
/// Ask control to wire this node up server-side for Tailscale Funnel
/// (`HostInfo.WireIngress`, capver 113), so the DNS/ingress records a Funnel node needs are
/// provisioned even when no Funnel endpoint is currently live. Mirrors Go `tsnet`'s
/// "would like to be wired up for Funnel" signal. `HostInfo.IngressEnabled` (endpoints actually
/// active) is intentionally left unset: this fork's [`crate::listen_funnel`] is fail-closed, so
/// no Funnel endpoint ever goes live.
pub fn wire_ingress(mut self, value: bool) -> Self {
self.host_info_mut().wire_ingress = value;
self
}
/// Signal that this node currently has at least one live Tailscale Funnel endpoint
/// (`HostInfo.IngressEnabled`), set while a [`crate::listen_funnel`] listener is active. Unlike
/// [`wire_ingress`](Self::wire_ingress) (the "would like to be wired up" hint), this advertises
/// that public ingress is *actually* being served, so control routes Funnel traffic to this node
/// via its ingress relay. Per Go's optimization, `IngressEnabled` implies `WireIngress`, so the
/// caller sends this *instead of* `WireIngress` when a Funnel listener is up. Defaults unset
/// (no live endpoint) — fail-closed: a node only advertises ingress while it can serve it.
pub fn ingress_enabled(mut self, value: bool) -> Self {
self.host_info_mut().ingress_enabled = value;
self
}
/// Set the opaque VIP-services hash this node advertises (`HostInfo.ServicesHash`), the
/// advertise-side signal that tells control to (re)fetch the node's hosted VIP-service list via
/// the c2n `GET /vip-services` endpoint when it changes. Compute it with
/// [`crate::services_hash`] over [`Config::advertised_vip_services`](crate::Config::advertised_vip_services).
/// An empty string (the default / no-services-advertised case) leaves the wire field omitted, so
/// non-advertising nodes are byte-for-byte unchanged.
pub fn services_hash(mut self, hash: &'a str) -> Self {
self.host_info_mut().services_hash = hash;
self
}
fn host_info_mut(&mut self) -> &mut HostInfo<'a> {
self.req.host_info.get_or_insert_default()
}
fn net_info_mut(&mut self) -> &mut NetInfo<'a> {
self.host_info_mut().net_info.get_or_insert_default()
}
}
#[cfg(test)]
mod tests {
use ts_control_serde::EndpointType;
use super::*;
#[test]
fn endpoints_setter_populates_request() {
let node_state = ts_keys::NodeState::generate();
let endpoint = Endpoint {
endpoint: "203.0.113.7:41641".parse().unwrap(),
ty: EndpointType::Stun,
};
let req = MapRequestBuilder::new(&node_state)
.endpoints([endpoint])
.build();
assert_eq!(req.endpoints.len(), 1);
assert_eq!(req.endpoints[0], endpoint);
}
#[test]
fn routable_ips_setter_populates_host_info() {
let node_state = ts_keys::NodeState::generate();
let route: ipnet::IpNet = "10.0.0.0/24".parse().unwrap();
let req = MapRequestBuilder::new(&node_state)
.routable_ips([route])
.build();
assert_eq!(
req.host_info.unwrap().routable_ips,
Some(alloc::vec![route])
);
}
#[test]
fn routable_ips_setter_empty_leaves_field_none() {
let node_state = ts_keys::NodeState::generate();
let req = MapRequestBuilder::new(&node_state).routable_ips([]).build();
// Empty advertise set: the field stays None and is omitted from the wire request.
assert_eq!(req.host_info.unwrap().routable_ips, None);
}
#[test]
fn request_tags_setter_populates_host_info() {
let node_state = ts_keys::NodeState::generate();
let req = MapRequestBuilder::new(&node_state)
.request_tags(["tag:exit", "tag:server"])
.build();
assert_eq!(
req.host_info.unwrap().request_tags,
Some(alloc::vec!["tag:exit", "tag:server"])
);
}
#[test]
fn request_tags_setter_empty_leaves_field_none() {
let node_state = ts_keys::NodeState::generate();
let req = MapRequestBuilder::new(&node_state).request_tags([]).build();
// Empty tag set: the field stays None and is omitted from the wire request.
assert_eq!(req.host_info.unwrap().request_tags, None);
}
#[test]
fn wire_ingress_setter_populates_host_info() {
let node_state = ts_keys::NodeState::generate();
let req = MapRequestBuilder::new(&node_state)
.wire_ingress(true)
.build();
let hi = req.host_info.unwrap();
// WireIngress is the capver-113 "wire me up for Funnel" signal; IngressEnabled (endpoints
// actually live) must stay false — listen_funnel is fail-closed in this fork.
assert!(hi.wire_ingress);
assert!(!hi.ingress_enabled);
}
#[test]
fn wire_ingress_setter_defaults_false() {
let node_state = ts_keys::NodeState::generate();
let req = MapRequestBuilder::new(&node_state).build();
assert!(!req.host_info.unwrap().wire_ingress);
}
#[test]
fn services_hash_setter_populates_host_info() {
let node_state = ts_keys::NodeState::generate();
let req = MapRequestBuilder::new(&node_state)
.services_hash("deadbeef")
.build();
assert_eq!(req.host_info.unwrap().services_hash, "deadbeef");
}
#[test]
fn services_hash_setter_defaults_empty() {
let node_state = ts_keys::NodeState::generate();
let req = MapRequestBuilder::new(&node_state).build();
// Empty hash = no VIP services advertised; the field is omitted from the wire request.
assert_eq!(req.host_info.unwrap().services_hash, "");
}
#[test]
fn map_session_setter_populates_resume_fields() {
let node_state = ts_keys::NodeState::generate();
let req = MapRequestBuilder::new(&node_state)
.map_session("sess-abc", 42)
.build();
assert_eq!(req.map_session_handle, "sess-abc");
assert_eq!(req.map_session_seq, 42);
}
#[test]
fn map_session_empty_handle_zeroes_seq() {
let node_state = ts_keys::NodeState::generate();
// No prior session: a stray seq must not be sent without a handle (control would ignore
// it, but we keep the wire request clean and unambiguous).
let req = MapRequestBuilder::new(&node_state)
.map_session("", 99)
.build();
assert_eq!(req.map_session_handle, "");
assert_eq!(req.map_session_seq, 0);
}
}