Skip to main content

tailscale_mcp/tools/
tailnet_webhooks.rs

1//! Where Tailscale posts events, and what it posts about.
2//!
3//! **The secret is the point of the endpoint.** A webhook's secret signs the
4//! `Tailscale-Webhook-Signature` header, which is how a receiver tells a real
5//! delivery from a forged one. It comes back twice in an endpoint's life: when
6//! it is created and when it is rotated, and never on a read. Both answers are
7//! forwarded whole, and `tests/minted_secrets.rs` holds them to the same rule
8//! as a minted key.
9//!
10//! **Rotation is destructive.** The old secret stops verifying the moment the
11//! new one exists, so every receiver checking signatures rejects every delivery
12//! until it has been given the new one. That is a break in an existing
13//! integration rather than a new thing being made, which is why it sits at the
14//! destructive tier alongside deleting the endpoint.
15//!
16//! **A test delivery is a write with no state change.** It queues a real
17//! `test` event at the endpoint — the control plane answers 202, accepted —
18//! so it changes nothing here and does something out there.
19
20use rmcp::schemars::JsonSchema;
21use serde::Deserialize;
22use serde_json::Value;
23
24use tailscale_rest::models::webhook::{CreateWebhookRequest, UpdateWebhookRequest};
25
26use crate::context::ToolContext;
27use crate::error::{ToolError, ToolResult};
28use crate::tools::common::{Done, answered_or, each_present, path_segment, report};
29
30crate::tools! {
31    /// List the tailnet's webhook endpoints. No secret is included — a
32    /// webhook's secret comes back only when it is created or rotated.
33    tailnet_webhook_list => NoParams, webhook_list,
34        toolset: TailnetWebhooks, tier: Read, idempotent: true;
35
36    /// Create a webhook endpoint.
37    ///
38    /// **The signing secret is in the answer and nowhere else.** It signs the
39    /// `Tailscale-Webhook-Signature` header; a receiver that checks signatures
40    /// needs it, and there is no way to read it again — only to rotate it,
41    /// which breaks the old one.
42    ///
43    /// `provider_type` shapes the payload for a receiver that expects its own
44    /// format. Omit it for Tailscale's own shape.
45    tailnet_webhook_create => WebhookCreateParams, webhook_create,
46        toolset: TailnetWebhooks, tier: Write;
47
48    /// Read one webhook endpoint by its id.
49    tailnet_webhook_get => WebhookParams, webhook_get,
50        toolset: TailnetWebhooks, tier: Read, idempotent: true;
51
52    /// Replace which events an endpoint is sent.
53    ///
54    /// The whole list, not an addition: an event not in `subscriptions` stops
55    /// being delivered. The endpoint's URL and provider cannot be changed —
56    /// delete it and create another.
57    tailnet_webhook_subscriptions_replace => WebhookSubscriptionsParams, webhook_subscriptions_replace,
58        toolset: TailnetWebhooks, tier: Write, idempotent: true;
59
60    /// Delete a webhook endpoint. Deliveries stop at once.
61    tailnet_webhook_delete => WebhookParams, webhook_delete,
62        toolset: TailnetWebhooks, tier: Destructive, idempotent: true;
63
64    /// Queue a `test` event at the endpoint, to check it is reachable.
65    ///
66    /// Changes nothing in the tailnet and does send a real delivery. The
67    /// control plane accepts it and delivers asynchronously, so a success here
68    /// means queued rather than received.
69    tailnet_webhook_test => WebhookParams, webhook_test,
70        toolset: TailnetWebhooks, tier: Write;
71
72    /// Replace an endpoint's signing secret, and answer with the new one.
73    ///
74    /// **The old secret stops verifying immediately.** Every receiver checking
75    /// signatures rejects every delivery until it has the new secret, which is
76    /// in this answer and nowhere else.
77    tailnet_webhook_secret_rotate => WebhookParams, webhook_secret_rotate,
78        toolset: TailnetWebhooks, tier: Destructive;
79}
80
81fn webhook_path(id: &str, rest: &str) -> ToolResult<String> {
82    let id = path_segment("endpoint_id", id)?;
83    Ok(format!("/api/v2/webhooks/{id}{rest}"))
84}
85
86/// The events an endpoint may ask for, each held to the description's list.
87///
88/// Checked as a set rather than one at a time so that a caller naming three
89/// events with one typo is told which one, and refused before any of them is
90/// subscribed.
91fn checked_subscriptions(subscriptions: &[String]) -> ToolResult<Vec<String>> {
92    if subscriptions.is_empty() {
93        return Err(ToolError::invalid_args(
94            "`subscriptions` is empty; an endpoint with no events is one nothing is ever sent to",
95        ));
96    }
97    // Each event is present but not held to `SUBSCRIPTIONS`: the catalogue
98    // grows whenever a feature does, and the live API already carries category
99    // subscriptions the vendored description has not caught up with, so a copy
100    // of the list here would refuse an event that exists (Q84). The values are
101    // in the parameter's description, and the control plane refuses one it
102    // does not know.
103    each_present("subscriptions", subscriptions.to_vec())
104}
105
106#[derive(Debug, Deserialize, JsonSchema)]
107pub struct NoParams {}
108
109#[derive(Debug, Deserialize, JsonSchema)]
110pub struct WebhookParams {
111    /// The endpoint's id, as a listing reports it.
112    pub endpoint_id: String,
113}
114
115async fn webhook_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
116    let client = ctx.tailnet()?;
117    Ok(client
118        .get(client.tailnet_path(None, "/webhooks"))
119        .send_as::<Value>()
120        .await?)
121}
122
123async fn webhook_get(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
124    let client = ctx.tailnet()?;
125    Ok(client
126        .get(webhook_path(&params.endpoint_id, "")?)
127        .send_as::<Value>()
128        .await?)
129}
130
131#[derive(Debug, Deserialize, JsonSchema)]
132pub struct WebhookCreateParams {
133    /// Where deliveries are posted. Must be reachable from the internet.
134    pub endpoint_url: String,
135    /// `slack`, `mattermost`, `googlechat` or `discord`, to shape the payload
136    /// for that service. Omit for Tailscale's own shape.
137    #[serde(default)]
138    pub provider_type: Option<String>,
139    /// Which events to send, as `nodeCreated`, `userSuspended` and the rest.
140    pub subscriptions: Vec<String>,
141}
142
143async fn webhook_create(ctx: &ToolContext, params: WebhookCreateParams) -> ToolResult<Value> {
144    let client = ctx.tailnet()?;
145    let endpoint_url = params.endpoint_url.trim();
146    if endpoint_url.is_empty() {
147        return Err(ToolError::invalid_args(
148            "`endpoint_url` is empty; a webhook needs somewhere to post to",
149        ));
150    }
151    // The model, not a struct beside it: the drift test holds this shape to
152    // the description, and a second hand-written one is a shape it does not
153    // guard.
154    let mut body = CreateWebhookRequest::default();
155    body.endpoint_url = Some(endpoint_url.to_owned());
156    // Not held to `PROVIDER_TYPES` either: it names four chat products,
157    // which is a market rather than a specification (Q60, Q84).
158    body.provider_type = params.provider_type.clone();
159    body.subscriptions = Some(checked_subscriptions(&params.subscriptions)?);
160    Ok(client
161        .post(client.tailnet_path(None, "/webhooks"))
162        .json(&body)
163        .send_as::<Value>()
164        .await?)
165}
166
167#[derive(Debug, Deserialize, JsonSchema)]
168pub struct WebhookSubscriptionsParams {
169    /// The endpoint's id, as a listing reports it.
170    pub endpoint_id: String,
171    /// The complete list of events to send. An event not named here stops
172    /// being delivered.
173    pub subscriptions: Vec<String>,
174}
175
176async fn webhook_subscriptions_replace(
177    ctx: &ToolContext,
178    params: WebhookSubscriptionsParams,
179) -> ToolResult<Value> {
180    let client = ctx.tailnet()?;
181    let path = webhook_path(&params.endpoint_id, "")?;
182    let mut body = UpdateWebhookRequest::default();
183    body.subscriptions = Some(checked_subscriptions(&params.subscriptions)?);
184    Ok(client.patch(path).json(&body).send_as::<Value>().await?)
185}
186
187async fn webhook_delete(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
188    let client = ctx.tailnet()?;
189    client
190        .delete(webhook_path(&params.endpoint_id, "")?)
191        .send()
192        .await?;
193    report(Done::new("webhook deleted").about("endpoint_id", params.endpoint_id))
194}
195
196async fn webhook_test(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
197    let client = ctx.tailnet()?;
198    let answer = client
199        .post(webhook_path(&params.endpoint_id, "/test")?)
200        .send()
201        .await?;
202    answered_or(
203        answer,
204        // "Queued", not "delivered": the control plane answers 202 and sends
205        // the event afterwards, so a success here says nothing about whether
206        // the endpoint answered.
207        Done::new("test event queued for delivery").about("endpoint_id", params.endpoint_id),
208    )
209}
210
211async fn webhook_secret_rotate(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
212    let client = ctx.tailnet()?;
213    // Forwarded whole: the new secret is in here and nowhere else.
214    Ok(client
215        .post(webhook_path(&params.endpoint_id, "/rotate")?)
216        .send_as::<Value>()
217        .await?)
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn an_event_the_description_does_not_have_is_still_subscribed_to() {
226        let good = ["nodeCreated".to_owned(), "userDeleted".to_owned()];
227        assert_eq!(checked_subscriptions(&good).expect("known events"), good);
228
229        // The live API already carries category subscriptions the vendored
230        // description has not caught up with, so a list copied from it would
231        // refuse an event that exists (Q84).
232        let newer = ["categoryTailnetManagement".to_owned()];
233        assert_eq!(checked_subscriptions(&newer).expect("sent anyway"), newer);
234
235        // A blank entry is still a caller that meant to send nothing there.
236        let error = checked_subscriptions(&["nodeCreated".to_owned(), "  ".to_owned()])
237            .expect_err("one is blank");
238        let reported = serde_json::to_value(&error).expect("reportable");
239        assert_eq!(reported["code"], serde_json::json!("invalid_args"));
240    }
241
242    #[test]
243    fn an_endpoint_with_no_events_is_refused_rather_than_created() {
244        // The control plane accepts it and it is an endpoint nothing is ever
245        // sent to, which reads as a webhook that does not work.
246        assert!(checked_subscriptions(&[]).is_err());
247    }
248}