use rmcp::schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use tailscale_rest::models::logging::{AwsExternalIdRequest, AwsTrustPolicyRequest, LOG_TYPES};
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::tools::common::{Done, each_present, path_segment, report};
crate::tools! {
tailnet_audit_log_list => AuditLogParams, audit_log_list,
toolset: TailnetLogging, tier: Read, idempotent: true;
tailnet_network_log_list => NetworkLogParams, network_log_list,
toolset: TailnetLogging, tier: Read, idempotent: true;
tailnet_log_stream_get => LogStreamParams, log_stream_get,
toolset: TailnetLogging, tier: Read, idempotent: true;
tailnet_log_stream_status_get => LogStreamParams, log_stream_status_get,
toolset: TailnetLogging, tier: Read, idempotent: true;
tailnet_log_stream_replace => LogStreamReplaceParams, log_stream_replace,
toolset: TailnetLogging, tier: Write, idempotent: true;
tailnet_log_stream_delete => LogStreamParams, log_stream_delete,
toolset: TailnetLogging, tier: Destructive, idempotent: true;
tailnet_aws_external_id_create => AwsExternalIdParams, aws_external_id_create,
toolset: TailnetLogging, tier: Write, idempotent: false;
tailnet_aws_trust_policy_validate => AwsTrustPolicyParams, aws_trust_policy_validate,
toolset: TailnetLogging, tier: Read, idempotent: true;
}
fn window(start: &str, end: &str) -> ToolResult<()> {
for (name, value) in [("start", start), ("end", end)] {
if value.trim().is_empty() {
return Err(ToolError::invalid_args(format!(
"`{name}` is blank; give an RFC3339 timestamp such as \
`2023-12-19T16:39:57-08:00`"
)));
}
}
Ok(())
}
fn stream_path(log_type: &str, rest: &str) -> ToolResult<String> {
let log_type = path_segment("log_type", log_type)?;
Ok(format!("/logging/{log_type}/stream{rest}"))
}
fn explain_log_type(error: ToolError, log_type: &str) -> ToolError {
if error.status == Some(404) && !LOG_TYPES.contains(&log_type) {
return error.with_hint(format!(
"`{log_type}` is not one of the log types this description knows \
({}); check the spelling.",
LOG_TYPES.join(", ")
));
}
error
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AuditLogParams {
pub start: String,
pub end: String,
#[serde(default)]
pub actor: Option<Vec<String>>,
#[serde(default)]
pub target: Option<Vec<String>>,
#[serde(default)]
pub event: Option<Vec<String>>,
}
async fn audit_log_list(ctx: &ToolContext, params: AuditLogParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
window(¶ms.start, ¶ms.end)?;
let mut request = client
.get(client.tailnet_path(None, "/logging/configuration"))
.query("start", ¶ms.start)
.query("end", ¶ms.end);
for actor in each_present("actor", params.actor.unwrap_or_default())? {
request = request.query("actor", actor);
}
for target in each_present("target", params.target.unwrap_or_default())? {
request = request.query("target", target);
}
for event in each_present("event", params.event.unwrap_or_default())? {
request = request.query("event", event);
}
Ok(request.send_as::<Value>().await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NetworkLogParams {
pub start: String,
pub end: String,
}
async fn network_log_list(ctx: &ToolContext, params: NetworkLogParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
window(¶ms.start, ¶ms.end)?;
Ok(client
.get(client.tailnet_path(None, "/logging/network"))
.query("start", ¶ms.start)
.query("end", ¶ms.end)
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LogStreamParams {
pub log_type: String,
}
async fn log_stream_get(ctx: &ToolContext, params: LogStreamParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
client
.get(client.tailnet_path(None, &stream_path(¶ms.log_type, "")?))
.send_as::<Value>()
.await
.map_err(|error| explain_log_type(ToolError::from(error), ¶ms.log_type))
}
async fn log_stream_status_get(ctx: &ToolContext, params: LogStreamParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
client
.get(client.tailnet_path(None, &stream_path(¶ms.log_type, "/status")?))
.send_as::<Value>()
.await
.map_err(|error| explain_log_type(ToolError::from(error), ¶ms.log_type))
}
async fn log_stream_delete(ctx: &ToolContext, params: LogStreamParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = client.tailnet_path(None, &stream_path(¶ms.log_type, "")?);
client.delete(path).send().await?;
report(Done::new("log streaming disabled").about("log_type", params.log_type))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LogStreamReplaceParams {
pub log_type: String,
pub configuration: Value,
}
async fn log_stream_replace(
ctx: &ToolContext,
params: LogStreamReplaceParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = client.tailnet_path(None, &stream_path(¶ms.log_type, "")?);
let configuration = on_the_wire(params.configuration)?;
Ok(client
.put(path)
.json(&configuration)
.send_as::<Value>()
.await?)
}
fn on_the_wire(configuration: Value) -> ToolResult<Value> {
let Value::Object(mut configuration) = configuration else {
return Err(ToolError::invalid_args(
"`configuration` is an object describing the endpoint, not a list or a string",
));
};
configuration.remove("logType");
Ok(Value::Object(configuration))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AwsExternalIdParams {
#[serde(default)]
pub reusable: Option<bool>,
}
async fn aws_external_id_create(
ctx: &ToolContext,
params: AwsExternalIdParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let mut body = AwsExternalIdRequest::default();
body.reusable = params.reusable;
Ok(client
.post(client.tailnet_path(None, "/aws-external-id"))
.json(&body)
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AwsTrustPolicyParams {
pub external_id: String,
pub role_arn: String,
}
async fn aws_trust_policy_validate(
ctx: &ToolContext,
params: AwsTrustPolicyParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = client.tailnet_path(
None,
&format!(
"/aws-external-id/{}/validate-aws-trust-policy",
path_segment("external_id", ¶ms.external_id)?
),
);
let mut body = AwsTrustPolicyRequest::default();
body.role_arn = Some(params.role_arn.clone());
let answer = client.post(path).json(&body).send_as::<Value>().await?;
crate::tools::common::answered_or(
answer,
Done::new("trust policy accepted").about("role_arn", params.role_arn),
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_window_with_a_blank_end_is_refused_naming_the_end() {
let error = window("2023-12-19T16:39:57-08:00", " ").expect_err("blank");
let reported = serde_json::to_value(&error).expect("reportable");
let message = reported["message"].as_str().expect("a message");
assert!(
message.contains("`end`"),
"should name the parameter: {message}"
);
assert!(
message.contains("2023-12-19"),
"and show the shape it wants: {message}"
);
}
#[test]
fn the_log_type_decides_the_path_and_only_a_path_break_is_refused() {
assert_eq!(
stream_path("network", "/status").expect("known"),
"/logging/network/stream/status"
);
assert_eq!(
stream_path("posture", "").expect("sent anyway"),
"/logging/posture/stream"
);
assert!(stream_path("../acl", "").is_err());
}
#[test]
fn a_log_type_that_is_not_one_of_the_two_is_explained_on_the_way_back() {
let refused =
ToolError::new(crate::error::ErrorCode::ApiError, "not found").with_status(404);
let explained = explain_log_type(refused.clone(), "config");
assert!(
explained
.hint
.as_deref()
.is_some_and(|h| h.contains("configuration")),
"a typo should be told what the two are: {explained:?}"
);
assert_eq!(explain_log_type(refused, "network").hint, None);
}
#[test]
fn a_destination_the_description_forgot_still_reaches_the_control_plane() {
let sent = on_the_wire(json!({
"destinationType": "gcs",
"gcsBucket": "mycompany-mybucket",
"somethingNew": true,
}))
.expect("passed through");
assert_eq!(sent["destinationType"], json!("gcs"));
assert_eq!(sent["somethingNew"], json!(true));
assert!(on_the_wire(json!(["not", "a", "document"])).is_err());
}
#[test]
fn the_log_type_is_not_repeated_into_the_body() {
let sent = on_the_wire(json!({"logType": "network", "url": "https://example.com"}))
.expect("valid");
assert_eq!(sent.get("logType"), None);
assert_eq!(sent["url"], json!("https://example.com"));
}
}