diff --git a/tailscale/client.go b/tailscale/client.go index 2a2f5f6..9c31164 100644 --- a/tailscale/client.go +++ b/tailscale/client.go @@ -937,6 +937,188 @@ func (c *Client) SetDeviceIPv4Address(ctx context.Context, deviceID string, ipv4 return c.performRequest(req, nil) } +const ( + EmptyProviderType ProviderType = "" + SlackProviderType ProviderType = "slack" + MattermostProviderType ProviderType = "mattermost" + GoogleChatProviderType ProviderType = "googlechat" + DiscordProviderType ProviderType = "discord" +) + +// ProviderTypes is a convenience map from provider type name to ProviderType. +var ProviderTypes = map[string]ProviderType{ + "slack": SlackProviderType, + "mattermost": MattermostProviderType, + "googlechat": GoogleChatProviderType, + "discord": DiscordProviderType, +} + +const ( + NodeCreated SubscriptionType = "nodeCreated" + NodeNeedsApproval SubscriptionType = "nodeNeedsApproval" + NodeApproved SubscriptionType = "nodeApproved" + NodeKeyExpiringInOneDay SubscriptionType = "nodeKeyExpiringInOneDay" + NodeKeyExpired SubscriptionType = "nodeKeyExpired" + NodeDeleted SubscriptionType = "nodeDeleted" + PolicyUpdate SubscriptionType = "policyUpdate" + UserCreated SubscriptionType = "userCreated" + UserNeedsApproval SubscriptionType = "userNeedsApproval" + UserSuspended SubscriptionType = "userSuspended" + UserRestored SubscriptionType = "userRestored" + UserDeleted SubscriptionType = "userDeleted" + UserApproved SubscriptionType = "userApproved" + UserRoleUpdated SubscriptionType = "userRoleUpdated" + SubnetIPForwardingNotEnabled SubscriptionType = "subnetIPForwardingNotEnabled" + ExitNodeIPForwardingNotEnabled SubscriptionType = "exitNodeIPForwardingNotEnabled" +) + +// SubscriptionTypes is a convenience map from subscription type name to SubscriptionType. +var SubscriptionTypes = map[string]SubscriptionType{ + "nodeCreated": NodeCreated, + "nodeNeedsApproval": NodeNeedsApproval, + "nodeApproved": NodeApproved, + "nodeKeyExpiringInOneDay": NodeKeyExpiringInOneDay, + "nodeKeyExpired": NodeKeyExpired, + "nodeDeleted": NodeDeleted, + "policyUpdate": PolicyUpdate, + "userCreated": UserCreated, + "userNeedsApproval": UserNeedsApproval, + "userSuspended": UserSuspended, + "userRestored": UserRestored, + "userDeleted": UserDeleted, + "userApproved": UserApproved, + "userRoleUpdated": UserRoleUpdated, + "subnetIPForwardingNotEnabled": SubnetIPForwardingNotEnabled, + "exitNodeIPForwardingNotEnabled": ExitNodeIPForwardingNotEnabled, +} + +type ( + // ProviderType defines the provider type for a Webhook destination. + ProviderType string + + // SubscriptionType defines events in tailscale to subscribe a Webhook to. + SubscriptionType string + + // Webhook type defines a webhook endpoint within a tailnet. + Webhook struct { + EndpointID string `json:"endpointId"` + EndpointURL string `json:"endpointUrl"` + ProviderType ProviderType `json:"providerType"` + CreatorLoginName string `json:"creatorLoginName"` + Created time.Time `json:"created"` + LastModified time.Time `json:"lastModified"` + Subscriptions []SubscriptionType `json:"subscriptions"` + Secret string `json:"secret,omitempty"` + } + + // CreateWebhookRequest type describes the configuration for creating a Webhook. + CreateWebhookRequest struct { + EndpointURL string `json:"endpointUrl"` + ProviderType ProviderType `json:"providerType"` + Subscriptions []SubscriptionType `json:"subscriptions"` + } +) + +// CreateWebhook creates a new webhook with the specifications provided in the CreateWebhookRequest. +// Returns a Webhook if successful. +func (c *Client) CreateWebhook(ctx context.Context, request CreateWebhookRequest) (Webhook, error) { + const uriFmt = "/api/v2/tailnet/%s/webhooks" + + req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), requestBody(request)) + if err != nil { + return Webhook{}, err + } + + var webhook Webhook + return webhook, c.performRequest(req, &webhook) +} + +// Webhooks lists the webhooks in a tailnet. +func (c *Client) Webhooks(ctx context.Context) ([]Webhook, error) { + const uriFmt = "/api/v2/tailnet/%s/webhooks" + + req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet)) + if err != nil { + return nil, err + } + + resp := make(map[string][]Webhook) + if err = c.performRequest(req, &resp); err != nil { + return nil, err + } + + return resp["webhooks"], nil +} + +// Webhook retrieves a specific webhook. +func (c *Client) Webhook(ctx context.Context, endpointID string) (Webhook, error) { + const uriFmt = "/api/v2/webhooks/%s" + + req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, endpointID)) + if err != nil { + return Webhook{}, err + } + + var webhook Webhook + return webhook, c.performRequest(req, &webhook) +} + +// UpdateWebhook updates an existing webhook's subscriptions. +// Returns a Webhook on success. +func (c *Client) UpdateWebhook(ctx context.Context, endpointID string, subscriptions []SubscriptionType) (Webhook, error) { + const uriFmt = "/api/v2/webhooks/%s" + + req, err := c.buildRequest(ctx, http.MethodPatch, fmt.Sprintf(uriFmt, endpointID), requestBody(map[string][]SubscriptionType{ + "subscriptions": subscriptions, + })) + if err != nil { + return Webhook{}, err + } + + var webhook Webhook + return webhook, c.performRequest(req, &webhook) +} + +// DeleteWebhook deletes a specific webhook. +func (c *Client) DeleteWebhook(ctx context.Context, endpointID string) error { + const uriFmt = "/api/v2/webhooks/%s" + + req, err := c.buildRequest(ctx, http.MethodDelete, fmt.Sprintf(uriFmt, endpointID)) + if err != nil { + return err + } + + return c.performRequest(req, nil) +} + +// TestWebhook queues a test event to be sent to a specific webhook. +// Sending the test event is an asynchronous operation which should +// happen a few seconds after using this method. +func (c *Client) TestWebhook(ctx context.Context, endpointID string) error { + const uriFmt = "/api/v2/webhooks/%s/test" + + req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, endpointID)) + if err != nil { + return err + } + + return c.performRequest(req, nil) +} + +// RotateWebhookSecret rotates the secret associated with a webhook. +// A new secret will be generated and set on the returned Webhook. +func (c *Client) RotateWebhookSecret(ctx context.Context, endpointID string) (Webhook, error) { + const uriFmt = "/api/v2/webhooks/%s/rotate" + + req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, endpointID)) + if err != nil { + return Webhook{}, err + } + + var webhook Webhook + return webhook, c.performRequest(req, &webhook) +} + // IsNotFound returns true if the provided error implementation is an APIError with a status of 404. func IsNotFound(err error) bool { var apiErr APIError diff --git a/tailscale/client_test.go b/tailscale/client_test.go index 7abf0c4..57b4942 100644 --- a/tailscale/client_test.go +++ b/tailscale/client_test.go @@ -1128,3 +1128,174 @@ func TestClient_UserAgent(t *testing.T) { assert.NoError(t, client.SetDeviceAuthorized(context.Background(), "test", true)) assert.Contains(t, server.Header.Get("User-Agent"), "Go-http-client") } + +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.DiscordProviderType, + Subscriptions: []tailscale.SubscriptionType{tailscale.NodeCreated, tailscale.NodeApproved}, + } + + 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: "my-secret", + } + server.ResponseBody = expectedWebhook + + webhook, err := client.CreateWebhook(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.SubscriptionType{tailscale.NodeCreated, tailscale.NodeApproved}, + Secret: "my-secret", + }, + { + 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.SubscriptionType{tailscale.NodeApproved}, + Secret: "my-secret", + }, + }, + } + server.ResponseBody = expectedWebhooks + + actualWebhooks, err := client.Webhooks(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.SubscriptionType{tailscale.NodeApproved}, + Secret: "my-secret", + } + server.ResponseBody = expectedWebhook + + actualWebhook, err := client.Webhook(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.SubscriptionType{tailscale.NodeCreated, tailscale.NodeApproved, tailscale.NodeNeedsApproval} + + 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, + Secret: "my-secret", + } + server.ResponseBody = expectedWebhook + + actualWebhook, err := client.UpdateWebhook(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.DeleteWebhook(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.TestWebhook(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 + + 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.SubscriptionType{tailscale.NodeApproved}, + Secret: "my-new-secret", + } + server.ResponseBody = expectedWebhook + + actualWebhook, err := client.RotateWebhookSecret(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) +}