tailscale_mcp/tools/tailnet_logging.rs
1//! Reading the tailnet's two logs, and streaming either of them somewhere else.
2//!
3//! **Two logs, one shape of question.** Configuration audit logs record who
4//! changed what; network flow logs record which node talked to which. Both are
5//! read over a window that the API requires both ends of, and neither
6//! paginates: a window is the only way to bound the answer, which is why
7//! `start` and `end` are required here rather than defaulted to something this
8//! server chose.
9//!
10//! **Streaming is per log type.** `configuration` and `network` each have
11//! their own destination, their own status and their own delete, all under
12//! `logType` in the path. The configuration a read returns is not the one a
13//! write sends: `token`, `s3SecretAccessKey` and `gcsCredentials` are
14//! write-only, so an endpoint read back is missing exactly the fields that
15//! authenticate it. The tool descriptions say so, because a caller who reads
16//! the configuration, edits it and writes it back would otherwise silently
17//! erase the credential.
18//!
19//! **The AWS pair is for one destination.** Streaming to S3 with role-based
20//! authentication needs an external id, which Tailscale mints and the operator
21//! writes into an IAM trust policy; the validate call is how they check they
22//! got it right before wiring the stream up. Both are on this surface because
23//! they exist only to serve `destination_type: "s3"`.
24
25use rmcp::schemars::JsonSchema;
26use serde::Deserialize;
27use serde_json::Value;
28use tailscale_rest::models::logging::{AwsExternalIdRequest, AwsTrustPolicyRequest, LOG_TYPES};
29
30use crate::context::ToolContext;
31use crate::error::{ToolError, ToolResult};
32use crate::tools::common::{Done, each_present, path_segment, report};
33
34crate::tools! {
35 /// Read the configuration audit log over a window: who changed what, when
36 /// and from where.
37 ///
38 /// `start` and `end` are RFC3339 timestamps and both are required — the
39 /// endpoint does not paginate, so the window is what bounds the answer.
40 /// The optional filters are ANDed across kinds and ORed within one: give
41 /// two `event` values to see either.
42 tailnet_audit_log_list => AuditLogParams, audit_log_list,
43 toolset: TailnetLogging, tier: Read, idempotent: true;
44
45 /// Read network flow logs over a window: which node reached which, over
46 /// what protocol, and how much went each way.
47 ///
48 /// Requires network flow logging to be switched on for the tailnet —
49 /// `tailnet_settings_get` reports whether it is. `start` and `end` are
50 /// RFC3339 timestamps and both are required.
51 tailnet_network_log_list => NetworkLogParams, network_log_list,
52 toolset: TailnetLogging, tier: Read, idempotent: true;
53
54 /// Read where a log type is being streamed to.
55 ///
56 /// The credential fields are write-only and never come back: `token`,
57 /// `s3_secret_access_key` and `gcs_credentials` are absent from this
58 /// answer even when they are configured. Do not read this, edit it and
59 /// write it back — the write would clear them.
60 tailnet_log_stream_get => LogStreamParams, log_stream_get,
61 toolset: TailnetLogging, tier: Read, idempotent: true;
62
63 /// Read whether a log stream is actually being delivered: last activity,
64 /// last error, and the rates and counts behind them.
65 tailnet_log_stream_status_get => LogStreamParams, log_stream_status_get,
66 toolset: TailnetLogging, tier: Read, idempotent: true;
67
68 /// Replace where a log type is streamed to.
69 ///
70 /// The whole endpoint, not a merge: a field this does not carry is gone. Send
71 /// the credential — `token`, or `s3_secret_access_key`, or
72 /// `gcs_credentials` — every time, because a read never returns it and a
73 /// write without it removes it.
74 tailnet_log_stream_replace => LogStreamReplaceParams, log_stream_replace,
75 toolset: TailnetLogging, tier: Write, idempotent: true;
76
77 /// Stop streaming a log type. The logs are still recorded and still
78 /// readable here; only the delivery stops.
79 tailnet_log_stream_delete => LogStreamParams, log_stream_delete,
80 toolset: TailnetLogging, tier: Destructive, idempotent: true;
81
82 /// Mint an AWS external id for role-based S3 log streaming.
83 ///
84 /// The answer carries the id and the Tailscale AWS account id that will
85 /// assume the role; both go into the IAM trust policy. `reusable: true`
86 /// returns the same id to later reusable calls until it is linked to an
87 /// account, which is how a caller that may retry avoids stranding ids.
88 tailnet_aws_external_id_create => AwsExternalIdParams, aws_external_id_create,
89 toolset: TailnetLogging, tier: Write, idempotent: false;
90
91 /// Check that an IAM role's trust policy actually lets Tailscale assume it
92 /// with a given external id. Changes nothing; run it before configuring
93 /// the stream.
94 tailnet_aws_trust_policy_validate => AwsTrustPolicyParams, aws_trust_policy_validate,
95 toolset: TailnetLogging, tier: Read, idempotent: true;
96}
97
98/// Both log readings need the same window, and the API requires both ends.
99fn window(start: &str, end: &str) -> ToolResult<()> {
100 for (name, value) in [("start", start), ("end", end)] {
101 if value.trim().is_empty() {
102 return Err(ToolError::invalid_args(format!(
103 "`{name}` is blank; give an RFC3339 timestamp such as \
104 `2023-12-19T16:39:57-08:00`"
105 )));
106 }
107 }
108 Ok(())
109}
110
111/// The tailnet-relative path for a log type's stream, or its status.
112fn stream_path(log_type: &str, rest: &str) -> ToolResult<String> {
113 let log_type = path_segment("log_type", log_type)?;
114 Ok(format!("/logging/{log_type}/stream{rest}"))
115}
116
117/// Name the two log types the description knows, on a refusal that looks like
118/// a typo for one of them.
119///
120/// The list is not checked before the call (Q84): it is one of the enums Q60
121/// found open, and a log type Tailscale adds should work here on the day it
122/// exists rather than be refused by a copy of last year's list. What a caller
123/// who wrote `config` needs is for the 404 to say so, which is what this does
124/// — the same shape as the credential hint in `tailnet_invites`.
125fn explain_log_type(error: ToolError, log_type: &str) -> ToolError {
126 if error.status == Some(404) && !LOG_TYPES.contains(&log_type) {
127 return error.with_hint(format!(
128 "`{log_type}` is not one of the log types this description knows \
129 ({}); check the spelling.",
130 LOG_TYPES.join(", ")
131 ));
132 }
133 error
134}
135
136#[derive(Debug, Deserialize, JsonSchema)]
137pub struct AuditLogParams {
138 /// RFC3339, inclusive: `2023-12-19T16:39:57-08:00`.
139 pub start: String,
140 /// RFC3339, inclusive.
141 pub end: String,
142 /// Only entries by these actors. A user id or a `~`-prefixed login name.
143 #[serde(default)]
144 pub actor: Option<Vec<String>>,
145 /// Only entries about these targets.
146 #[serde(default)]
147 pub target: Option<Vec<String>>,
148 /// Only these events, spelled as the log spells them:
149 /// `NODE.UPDATE.ACL_TAGS`, `TAILNET.CREATE.TKA` and so on. The vocabulary
150 /// is `<TARGET>.<ACTION>` or `<TARGET>.<ACTION>.<PROPERTY>`, and the
151 /// control plane refuses one it does not know.
152 #[serde(default)]
153 pub event: Option<Vec<String>>,
154}
155
156async fn audit_log_list(ctx: &ToolContext, params: AuditLogParams) -> ToolResult<Value> {
157 let client = ctx.tailnet()?;
158 window(¶ms.start, ¶ms.end)?;
159 let mut request = client
160 .get(client.tailnet_path(None, "/logging/configuration"))
161 .query("start", ¶ms.start)
162 .query("end", ¶ms.end);
163 // Repeated rather than joined: the API reads one query parameter per
164 // value, and a comma-joined list would be one actor whose name has commas
165 // in it.
166 for actor in each_present("actor", params.actor.unwrap_or_default())? {
167 request = request.query("actor", actor);
168 }
169 for target in each_present("target", params.target.unwrap_or_default())? {
170 request = request.query("target", target);
171 }
172 // Not checked against `AUDIT_EVENTS`: the catalogue grows whenever a
173 // feature does (Q65), so a copy of it here would refuse an event that
174 // exists (Q84). The values are in the parameter's description instead.
175 for event in each_present("event", params.event.unwrap_or_default())? {
176 request = request.query("event", event);
177 }
178 Ok(request.send_as::<Value>().await?)
179}
180
181#[derive(Debug, Deserialize, JsonSchema)]
182pub struct NetworkLogParams {
183 /// RFC3339, inclusive.
184 pub start: String,
185 /// RFC3339, inclusive.
186 pub end: String,
187}
188
189async fn network_log_list(ctx: &ToolContext, params: NetworkLogParams) -> ToolResult<Value> {
190 let client = ctx.tailnet()?;
191 window(¶ms.start, ¶ms.end)?;
192 Ok(client
193 .get(client.tailnet_path(None, "/logging/network"))
194 .query("start", ¶ms.start)
195 .query("end", ¶ms.end)
196 .send_as::<Value>()
197 .await?)
198}
199
200#[derive(Debug, Deserialize, JsonSchema)]
201pub struct LogStreamParams {
202 /// `configuration` or `network`.
203 pub log_type: String,
204}
205
206async fn log_stream_get(ctx: &ToolContext, params: LogStreamParams) -> ToolResult<Value> {
207 let client = ctx.tailnet()?;
208 client
209 .get(client.tailnet_path(None, &stream_path(¶ms.log_type, "")?))
210 .send_as::<Value>()
211 .await
212 .map_err(|error| explain_log_type(ToolError::from(error), ¶ms.log_type))
213}
214
215async fn log_stream_status_get(ctx: &ToolContext, params: LogStreamParams) -> ToolResult<Value> {
216 let client = ctx.tailnet()?;
217 client
218 .get(client.tailnet_path(None, &stream_path(¶ms.log_type, "/status")?))
219 .send_as::<Value>()
220 .await
221 .map_err(|error| explain_log_type(ToolError::from(error), ¶ms.log_type))
222}
223
224async fn log_stream_delete(ctx: &ToolContext, params: LogStreamParams) -> ToolResult<Value> {
225 let client = ctx.tailnet()?;
226 let path = client.tailnet_path(None, &stream_path(¶ms.log_type, "")?);
227 client.delete(path).send().await?;
228 report(Done::new("log streaming disabled").about("log_type", params.log_type))
229}
230
231/// The endpoint to stream to.
232///
233/// Passed through as the caller wrote it rather than reassembled from named
234/// parameters (ADR-0004). Nineteen fields, most conditional on
235/// `destinationType`, and the conditions are the control plane's to enforce:
236/// a struct here would have to encode which of them are required for S3 with
237/// `rolearn` versus S3 with `accesskey` versus Splunk, and would be wrong the
238/// day a destination is added.
239#[derive(Debug, Deserialize, JsonSchema)]
240pub struct LogStreamReplaceParams {
241 /// `configuration` or `network`.
242 pub log_type: String,
243 /// The whole endpoint, in Tailscale's own field names:
244 /// `destinationType`, `url`, `user`, `token`, `uploadPeriodMinutes`,
245 /// `compressionFormat`, and for S3 `s3Bucket`, `s3Region`, `s3KeyPrefix`,
246 /// `s3AuthenticationType`, `s3AccessKeyId`, `s3SecretAccessKey`,
247 /// `s3RoleArn` and `s3ExternalId`, or for GCS `gcsBucket`, `gcsKeyPrefix`,
248 /// `gcsScopes` and `gcsCredentials`.
249 pub configuration: Value,
250}
251
252async fn log_stream_replace(
253 ctx: &ToolContext,
254 params: LogStreamReplaceParams,
255) -> ToolResult<Value> {
256 let client = ctx.tailnet()?;
257 let path = client.tailnet_path(None, &stream_path(¶ms.log_type, "")?);
258 let configuration = on_the_wire(params.configuration)?;
259 Ok(client
260 .put(path)
261 .json(&configuration)
262 .send_as::<Value>()
263 .await?)
264}
265
266/// The stream configuration as it goes on the wire.
267///
268/// Almost nothing happens here, deliberately. Which fields a destination
269/// requires, whether a URL is reachable, whether `destinationType` is a
270/// system Tailscale streams to — all of it is the control plane's, and it
271/// says so better than a guess would. The description's own `destinationType`
272/// enum is proof of why: it lists eight systems and no `gcs`, while
273/// `gcsBucket` in the same document says it is "Required if the destinationType
274/// is `gcs`". A list copied from it would refuse a configuration the API
275/// accepts (Q84).
276///
277/// The one thing removed is `logType`: it is read-only in the description and
278/// is in the path already, so a body carrying it says the same thing twice and
279/// can say it differently.
280fn on_the_wire(configuration: Value) -> ToolResult<Value> {
281 let Value::Object(mut configuration) = configuration else {
282 return Err(ToolError::invalid_args(
283 "`configuration` is an object describing the endpoint, not a list or a string",
284 ));
285 };
286 configuration.remove("logType");
287 Ok(Value::Object(configuration))
288}
289
290#[derive(Debug, Deserialize, JsonSchema)]
291pub struct AwsExternalIdParams {
292 /// Ask for an id that later reusable calls get back too, until it is
293 /// linked to an AWS account. Leave it off for a fresh id each time.
294 #[serde(default)]
295 pub reusable: Option<bool>,
296}
297
298async fn aws_external_id_create(
299 ctx: &ToolContext,
300 params: AwsExternalIdParams,
301) -> ToolResult<Value> {
302 let client = ctx.tailnet()?;
303 // The model rather than a map built beside it: the drift test holds this
304 // shape to the description, and a hand-written body is a second shape it
305 // does not guard.
306 let mut body = AwsExternalIdRequest::default();
307 body.reusable = params.reusable;
308 Ok(client
309 .post(client.tailnet_path(None, "/aws-external-id"))
310 .json(&body)
311 .send_as::<Value>()
312 .await?)
313}
314
315#[derive(Debug, Deserialize, JsonSchema)]
316pub struct AwsTrustPolicyParams {
317 /// The external id to validate, as `tailnet_aws_external_id_create`
318 /// returned it.
319 pub external_id: String,
320 /// The IAM role Tailscale should be able to assume, as an ARN:
321 /// `arn:aws:iam::000000000000:role/tailscale-log-writer`.
322 pub role_arn: String,
323}
324
325async fn aws_trust_policy_validate(
326 ctx: &ToolContext,
327 params: AwsTrustPolicyParams,
328) -> ToolResult<Value> {
329 let client = ctx.tailnet()?;
330 let path = client.tailnet_path(
331 None,
332 &format!(
333 "/aws-external-id/{}/validate-aws-trust-policy",
334 path_segment("external_id", ¶ms.external_id)?
335 ),
336 );
337 let mut body = AwsTrustPolicyRequest::default();
338 body.role_arn = Some(params.role_arn.clone());
339 let answer = client.post(path).json(&body).send_as::<Value>().await?;
340 // A pass is an empty body, and `null` reads as a tool that lost its
341 // answer rather than as a policy that checks out (Q67).
342 crate::tools::common::answered_or(
343 answer,
344 Done::new("trust policy accepted").about("role_arn", params.role_arn),
345 )
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use serde_json::json;
352
353 #[test]
354 fn a_window_with_a_blank_end_is_refused_naming_the_end() {
355 let error = window("2023-12-19T16:39:57-08:00", " ").expect_err("blank");
356 let reported = serde_json::to_value(&error).expect("reportable");
357 let message = reported["message"].as_str().expect("a message");
358 assert!(
359 message.contains("`end`"),
360 "should name the parameter: {message}"
361 );
362 assert!(
363 message.contains("2023-12-19"),
364 "and show the shape it wants: {message}"
365 );
366 }
367
368 #[test]
369 fn the_log_type_decides_the_path_and_only_a_path_break_is_refused() {
370 assert_eq!(
371 stream_path("network", "/status").expect("known"),
372 "/logging/network/stream/status"
373 );
374 // A log type the description does not list still reaches the control
375 // plane, which is the point (Q84); only a segment that would rewrite
376 // the path is refused.
377 assert_eq!(
378 stream_path("posture", "").expect("sent anyway"),
379 "/logging/posture/stream"
380 );
381 assert!(stream_path("../acl", "").is_err());
382 }
383
384 #[test]
385 fn a_log_type_that_is_not_one_of_the_two_is_explained_on_the_way_back() {
386 let refused =
387 ToolError::new(crate::error::ErrorCode::ApiError, "not found").with_status(404);
388 let explained = explain_log_type(refused.clone(), "config");
389 assert!(
390 explained
391 .hint
392 .as_deref()
393 .is_some_and(|h| h.contains("configuration")),
394 "a typo should be told what the two are: {explained:?}"
395 );
396 // A real log type that 404s is a missing stream, not a typo.
397 assert_eq!(explain_log_type(refused, "network").hint, None);
398 }
399
400 #[test]
401 fn a_destination_the_description_forgot_still_reaches_the_control_plane() {
402 // `gcs` is required by `gcsBucket`'s own description and missing from
403 // the `destinationType` enum, so a list copied from it would refuse a
404 // configuration the API accepts (Q84).
405 let sent = on_the_wire(json!({
406 "destinationType": "gcs",
407 "gcsBucket": "mycompany-mybucket",
408 "somethingNew": true,
409 }))
410 .expect("passed through");
411 assert_eq!(sent["destinationType"], json!("gcs"));
412 assert_eq!(sent["somethingNew"], json!(true));
413 assert!(on_the_wire(json!(["not", "a", "document"])).is_err());
414 }
415
416 #[test]
417 fn the_log_type_is_not_repeated_into_the_body() {
418 // It is read-only upstream and already in the path; sent in both
419 // places it could disagree with itself.
420 let sent = on_the_wire(json!({"logType": "network", "url": "https://example.com"}))
421 .expect("valid");
422 assert_eq!(sent.get("logType"), None);
423 assert_eq!(sent["url"], json!("https://example.com"));
424 }
425}