tailscale_mcp/tools/tailnet_devices.rs
1//! The devices in the tailnet, as the control plane sees them.
2//!
3//! The first tailnet toolset, so it settles a few things the rest follow.
4//!
5//! **Identifiers.** Every device endpoint takes a `device_id`, and the API
6//! accepts either the node id (`n1234567CNTRL`, which the listing calls
7//! `nodeId`) or the numeric `id`. Both are passed through as the caller wrote
8//! them; nothing here converts one to the other, because either is valid and
9//! guessing wrong would be a call to the wrong device.
10//!
11//! **Answers.** ADR-0004 has Tailscale's bodies come back in Tailscale's shape,
12//! so a listing answers with `{"devices": [...]}` and a device with the device.
13//! The one thing added is on the endpoints that answer with nothing: a caller
14//! that asked for a deletion gets a small report saying what was deleted rather
15//! than `null`, because `null` is indistinguishable from a bug.
16//!
17//! **Severing.** Six of these can cut this node off from the tailnet it is
18//! being reached over — deleting it, expiring its key, de-authorising it,
19//! moving its address, retagging it, or dropping the routes it carries. None
20//! is marked self-severing here, because nothing yet recognises *this* node
21//! among the arguments: ticket 21 builds that, and marking them before it
22//! exists would put a claim in the annotations that the server cannot keep.
23
24use rmcp::schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27use tailscale_rest::models::device::DEVICE_FIELDS;
28
29use crate::context::ToolContext;
30use crate::error::{ToolError, ToolResult};
31use crate::tools::common::{
32 Done, SelfConfirmation, answered_or, not_at_ourselves, one_of, path_segment, report,
33 require_destructive,
34};
35
36crate::tools! {
37 /// List the devices in the tailnet. Answers with the control plane's own
38 /// `{"devices": [...]}`, or with a `window` beside it when `limit` or
39 /// `offset` narrowed the list here.
40 ///
41 /// The endpoint has no pagination, so a large tailnet answers whole and
42 /// may exceed this server's result cap. `filters` is what asks the control
43 /// plane for less, and so the only one of these that can rescue such a
44 /// listing; `limit` and `offset` are applied to an answer that has already
45 /// arrived. `fields: "all"` adds the costlier fields — posture identity,
46 /// client connectivity — that `default` leaves out, and `fields:
47 /// "default"` is the smaller answer.
48 tailnet_device_list => DeviceListParams, device_list,
49 toolset: TailnetDevices, tier: Read, idempotent: true;
50
51 /// Read one device, by any of the names or identifiers it answers to.
52 tailnet_device_get => DeviceGetParams, device_get,
53 toolset: TailnetDevices, tier: Read, idempotent: true;
54
55 /// Remove a device from the tailnet permanently. The machine has to
56 /// re-authenticate to come back, as a new device with a new address.
57 ///
58 /// Only devices belonging to this tailnet can be deleted; a device shared
59 /// in from another tailnet is refused by the control plane.
60 tailnet_device_delete => SeveringDeviceParams, device_delete,
61 toolset: TailnetDevices, tier: Destructive, idempotent: true, severs_local: true;
62
63 /// Expire a device's node key, which disconnects it until someone
64 /// re-authenticates the machine. The device itself is kept.
65 tailnet_device_expire => SeveringDeviceParams, device_expire,
66 toolset: TailnetDevices, tier: Destructive, severs_local: true;
67
68 /// Authorise a device, or revoke its authorisation.
69 ///
70 /// Only meaningful on a tailnet that requires device approval. Authorising
71 /// is a write; revoking disconnects the device until it is authorised
72 /// again, and needs the destructive tier.
73 tailnet_device_authorize => DeviceAuthorizeParams, device_authorize,
74 toolset: TailnetDevices, tier: Write, idempotent: true, severs_local: true,
75 varying: true;
76
77 /// Rename a device. Its old MagicDNS names stop resolving, so anything
78 /// addressing it by name has to be updated.
79 tailnet_device_rename => DeviceRenameParams, device_rename,
80 toolset: TailnetDevices, tier: Write, idempotent: true, severs_local: true;
81
82 /// Replace a device's tags. Tags must already be defined in the tailnet
83 /// policy file, and the credential's own tags limit what it may assign.
84 ///
85 /// This replaces the whole set: tags not listed are removed, and removing
86 /// a tag can remove the access a policy rule granted through it.
87 tailnet_device_tags_set => DeviceTagsParams, device_tags_set,
88 toolset: TailnetDevices, tier: Write, idempotent: true, severs_local: true;
89
90 /// Turn a device's key expiry off, so it stays connected without
91 /// re-authenticating, or back on.
92 tailnet_device_key_expiry_set => DeviceKeyExpiryParams, device_key_expiry_set,
93 toolset: TailnetDevices, tier: Write, idempotent: true;
94
95 /// Move a device to a different Tailscale IPv4 address.
96 ///
97 /// Existing connections to the old address break. The address has to come
98 /// from the tailnet's own range.
99 tailnet_device_ip_set => DeviceIpParams, device_ip_set,
100 toolset: TailnetDevices, tier: Write, idempotent: true, severs_local: true;
101
102 /// Read the routes a device advertises and the subset that is enabled.
103 tailnet_device_routes_get => DeviceParams, device_routes_get,
104 toolset: TailnetDevices, tier: Read, idempotent: true;
105
106 /// Replace the set of advertised routes that are enabled for a device.
107 ///
108 /// Only the enabled set can be set here; what a device advertises is the
109 /// device's own configuration. Enabling a route the device does not
110 /// advertise has no effect until it does.
111 tailnet_device_routes_set => DeviceRoutesParams, device_routes_set,
112 toolset: TailnetDevices, tier: Write, idempotent: true, severs_local: true;
113
114 /// Read the custom posture attributes set on a device, and when each
115 /// expires.
116 tailnet_device_attributes_get => DeviceParams, device_attributes_get,
117 toolset: TailnetDevices, tier: Read, idempotent: true;
118
119 /// Set one custom posture attribute on one device.
120 ///
121 /// The key must begin `custom:`. The value's type — string, number or
122 /// boolean — is fixed by the first write and a later write of a different
123 /// type is refused by the control plane.
124 tailnet_device_attribute_set => AttributeSetParams, device_attribute_set,
125 toolset: TailnetDevices, tier: Write, idempotent: true;
126
127 /// Delete one custom posture attribute from one device. Only `custom:`
128 /// attributes can be deleted; the ones Tailscale sets are read-only.
129 tailnet_device_attribute_delete => AttributeParams, device_attribute_delete,
130 toolset: TailnetDevices, tier: Destructive, idempotent: true;
131
132 /// Set custom posture attributes on many devices in one call.
133 ///
134 /// A merge: a device the map does not mention is left alone, an attribute
135 /// it does not mention is left alone, and an attribute given `null` is
136 /// deleted. Each value is a string, a number, a boolean, `null`, or an
137 /// object `{"value": ..., "expiry": "<RFC 3339>"}` to set an expiry too.
138 tailnet_device_attributes_update => AttributesBatchParams, device_attributes_update,
139 toolset: TailnetDevices, tier: Write, idempotent: true;
140}
141
142// ---------------------------------------------------------------------------
143// Paths
144// ---------------------------------------------------------------------------
145
146/// `/api/v2/device/<id>`, with whatever follows it.
147///
148/// Not a tailnet path: a device is addressed globally by its own id, and the
149/// control plane resolves which tailnet it belongs to. Only the two
150/// tailnet-wide endpoints — the listing and the batch attribute update — go
151/// through [`tailscale_rest::Client::tailnet_path`].
152/// Resolve the device, refusing first if either spelling names this node.
153///
154/// The guard runs twice on purpose. Against what the caller wrote it costs
155/// nothing and asks no control plane, so severing this node stays refused even
156/// when the listing is unreachable — which is the direction that matters, since
157/// a missed match is how a session cuts itself off. Against what that resolved
158/// to it catches the one spelling [`SelfIdentity::matches`] does not know: the
159/// machine's own hostname, which is not any of the identifiers the API accepts.
160///
161/// [`SelfIdentity::matches`]: crate::context::SelfIdentity::matches
162async fn resolve_not_ourselves(
163 ctx: &ToolContext,
164 what: &str,
165 given: &str,
166 confirmation: &crate::tools::common::SelfConfirmation,
167) -> ToolResult<String> {
168 // Before the lookup, not after: this check needs no control plane, and a
169 // call that is about to sever the session it arrived on should be refused
170 // without reaching for the network first.
171 not_at_ourselves(ctx, what, given, confirmation).await?;
172 let device = resolve(ctx, given).await?;
173 if device != given.trim() {
174 not_at_ourselves(ctx, what, &device, confirmation).await?;
175 }
176 Ok(device)
177}
178
179/// The same check, for the handlers that resolve before they know they need it.
180///
181/// `device_authorize` only guards one of its two directions and `device_routes_set`
182/// has a paragraph to say first, so neither can be folded into the call above;
183/// both still owe the caller the same two checks.
184async fn neither_names_us(
185 ctx: &ToolContext,
186 what: &str,
187 given: &str,
188 resolved: &str,
189 confirmation: &crate::tools::common::SelfConfirmation,
190) -> ToolResult<()> {
191 not_at_ourselves(ctx, what, given, confirmation).await?;
192 if resolved != given.trim() {
193 not_at_ourselves(ctx, what, resolved, confirmation).await?;
194 }
195 Ok(())
196}
197
198/// Whether a value is already an identifier the control plane will take.
199///
200/// The two accepted shapes are distinctive enough to recognise without asking
201/// anybody: a node id is `n`, alphanumerics, then `CNTRL`, and the legacy id is
202/// all digits. Everything else is a name, and gets resolved. Recognising rather
203/// than resolving is what keeps every call that works today working byte for
204/// byte, and keeps a mistyped node id reaching the control plane so the answer
205/// comes from the authority on it.
206#[must_use]
207pub(crate) fn is_identifier(value: &str) -> bool {
208 let value = value.trim();
209 if value.is_empty() {
210 return false;
211 }
212 let numeric = value.bytes().all(|byte| byte.is_ascii_digit());
213 let node_id = value.len() > "nCNTRL".len()
214 && value.starts_with('n')
215 && value.ends_with("CNTRL")
216 && value[1..value.len() - "CNTRL".len()]
217 .bytes()
218 .all(|byte| byte.is_ascii_alphanumeric());
219 numeric || node_id
220}
221
222/// The identifier the control plane accepts for whatever the caller named.
223///
224/// The instructions promise a device can be named by its address or its
225/// MagicDNS name, and on the local surface the CLI makes that true. Here it is
226/// true because of this: anything that is not already an identifier is looked
227/// up in the tailnet's own listing.
228///
229/// It refuses rather than guesses. Hostnames are not unique — two machines
230/// called `macbook-air` is an ordinary state of affairs — and picking one of
231/// them is how the wrong device gets deleted. A caller given the candidates can
232/// retry unambiguously; a caller given a silent choice cannot.
233pub(crate) async fn resolve(ctx: &ToolContext, given: &str) -> ToolResult<String> {
234 if is_identifier(given) {
235 return Ok(given.trim().to_owned());
236 }
237 let wanted = given.trim().to_ascii_lowercase();
238 let devices = ctx.tailnet_devices().await?;
239 let mut found = devices.iter().filter(|device| device.answers_to(&wanted));
240 let Some(first) = found.next() else {
241 return Err(ToolError::not_found(&format!(
242 "no device in this tailnet is called `{given}`; the name, the hostname, the \
243 MagicDNS name and the addresses were all checked"
244 ))
245 .with_hint("`tailnet_device_list` names every device this credential can see."));
246 };
247 let rest: Vec<&str> = found
248 .map(|device| device.node_id.as_str())
249 .take(4)
250 .collect();
251 if rest.is_empty() {
252 return Ok(first.node_id.clone());
253 }
254 let mut named = vec![first.node_id.as_str()];
255 named.extend(rest);
256 Err(ToolError::invalid_args(format!(
257 "`{given}` names more than one device in this tailnet: {}. Give one of those node \
258 ids instead.",
259 named.join(", ")
260 )))
261}
262
263pub(crate) fn device_path(device: &str, rest: &str) -> ToolResult<String> {
264 let id = device_id("device_id", device)?;
265 Ok(format!("/api/v2/device/{id}{rest}"))
266}
267
268/// A device identifier, checked and trimmed.
269///
270/// Wraps [`path_segment`] under a name that fits both its uses: most of these
271/// identifiers become a path segment, and the batch attribute update's become
272/// keys in a body. The check wanted is the same either way — an identifier and
273/// not something that reshapes what it is put into — and `what` names the
274/// parameter it came from, so a refusal points at the caller's own argument.
275fn device_id(what: &str, device: &str) -> ToolResult<String> {
276 path_segment(what, device)
277}
278
279/// The same, for the one endpoint that also names an attribute.
280fn attribute_path(device: &str, key: &str) -> ToolResult<String> {
281 let key = path_segment("attribute_key", key)?;
282 if !key.starts_with("custom:") {
283 return Err(ToolError::invalid_args(format!(
284 "`attribute_key` has to begin with `custom:`; `{key}` is one of Tailscale's own \
285 attributes, which are read-only"
286 ))
287 .with_hint("Prefix the name with `custom:`, as in `custom:diskEncrypted`."));
288 }
289 device_path(device, &format!("/attributes/{key}"))
290}
291
292// ---------------------------------------------------------------------------
293// Listing
294// ---------------------------------------------------------------------------
295
296/// One filter value, or several for the same field.
297///
298/// The API ANDs repeated parameters — `tags=tag:prod&tags=tag:subnet` selects
299/// devices carrying both — so a map of one value per field could not express
300/// half of what the endpoint offers. Either spelling is accepted because the
301/// single value is much the commoner and `["tag:prod"]` is a worse way to
302/// write it.
303#[derive(Debug, Deserialize, JsonSchema)]
304#[serde(untagged)]
305pub enum Filter {
306 One(String),
307 Several(Vec<String>),
308}
309
310impl Filter {
311 fn values(&self) -> &[String] {
312 match self {
313 Self::One(value) => std::slice::from_ref(value),
314 Self::Several(values) => values,
315 }
316 }
317}
318
319#[derive(Debug, Deserialize, JsonSchema)]
320pub struct DeviceListParams {
321 /// Which fields each device carries: `default` for the common ones, `all`
322 /// to add posture identity and client connectivity. Omit for `default`.
323 #[serde(default)]
324 pub fields: Option<String>,
325 /// Server-side filters, as field name to value, combined with AND — for
326 /// example `{"isEphemeral": "true", "tags": ["tag:prod", "tag:subnet"]}`
327 /// selects ephemeral devices carrying both tags. The field names are
328 /// Tailscale's own, as they appear in a device.
329 #[serde(default)]
330 pub filters: Option<std::collections::BTreeMap<String, Filter>>,
331 /// Return at most this many devices. Applied here rather than by the
332 /// control plane, which has no pagination: the whole listing is fetched
333 /// and this limits what is passed on. It cannot rescue a listing too large
334 /// for the result cap, which is applied to the answer as it arrives.
335 #[serde(default)]
336 pub limit: Option<usize>,
337 /// Skip this many devices before `limit` applies.
338 #[serde(default)]
339 pub offset: Option<usize>,
340}
341
342/// What a windowed listing answers with.
343///
344/// Only a *windowed* one: without `limit` or `offset` the answer is the
345/// control plane's own, forwarded whole (ADR-0004). The counts exist because
346/// the API sends none, so without them a truncated list and a short tailnet
347/// look identical (Q69).
348#[derive(Debug, Serialize)]
349struct DeviceListReport {
350 devices: Vec<Value>,
351 window: Window,
352}
353
354#[derive(Debug, Serialize)]
355struct Window {
356 /// How many the control plane sent, before the window.
357 total: usize,
358 /// How many are in `devices`.
359 returned: usize,
360 offset: usize,
361 /// The limit asked for, so that an empty page is legible: `returned: 0`
362 /// with a limit of zero is one thing and an offset past the end another.
363 #[serde(skip_serializing_if = "Option::is_none")]
364 limit: Option<usize>,
365}
366
367async fn device_list(ctx: &ToolContext, params: DeviceListParams) -> ToolResult<Value> {
368 let client = ctx.tailnet()?;
369 let fields = checked_fields(params.fields.as_deref())?;
370 let mut request = client
371 .get(client.tailnet_path(None, "/devices"))
372 .maybe_query("fields", fields);
373 for (name, filter) in params.filters.iter().flatten() {
374 // The API's filters are `<field>=<value>` pairs alongside `fields`, so
375 // a caller could shadow it by naming it here. That would be a filter
376 // silently changing which fields came back, so it is refused.
377 if name == "fields" {
378 return Err(ToolError::invalid_args(
379 "`fields` is its own parameter and cannot also be a filter",
380 ));
381 }
382 for value in filter.values() {
383 request = request.query(name, value);
384 }
385 }
386 let answer = request.send_as::<Value>().await?;
387
388 // Unwindowed, the answer is the control plane's, forwarded whole. Reading
389 // `devices` out and rebuilding around it would drop any other key the
390 // control plane sent and would report an empty tailnet for an answer whose
391 // shape had changed, which is the opposite of what ADR-0004 asks for.
392 match (params.limit, params.offset.unwrap_or(0)) {
393 (None, 0) => Ok(answer),
394 (limit, offset) => window(answer, limit, offset),
395 }
396}
397
398/// Slice a listing, keeping its shape.
399///
400/// Refuses rather than guesses when the answer is not the shape the window
401/// needs: a body with no `devices` array is one this build does not
402/// understand, and windowing it would mean answering with an empty list.
403fn window(answer: Value, limit: Option<usize>, offset: usize) -> ToolResult<Value> {
404 let Some(all) = answer.get("devices").and_then(Value::as_array) else {
405 return Err(ToolError::new(
406 crate::error::ErrorCode::ApiError,
407 "the control plane answered without a `devices` list, so there is nothing to window",
408 )
409 .with_hint("Call again without `limit` or `offset` to see the answer as it arrived."));
410 };
411 let total = all.len();
412 let taken = all.iter().skip(offset);
413 let devices: Vec<Value> = match limit {
414 Some(limit) => taken.take(limit).cloned().collect(),
415 None => taken.cloned().collect(),
416 };
417 report(DeviceListReport {
418 window: Window {
419 total,
420 returned: devices.len(),
421 offset,
422 limit,
423 },
424 devices,
425 })
426}
427
428/// `fields` is a documented string, so the value is checked against the list
429/// beside the model rather than sent onward to be rejected.
430fn checked_fields(fields: Option<&str>) -> ToolResult<Option<String>> {
431 fields
432 .map(|value| one_of("fields", value, DEVICE_FIELDS))
433 .transpose()
434}
435
436// ---------------------------------------------------------------------------
437// One device
438// ---------------------------------------------------------------------------
439
440/// A device, and nothing else.
441///
442/// Separate from [`DeviceGetParams`] rather than one type with an optional
443/// `fields`, because a schema is a promise: a parameter a tool advertises and
444/// never reads is a caller being told something works when it does nothing.
445#[derive(Debug, Deserialize, JsonSchema)]
446pub struct DeviceParams {
447 /// The device, by its node id (`n1234567CNTRL`, the `nodeId` in a
448 /// listing), its numeric `id`, its MagicDNS name, the short name before
449 /// the first dot of that, its hostname, or one of its addresses. A name
450 /// matching more than one device is refused rather than guessed at.
451 pub device_id: String,
452}
453
454/// A device, for an operation that cuts this node off when the device is ours.
455#[derive(Debug, Deserialize, JsonSchema)]
456pub struct SeveringDeviceParams {
457 /// The device, by its node id (`n1234567CNTRL`, the `nodeId` in a
458 /// listing), its numeric `id`, its MagicDNS name, the short name before
459 /// the first dot of that, its hostname, or one of its addresses. A name
460 /// matching more than one device is refused rather than guessed at.
461 pub device_id: String,
462 #[serde(flatten)]
463 pub confirmation: SelfConfirmation,
464}
465
466/// A device, and which of its fields to read.
467#[derive(Debug, Deserialize, JsonSchema)]
468pub struct DeviceGetParams {
469 /// The device, by its node id (`n1234567CNTRL`, the `nodeId` in a
470 /// listing), its numeric `id`, its MagicDNS name, the short name before
471 /// the first dot of that, its hostname, or one of its addresses. A name
472 /// matching more than one device is refused rather than guessed at.
473 pub device_id: String,
474 /// Which fields to return: `default` for the common ones, `all` to add
475 /// posture identity and client connectivity.
476 #[serde(default)]
477 pub fields: Option<String>,
478}
479
480async fn device_get(ctx: &ToolContext, params: DeviceGetParams) -> ToolResult<Value> {
481 let device = resolve(ctx, ¶ms.device_id).await?;
482 let client = ctx.tailnet()?;
483 let fields = checked_fields(params.fields.as_deref())?;
484 Ok(client
485 .get(device_path(&device, "")?)
486 .maybe_query("fields", fields)
487 .send_as::<Value>()
488 .await?)
489}
490
491async fn device_delete(ctx: &ToolContext, params: SeveringDeviceParams) -> ToolResult<Value> {
492 let device = resolve_not_ourselves(
493 ctx,
494 "removing it from the tailnet",
495 ¶ms.device_id,
496 ¶ms.confirmation,
497 )
498 .await?;
499 let client = ctx.tailnet()?;
500 client.delete(device_path(&device, "")?).send().await?;
501 report(Done::new("deleted").about("device_id", params.device_id))
502}
503
504async fn device_expire(ctx: &ToolContext, params: SeveringDeviceParams) -> ToolResult<Value> {
505 let device = resolve_not_ourselves(
506 ctx,
507 "expiring its node key",
508 ¶ms.device_id,
509 ¶ms.confirmation,
510 )
511 .await?;
512 let client = ctx.tailnet()?;
513 client.post(device_path(&device, "/expire")?).send().await?;
514 report(Done::new("key expired").about("device_id", params.device_id))
515}
516
517// ---------------------------------------------------------------------------
518// Changing a device
519// ---------------------------------------------------------------------------
520
521#[derive(Debug, Deserialize, JsonSchema)]
522pub struct DeviceAuthorizeParams {
523 /// The device, by node id, numeric id, MagicDNS name, short name,
524 /// hostname or address.
525 pub device_id: String,
526 /// `true` to authorise, `false` to revoke an existing authorisation.
527 pub authorized: bool,
528 #[serde(flatten)]
529 pub confirmation: SelfConfirmation,
530}
531
532async fn device_authorize(ctx: &ToolContext, params: DeviceAuthorizeParams) -> ToolResult<Value> {
533 // The inventory classifies this endpoint WRITE and ticket 17 says
534 // "de-authorisation are destructive". Both are right about different
535 // calls, so the row carries the floor and the call decides (Q70) — the
536 // same arrangement the passthrough uses, and what `varying: true` means.
537 if !params.authorized {
538 // The tier first: a session that may not do this at all should hear
539 // that, not be asked to confirm something it would then be refused.
540 require_destructive(ctx, "revoking a device's authorisation")?;
541 // Only revoking can sever: authorising this node changes nothing about
542 // the connection the caller is already using.
543 not_at_ourselves(
544 ctx,
545 "revoking its authorisation",
546 ¶ms.device_id,
547 ¶ms.confirmation,
548 )
549 .await?;
550 }
551 let device = resolve(ctx, ¶ms.device_id).await?;
552 if !params.authorized {
553 neither_names_us(
554 ctx,
555 "revoking its authorisation",
556 ¶ms.device_id,
557 &device,
558 ¶ms.confirmation,
559 )
560 .await?;
561 }
562 let client = ctx.tailnet()?;
563 client
564 .post(device_path(&device, "/authorized")?)
565 .json(&json!({"authorized": params.authorized}))
566 .send()
567 .await?;
568 report(
569 Done::new(if params.authorized {
570 "authorized"
571 } else {
572 "authorization revoked"
573 })
574 .about("device_id", params.device_id),
575 )
576}
577
578#[derive(Debug, Deserialize, JsonSchema)]
579pub struct DeviceRenameParams {
580 /// The device, by node id, numeric id, MagicDNS name, short name,
581 /// hostname or address.
582 pub device_id: String,
583 /// The new name. The control plane derives the MagicDNS name from it.
584 pub name: String,
585 #[serde(flatten)]
586 pub confirmation: SelfConfirmation,
587}
588
589async fn device_rename(ctx: &ToolContext, params: DeviceRenameParams) -> ToolResult<Value> {
590 // A rename does not drop the connection the caller is on, but it does take
591 // away the name it dialled: anything that reconnects by MagicDNS stops
592 // finding this node. That is the same loss as an address change, arriving
593 // one round-trip later (Q88).
594 let device =
595 resolve_not_ourselves(ctx, "renaming it", ¶ms.device_id, ¶ms.confirmation).await?;
596 let client = ctx.tailnet()?;
597 client
598 .post(device_path(&device, "/name")?)
599 .json(&json!({"name": params.name}))
600 .send()
601 .await?;
602 report(Done::new("renamed").about("device_id", params.device_id))
603}
604
605#[derive(Debug, Deserialize, JsonSchema)]
606pub struct DeviceTagsParams {
607 /// The device, by node id, numeric id, MagicDNS name, short name,
608 /// hostname or address.
609 pub device_id: String,
610 /// The complete set of tags, each `tag:` prefixed. An empty list removes
611 /// every tag, which returns the device to its owner's identity.
612 pub tags: Vec<String>,
613 #[serde(flatten)]
614 pub confirmation: SelfConfirmation,
615}
616
617async fn device_tags_set(ctx: &ToolContext, params: DeviceTagsParams) -> ToolResult<Value> {
618 // Retagging replaces the whole set, and a policy rule that granted
619 // this caller its route in through one of the old tags stops applying.
620 let device = resolve_not_ourselves(
621 ctx,
622 "replacing its tags",
623 ¶ms.device_id,
624 ¶ms.confirmation,
625 )
626 .await?;
627 let client = ctx.tailnet()?;
628 client
629 .post(device_path(&device, "/tags")?)
630 .json(&json!({"tags": params.tags}))
631 .send()
632 .await?;
633 report(Done::new("tags set").about("device_id", params.device_id))
634}
635
636#[derive(Debug, Deserialize, JsonSchema)]
637pub struct DeviceKeyExpiryParams {
638 /// The device, by node id, numeric id, MagicDNS name, short name,
639 /// hostname or address.
640 pub device_id: String,
641 /// `true` to stop the device's key expiring, `false` to let it expire on
642 /// the tailnet's schedule again.
643 pub key_expiry_disabled: bool,
644}
645
646async fn device_key_expiry_set(
647 ctx: &ToolContext,
648 params: DeviceKeyExpiryParams,
649) -> ToolResult<Value> {
650 let device = resolve(ctx, ¶ms.device_id).await?;
651 let client = ctx.tailnet()?;
652 client
653 .post(device_path(&device, "/key")?)
654 .json(&json!({"keyExpiryDisabled": params.key_expiry_disabled}))
655 .send()
656 .await?;
657 report(
658 Done::new(if params.key_expiry_disabled {
659 "key expiry disabled"
660 } else {
661 "key expiry enabled"
662 })
663 .about("device_id", params.device_id),
664 )
665}
666
667#[derive(Debug, Deserialize, JsonSchema)]
668pub struct DeviceIpParams {
669 /// The device, by node id, numeric id, MagicDNS name, short name,
670 /// hostname or address.
671 pub device_id: String,
672 /// The new IPv4 address, from the tailnet's own range.
673 pub ipv4: String,
674 #[serde(flatten)]
675 pub confirmation: SelfConfirmation,
676}
677
678async fn device_ip_set(ctx: &ToolContext, params: DeviceIpParams) -> ToolResult<Value> {
679 let device = resolve_not_ourselves(
680 ctx,
681 "moving it to another address",
682 ¶ms.device_id,
683 ¶ms.confirmation,
684 )
685 .await?;
686 let client = ctx.tailnet()?;
687 client
688 .post(device_path(&device, "/ip")?)
689 .json(&json!({"ipv4": params.ipv4}))
690 .send()
691 .await?;
692 report(Done::new("address set").about("device_id", params.device_id))
693}
694
695// ---------------------------------------------------------------------------
696// Routes
697// ---------------------------------------------------------------------------
698
699async fn device_routes_get(ctx: &ToolContext, params: DeviceParams) -> ToolResult<Value> {
700 let device = resolve(ctx, ¶ms.device_id).await?;
701 let client = ctx.tailnet()?;
702 Ok(client
703 .get(device_path(&device, "/routes")?)
704 .send_as::<Value>()
705 .await?)
706}
707
708#[derive(Debug, Deserialize, JsonSchema)]
709pub struct DeviceRoutesParams {
710 /// The device, by node id, numeric id, MagicDNS name, short name,
711 /// hostname or address.
712 pub device_id: String,
713 /// The complete set of enabled routes, as CIDR blocks. An empty list
714 /// disables every route the device advertises.
715 pub routes: Vec<String>,
716 #[serde(flatten)]
717 pub confirmation: SelfConfirmation,
718}
719
720async fn device_routes_set(ctx: &ToolContext, params: DeviceRoutesParams) -> ToolResult<Value> {
721 // A caller reached over a subnet this node advertises loses its route
722 // in when the route stops being enabled.
723 let device = resolve_not_ourselves(
724 ctx,
725 "changing which of its routes are enabled",
726 ¶ms.device_id,
727 ¶ms.confirmation,
728 )
729 .await?;
730 let client = ctx.tailnet()?;
731 // This one answers with the routes it settled on, which is worth more to a
732 // caller than a report that it worked.
733 Ok(client
734 .post(device_path(&device, "/routes")?)
735 .json(&json!({"routes": params.routes}))
736 .send_as::<Value>()
737 .await?)
738}
739
740// ---------------------------------------------------------------------------
741// Posture attributes
742// ---------------------------------------------------------------------------
743
744async fn device_attributes_get(ctx: &ToolContext, params: DeviceParams) -> ToolResult<Value> {
745 let device = resolve(ctx, ¶ms.device_id).await?;
746 let client = ctx.tailnet()?;
747 Ok(client
748 .get(device_path(&device, "/attributes")?)
749 .send_as::<Value>()
750 .await?)
751}
752
753#[derive(Debug, Deserialize, JsonSchema)]
754pub struct AttributeParams {
755 /// The device, by node id, numeric id, MagicDNS name, short name,
756 /// hostname or address.
757 pub device_id: String,
758 /// The attribute's name, which has to begin `custom:`.
759 pub attribute_key: String,
760}
761
762#[derive(Debug, Deserialize, JsonSchema)]
763pub struct AttributeSetParams {
764 /// The device, by node id, numeric id, MagicDNS name, short name,
765 /// hostname or address.
766 pub device_id: String,
767 /// The attribute's name, which has to begin `custom:`.
768 pub attribute_key: String,
769 /// The value: a string, a number or a boolean. The type is fixed by the
770 /// first write to this key.
771 pub value: Value,
772 /// When the control plane should forget the attribute, as RFC 3339. Omit
773 /// for one that does not expire.
774 #[serde(default)]
775 pub expiry: Option<String>,
776 /// A note recorded with the change, which the audit log keeps.
777 #[serde(default)]
778 pub comment: Option<String>,
779}
780
781async fn device_attribute_set(ctx: &ToolContext, params: AttributeSetParams) -> ToolResult<Value> {
782 let device = resolve(ctx, ¶ms.device_id).await?;
783 let client = ctx.tailnet()?;
784 let path = attribute_path(&device, ¶ms.attribute_key)?;
785 let mut body = json!({"value": params.value});
786 if let Some(expiry) = ¶ms.expiry {
787 body["expiry"] = json!(expiry);
788 }
789 if let Some(comment) = ¶ms.comment {
790 body["comment"] = json!(comment);
791 }
792 // The one write among these the description gives a body: a `200` holding
793 // the device's attributes as they now stand. That is worth more to a
794 // caller than a report that it worked, so it is forwarded — but the
795 // control plane also answers this call with nothing at all, and `null` is
796 // not an answer, so an empty body falls back to the report (Q67).
797 let answer = client.post(path).json(&body).send().await?;
798 answered_or(
799 answer,
800 Done::new("attribute set")
801 .about("device_id", params.device_id)
802 .about("attribute_key", params.attribute_key),
803 )
804}
805
806async fn device_attribute_delete(ctx: &ToolContext, params: AttributeParams) -> ToolResult<Value> {
807 let device = resolve(ctx, ¶ms.device_id).await?;
808 let client = ctx.tailnet()?;
809 let path = attribute_path(&device, ¶ms.attribute_key)?;
810 client.delete(path).send().await?;
811 report(
812 Done::new("attribute deleted")
813 .about("device_id", params.device_id)
814 .about("attribute_key", params.attribute_key),
815 )
816}
817
818#[derive(Debug, Deserialize, JsonSchema)]
819pub struct AttributesBatchParams {
820 /// Device id to attribute map. Each attribute name has to begin `custom:`;
821 /// each value is a string, number, boolean, `null` to delete it, or
822 /// `{"value": ..., "expiry": "<RFC 3339>"}`.
823 pub nodes: std::collections::BTreeMap<String, std::collections::BTreeMap<String, Value>>,
824 /// A note recorded with the change, which the audit log keeps.
825 #[serde(default)]
826 pub comment: Option<String>,
827}
828
829async fn device_attributes_update(
830 ctx: &ToolContext,
831 params: AttributesBatchParams,
832) -> ToolResult<Value> {
833 let client = ctx.tailnet()?;
834 if params.nodes.is_empty() {
835 return Err(ToolError::invalid_args(
836 "`nodes` names no devices, so there is nothing to change",
837 ));
838 }
839 // Checked before anything is sent, because the call is all-or-nothing: one
840 // bad key among a hundred devices would fail the whole batch after it had
841 // gone, and the refusal here names the device and the key. The checked
842 // spelling is what is sent, so an id the caller padded is the id it meant.
843 let mut nodes = std::collections::BTreeMap::new();
844 let mut changed = 0usize;
845 for (device, attributes) in ¶ms.nodes {
846 // Named the same way as everywhere else: a caller who may write
847 // `laptop` for one device should not have to write a node id here
848 // because there are several. One listing serves the whole batch.
849 let device = device_id("nodes", &resolve(ctx, device).await?)?;
850 for key in attributes.keys() {
851 if !key.starts_with("custom:") {
852 return Err(ToolError::invalid_args(format!(
853 "`{key}` on `{device}` does not begin with `custom:`; only custom attributes \
854 can be set"
855 )));
856 }
857 }
858 changed += attributes.len();
859 nodes.insert(device, attributes);
860 }
861 let devices = nodes.len();
862 let mut body = json!({"nodes": nodes});
863 if let Some(comment) = ¶ms.comment {
864 body["comment"] = json!(comment);
865 }
866 client
867 .patch(client.tailnet_path(None, "/device-attributes"))
868 .json(&body)
869 .send()
870 .await?;
871 report(
872 Done::new("attributes updated")
873 .about("devices", devices)
874 .about("attributes", changed),
875 )
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881
882 #[test]
883 fn an_identifier_that_would_rewrite_the_path_is_refused() {
884 // The reason `path_segment` exists. Escaping these would send a call
885 // that fails somewhere less legible; refusing says which argument.
886 for bad in [
887 "n123/../tailnet/-/devices",
888 "n123?fields=all",
889 "n123 456",
890 "",
891 ] {
892 let error = device_path(bad, "").expect_err("{bad} should be refused");
893 assert_eq!(
894 serde_json::to_value(&error).expect("reportable")["code"],
895 json!("invalid_args"),
896 "{bad}"
897 );
898 }
899 }
900
901 #[test]
902 fn an_attribute_this_server_may_not_set_is_refused_before_the_call() {
903 let error = attribute_path("n1", "node:os").expect_err("not a custom attribute");
904 let reported = serde_json::to_value(&error).expect("reportable");
905 assert_eq!(reported["code"], json!("invalid_args"));
906 assert!(
907 reported["message"]
908 .as_str()
909 .is_some_and(|m| m.contains("custom:")),
910 "{reported:#?}"
911 );
912 assert_eq!(
913 attribute_path("n1", "custom:diskEncrypted").expect("a custom one"),
914 "/api/v2/device/n1/attributes/custom:diskEncrypted"
915 );
916 }
917}