Skip to main content

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(&params.start, &params.end)?;
159    let mut request = client
160        .get(client.tailnet_path(None, "/logging/configuration"))
161        .query("start", &params.start)
162        .query("end", &params.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(&params.start, &params.end)?;
192    Ok(client
193        .get(client.tailnet_path(None, "/logging/network"))
194        .query("start", &params.start)
195        .query("end", &params.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(&params.log_type, "")?))
210        .send_as::<Value>()
211        .await
212        .map_err(|error| explain_log_type(ToolError::from(error), &params.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(&params.log_type, "/status")?))
219        .send_as::<Value>()
220        .await
221        .map_err(|error| explain_log_type(ToolError::from(error), &params.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(&params.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(&params.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", &params.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}