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        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}