Skip to main content

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}