tailscale_rest/models/device.rs
1//! Devices, the routes they advertise, their posture, and shares of them.
2
3use std::collections::BTreeMap;
4
5use serde_json::Value;
6
7use crate::Secret;
8use crate::model;
9use crate::models::KnownValues;
10
11/// The posture providers the description knows.
12pub const POSTURE_PROVIDERS: &[&str] = &[
13 "falcon",
14 "intune",
15 "jamfpro",
16 "kandji",
17 "kolide",
18 "sentinelone",
19];
20
21/// What a device listing may ask for.
22///
23/// `default` is the subset the endpoint sends when the parameter is absent;
24/// `all` adds the fields that cost the control plane something to gather.
25pub const DEVICE_FIELDS: &[&str] = &["all", "default"];
26
27pub const KNOWN_VALUES: &[KnownValues] = &[("PostureIntegration.provider", POSTURE_PROVIDERS),
28 ("?fields", DEVICE_FIELDS),
29];
30
31model! {
32 /// A machine in a tailnet.
33 ///
34 /// `nodeId` is the identifier to use; `id` is the older numeric one, still
35 /// accepted wherever a device is named and still sent back here.
36 Device {
37 /// Both families: `100.x.y.z` and `fd7a:115c:…`.
38 addresses: "addresses" => Vec<String>,
39 id: "id" => String,
40 node_id: "nodeId" => String,
41 /// Who registered it. For an untagged device this is its owner; a
42 /// tagged device is owned by its tags instead.
43 user: "user" => String,
44 /// The MagicDNS name.
45 name: "name" => String,
46 /// The machine name shown in the admin console.
47 hostname: "hostname" => String,
48 /// Empty for a device shared in from another tailnet.
49 client_version: "clientVersion" => String,
50 update_available: "updateAvailable" => bool,
51 os: "os" => String,
52 created: "created" => String,
53 connected_to_control: "connectedToControl" => bool,
54 /// Omitted for a device that has never been online, and for one that
55 /// is online now.
56 last_seen: "lastSeen" => String,
57 key_expiry_disabled: "keyExpiryDisabled" => bool,
58 expires: "expires" => String,
59 authorized: "authorized" => bool,
60 /// `true` for a device shared into this tailnet rather than a member
61 /// of it.
62 is_external: "isExternal" => bool,
63 /// Several devices using one node key, which usually means node state
64 /// was copied between machines.
65 multiple_connections: "multipleConnections" => bool,
66 /// A public key, not a secret, and of no use to any call here.
67 machine_key: "machineKey" => String,
68 /// A public key, and the one a locked tailnet signs to admit a node.
69 node_key: "nodeKey" => String,
70 blocks_incoming_connections: "blocksIncomingConnections" => bool,
71 /// The advertised routes an admin has approved.
72 enabled_routes: "enabledRoutes" => Vec<String>,
73 /// The routes this device asks to expose, approved or not.
74 advertised_routes: "advertisedRoutes" => Vec<String>,
75 client_connectivity: "clientConnectivity" => ClientConnectivity,
76 tags: "tags" => Vec<String>,
77 /// Only populated where tailnet lock is on.
78 tailnet_lock_error: "tailnetLockError" => String,
79 /// Present whether or not tailnet lock is on: every node generates one.
80 tailnet_lock_key: "tailnetLockKey" => String,
81 ssh_enabled: "sshEnabled" => bool,
82 posture_identity: "postureIdentity" => PostureIdentity,
83 posture_status: "postureStatus" => PostureStatus,
84 is_ephemeral: "isEphemeral" => bool,
85 distro: "distro" => Distro,
86 }
87
88 /// What the device reports about the network it is on.
89 ClientConnectivity as "Device.clientConnectivity" {
90 /// The magicsock UDP `ip:port` endpoints, of either family.
91 endpoints: "endpoints" => Vec<String>,
92 /// `true` where the host's NAT mappings depend on the destination.
93 mapping_varies_by_dest_ip: "mappingVariesByDestIP" => bool,
94 /// Keyed by DERP server location.
95 latency: "latency" => BTreeMap<String, DerpLatency>,
96 client_supports: "clientSupports" => ClientSupports,
97 }
98
99 /// One DERP server's distance from the device.
100 DerpLatency as "Device.clientConnectivity.latency{}" {
101 /// `true` for the server this node prefers for incoming traffic.
102 preferred: "preferred" => bool,
103 latency_ms: "latencyMs" => f64,
104 }
105
106 /// NAT and address-family features the client reports.
107 ///
108 /// Every field is nullable in the description and `hairPinning` is
109 /// documented as always null now, so absent and false are different
110 /// answers here and the `Option` is carrying real information.
111 ClientSupports as "Device.clientConnectivity.clientSupports" {
112 /// No longer tracked; always null.
113 hair_pinning: "hairPinning" => bool,
114 /// Whether the OS supports IPv6, not whether IPv6 works here.
115 ipv6: "ipv6" => bool,
116 pcp: "pcp" => bool,
117 pmp: "pmp" => bool,
118 udp: "udp" => bool,
119 upnp: "upnp" => bool,
120 }
121
122 /// Hardware identifiers, where the tailnet collects them.
123 ///
124 /// A device that has not opted in reports `{"disabled": true}` rather than
125 /// nothing, which is why `disabled` is worth reading.
126 PostureIdentity as "Device.postureIdentity" {
127 serial_numbers: "serialNumbers" => Vec<String>,
128 disabled: "disabled" => bool,
129 }
130
131 /// Whether the device passes a posture, and what it fails if not.
132 PostureStatus as "Device.postureStatus" {
133 passing: "passing" => bool,
134 /// Whether failing it cuts the device's connectivity.
135 impacting: "impacting" => bool,
136 /// Empty when the device passes.
137 failing_assertions: "failingAssertions" => Vec<String>,
138 }
139
140 /// The operating system distribution, where the client can tell.
141 Distro as "Device.distro" {
142 name: "name" => String,
143 version: "version" => String,
144 code_name: "codeName" => String,
145 }
146
147 /// The routes a device advertises, and which of them are approved.
148 DeviceRoutes {
149 advertised_routes: "advertisedRoutes" => Vec<String>,
150 enabled_routes: "enabledRoutes" => Vec<String>,
151 }
152
153 /// Posture attributes collected from a device.
154 DevicePostureAttributes {
155 /// Values are strings, numbers or booleans, so the map is untyped.
156 attributes: "attributes" => BTreeMap<String, Value>,
157 /// When each attribute stops counting, for those that expire.
158 expiries: "expiries" => BTreeMap<String, String>,
159 }
160
161 /// An invitation sharing one device with a user outside its tailnet.
162 DeviceInvite {
163 id: "id" => String,
164 created: "created" => String,
165 tailnet_id: "tailnetId" => i64,
166 device_id: "deviceId" => i64,
167 sharer_id: "sharerId" => i64,
168 multi_use: "multiUse" => bool,
169 /// Whether the invited user may use the device as an exit node, where
170 /// it advertises as one.
171 allow_exit_node: "allowExitNode" => bool,
172 /// Empty for an invite nobody was mailed, whose URL is shared by hand.
173 email: "email" => String,
174 last_email_sent_at: "lastEmailSentAt" => String,
175 /// Anyone holding this link can accept, not only the addressee.
176 invite_url: "inviteUrl" => String,
177 accepted: "accepted" => bool,
178 accepted_by: "acceptedBy" => InviteAcceptor,
179 }
180
181 /// Who accepted a share.
182 InviteAcceptor as "DeviceInvite.acceptedBy" {
183 id: "id" => String,
184 login_name: "loginName" => String,
185 profile_pic_url: "profilePicUrl" => String,
186 }
187
188 /// One share to create, as the request's array carries them.
189 CreateDeviceInvite as "POST /device/{deviceId}/device-invites body[]" {
190 /// Whether more than one person may accept this invite.
191 multi_use: "multiUse" => bool,
192 /// Whether the invited user may use the device as an exit node, where
193 /// it advertises as one.
194 allow_exit_node: "allowExitNode" => bool,
195 /// Omit to create an invite nobody is mailed, whose `inviteUrl` is
196 /// then shared by hand.
197 email: "email" => String,
198 }
199
200 /// What accepting a share sends: the invite, as a URL or as its bare id.
201 AcceptDeviceInvite as "POST /device-invites/-/accept body" {
202 invite: "invite" => String,
203 }
204
205 /// What accepting a share answers with.
206 ///
207 /// Three flat objects rather than the [`Device`] and `User` models
208 /// beside them: the description gives each its own small shape here, and a
209 /// caller reading `device.ipv4` would not find it on a `Device`.
210 AcceptedDeviceInvite as "POST /device-invites/-/accept 200" {
211 device: "device" => SharedDevice,
212 /// Whose device it is.
213 sharer: "sharer" => SharePartner,
214 /// Who now has it, which is the credential that made this call.
215 accepted_by: "acceptedBy" => SharePartner,
216 }
217
218 /// The device a share hands over, as the acceptance describes it.
219 SharedDevice as "POST /device-invites/-/accept 200.device" {
220 id: "id" => String,
221 os: "os" => String,
222 name: "name" => String,
223 fqdn: "fqdn" => String,
224 ipv4: "ipv4" => String,
225 ipv6: "ipv6" => String,
226 /// Whether this share carries the device's exit node.
227 include_exit_node: "includeExitNode" => bool,
228 }
229
230 /// One side of a share: whoever offered it, or whoever took it.
231 SharePartner as "POST /device-invites/-/accept 200.sharer" {
232 id: "id" => String,
233 display_name: "displayName" => String,
234 login_name: "loginName" => String,
235 profile_pic_url: "profilePicURL" => String,
236 }
237
238 /// The other side, which the description gives the same shape.
239 SharePartnerAcceptor as "POST /device-invites/-/accept 200.acceptedBy" is SharePartner;
240
241 /// A configured link to a device posture provider.
242 PostureIntegration {
243 /// One of [`POSTURE_PROVIDERS`]. Required when creating, ignored when
244 /// updating.
245 provider: "provider" => String,
246 /// Which of the provider's clouds: `us-1`, `eu-1` and so on for Falcon,
247 /// a region for Intune, blank where the provider has one.
248 cloud_id: "cloudId" => String,
249 client_id: "clientId" => String,
250 /// Intune's directory (tenant) ID; blank for every other provider.
251 tenant_id: "tenantId" => String,
252 /// Required when creating; omitted when updating leaves it as it was.
253 client_secret: "clientSecret" => Secret,
254 id: "id" => String,
255 config_updated: "configUpdated" => String,
256 status: "status" => PostureIntegrationStatus,
257 }
258
259 /// How the last sync with a posture provider went.
260 PostureIntegrationStatus as "PostureIntegration.status" {
261 last_sync: "lastSync" => String,
262 error: "error" => String,
263 provider_host_count: "providerHostCount" => i64,
264 matched_count: "matchedCount" => i64,
265 possible_matched_count: "possibleMatchedCount" => i64,
266 }
267
268 // -----------------------------------------------------------------------
269 // What the endpoints send and answer with.
270 //
271 // The description spells these out where they are used rather than naming
272 // them, so their paths are the routes rather than schema names (Q64). They
273 // are here because the tools that build them are, and because the drift
274 // test holds a route to its shape exactly as it holds a named schema.
275 // -----------------------------------------------------------------------
276
277 /// What a device listing answers with.
278 ///
279 /// One field, and it stays one field: the endpoint has no pagination and
280 /// no total, so a caller windowing the list is windowing the whole of it.
281 DeviceList as "GET /tailnet/{tailnet}/devices 200" {
282 devices: "devices" => Vec<Device>,
283 }
284
285 /// Authorise a device, or revoke its authorisation with `false`.
286 DeviceAuthorization as "POST /device/{deviceId}/authorized body" {
287 authorized: "authorized" => bool,
288 }
289
290 /// Move a device to another address in the tailnet's range.
291 DeviceAddress as "POST /device/{deviceId}/ip body" {
292 ipv4: "ipv4" => String,
293 }
294
295 /// Turn a device's key expiry off, or back on.
296 DeviceKeyExpiry as "POST /device/{deviceId}/key body" {
297 key_expiry_disabled: "keyExpiryDisabled" => bool,
298 }
299
300 /// Rename a device, which retires its old MagicDNS names.
301 DeviceName as "POST /device/{deviceId}/name body" {
302 name: "name" => String,
303 }
304
305 /// Replace the routes a device is permitted to carry.
306 ///
307 /// Only the enabled set: what a device *advertises* it can route is the
308 /// device's own business and cannot be set through the API.
309 DeviceEnabledRoutes as "POST /device/{deviceId}/routes body" {
310 routes: "routes" => Vec<String>,
311 }
312
313 /// Replace a device's tags.
314 DeviceTags as "POST /device/{deviceId}/tags body" {
315 tags: "tags" => Vec<String>,
316 }
317
318 /// Set one custom posture attribute on one device.
319 PostureAttribute as "POST /device/{deviceId}/attributes/{attributeKey} body" {
320 /// A string, a number or a boolean. The type is fixed by the first
321 /// write and a later write of another type is refused.
322 value: "value" => Value,
323 /// When the control plane should forget it.
324 expiry: "expiry" => String,
325 comment: "comment" => String,
326 }
327
328 /// Set custom posture attributes on many devices at once.
329 ///
330 /// A JSON Merge Patch: a `null` value deletes the attribute, and a device
331 /// the map does not mention is left alone.
332 PostureAttributeBatch as "PATCH /tailnet/{tailnet}/device-attributes body" {
333 nodes: "nodes" => BTreeMap<String, BTreeMap<String, Value>>,
334 comment: "comment" => String,
335 }
336
337 /// The longer form a batched attribute may take, where a bare value would
338 /// not carry an expiry.
339 PostureAttributeValue
340 as "PATCH /tailnet/{tailnet}/device-attributes body.nodes{}{}|anyOf[0]" {
341 value: "value" => Value,
342 expiry: "expiry" => String,
343 }
344
345 /// What a posture integration listing answers with.
346 PostureIntegrationList as "GET /tailnet/{tailnet}/posture/integrations 200" {
347 integrations: "integrations" => Vec<PostureIntegration>,
348 }
349}