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 is_ephemeral: "isEphemeral" => bool,
84 distro: "distro" => Distro,
85 }
86
87 /// What the device reports about the network it is on.
88 ClientConnectivity as "Device.clientConnectivity" {
89 /// The magicsock UDP `ip:port` endpoints, of either family.
90 endpoints: "endpoints" => Vec<String>,
91 /// `true` where the host's NAT mappings depend on the destination.
92 mapping_varies_by_dest_ip: "mappingVariesByDestIP" => bool,
93 /// Keyed by DERP server location.
94 latency: "latency" => BTreeMap<String, DerpLatency>,
95 client_supports: "clientSupports" => ClientSupports,
96 }
97
98 /// One DERP server's distance from the device.
99 DerpLatency as "Device.clientConnectivity.latency{}" {
100 /// `true` for the server this node prefers for incoming traffic.
101 preferred: "preferred" => bool,
102 latency_ms: "latencyMs" => f64,
103 }
104
105 /// NAT and address-family features the client reports.
106 ///
107 /// Every field is nullable in the description and `hairPinning` is
108 /// documented as always null now, so absent and false are different
109 /// answers here and the `Option` is carrying real information.
110 ClientSupports as "Device.clientConnectivity.clientSupports" {
111 /// No longer tracked; always null.
112 hair_pinning: "hairPinning" => bool,
113 /// Whether the OS supports IPv6, not whether IPv6 works here.
114 ipv6: "ipv6" => bool,
115 pcp: "pcp" => bool,
116 pmp: "pmp" => bool,
117 udp: "udp" => bool,
118 upnp: "upnp" => bool,
119 }
120
121 /// Hardware identifiers, where the tailnet collects them.
122 ///
123 /// A device that has not opted in reports `{"disabled": true}` rather than
124 /// nothing, which is why `disabled` is worth reading.
125 PostureIdentity as "Device.postureIdentity" {
126 serial_numbers: "serialNumbers" => Vec<String>,
127 disabled: "disabled" => bool,
128 }
129
130 /// The operating system distribution, where the client can tell.
131 Distro as "Device.distro" {
132 name: "name" => String,
133 version: "version" => String,
134 code_name: "codeName" => String,
135 }
136
137 /// The routes a device advertises, and which of them are approved.
138 DeviceRoutes {
139 advertised_routes: "advertisedRoutes" => Vec<String>,
140 enabled_routes: "enabledRoutes" => Vec<String>,
141 }
142
143 /// Posture attributes collected from a device.
144 DevicePostureAttributes {
145 /// Values are strings, numbers or booleans, so the map is untyped.
146 attributes: "attributes" => BTreeMap<String, Value>,
147 /// When each attribute stops counting, for those that expire.
148 expiries: "expiries" => BTreeMap<String, String>,
149 }
150
151 /// An invitation sharing one device with a user outside its tailnet.
152 DeviceInvite {
153 id: "id" => String,
154 created: "created" => String,
155 tailnet_id: "tailnetId" => i64,
156 device_id: "deviceId" => i64,
157 sharer_id: "sharerId" => i64,
158 multi_use: "multiUse" => bool,
159 /// Whether the invited user may use the device as an exit node, where
160 /// it advertises as one.
161 allow_exit_node: "allowExitNode" => bool,
162 /// Empty for an invite nobody was mailed, whose URL is shared by hand.
163 email: "email" => String,
164 last_email_sent_at: "lastEmailSentAt" => String,
165 /// Anyone holding this link can accept, not only the addressee.
166 invite_url: "inviteUrl" => String,
167 accepted: "accepted" => bool,
168 accepted_by: "acceptedBy" => InviteAcceptor,
169 }
170
171 /// Who accepted a share.
172 InviteAcceptor as "DeviceInvite.acceptedBy" {
173 id: "id" => String,
174 login_name: "loginName" => String,
175 profile_pic_url: "profilePicUrl" => String,
176 }
177
178 /// One share to create, as the request's array carries them.
179 CreateDeviceInvite as "POST /device/{deviceId}/device-invites body[]" {
180 /// Whether more than one person may accept this invite.
181 multi_use: "multiUse" => bool,
182 /// Whether the invited user may use the device as an exit node, where
183 /// it advertises as one.
184 allow_exit_node: "allowExitNode" => bool,
185 /// Omit to create an invite nobody is mailed, whose `inviteUrl` is
186 /// then shared by hand.
187 email: "email" => String,
188 }
189
190 /// What accepting a share sends: the invite, as a URL or as its bare id.
191 AcceptDeviceInvite as "POST /device-invites/-/accept body" {
192 invite: "invite" => String,
193 }
194
195 /// What accepting a share answers with.
196 ///
197 /// Three flat objects rather than the [`Device`] and `User` models
198 /// beside them: the description gives each its own small shape here, and a
199 /// caller reading `device.ipv4` would not find it on a `Device`.
200 AcceptedDeviceInvite as "POST /device-invites/-/accept 200" {
201 device: "device" => SharedDevice,
202 /// Whose device it is.
203 sharer: "sharer" => SharePartner,
204 /// Who now has it, which is the credential that made this call.
205 accepted_by: "acceptedBy" => SharePartner,
206 }
207
208 /// The device a share hands over, as the acceptance describes it.
209 SharedDevice as "POST /device-invites/-/accept 200.device" {
210 id: "id" => String,
211 os: "os" => String,
212 name: "name" => String,
213 fqdn: "fqdn" => String,
214 ipv4: "ipv4" => String,
215 ipv6: "ipv6" => String,
216 /// Whether this share carries the device's exit node.
217 include_exit_node: "includeExitNode" => bool,
218 }
219
220 /// One side of a share: whoever offered it, or whoever took it.
221 SharePartner as "POST /device-invites/-/accept 200.sharer" {
222 id: "id" => String,
223 display_name: "displayName" => String,
224 login_name: "loginName" => String,
225 profile_pic_url: "profilePicURL" => String,
226 }
227
228 /// The other side, which the description gives the same shape.
229 SharePartnerAcceptor as "POST /device-invites/-/accept 200.acceptedBy" is SharePartner;
230
231 /// A configured link to a device posture provider.
232 PostureIntegration {
233 /// One of [`POSTURE_PROVIDERS`]. Required when creating, ignored when
234 /// updating.
235 provider: "provider" => String,
236 /// Which of the provider's clouds: `us-1`, `eu-1` and so on for Falcon,
237 /// a region for Intune, blank where the provider has one.
238 cloud_id: "cloudId" => String,
239 client_id: "clientId" => String,
240 /// Intune's directory (tenant) ID; blank for every other provider.
241 tenant_id: "tenantId" => String,
242 /// Required when creating; omitted when updating leaves it as it was.
243 client_secret: "clientSecret" => Secret,
244 id: "id" => String,
245 config_updated: "configUpdated" => String,
246 status: "status" => PostureIntegrationStatus,
247 }
248
249 /// How the last sync with a posture provider went.
250 PostureIntegrationStatus as "PostureIntegration.status" {
251 last_sync: "lastSync" => String,
252 error: "error" => String,
253 provider_host_count: "providerHostCount" => i64,
254 matched_count: "matchedCount" => i64,
255 possible_matched_count: "possibleMatchedCount" => i64,
256 }
257
258 // -----------------------------------------------------------------------
259 // What the endpoints send and answer with.
260 //
261 // The description spells these out where they are used rather than naming
262 // them, so their paths are the routes rather than schema names (Q64). They
263 // are here because the tools that build them are, and because the drift
264 // test holds a route to its shape exactly as it holds a named schema.
265 // -----------------------------------------------------------------------
266
267 /// What a device listing answers with.
268 ///
269 /// One field, and it stays one field: the endpoint has no pagination and
270 /// no total, so a caller windowing the list is windowing the whole of it.
271 DeviceList as "GET /tailnet/{tailnet}/devices 200" {
272 devices: "devices" => Vec<Device>,
273 }
274
275 /// Authorise a device, or revoke its authorisation with `false`.
276 DeviceAuthorization as "POST /device/{deviceId}/authorized body" {
277 authorized: "authorized" => bool,
278 }
279
280 /// Move a device to another address in the tailnet's range.
281 DeviceAddress as "POST /device/{deviceId}/ip body" {
282 ipv4: "ipv4" => String,
283 }
284
285 /// Turn a device's key expiry off, or back on.
286 DeviceKeyExpiry as "POST /device/{deviceId}/key body" {
287 key_expiry_disabled: "keyExpiryDisabled" => bool,
288 }
289
290 /// Rename a device, which retires its old MagicDNS names.
291 DeviceName as "POST /device/{deviceId}/name body" {
292 name: "name" => String,
293 }
294
295 /// Replace the routes a device is permitted to carry.
296 ///
297 /// Only the enabled set: what a device *advertises* it can route is the
298 /// device's own business and cannot be set through the API.
299 DeviceEnabledRoutes as "POST /device/{deviceId}/routes body" {
300 routes: "routes" => Vec<String>,
301 }
302
303 /// Replace a device's tags.
304 DeviceTags as "POST /device/{deviceId}/tags body" {
305 tags: "tags" => Vec<String>,
306 }
307
308 /// Set one custom posture attribute on one device.
309 PostureAttribute as "POST /device/{deviceId}/attributes/{attributeKey} body" {
310 /// A string, a number or a boolean. The type is fixed by the first
311 /// write and a later write of another type is refused.
312 value: "value" => Value,
313 /// When the control plane should forget it.
314 expiry: "expiry" => String,
315 comment: "comment" => String,
316 }
317
318 /// Set custom posture attributes on many devices at once.
319 ///
320 /// A JSON Merge Patch: a `null` value deletes the attribute, and a device
321 /// the map does not mention is left alone.
322 PostureAttributeBatch as "PATCH /tailnet/{tailnet}/device-attributes body" {
323 nodes: "nodes" => BTreeMap<String, BTreeMap<String, Value>>,
324 comment: "comment" => String,
325 }
326
327 /// The longer form a batched attribute may take, where a bare value would
328 /// not carry an expiry.
329 PostureAttributeValue
330 as "PATCH /tailnet/{tailnet}/device-attributes body.nodes{}{}|anyOf[0]" {
331 value: "value" => Value,
332 expiry: "expiry" => String,
333 }
334
335 /// What a posture integration listing answers with.
336 PostureIntegrationList as "GET /tailnet/{tailnet}/posture/integrations 200" {
337 integrations: "integrations" => Vec<PostureIntegration>,
338 }
339}