use rmcp::schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use tailscale_rest::ApiError;
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::tools::common::{Done, answered_or, path_segment, require_destructive};
crate::tools! {
tailnet_service_list => NoParams, service_list,
toolset: TailnetServices, tier: Read, idempotent: true;
tailnet_service_get => ServiceParams, service_get,
toolset: TailnetServices, tier: Read, idempotent: true;
tailnet_service_replace => ServiceReplaceParams, service_replace,
toolset: TailnetServices, tier: Write, idempotent: true;
tailnet_service_delete => ServiceParams, service_delete,
toolset: TailnetServices, tier: Destructive, idempotent: true;
tailnet_service_devices_list => ServiceParams, service_devices_list,
toolset: TailnetServices, tier: Read, idempotent: true;
tailnet_service_approval_get => ServiceDeviceParams, service_approval_get,
toolset: TailnetServices, tier: Read, idempotent: true;
tailnet_service_approval_set => ServiceApprovalParams, service_approval_set,
toolset: TailnetServices, tier: Write, idempotent: true, varying: true;
}
const DOCUMENTED: &str = "/services";
const GO_CLIENT: &str = "/vip-services";
async fn either_spelling<F, Fut>(send: F) -> ToolResult<Value>
where
F: Fn(&'static str) -> Fut,
Fut: std::future::Future<Output = Result<Value, ApiError>>,
{
match send(DOCUMENTED).await {
Err(error) if error.status() == Some(404) => Ok(send(GO_CLIENT).await?),
other => Ok(other?),
}
}
fn service_path(client: &tailscale_rest::Client, base: &str, name: &str, rest: &str) -> String {
client.tailnet_path(None, &format!("{base}/{name}{rest}"))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NoParams {}
async fn service_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
either_spelling(|base| {
client
.get(client.tailnet_path(None, base))
.send_as::<Value>()
})
.await
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ServiceParams {
pub service_name: String,
}
async fn service_get(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let name = &path_segment("service_name", ¶ms.service_name)?;
either_spelling(|base| {
client
.get(service_path(client, base, name, ""))
.send_as::<Value>()
})
.await
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ServiceReplaceParams {
pub service_name: String,
pub service: Value,
}
async fn service_replace(ctx: &ToolContext, params: ServiceReplaceParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
if !params.service.is_object() {
return Err(ToolError::invalid_args(
"`service` is the service document, an object with `name`, `displayName`, `addrs`, \
`comment`, `ports` and `tags`",
)
.with_hint("Call `tailnet_service_get` and send back what it answered."));
}
let name = &path_segment("service_name", ¶ms.service_name)?;
let body = ¶ms.service;
either_spelling(|base| {
client
.put(service_path(client, base, name, ""))
.json(body)
.send_as::<Value>()
})
.await
}
async fn service_delete(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let name = &path_segment("service_name", ¶ms.service_name)?;
let answer = either_spelling(|base| {
client
.delete(service_path(client, base, name, ""))
.send_as::<Value>()
})
.await?;
answered_or(
answer,
Done::new("service deleted").about("service_name", params.service_name.clone()),
)
}
async fn service_devices_list(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let name = &path_segment("service_name", ¶ms.service_name)?;
either_spelling(|base| {
client
.get(service_path(client, base, name, "/devices"))
.send_as::<Value>()
})
.await
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ServiceDeviceParams {
pub service_name: String,
pub device_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ServiceApprovalParams {
pub service_name: String,
pub device_id: String,
pub approved: bool,
}
fn approval_suffix(device: &str) -> ToolResult<String> {
let device = path_segment("device_id", device)?;
Ok(format!("/device/{device}/approved"))
}
async fn service_approval_get(ctx: &ToolContext, params: ServiceDeviceParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let name = &path_segment("service_name", ¶ms.service_name)?;
let device = crate::tools::tailnet_devices::resolve(ctx, ¶ms.device_id).await?;
let suffix = approval_suffix(&device)?;
either_spelling(|base| {
client
.get(service_path(client, base, name, &suffix))
.send_as::<Value>()
})
.await
}
async fn service_approval_set(
ctx: &ToolContext,
params: ServiceApprovalParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
if !params.approved {
require_destructive(ctx, "withdrawing a device's approval to serve a service")?;
}
let name = &path_segment("service_name", ¶ms.service_name)?;
let device = crate::tools::tailnet_devices::resolve(ctx, ¶ms.device_id).await?;
let suffix = approval_suffix(&device)?;
let body = tailscale_rest::models::service::ServiceApprovalRequest {
approved: Some(params.approved),
unknown: Default::default(),
};
either_spelling(|base| {
client
.post(service_path(client, base, name, &suffix))
.json(&body)
.send_as::<Value>()
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
fn client() -> tailscale_rest::Client {
tailscale_rest::Client::new(tailscale_rest::ClientConfig::new(
tailscale_rest::credentials::Credentials::ApiKey(tailscale_rest::Secret::new(
"tskey-api-nExAmPlE-redacted",
)),
))
.expect("a client with no network behind it")
}
#[test]
fn a_service_name_keeps_its_colon_and_a_dot_segment_is_still_refused() {
let client = client();
let name = &path_segment("service_name", "svc:example").expect("a name");
assert_eq!(
service_path(&client, DOCUMENTED, name, "/devices"),
"/api/v2/tailnet/-/services/svc:example/devices"
);
assert_eq!(
service_path(&client, GO_CLIENT, name, ""),
"/api/v2/tailnet/-/vip-services/svc:example"
);
assert!(path_segment("service_name", "..").is_err());
assert!(approval_suffix("../..").is_err());
}
}