use rmcp::schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tailscale_rest::models::device::DEVICE_FIELDS;
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::tools::common::{
Done, SelfConfirmation, answered_or, not_at_ourselves, one_of, path_segment, report,
require_destructive,
};
crate::tools! {
tailnet_device_list => DeviceListParams, device_list,
toolset: TailnetDevices, tier: Read, idempotent: true;
tailnet_device_get => DeviceGetParams, device_get,
toolset: TailnetDevices, tier: Read, idempotent: true;
tailnet_device_delete => SeveringDeviceParams, device_delete,
toolset: TailnetDevices, tier: Destructive, idempotent: true, severs_local: true;
tailnet_device_expire => SeveringDeviceParams, device_expire,
toolset: TailnetDevices, tier: Destructive, severs_local: true;
tailnet_device_authorize => DeviceAuthorizeParams, device_authorize,
toolset: TailnetDevices, tier: Write, idempotent: true, severs_local: true,
varying: true;
tailnet_device_rename => DeviceRenameParams, device_rename,
toolset: TailnetDevices, tier: Write, idempotent: true, severs_local: true;
tailnet_device_tags_set => DeviceTagsParams, device_tags_set,
toolset: TailnetDevices, tier: Write, idempotent: true, severs_local: true;
tailnet_device_key_expiry_set => DeviceKeyExpiryParams, device_key_expiry_set,
toolset: TailnetDevices, tier: Write, idempotent: true;
tailnet_device_ip_set => DeviceIpParams, device_ip_set,
toolset: TailnetDevices, tier: Write, idempotent: true, severs_local: true;
tailnet_device_routes_get => DeviceParams, device_routes_get,
toolset: TailnetDevices, tier: Read, idempotent: true;
tailnet_device_routes_set => DeviceRoutesParams, device_routes_set,
toolset: TailnetDevices, tier: Write, idempotent: true, severs_local: true;
tailnet_device_attributes_get => DeviceParams, device_attributes_get,
toolset: TailnetDevices, tier: Read, idempotent: true;
tailnet_device_attribute_set => AttributeSetParams, device_attribute_set,
toolset: TailnetDevices, tier: Write, idempotent: true;
tailnet_device_attribute_delete => AttributeParams, device_attribute_delete,
toolset: TailnetDevices, tier: Destructive, idempotent: true;
tailnet_device_attributes_update => AttributesBatchParams, device_attributes_update,
toolset: TailnetDevices, tier: Write, idempotent: true;
}
async fn resolve_not_ourselves(
ctx: &ToolContext,
what: &str,
given: &str,
confirmation: &crate::tools::common::SelfConfirmation,
) -> ToolResult<String> {
not_at_ourselves(ctx, what, given, confirmation).await?;
let device = resolve(ctx, given).await?;
if device != given.trim() {
not_at_ourselves(ctx, what, &device, confirmation).await?;
}
Ok(device)
}
async fn neither_names_us(
ctx: &ToolContext,
what: &str,
given: &str,
resolved: &str,
confirmation: &crate::tools::common::SelfConfirmation,
) -> ToolResult<()> {
not_at_ourselves(ctx, what, given, confirmation).await?;
if resolved != given.trim() {
not_at_ourselves(ctx, what, resolved, confirmation).await?;
}
Ok(())
}
#[must_use]
pub(crate) fn is_identifier(value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return false;
}
let numeric = value.bytes().all(|byte| byte.is_ascii_digit());
let node_id = value.len() > "nCNTRL".len()
&& value.starts_with('n')
&& value.ends_with("CNTRL")
&& value[1..value.len() - "CNTRL".len()]
.bytes()
.all(|byte| byte.is_ascii_alphanumeric());
numeric || node_id
}
pub(crate) async fn resolve(ctx: &ToolContext, given: &str) -> ToolResult<String> {
if is_identifier(given) {
return Ok(given.trim().to_owned());
}
let wanted = given.trim().to_ascii_lowercase();
let devices = ctx.tailnet_devices().await?;
let mut found = devices.iter().filter(|device| device.answers_to(&wanted));
let Some(first) = found.next() else {
return Err(ToolError::not_found(&format!(
"no device in this tailnet is called `{given}`; the name, the hostname, the \
MagicDNS name and the addresses were all checked"
))
.with_hint("`tailnet_device_list` names every device this credential can see."));
};
let rest: Vec<&str> = found
.map(|device| device.node_id.as_str())
.take(4)
.collect();
if rest.is_empty() {
return Ok(first.node_id.clone());
}
let mut named = vec![first.node_id.as_str()];
named.extend(rest);
Err(ToolError::invalid_args(format!(
"`{given}` names more than one device in this tailnet: {}. Give one of those node \
ids instead.",
named.join(", ")
)))
}
pub(crate) fn device_path(device: &str, rest: &str) -> ToolResult<String> {
let id = device_id("device_id", device)?;
Ok(format!("/api/v2/device/{id}{rest}"))
}
fn device_id(what: &str, device: &str) -> ToolResult<String> {
path_segment(what, device)
}
fn attribute_path(device: &str, key: &str) -> ToolResult<String> {
let key = path_segment("attribute_key", key)?;
if !key.starts_with("custom:") {
return Err(ToolError::invalid_args(format!(
"`attribute_key` has to begin with `custom:`; `{key}` is one of Tailscale's own \
attributes, which are read-only"
))
.with_hint("Prefix the name with `custom:`, as in `custom:diskEncrypted`."));
}
device_path(device, &format!("/attributes/{key}"))
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Filter {
One(String),
Several(Vec<String>),
}
impl Filter {
fn values(&self) -> &[String] {
match self {
Self::One(value) => std::slice::from_ref(value),
Self::Several(values) => values,
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeviceListParams {
#[serde(default)]
pub fields: Option<String>,
#[serde(default)]
pub filters: Option<std::collections::BTreeMap<String, Filter>>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub offset: Option<usize>,
}
#[derive(Debug, Serialize)]
struct DeviceListReport {
devices: Vec<Value>,
window: Window,
}
#[derive(Debug, Serialize)]
struct Window {
total: usize,
returned: usize,
offset: usize,
#[serde(skip_serializing_if = "Option::is_none")]
limit: Option<usize>,
}
async fn device_list(ctx: &ToolContext, params: DeviceListParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let fields = checked_fields(params.fields.as_deref())?;
let mut request = client
.get(client.tailnet_path(None, "/devices"))
.maybe_query("fields", fields);
for (name, filter) in params.filters.iter().flatten() {
if name == "fields" {
return Err(ToolError::invalid_args(
"`fields` is its own parameter and cannot also be a filter",
));
}
for value in filter.values() {
request = request.query(name, value);
}
}
let answer = request.send_as::<Value>().await?;
match (params.limit, params.offset.unwrap_or(0)) {
(None, 0) => Ok(answer),
(limit, offset) => window(answer, limit, offset),
}
}
fn window(answer: Value, limit: Option<usize>, offset: usize) -> ToolResult<Value> {
let Some(all) = answer.get("devices").and_then(Value::as_array) else {
return Err(ToolError::new(
crate::error::ErrorCode::ApiError,
"the control plane answered without a `devices` list, so there is nothing to window",
)
.with_hint("Call again without `limit` or `offset` to see the answer as it arrived."));
};
let total = all.len();
let taken = all.iter().skip(offset);
let devices: Vec<Value> = match limit {
Some(limit) => taken.take(limit).cloned().collect(),
None => taken.cloned().collect(),
};
report(DeviceListReport {
window: Window {
total,
returned: devices.len(),
offset,
limit,
},
devices,
})
}
fn checked_fields(fields: Option<&str>) -> ToolResult<Option<String>> {
fields
.map(|value| one_of("fields", value, DEVICE_FIELDS))
.transpose()
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeviceParams {
pub device_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SeveringDeviceParams {
pub device_id: String,
#[serde(flatten)]
pub confirmation: SelfConfirmation,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeviceGetParams {
pub device_id: String,
#[serde(default)]
pub fields: Option<String>,
}
async fn device_get(ctx: &ToolContext, params: DeviceGetParams) -> ToolResult<Value> {
let device = resolve(ctx, ¶ms.device_id).await?;
let client = ctx.tailnet()?;
let fields = checked_fields(params.fields.as_deref())?;
Ok(client
.get(device_path(&device, "")?)
.maybe_query("fields", fields)
.send_as::<Value>()
.await?)
}
async fn device_delete(ctx: &ToolContext, params: SeveringDeviceParams) -> ToolResult<Value> {
let device = resolve_not_ourselves(
ctx,
"removing it from the tailnet",
¶ms.device_id,
¶ms.confirmation,
)
.await?;
let client = ctx.tailnet()?;
client.delete(device_path(&device, "")?).send().await?;
report(Done::new("deleted").about("device_id", params.device_id))
}
async fn device_expire(ctx: &ToolContext, params: SeveringDeviceParams) -> ToolResult<Value> {
let device = resolve_not_ourselves(
ctx,
"expiring its node key",
¶ms.device_id,
¶ms.confirmation,
)
.await?;
let client = ctx.tailnet()?;
client.post(device_path(&device, "/expire")?).send().await?;
report(Done::new("key expired").about("device_id", params.device_id))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeviceAuthorizeParams {
pub device_id: String,
pub authorized: bool,
#[serde(flatten)]
pub confirmation: SelfConfirmation,
}
async fn device_authorize(ctx: &ToolContext, params: DeviceAuthorizeParams) -> ToolResult<Value> {
if !params.authorized {
require_destructive(ctx, "revoking a device's authorisation")?;
not_at_ourselves(
ctx,
"revoking its authorisation",
¶ms.device_id,
¶ms.confirmation,
)
.await?;
}
let device = resolve(ctx, ¶ms.device_id).await?;
if !params.authorized {
neither_names_us(
ctx,
"revoking its authorisation",
¶ms.device_id,
&device,
¶ms.confirmation,
)
.await?;
}
let client = ctx.tailnet()?;
client
.post(device_path(&device, "/authorized")?)
.json(&json!({"authorized": params.authorized}))
.send()
.await?;
report(
Done::new(if params.authorized {
"authorized"
} else {
"authorization revoked"
})
.about("device_id", params.device_id),
)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeviceRenameParams {
pub device_id: String,
pub name: String,
#[serde(flatten)]
pub confirmation: SelfConfirmation,
}
async fn device_rename(ctx: &ToolContext, params: DeviceRenameParams) -> ToolResult<Value> {
let device =
resolve_not_ourselves(ctx, "renaming it", ¶ms.device_id, ¶ms.confirmation).await?;
let client = ctx.tailnet()?;
client
.post(device_path(&device, "/name")?)
.json(&json!({"name": params.name}))
.send()
.await?;
report(Done::new("renamed").about("device_id", params.device_id))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeviceTagsParams {
pub device_id: String,
pub tags: Vec<String>,
#[serde(flatten)]
pub confirmation: SelfConfirmation,
}
async fn device_tags_set(ctx: &ToolContext, params: DeviceTagsParams) -> ToolResult<Value> {
let device = resolve_not_ourselves(
ctx,
"replacing its tags",
¶ms.device_id,
¶ms.confirmation,
)
.await?;
let client = ctx.tailnet()?;
client
.post(device_path(&device, "/tags")?)
.json(&json!({"tags": params.tags}))
.send()
.await?;
report(Done::new("tags set").about("device_id", params.device_id))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeviceKeyExpiryParams {
pub device_id: String,
pub key_expiry_disabled: bool,
}
async fn device_key_expiry_set(
ctx: &ToolContext,
params: DeviceKeyExpiryParams,
) -> ToolResult<Value> {
let device = resolve(ctx, ¶ms.device_id).await?;
let client = ctx.tailnet()?;
client
.post(device_path(&device, "/key")?)
.json(&json!({"keyExpiryDisabled": params.key_expiry_disabled}))
.send()
.await?;
report(
Done::new(if params.key_expiry_disabled {
"key expiry disabled"
} else {
"key expiry enabled"
})
.about("device_id", params.device_id),
)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeviceIpParams {
pub device_id: String,
pub ipv4: String,
#[serde(flatten)]
pub confirmation: SelfConfirmation,
}
async fn device_ip_set(ctx: &ToolContext, params: DeviceIpParams) -> ToolResult<Value> {
let device = resolve_not_ourselves(
ctx,
"moving it to another address",
¶ms.device_id,
¶ms.confirmation,
)
.await?;
let client = ctx.tailnet()?;
client
.post(device_path(&device, "/ip")?)
.json(&json!({"ipv4": params.ipv4}))
.send()
.await?;
report(Done::new("address set").about("device_id", params.device_id))
}
async fn device_routes_get(ctx: &ToolContext, params: DeviceParams) -> ToolResult<Value> {
let device = resolve(ctx, ¶ms.device_id).await?;
let client = ctx.tailnet()?;
Ok(client
.get(device_path(&device, "/routes")?)
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeviceRoutesParams {
pub device_id: String,
pub routes: Vec<String>,
#[serde(flatten)]
pub confirmation: SelfConfirmation,
}
async fn device_routes_set(ctx: &ToolContext, params: DeviceRoutesParams) -> ToolResult<Value> {
let device = resolve_not_ourselves(
ctx,
"changing which of its routes are enabled",
¶ms.device_id,
¶ms.confirmation,
)
.await?;
let client = ctx.tailnet()?;
Ok(client
.post(device_path(&device, "/routes")?)
.json(&json!({"routes": params.routes}))
.send_as::<Value>()
.await?)
}
async fn device_attributes_get(ctx: &ToolContext, params: DeviceParams) -> ToolResult<Value> {
let device = resolve(ctx, ¶ms.device_id).await?;
let client = ctx.tailnet()?;
Ok(client
.get(device_path(&device, "/attributes")?)
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AttributeParams {
pub device_id: String,
pub attribute_key: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AttributeSetParams {
pub device_id: String,
pub attribute_key: String,
pub value: Value,
#[serde(default)]
pub expiry: Option<String>,
#[serde(default)]
pub comment: Option<String>,
}
async fn device_attribute_set(ctx: &ToolContext, params: AttributeSetParams) -> ToolResult<Value> {
let device = resolve(ctx, ¶ms.device_id).await?;
let client = ctx.tailnet()?;
let path = attribute_path(&device, ¶ms.attribute_key)?;
let mut body = json!({"value": params.value});
if let Some(expiry) = ¶ms.expiry {
body["expiry"] = json!(expiry);
}
if let Some(comment) = ¶ms.comment {
body["comment"] = json!(comment);
}
let answer = client.post(path).json(&body).send().await?;
answered_or(
answer,
Done::new("attribute set")
.about("device_id", params.device_id)
.about("attribute_key", params.attribute_key),
)
}
async fn device_attribute_delete(ctx: &ToolContext, params: AttributeParams) -> ToolResult<Value> {
let device = resolve(ctx, ¶ms.device_id).await?;
let client = ctx.tailnet()?;
let path = attribute_path(&device, ¶ms.attribute_key)?;
client.delete(path).send().await?;
report(
Done::new("attribute deleted")
.about("device_id", params.device_id)
.about("attribute_key", params.attribute_key),
)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AttributesBatchParams {
pub nodes: std::collections::BTreeMap<String, std::collections::BTreeMap<String, Value>>,
#[serde(default)]
pub comment: Option<String>,
}
async fn device_attributes_update(
ctx: &ToolContext,
params: AttributesBatchParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
if params.nodes.is_empty() {
return Err(ToolError::invalid_args(
"`nodes` names no devices, so there is nothing to change",
));
}
let mut nodes = std::collections::BTreeMap::new();
let mut changed = 0usize;
for (device, attributes) in ¶ms.nodes {
let device = device_id("nodes", &resolve(ctx, device).await?)?;
for key in attributes.keys() {
if !key.starts_with("custom:") {
return Err(ToolError::invalid_args(format!(
"`{key}` on `{device}` does not begin with `custom:`; only custom attributes \
can be set"
)));
}
}
changed += attributes.len();
nodes.insert(device, attributes);
}
let devices = nodes.len();
let mut body = json!({"nodes": nodes});
if let Some(comment) = ¶ms.comment {
body["comment"] = json!(comment);
}
client
.patch(client.tailnet_path(None, "/device-attributes"))
.json(&body)
.send()
.await?;
report(
Done::new("attributes updated")
.about("devices", devices)
.about("attributes", changed),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_device_is_addressed_globally_rather_than_through_a_tailnet() {
assert_eq!(
device_path("n1234567CNTRL", "/routes").expect("a valid id"),
"/api/v2/device/n1234567CNTRL/routes"
);
assert_eq!(
device_path("123456789", "").expect("the numeric form is valid too"),
"/api/v2/device/123456789"
);
}
#[test]
fn an_identifier_that_would_rewrite_the_path_is_refused() {
for bad in [
"n123/../tailnet/-/devices",
"n123?fields=all",
"n123 456",
"",
] {
let error = device_path(bad, "").expect_err("{bad} should be refused");
assert_eq!(
serde_json::to_value(&error).expect("reportable")["code"],
json!("invalid_args"),
"{bad}"
);
}
}
#[test]
fn an_attribute_this_server_may_not_set_is_refused_before_the_call() {
let error = attribute_path("n1", "node:os").expect_err("not a custom attribute");
let reported = serde_json::to_value(&error).expect("reportable");
assert_eq!(reported["code"], json!("invalid_args"));
assert!(
reported["message"]
.as_str()
.is_some_and(|m| m.contains("custom:")),
"{reported:#?}"
);
assert_eq!(
attribute_path("n1", "custom:diskEncrypted").expect("a custom one"),
"/api/v2/device/n1/attributes/custom:diskEncrypted"
);
}
#[test]
fn the_field_selection_is_checked_against_the_list_beside_the_model() {
assert_eq!(checked_fields(None).expect("absent is fine"), None);
assert_eq!(
checked_fields(Some("all"))
.expect("a known value")
.as_deref(),
Some("all")
);
let error = checked_fields(Some("everything")).expect_err("not a known value");
assert!(
serde_json::to_value(&error).expect("reportable")["message"]
.as_str()
.is_some_and(|m| m.contains("all") && m.contains("default")),
"the refusal should quote the values: {error:?}"
);
}
}