Skip to content

Commit

Permalink
tailscale: support webhook endpoints
Browse files Browse the repository at this point in the history
Support webhook endpoints in the client.

Updates tailscale/corp#21625
  • Loading branch information
mpminardi committed Jul 18, 2024
1 parent 016961e commit 1f07612
Show file tree
Hide file tree
Showing 2 changed files with 353 additions and 0 deletions.
182 changes: 182 additions & 0 deletions tailscale/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions tailscale/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "[email protected]",
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: "[email protected]",
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: "[email protected]",
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: "[email protected]",
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: "[email protected]",
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: "[email protected]",
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)
}

0 comments on commit 1f07612

Please sign in to comment.