Skip to main content

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, &params.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        &params.device_id,
496        &params.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        &params.device_id,
509        &params.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            &params.device_id,
547            &params.confirmation,
548        )
549        .await?;
550    }
551    let device = resolve(ctx, &params.device_id).await?;
552    if !params.authorized {
553        neither_names_us(
554            ctx,
555            "revoking its authorisation",
556            &params.device_id,
557            &device,
558            &params.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", &params.device_id, &params.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        &params.device_id,
624        &params.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, &params.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        &params.device_id,
683        &params.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, &params.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        &params.device_id,
727        &params.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, &params.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, &params.device_id).await?;
783    let client = ctx.tailnet()?;
784    let path = attribute_path(&device, &params.attribute_key)?;
785    let mut body = json!({"value": params.value});
786    if let Some(expiry) = &params.expiry {
787        body["expiry"] = json!(expiry);
788    }
789    if let Some(comment) = &params.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, &params.device_id).await?;
808    let client = ctx.tailnet()?;
809    let path = attribute_path(&device, &params.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 &params.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) = &params.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}