From bbf53147c21ebda1b233568e62515e7527f34c7c Mon Sep 17 00:00:00 2001 From: Percy Wegmann Date: Sat, 3 Aug 2024 14:14:25 -0500 Subject: [PATCH] v2: added WebhooksResource Updates tailscale/corp#21867 Co-authored-by: Mario Minardi Signed-off-by: Percy Wegmann --- v2/client.go | 28 +++++-- v2/devices.go | 2 +- v2/webhooks.go | 152 +++++++++++++++++++++++++++++++++++++ v2/webhooks_test.go | 180 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 v2/webhooks.go create mode 100644 v2/webhooks_test.go diff --git a/v2/client.go b/v2/client.go index 6c05507..d33b92b 100644 --- a/v2/client.go +++ b/v2/client.go @@ -38,7 +38,8 @@ type ( initOnce sync.Once // Specific resources - devices *DevicesResource + devices *DevicesResource + webhooks *WebhooksResource } // APIError type describes an error as returned by the Tailscale API. @@ -91,6 +92,7 @@ func (c *Client) init() { c.http = &http.Client{Timeout: defaultHttpClientTimeout} } c.devices = &DevicesResource{c} + c.webhooks = &WebhooksResource{c} }) } @@ -113,6 +115,11 @@ func (c *Client) Devices() *DevicesResource { return c.devices } +func (c *Client) Webhooks() *WebhooksResource { + c.init() + return c.webhooks +} + type requestParams struct { headers map[string]string body any @@ -139,18 +146,29 @@ func requestContentType(ct string) requestOption { } } -// buildURL builds a url to /api/v2/... using the given pathElements. It -// url escapes each path element, so the caller doesn't need to worry about -// that. +// buildURL builds a url to /api/v2/... using the given pathElements. +// It url escapes each path element, so the caller doesn't need to worry about that. func (c *Client) buildURL(pathElements ...any) *url.URL { elem := make([]string, 1, len(pathElements)+1) elem[0] = "/api/v2" for _, pathElement := range pathElements { - elem = append(elem, fmt.Sprint(pathElement)) + elem = append(elem, url.PathEscape(fmt.Sprint(pathElement))) } return c.BaseURL.JoinPath(elem...) } +// buildTailnetURL builds a url to /api/v2/tailnet//... using the given pathElements. +// It url escapes each path element, so the caller doesn't need to worry about that. +func (c *Client) buildTailnetURL(pathElements ...any) *url.URL { + allElements := make([]any, 2, len(pathElements)+2) + allElements[0] = "tailnet" + allElements[1] = url.PathEscape(c.Tailnet) + for _, element := range pathElements { + allElements = append(allElements, element) + } + return c.buildURL(allElements...) +} + func (c *Client) buildRequest(ctx context.Context, method string, uri *url.URL, opts ...requestOption) (*http.Request, error) { rof := &requestParams{ contentType: defaultContentType, diff --git a/v2/devices.go b/v2/devices.go index d97b7f4..d6848d3 100644 --- a/v2/devices.go +++ b/v2/devices.go @@ -76,7 +76,7 @@ func (dr *DevicesResource) Get(ctx context.Context, deviceID string) (*Device, e // List lists the devices in a tailnet. func (dr *DevicesResource) List(ctx context.Context) ([]Device, error) { - req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildURL("tailnet", dr.Tailnet, "devices")) + req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildTailnetURL("devices")) if err != nil { return nil, err } diff --git a/v2/webhooks.go b/v2/webhooks.go new file mode 100644 index 0000000..9dee1cd --- /dev/null +++ b/v2/webhooks.go @@ -0,0 +1,152 @@ +package tailscale + +import ( + "context" + "net/http" + "time" +) + +const ( + WebhookEmptyProviderType WebhookProviderType = "" + WebhookSlackProviderType WebhookProviderType = "slack" + WebhookMattermostProviderType WebhookProviderType = "mattermost" + WebhookGoogleChatProviderType WebhookProviderType = "googlechat" + WebhookDiscordProviderType WebhookProviderType = "discord" +) + +const ( + WebhookNodeCreated WebhookSubscriptionType = "nodeCreated" + WebhookNodeNeedsApproval WebhookSubscriptionType = "nodeNeedsApproval" + WebhookNodeApproved WebhookSubscriptionType = "nodeApproved" + WebhookNodeKeyExpiringInOneDay WebhookSubscriptionType = "nodeKeyExpiringInOneDay" + WebhookNodeKeyExpired WebhookSubscriptionType = "nodeKeyExpired" + WebhookNodeDeleted WebhookSubscriptionType = "nodeDeleted" + WebhookPolicyUpdate WebhookSubscriptionType = "policyUpdate" + WebhookUserCreated WebhookSubscriptionType = "userCreated" + WebhookUserNeedsApproval WebhookSubscriptionType = "userNeedsApproval" + WebhookUserSuspended WebhookSubscriptionType = "userSuspended" + WebhookUserRestored WebhookSubscriptionType = "userRestored" + WebhookUserDeleted WebhookSubscriptionType = "userDeleted" + WebhookUserApproved WebhookSubscriptionType = "userApproved" + WebhookUserRoleUpdated WebhookSubscriptionType = "userRoleUpdated" + WebhookSubnetIPForwardingNotEnabled WebhookSubscriptionType = "subnetIPForwardingNotEnabled" + WebhookExitNodeIPForwardingNotEnabled WebhookSubscriptionType = "exitNodeIPForwardingNotEnabled" +) + +type ( + // WebhookProviderType defines the provider type for a Webhook destination. + WebhookProviderType string + + // WebhookSubscriptionType defines events in tailscale to subscribe a Webhook to. + WebhookSubscriptionType string + + // Webhook type defines a webhook endpoint within a tailnet. + Webhook struct { + EndpointID string `json:"endpointId"` + EndpointURL string `json:"endpointUrl"` + ProviderType WebhookProviderType `json:"providerType"` + CreatorLoginName string `json:"creatorLoginName"` + Created time.Time `json:"created"` + LastModified time.Time `json:"lastModified"` + Subscriptions []WebhookSubscriptionType `json:"subscriptions"` + // Secret is only populated on Webhook creation and after secret rotation. + Secret *string `json:"secret,omitempty"` + } + + // CreateWebhookRequest type describes the configuration for creating a Webhook. + CreateWebhookRequest struct { + EndpointURL string `json:"endpointUrl"` + ProviderType WebhookProviderType `json:"providerType"` + Subscriptions []WebhookSubscriptionType `json:"subscriptions"` + } +) + +type WebhooksResource struct { + *Client +} + +// Create creates a new webhook with the specifications provided in the CreateWebhookRequest. +// Returns a Webhook if successful. +func (wr *WebhooksResource) Create(ctx context.Context, request CreateWebhookRequest) (*Webhook, error) { + req, err := wr.buildRequest(ctx, http.MethodPost, wr.buildTailnetURL("webhooks"), requestBody(request)) + if err != nil { + return nil, err + } + + var webhook Webhook + return &webhook, wr.do(req, &webhook) +} + +// List lists the webhooks in a tailnet. +func (wr *WebhooksResource) List(ctx context.Context) ([]Webhook, error) { + req, err := wr.buildRequest(ctx, http.MethodGet, wr.buildTailnetURL("webhooks")) + if err != nil { + return nil, err + } + + resp := make(map[string][]Webhook) + if err = wr.do(req, &resp); err != nil { + return nil, err + } + + return resp["webhooks"], nil +} + +// Get retrieves a specific webhook. +func (wr *WebhooksResource) Get(ctx context.Context, endpointID string) (*Webhook, error) { + req, err := wr.buildRequest(ctx, http.MethodGet, wr.buildURL("webhooks", endpointID)) + if err != nil { + return nil, err + } + + var webhook Webhook + return &webhook, wr.do(req, &webhook) +} + +// Update updates an existing webhook's subscriptions. +// Returns a Webhook on success. +func (wr *WebhooksResource) Update(ctx context.Context, endpointID string, subscriptions []WebhookSubscriptionType) (*Webhook, error) { + req, err := wr.buildRequest(ctx, http.MethodPatch, wr.buildURL("webhooks", endpointID), requestBody(map[string][]WebhookSubscriptionType{ + "subscriptions": subscriptions, + })) + if err != nil { + return nil, err + } + + var webhook Webhook + return &webhook, wr.do(req, &webhook) +} + +// Delete deletes a specific webhook. +func (wr *WebhooksResource) Delete(ctx context.Context, endpointID string) error { + req, err := wr.buildRequest(ctx, http.MethodDelete, wr.buildURL("webhooks", endpointID)) + if err != nil { + return err + } + + return wr.do(req, nil) +} + +// Test queues a test event to be sent to a specific webhook. +// Sending the test event is an asynchronous operation which will +// typically happen a few seconds after using this method. +func (wr *WebhooksResource) Test(ctx context.Context, endpointID string) error { + req, err := wr.buildRequest(ctx, http.MethodPost, wr.buildURL("webhooks", endpointID, "test")) + if err != nil { + return err + } + + return wr.do(req, nil) +} + +// RotateSecret rotates the secret associated with a webhook. +// A new secret will be generated and set on the returned Webhook. +func (wr *WebhooksResource) RotateSecret(ctx context.Context, endpointID string) (*Webhook, error) { + req, err := wr.buildRequest(ctx, http.MethodPost, wr.buildURL("webhooks", endpointID, "rotate")) + if err != nil { + return nil, err + } + + var webhook Webhook + return &webhook, wr.do(req, &webhook) +} diff --git a/v2/webhooks_test.go b/v2/webhooks_test.go new file mode 100644 index 0000000..227c719 --- /dev/null +++ b/v2/webhooks_test.go @@ -0,0 +1,180 @@ +package tailscale_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/tailscale/tailscale-client-go/v2" +) + +func TestClient_CreateWebhook(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + server.ResponseCode = http.StatusOK + + req := tailscale.CreateWebhookRequest{ + EndpointURL: "https://example.com/my/endpoint", + ProviderType: tailscale.WebhookDiscordProviderType, + Subscriptions: []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeCreated, tailscale.WebhookNodeApproved}, + } + + expectedSecret := "my-secret" + expectedWebhook := &tailscale.Webhook{ + EndpointID: "12345", + EndpointURL: req.EndpointURL, + ProviderType: req.ProviderType, + CreatorLoginName: "pretend@example.com", + Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + Subscriptions: req.Subscriptions, + Secret: &expectedSecret, + } + server.ResponseBody = expectedWebhook + + webhook, err := client.Webhooks().Create(context.Background(), req) + assert.NoError(t, err) + assert.Equal(t, http.MethodPost, server.Method) + assert.Equal(t, "/api/v2/tailnet/example.com/webhooks", server.Path) + assert.Equal(t, expectedWebhook, webhook) +} + +func TestClient_Webhooks(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + server.ResponseCode = http.StatusOK + + expectedWebhooks := map[string][]tailscale.Webhook{ + "webhooks": { + { + EndpointID: "12345", + EndpointURL: "https://example.com/my/endpoint", + ProviderType: "", + CreatorLoginName: "pretend@example.com", + Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + Subscriptions: []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeCreated, tailscale.WebhookNodeApproved}, + }, + { + EndpointID: "54321", + EndpointURL: "https://example.com/my/endpoint/other", + ProviderType: "slack", + CreatorLoginName: "pretend2@example.com", + Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + Subscriptions: []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeApproved}, + }, + }, + } + server.ResponseBody = expectedWebhooks + + actualWebhooks, err := client.Webhooks().List(context.Background()) + assert.NoError(t, err) + assert.Equal(t, http.MethodGet, server.Method) + assert.Equal(t, "/api/v2/tailnet/example.com/webhooks", server.Path) + assert.Equal(t, expectedWebhooks["webhooks"], actualWebhooks) +} + +func TestClient_Webhook(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + server.ResponseCode = http.StatusOK + + expectedWebhook := &tailscale.Webhook{ + EndpointID: "54321", + EndpointURL: "https://example.com/my/endpoint/other", + ProviderType: "slack", + CreatorLoginName: "pretend2@example.com", + Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + Subscriptions: []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeApproved}, + } + server.ResponseBody = expectedWebhook + + actualWebhook, err := client.Webhooks().Get(context.Background(), "54321") + assert.NoError(t, err) + assert.Equal(t, http.MethodGet, server.Method) + assert.Equal(t, "/api/v2/webhooks/54321", server.Path) + assert.Equal(t, expectedWebhook, actualWebhook) +} + +func TestClient_UpdateWebhook(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + server.ResponseCode = http.StatusOK + + subscriptions := []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeCreated, tailscale.WebhookNodeApproved, tailscale.WebhookNodeNeedsApproval} + + expectedWebhook := &tailscale.Webhook{ + EndpointID: "54321", + EndpointURL: "https://example.com/my/endpoint/other", + ProviderType: "slack", + CreatorLoginName: "pretend2@example.com", + Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + Subscriptions: subscriptions, + } + server.ResponseBody = expectedWebhook + + actualWebhook, err := client.Webhooks().Update(context.Background(), "54321", subscriptions) + assert.NoError(t, err) + assert.Equal(t, http.MethodPatch, server.Method) + assert.Equal(t, "/api/v2/webhooks/54321", server.Path) + assert.Equal(t, expectedWebhook, actualWebhook) +} + +func TestClient_DeleteWebhook(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + server.ResponseCode = http.StatusOK + + err := client.Webhooks().Delete(context.Background(), "54321") + assert.NoError(t, err) + assert.Equal(t, http.MethodDelete, server.Method) + assert.Equal(t, "/api/v2/webhooks/54321", server.Path) +} + +func TestClient_TestWebhook(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + server.ResponseCode = http.StatusAccepted + + err := client.Webhooks().Test(context.Background(), "54321") + assert.NoError(t, err) + assert.Equal(t, http.MethodPost, server.Method) + assert.Equal(t, "/api/v2/webhooks/54321/test", server.Path) +} + +func TestClient_RotateWebhookSecret(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + server.ResponseCode = http.StatusOK + + expectedSecret := "my-new-secret" + expectedWebhook := &tailscale.Webhook{ + EndpointID: "54321", + EndpointURL: "https://example.com/my/endpoint/other", + ProviderType: "slack", + CreatorLoginName: "pretend2@example.com", + Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), + Subscriptions: []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeApproved}, + Secret: &expectedSecret, + } + server.ResponseBody = expectedWebhook + + actualWebhook, err := client.Webhooks().RotateSecret(context.Background(), "54321") + assert.NoError(t, err) + assert.Equal(t, http.MethodPost, server.Method) + assert.Equal(t, "/api/v2/webhooks/54321/rotate", server.Path) + assert.Equal(t, expectedWebhook, actualWebhook) +}