diff --git a/README.md b/README.md index 0367b96..db8e690 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,11 @@ # tailscale-client-go -A Go client implementation for the [Tailscale API](https://tailscale.com/api). - -## Current Version +> [!IMPORTANT] +> This implementation is no longer maintained. The replacement is available at [tailscale-client-go-v2](https://github.com/tailscale/tailscale-client-go-v2). - * [V2](https://github.com/tailscale/tailscale-client-go/tree/main/v2#readme): `import "github.com/tailscale/tailscale-client-go/v2"` - - [![Go Reference](https://pkg.go.dev/badge/github.com/tailscale/tailscale-client-go.svg)](https://pkg.go.dev/github.com/tailscale/tailscale-client-go/v2) +A Go client implementation for the [Tailscale API](https://tailscale.com/api). -## Old versions +## Old version * V1: `import "github.com/tailscale/tailscale-client-go"` diff --git a/v2/README.md b/v2/README.md deleted file mode 100644 index 5771a92..0000000 --- a/v2/README.md +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -# tailscale-client-go/v2 - -[![Go Reference](https://pkg.go.dev/badge/github.com/tailscale/tailscale-client-go/v2.svg)](https://pkg.go.dev/github.com/tailscale/tailscale-client-go/v2) -[![Go Report Card](https://goreportcard.com/badge/github.com/tailscale/tailscale-client-go/v2)](https://goreportcard.com/report/github.com/tailscale/tailscale-client-go/v2) -![Github Actions](https://github.com/tailscale/tailscale-client-go/actions/workflows/ci.yml/badge.svg?branch=main) - -A client implementation for the [Tailscale](https://tailscale.com) HTTP API. -For more details, please see [API documentation](https://tailscale.com/api). - -## Example (Using API Key) - -```go -package main - -import ( - "context" - "os" - - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -func main() { - client := &tsclient.Client{ - Tailnet: os.Getenv("TAILSCALE_TAILNET"), - APIKey: os.Getenv("TAILSCALE_API_KEY"), - } - - devices, err := client.Devices().List(context.Background()) -} -``` - -## Example (Using OAuth) - -```go -package main - -import ( - "context" - "os" - - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -func main() { - client := &tsclient.Client{ - Tailnet: os.Getenv("TAILSCALE_TAILNET"), - HTTP: tsclient.OAuthConfig{ - ClientID: os.Getenv("TAILSCALE_OAUTH_CLIENT_ID"), - ClientSecret: os.Getenv("TAILSCALE_OAUTH_CLIENT_SECRET"), - Scopes: []string{"all:write"}, - }.HTTPClient(), - } - - devices, err := client.Devices().List(context.Background()) -} -``` diff --git a/v2/client.go b/v2/client.go index 57892aa..302e72d 100644 --- a/v2/client.go +++ b/v2/client.go @@ -1,401 +1,8 @@ // Copyright (c) David Bond, Tailscale Inc, & Contributors // SPDX-License-Identifier: MIT -// Package tsclient contains a basic implementation of a client for the Tailscale HTTP API. +// Package tsclient used to contain a basic implementation of a client for the Tailscale HTTP API. +// It has been superseded by tailscale.com/client/tailscale/v2. // -// Documentation is at https://tailscale.com/api +// Deprecated: use tailscale.com/client/tailscale/v2 instead. package tsclient - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "sync" - "time" - - "github.com/tailscale/hujson" -) - -// Client is used to perform actions against the Tailscale API. -type Client struct { - // BaseURL is the base URL for accessing the Tailscale API server. Defaults to https://api.tailscale.com. - BaseURL *url.URL - // UserAgent configures the User-Agent HTTP header for requests. Defaults to "tailscale-client-go". - UserAgent string - // APIKey allows specifying an APIKey to use for authentication. - // To use OAuth Client credentials, construct an [http.Client] using [OAuthConfig] and specify that below. - APIKey string - // Tailnet allows specifying a specific Tailnet by name, to which this Client will connect by default. - Tailnet string - - // HTTP is the [http.Client] to use for requests to the API server. - // If not specified, a new [http.Client] with a Timeout of 1 minute will be used. - HTTP *http.Client - - initOnce sync.Once - - // Specific resources - contacts *ContactsResource - devicePosture *DevicePostureResource - devices *DevicesResource - dns *DNSResource - keys *KeysResource - logging *LoggingResource - policyFile *PolicyFileResource - tailnetSettings *TailnetSettingsResource - users *UsersResource - webhooks *WebhooksResource -} - -// APIError type describes an error as returned by the Tailscale API. -type APIError struct { - Message string `json:"message"` - Data []APIErrorData `json:"data"` - status int -} - -// APIErrorData type describes elements of the data field within errors returned by the Tailscale API. -type APIErrorData struct { - User string `json:"user"` - Errors []string `json:"errors"` -} - -const defaultContentType = "application/json" -const defaultHttpClientTimeout = time.Minute -const defaultUserAgent = "tailscale-client-go" - -var defaultBaseURL *url.URL - -func init() { - var err error - defaultBaseURL, err = url.Parse("https://api.tailscale.com") - if err != nil { - panic(fmt.Errorf("failed to parse defaultBaseURL: %w", err)) - } -} - -// init returns a new instance of the Client type that will perform operations against a chosen tailnet and will -// provide the apiKey for authorization. -func (c *Client) init() { - c.initOnce.Do(func() { - if c.BaseURL == nil { - c.BaseURL = defaultBaseURL - } - if c.UserAgent == "" { - c.UserAgent = defaultUserAgent - } - if c.HTTP == nil { - c.HTTP = &http.Client{Timeout: defaultHttpClientTimeout} - } - c.contacts = &ContactsResource{c} - c.devicePosture = &DevicePostureResource{c} - c.devices = &DevicesResource{c} - c.dns = &DNSResource{c} - c.keys = &KeysResource{c} - c.logging = &LoggingResource{c} - c.policyFile = &PolicyFileResource{c} - c.tailnetSettings = &TailnetSettingsResource{c} - c.users = &UsersResource{c} - c.webhooks = &WebhooksResource{c} - }) -} - -// Contacts() provides access to https://tailscale.com/api#tag/contacts. -func (c *Client) Contacts() *ContactsResource { - c.init() - return c.contacts -} - -// DevicePosture provides access to https://tailscale.com/api#tag/deviceposture. -func (c *Client) DevicePosture() *DevicePostureResource { - c.init() - return c.devicePosture -} - -// Devices provides access to https://tailscale.com/api#tag/devices. -func (c *Client) Devices() *DevicesResource { - c.init() - return c.devices -} - -// DNS provides access to https://tailscale.com/api#tag/dns. -func (c *Client) DNS() *DNSResource { - c.init() - return c.dns -} - -// Keys provides access to https://tailscale.com/api#tag/keys. -func (c *Client) Keys() *KeysResource { - c.init() - return c.keys -} - -// Logging provides access to https://tailscale.com/api#tag/logging. -func (c *Client) Logging() *LoggingResource { - c.init() - return c.logging -} - -// PolicyFile provides access to https://tailscale.com/api#tag/policyfile. -func (c *Client) PolicyFile() *PolicyFileResource { - c.init() - return c.policyFile -} - -// TailnetSettings provides access to https://tailscale.com/api#tag/tailnetsettings. -func (c *Client) TailnetSettings() *TailnetSettingsResource { - c.init() - return c.tailnetSettings -} - -// Users provides access to https://tailscale.com/api#tag/users. -func (c *Client) Users() *UsersResource { - c.init() - return c.users -} - -// Webhooks provides access to https://tailscale.com/api#tag/webhooks. -func (c *Client) Webhooks() *WebhooksResource { - c.init() - return c.webhooks -} - -type requestParams struct { - headers map[string]string - body any - contentType string -} - -type requestOption func(*requestParams) - -func requestBody(body any) requestOption { - return func(rof *requestParams) { - rof.body = body - } -} - -func requestHeaders(headers map[string]string) requestOption { - return func(rof *requestParams) { - rof.headers = headers - } -} - -func requestContentType(ct string) requestOption { - return func(rof *requestParams) { - rof.contentType = ct - } -} - -// 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, 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] = c.Tailnet - allElements = append(allElements, pathElements...) - 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, - } - for _, opt := range opts { - opt(rof) - } - - var bodyBytes []byte - if rof.body != nil { - switch body := rof.body.(type) { - case string: - bodyBytes = []byte(body) - case []byte: - bodyBytes = body - default: - var err error - bodyBytes, err = json.Marshal(rof.body) - if err != nil { - return nil, err - } - } - } - - req, err := http.NewRequestWithContext(ctx, method, uri.String(), bytes.NewBuffer(bodyBytes)) - if err != nil { - return nil, err - } - - if c.UserAgent != "" { - req.Header.Set("User-Agent", c.UserAgent) - } - - for k, v := range rof.headers { - req.Header.Set(k, v) - } - - switch { - case rof.body == nil: - req.Header.Set("Accept", rof.contentType) - default: - req.Header.Set("Content-Type", rof.contentType) - } - - if c.APIKey != "" { - req.SetBasicAuth(c.APIKey, "") - } - - return req, nil -} - -// doer is a resource type (such as *ContactsResource) with a doWithResponseHeaders -// method that sends an HTTP request and decodes its body into out. -// -// Concretely, the doWithResponseHeaders method will usually be (*Client).doWithResponseHeaders, -// as all the Resource types embed a *Client. -type doer interface { - doWithResponseHeaders(req *http.Request, out any) (http.Header, error) -} - -// body calls resource.do, passing a *T to do, and returns -// exactly one non-zero value depending on the result of do. -func body[T any](resource doer, req *http.Request) (*T, error) { - t, _, err := bodyWithResponseHeader[T](resource, req) - return t, err -} - -// bodyWithResponseHeader is like [body] but also returns the response header. -func bodyWithResponseHeader[T any](resource doer, req *http.Request) (*T, http.Header, error) { - var v T - header, err := resource.doWithResponseHeaders(req, &v) - if err != nil { - return nil, nil, err - } - return &v, header, nil -} - -func (c *Client) do(req *http.Request, out any) error { - _, err := c.doWithResponseHeaders(req, out) - return err -} - -func (c *Client) doWithResponseHeaders(req *http.Request, out any) (http.Header, error) { - res, err := c.HTTP.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if res.StatusCode >= http.StatusOK && res.StatusCode < http.StatusMultipleChoices { - // If we don't care about the response body, leave. This check is required as some - // API responses have empty bodies, so we don't want to try and standardize them for - // parsing. - if out == nil { - return res.Header, nil - } - - // If we're expected to write result into a []byte, do not attempt to parse it. - if o, ok := out.(*[]byte); ok { - *o = bytes.Clone(body) - return res.Header, nil - } - - // If we've got hujson back, convert it to JSON, so we can natively parse it. - if !json.Valid(body) { - body, err = hujson.Standardize(body) - if err != nil { - return res.Header, err - } - } - - return res.Header, json.Unmarshal(body, out) - } - - if res.StatusCode >= http.StatusBadRequest { - var apiErr APIError - if err := json.Unmarshal(body, &apiErr); err != nil { - return res.Header, err - } - - apiErr.status = res.StatusCode - return res.Header, apiErr - } - - return res.Header, nil -} - -func (err APIError) Error() string { - return fmt.Sprintf("%s (%v)", err.Message, err.status) -} - -// IsNotFound returns true if the provided error implementation is an APIError with a status of 404. -func IsNotFound(err error) bool { - var apiErr APIError - if errors.As(err, &apiErr) { - return apiErr.status == http.StatusNotFound - } - - return false -} - -// ErrorData returns the contents of the [APIError].Data field from the provided error if it is of type [APIError]. -// Returns a nil slice if the given error is not of type [APIError]. -func ErrorData(err error) []APIErrorData { - var apiErr APIError - if errors.As(err, &apiErr) { - return apiErr.Data - } - - return nil -} - -// Duration wraps a [time.Duration], allowing it to be JSON marshalled as a string like "20h" rather than -// a numeric value. -type Duration time.Duration - -func (d Duration) String() string { - return time.Duration(d).String() -} - -func (d Duration) MarshalText() ([]byte, error) { - return []byte(d.String()), nil -} - -func (d *Duration) UnmarshalText(b []byte) error { - text := string(b) - if text == "" { - text = "0s" - } - pd, err := time.ParseDuration(text) - if err != nil { - return err - } - *d = Duration(pd) - return nil -} - -// PointerTo returns a pointer to the given value. -// Pointers are used in PATCH requests to distinguish between specified and unspecified values. -func PointerTo[T any](value T) *T { - return &value -} diff --git a/v2/client_test.go b/v2/client_test.go deleted file mode 100644 index 0fd5038..0000000 --- a/v2/client_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - _ "embed" - "io" - "net/url" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestErrorData(t *testing.T) { - t.Parallel() - - t.Run("It should return the data element from a valid error", func(t *testing.T) { - expected := APIError{ - Data: []APIErrorData{ - { - User: "user1@example.com", - Errors: []string{ - "address \"user2@example.com:400\": want: Accept, got: Drop", - }, - }, - }, - } - - actual := ErrorData(expected) - assert.EqualValues(t, expected.Data, actual) - }) - - t.Run("It should return an empty slice for any other error", func(t *testing.T) { - assert.Empty(t, ErrorData(io.EOF)) - }) -} - -func Test_BuildTailnetURL(t *testing.T) { - t.Parallel() - - base, err := url.Parse("http://example.com") - require.NoError(t, err) - - c := &Client{ - BaseURL: base, - Tailnet: "tn/with/slashes", - } - actual := c.buildTailnetURL("component/with/slashes") - expected, err := url.Parse("http://example.com/api/v2/tailnet/tn%2Fwith%2Fslashes/component%2Fwith%2Fslashes") - require.NoError(t, err) - assert.EqualValues(t, expected.String(), actual.String()) -} diff --git a/v2/contacts.go b/v2/contacts.go deleted file mode 100644 index 4236b64..0000000 --- a/v2/contacts.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - "context" - "net/http" -) - -// ContactsResource provides access to https://tailscale.com/api#tag/contacts. -type ContactsResource struct { - *Client -} - -const ( - ContactAccount ContactType = "account" - ContactSupport ContactType = "support" - ContactSecurity ContactType = "security" -) - -// ContactType defines the type of contact. -type ContactType string - -// Contacts type defines the object returned when retrieving contacts. -type Contacts struct { - Account Contact `json:"account"` - Support Contact `json:"support"` - Security Contact `json:"security"` -} - -// Contact type defines the structure of an individual contact for the tailnet. -type Contact struct { - Email string `json:"email"` - // FallbackEmail is the email used when Email has not been verified. - FallbackEmail string `json:"fallbackEmail,omitempty"` - // NeedsVerification is true if Email needs to be verified. - NeedsVerification bool `json:"needsVerification"` -} - -// UpdateContactRequest type defines the structure of a request to update a Contact. -type UpdateContactRequest struct { - Email *string `json:"email,omitempty"` -} - -// Get retieves the [Contacts] for the tailnet. -func (cr *ContactsResource) Get(ctx context.Context) (*Contacts, error) { - req, err := cr.buildRequest(ctx, http.MethodGet, cr.buildTailnetURL("contacts")) - if err != nil { - return nil, err - } - - return body[Contacts](cr, req) -} - -// Update updates the email for the specified [ContactType] within the tailnet. -// If the email address changes, the system will send a verification email to confirm the change. -func (cr *ContactsResource) Update(ctx context.Context, contactType ContactType, contact UpdateContactRequest) error { - req, err := cr.buildRequest(ctx, http.MethodPatch, cr.buildTailnetURL("contacts", contactType), requestBody(contact)) - if err != nil { - return err - } - - return cr.do(req, nil) -} diff --git a/v2/contacts_test.go b/v2/contacts_test.go deleted file mode 100644 index 430436e..0000000 --- a/v2/contacts_test.go +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient_test - -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -func TestClient_Contacts(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - expectedContacts := &tsclient.Contacts{ - Account: tsclient.Contact{ - Email: "test@example.com", - FallbackEmail: "test2@example.com", - NeedsVerification: false, - }, - Support: tsclient.Contact{ - Email: "test3@example.com", - NeedsVerification: false, - }, - Security: tsclient.Contact{ - Email: "test4@example.com", - FallbackEmail: "test5@example.com", - NeedsVerification: true, - }, - } - server.ResponseBody = expectedContacts - - actualContacts, err := client.Contacts().Get(context.Background()) - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/contacts", server.Path) - assert.Equal(t, expectedContacts, actualContacts) -} - -func TestClient_UpdateContact(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - server.ResponseBody = nil - - email := "new@example.com" - updateRequest := tsclient.UpdateContactRequest{ - Email: &email, - } - err := client.Contacts().Update(context.Background(), tsclient.ContactAccount, updateRequest) - assert.NoError(t, err) - assert.Equal(t, http.MethodPatch, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/contacts/account", server.Path) - var receivedRequest tsclient.UpdateContactRequest - err = json.Unmarshal(server.Body.Bytes(), &receivedRequest) - assert.NoError(t, err) - assert.EqualValues(t, updateRequest, receivedRequest) -} diff --git a/v2/device_posture.go b/v2/device_posture.go deleted file mode 100644 index 0b09757..0000000 --- a/v2/device_posture.go +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - "context" - "net/http" -) - -// DevicePostureResource provides access to https://tailscale.com/api#tag/deviceposture. -type DevicePostureResource struct { - *Client -} - -const ( - PostureIntegrationProviderFalcon PostureIntegrationProvider = "falcon" - PostureIntegrationProviderIntune PostureIntegrationProvider = "intune" - PostureIntegrationProviderJamfPro PostureIntegrationProvider = "jamfpro" - PostureIntegrationProviderKandji PostureIntegrationProvider = "kandji" - PostureIntegrationProviderKolide PostureIntegrationProvider = "kolide" - PostureIntegrationProviderSentinelOne PostureIntegrationProvider = "sentinelone" -) - -// PostureIntegrationProvider identifies a supported posture integration data provider. -type PostureIntegrationProvider string - -// PostureIntegration is a configured posture integration. -type PostureIntegration struct { - ID string `json:"id,omitempty"` - Provider PostureIntegrationProvider `json:"provider,omitempty"` - CloudID string `json:"cloudId,omitempty"` - ClientID string `json:"clientId,omitempty"` - TenantID string `json:"tenantId,omitempty"` -} - -// CreatePostureIntegrationRequest is a request to create a posture integration. -type CreatePostureIntegrationRequest struct { - Provider PostureIntegrationProvider `json:"provider,omitempty"` - CloudID string `json:"cloudId,omitempty"` - ClientID string `json:"clientId,omitempty"` - TenantID string `json:"tenantId,omitempty"` - ClientSecret string `json:"clientSecret,omitempty"` -} - -// UpdatePostureIntegrationRequest is a request to update a posture integration. -type UpdatePostureIntegrationRequest struct { - CloudID string `json:"cloudId,omitempty"` - ClientID string `json:"clientId,omitempty"` - TenantID string `json:"tenantId,omitempty"` - // ClientSecret may be omitted to preserve the existing value - ClientSecret *string `json:"clientSecret,omitempty"` -} - -// List lists every configured [PostureIntegration]. -func (pr *DevicePostureResource) ListIntegrations(ctx context.Context) ([]PostureIntegration, error) { - req, err := pr.buildRequest(ctx, http.MethodGet, pr.buildTailnetURL("posture", "integrations")) - if err != nil { - return nil, err - } - - m := make(map[string][]PostureIntegration) - err = pr.do(req, &m) - if err != nil { - return nil, err - } - - return m["integrations"], nil -} - -// CreateIntegration creates a new posture integration, returning the resulting [PostureIntegration]. -func (pr *DevicePostureResource) CreateIntegration(ctx context.Context, intg CreatePostureIntegrationRequest) (*PostureIntegration, error) { - req, err := pr.buildRequest(ctx, http.MethodPost, pr.buildTailnetURL("posture", "integrations"), requestBody(intg)) - if err != nil { - return nil, err - } - - return body[PostureIntegration](pr, req) -} - -// UpdateIntegration updates the existing posture integration identified by id, returning the resulting [PostureIntegration]. -func (pr *DevicePostureResource) UpdateIntegration(ctx context.Context, id string, intg UpdatePostureIntegrationRequest) (*PostureIntegration, error) { - req, err := pr.buildRequest(ctx, http.MethodPatch, pr.buildURL("posture", "integrations", id), requestBody(intg)) - if err != nil { - return nil, err - } - - return body[PostureIntegration](pr, req) -} - -// DeleteIntegration deletes the posture integration identified by id. -func (pr *DevicePostureResource) DeleteIntegration(ctx context.Context, id string) error { - req, err := pr.buildRequest(ctx, http.MethodDelete, pr.buildURL("posture", "integrations", id)) - if err != nil { - return err - } - - return pr.do(req, nil) -} - -// GetIntegration gets the posture integration identified by id. -func (pr *DevicePostureResource) GetIntegration(ctx context.Context, id string) (*PostureIntegration, error) { - req, err := pr.buildRequest(ctx, http.MethodGet, pr.buildURL("posture", "integrations", id)) - if err != nil { - return nil, err - } - - return body[PostureIntegration](pr, req) -} diff --git a/v2/device_posture_test.go b/v2/device_posture_test.go deleted file mode 100644 index 7703346..0000000 --- a/v2/device_posture_test.go +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient_test - -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -func TestClient_DevicePosture_CreateIntegration(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - req := tsclient.CreatePostureIntegrationRequest{ - Provider: tsclient.PostureIntegrationProviderIntune, - CloudID: "cloudid", - ClientID: "clientid", - TenantID: "tenantid", - ClientSecret: "clientsecret", - } - - resp := &tsclient.PostureIntegration{ - ID: "1", - Provider: tsclient.PostureIntegrationProviderIntune, - CloudID: "cloudid", - ClientID: "clientid", - TenantID: "tenantid", - } - server.ResponseBody = resp - - integration, err := client.DevicePosture().CreateIntegration(context.Background(), req) - require.NoError(t, err) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/posture/integrations", server.Path) - assert.Equal(t, resp, integration) - - var actualRequest tsclient.CreatePostureIntegrationRequest - err = json.Unmarshal(server.Body.Bytes(), &actualRequest) - require.NoError(t, err) - assert.Equal(t, req, actualRequest) -} - -func TestClient_DevicePosture_UpdateIntegration(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - req := tsclient.UpdatePostureIntegrationRequest{ - CloudID: "cloudid", - ClientID: "clientid", - TenantID: "tenantid", - ClientSecret: tsclient.PointerTo("clientsecret"), - } - - resp := &tsclient.PostureIntegration{ - ID: "1", - Provider: tsclient.PostureIntegrationProviderIntune, - CloudID: "cloudid", - ClientID: "clientid", - TenantID: "tenantid", - } - server.ResponseBody = resp - - actualResp, err := client.DevicePosture().UpdateIntegration(context.Background(), "1", req) - assert.NoError(t, err) - assert.Equal(t, http.MethodPatch, server.Method) - assert.Equal(t, "/api/v2/posture/integrations/1", server.Path) - assert.Equal(t, resp, actualResp) - - var actualRequest tsclient.UpdatePostureIntegrationRequest - err = json.Unmarshal(server.Body.Bytes(), &actualRequest) - require.NoError(t, err) - assert.Equal(t, req, actualRequest) -} - -func TestClient_DevicePosture_DeleteIntegration(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - err := client.DevicePosture().DeleteIntegration(context.Background(), "1") - assert.NoError(t, err) - assert.Equal(t, http.MethodDelete, server.Method) - assert.Equal(t, "/api/v2/posture/integrations/1", server.Path) -} - -func TestClient_DevicePosture_GetIntegration(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - resp := &tsclient.PostureIntegration{ - ID: "1", - Provider: tsclient.PostureIntegrationProviderIntune, - CloudID: "cloudid1", - ClientID: "clientid1", - TenantID: "tenantid1", - } - server.ResponseBody = resp - - actualResp, err := client.DevicePosture().GetIntegration(context.Background(), "1") - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/posture/integrations/1", server.Path) - assert.Equal(t, resp, actualResp) -} - -func TestClient_DevicePosture_ListIntegrations(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - resp := []tsclient.PostureIntegration{ - { - ID: "1", - Provider: tsclient.PostureIntegrationProviderIntune, - CloudID: "cloudid1", - ClientID: "clientid1", - TenantID: "tenantid1", - }, - { - ID: "2", - Provider: tsclient.PostureIntegrationProviderJamfPro, - CloudID: "cloudid2", - ClientID: "clientid2", - TenantID: "tenantid2", - }, - } - server.ResponseBody = map[string][]tsclient.PostureIntegration{ - "integrations": resp, - } - - actualResp, err := client.DevicePosture().ListIntegrations(context.Background()) - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/posture/integrations", server.Path) - assert.Equal(t, resp, actualResp) -} diff --git a/v2/devices.go b/v2/devices.go deleted file mode 100644 index 00ef93d..0000000 --- a/v2/devices.go +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - "context" - "encoding/json" - "net/http" - "time" -) - -// DevicesResource provides access to https://tailscale.com/api#tag/devices. -type DevicesResource struct { - *Client -} - -type DeviceRoutes struct { - Advertised []string `json:"advertisedRoutes"` - Enabled []string `json:"enabledRoutes"` -} - -// Time wraps a time and allows for unmarshalling timestamps that represent an empty time as an empty string (e.g "") -// this is used by the tailscale API when it returns devices that have no created date, such as its hello service. -type Time struct { - time.Time -} - -// MarshalJSON is an implementation of json.Marshal. -func (t Time) MarshalJSON() ([]byte, error) { - return json.Marshal(t.Time) -} - -// UnmarshalJSON unmarshals the content of data as a time.Time, a blank string will keep the time at its zero value. -func (t *Time) UnmarshalJSON(data []byte) error { - if string(data) == `""` { - return nil - } - - if err := json.Unmarshal(data, &t.Time); err != nil { - return err - } - - return nil -} - -type Device struct { - Addresses []string `json:"addresses"` - Name string `json:"name"` - ID string `json:"id"` - Authorized bool `json:"authorized"` - User string `json:"user"` - Tags []string `json:"tags"` - KeyExpiryDisabled bool `json:"keyExpiryDisabled"` - BlocksIncomingConnections bool `json:"blocksIncomingConnections"` - ClientVersion string `json:"clientVersion"` - Created Time `json:"created"` - Expires Time `json:"expires"` - Hostname string `json:"hostname"` - IsExternal bool `json:"isExternal"` - LastSeen Time `json:"lastSeen"` - MachineKey string `json:"machineKey"` - NodeKey string `json:"nodeKey"` - OS string `json:"os"` - TailnetLockError string `json:"tailnetLockError"` - TailnetLockKey string `json:"tailnetLockKey"` - UpdateAvailable bool `json:"updateAvailable"` -} - -type DevicePostureAttributes struct { - Attributes map[string]any `json:"attributes"` - Expiries map[string]Time `json:"expiries"` -} - -type DevicePostureAttributeRequest struct { - Value any `json:"value"` - Expiry Time `json:"expiry"` - Comment string `json:"comment"` -} - -// Get gets the [Device] identified by deviceID. -func (dr *DevicesResource) Get(ctx context.Context, deviceID string) (*Device, error) { - req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildURL("device", deviceID)) - if err != nil { - return nil, err - } - - return body[Device](dr, req) -} - -// GetPostureAttributes retrieves the posture attributes of the device identified by deviceID. -func (dr *DevicesResource) GetPostureAttributes(ctx context.Context, deviceID string) (*DevicePostureAttributes, error) { - req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildURL("device", deviceID, "attributes")) - if err != nil { - return nil, err - } - - return body[DevicePostureAttributes](dr, req) -} - -// SetPostureAttribute sets the posture attribute of the device identified by deviceID. -func (dr *DevicesResource) SetPostureAttribute(ctx context.Context, deviceID, attributeKey string, request DevicePostureAttributeRequest) error { - req, err := dr.buildRequest(ctx, http.MethodPost, dr.buildURL("device", deviceID, "attributes", attributeKey), requestBody(request)) - if err != nil { - return err - } - - return dr.do(req, nil) -} - -// List lists every [Device] in the tailnet. -func (dr *DevicesResource) List(ctx context.Context) ([]Device, error) { - req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildTailnetURL("devices")) - if err != nil { - return nil, err - } - - m := make(map[string][]Device) - err = dr.do(req, &m) - if err != nil { - return nil, err - } - - return m["devices"], nil -} - -// SetAuthorized marks the specified device as authorized or not. -func (dr *DevicesResource) SetAuthorized(ctx context.Context, deviceID string, authorized bool) error { - req, err := dr.buildRequest(ctx, http.MethodPost, dr.buildURL("device", deviceID, "authorized"), requestBody(map[string]bool{ - "authorized": authorized, - })) - if err != nil { - return err - } - - return dr.do(req, nil) -} - -// Delete deletes the device identified by deviceID. -func (dr *DevicesResource) Delete(ctx context.Context, deviceID string) error { - req, err := dr.buildRequest(ctx, http.MethodDelete, dr.buildURL("device", deviceID)) - if err != nil { - return err - } - - return dr.do(req, nil) -} - -// SetName updates the name of the device identified by deviceID. -func (dr *DevicesResource) SetName(ctx context.Context, deviceID, name string) error { - req, err := dr.buildRequest(ctx, http.MethodPost, dr.buildURL("device", deviceID, "name"), requestBody(map[string]string{ - "name": name, - })) - if err != nil { - return err - } - - return dr.do(req, nil) -} - -// SetTags updates the tags of the device identified by deviceID. -func (dr *DevicesResource) SetTags(ctx context.Context, deviceID string, tags []string) error { - req, err := dr.buildRequest(ctx, http.MethodPost, dr.buildURL("device", deviceID, "tags"), requestBody(map[string][]string{ - "tags": tags, - })) - if err != nil { - return err - } - - return dr.do(req, nil) -} - -// DeviceKey type represents the properties of the key of an individual device within -// the tailnet. -type DeviceKey struct { - KeyExpiryDisabled bool `json:"keyExpiryDisabled"` // Whether or not this device's key will ever expire. -} - -// SetKey updates the properties of a device's key. -func (dr *DevicesResource) SetKey(ctx context.Context, deviceID string, key DeviceKey) error { - req, err := dr.buildRequest(ctx, http.MethodPost, dr.buildURL("device", deviceID, "key"), requestBody(key)) - if err != nil { - return err - } - - return dr.do(req, nil) -} - -// SetDeviceIPv4Address sets the Tailscale IPv4 address of the device. -func (dr *DevicesResource) SetIPv4Address(ctx context.Context, deviceID string, ipv4Address string) error { - req, err := dr.buildRequest(ctx, http.MethodPost, dr.buildURL("device", deviceID, "ip"), requestBody(map[string]string{ - "ipv4": ipv4Address, - })) - if err != nil { - return err - } - - return dr.do(req, nil) -} - -// SetSubnetRoutes sets which subnet routes are enabled to be routed by a device by replacing the existing list -// of subnet routes with the supplied routes. Routes can be enabled without a device advertising them (e.g. for preauth). -func (dr *DevicesResource) SetSubnetRoutes(ctx context.Context, deviceID string, routes []string) error { - req, err := dr.buildRequest(ctx, http.MethodPost, dr.buildURL("device", deviceID, "routes"), requestBody(map[string][]string{ - "routes": routes, - })) - if err != nil { - return err - } - - return dr.do(req, nil) -} - -// SubnetRoutes Retrieves the list of subnet routes that a device is advertising, as well as those that are -// enabled for it. Enabled routes are not necessarily advertised (e.g. for pre-enabling), and likewise, advertised -// routes are not necessarily enabled. -func (dr *DevicesResource) SubnetRoutes(ctx context.Context, deviceID string) (*DeviceRoutes, error) { - req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildURL("device", deviceID, "routes")) - if err != nil { - return nil, err - } - - return body[DeviceRoutes](dr, req) -} diff --git a/v2/devices_test.go b/v2/devices_test.go deleted file mode 100644 index 5786090..0000000 --- a/v2/devices_test.go +++ /dev/null @@ -1,391 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient_test - -import ( - "context" - _ "embed" - "encoding/json" - "net/http" - "testing" - "time" - - "github.com/stretchr/testify/assert" - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -var ( - //go:embed testdata/devices.json - jsonDevices []byte -) - -func TestClient_SetDeviceSubnetRoutes(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - const deviceID = "test" - routes := []string{"127.0.0.1"} - - assert.NoError(t, client.Devices().SetSubnetRoutes(context.Background(), deviceID, routes)) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/device/test/routes", server.Path) - - body := make(map[string][]string) - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) - assert.EqualValues(t, routes, body["routes"]) -} - -func TestClient_Devices_Get(t *testing.T) { - t.Parallel() - - expectedDevice := &tsclient.Device{ - Addresses: []string{"127.0.0.1"}, - Name: "test", - ID: "testid", - Authorized: true, - KeyExpiryDisabled: true, - User: "test@example.com", - Tags: []string{ - "tag:value", - }, - BlocksIncomingConnections: false, - ClientVersion: "1.22.1", - Created: tsclient.Time{time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC)}, - Expires: tsclient.Time{time.Date(2022, 8, 9, 11, 50, 23, 0, time.UTC)}, - Hostname: "test", - IsExternal: false, - LastSeen: tsclient.Time{time.Date(2022, 3, 9, 20, 3, 42, 0, time.UTC)}, - MachineKey: "mkey:test", - NodeKey: "nodekey:test", - OS: "windows", - TailnetLockError: "test error", - TailnetLockKey: "tlpub:test", - UpdateAvailable: true, - } - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - server.ResponseBody = expectedDevice - - actualDevice, err := client.Devices().Get(context.Background(), "testid") - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/device/testid", server.Path) - assert.EqualValues(t, expectedDevice, actualDevice) -} - -func TestClient_Devices_GetPostureAttributes(t *testing.T) { - t.Parallel() - - expectedAttributes := &tsclient.DevicePostureAttributes{ - Attributes: map[string]interface{}{ - "custom:key": "value", - "node:os": "linux", - "node:osVersion": "5.19.0-42-generic", - "node:tsReleaseTrack": "stable", - "node:tsVersion": "1.40.0", - "node:tsAutoUpdate": false, - }, - Expiries: map[string]tsclient.Time{ - "custom:key": {time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC)}, - }, - } - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - server.ResponseBody = expectedAttributes - - actualAttributes, err := client.Devices().GetPostureAttributes(context.Background(), "testid") - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/device/testid/attributes", server.Path) - - assert.EqualValues(t, expectedAttributes, actualAttributes) -} - -func TestClient_Devices_List(t *testing.T) { - t.Parallel() - - expectedDevices := map[string][]tsclient.Device{ - "devices": { - { - Addresses: []string{"127.0.0.1"}, - Name: "test", - ID: "test", - Authorized: true, - KeyExpiryDisabled: true, - User: "test@example.com", - Tags: []string{ - "tag:value", - }, - BlocksIncomingConnections: false, - ClientVersion: "1.22.1", - Created: tsclient.Time{time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC)}, - Expires: tsclient.Time{time.Date(2022, 8, 9, 11, 50, 23, 0, time.UTC)}, - Hostname: "test", - IsExternal: false, - LastSeen: tsclient.Time{time.Date(2022, 3, 9, 20, 3, 42, 0, time.UTC)}, - MachineKey: "mkey:test", - NodeKey: "nodekey:test", - OS: "windows", - UpdateAvailable: true, - }, - }, - } - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - server.ResponseBody = expectedDevices - - actualDevices, err := client.Devices().List(context.Background()) - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/devices", server.Path) - assert.EqualValues(t, expectedDevices["devices"], actualDevices) -} - -func TestDevices_Unmarshal(t *testing.T) { - t.Parallel() - - tt := []struct { - Name string - DevicesContent []byte - Expected []tsclient.Device - UnmarshalFunc func(data []byte, v interface{}) error - }{ - { - Name: "It should handle badly formed devices", - DevicesContent: jsonDevices, - UnmarshalFunc: json.Unmarshal, - Expected: []tsclient.Device{ - { - Addresses: []string{"100.101.102.103", "fd7a:115c:a1e0:ab12:4843:cd96:6265:6667"}, - Authorized: true, - BlocksIncomingConnections: false, - ClientVersion: "", - Created: tsclient.Time{}, - Expires: tsclient.Time{ - time.Date(1, 1, 1, 00, 00, 00, 0, time.UTC), - }, - Hostname: "hello", - ID: "50052", - IsExternal: true, - KeyExpiryDisabled: true, - LastSeen: tsclient.Time{ - time.Date(2022, 4, 15, 13, 24, 40, 0, time.UTC), - }, - MachineKey: "", - Name: "hello.example.com", - NodeKey: "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", - OS: "linux", - UpdateAvailable: false, - User: "services@example.com", - }, - { - Addresses: []string{"100.121.200.21", "fd7a:115c:a1e0:ab12:4843:cd96:6265:e618"}, - Authorized: true, - BlocksIncomingConnections: false, - ClientVersion: "1.22.2-t60b671955-gecc5d9846", - Created: tsclient.Time{ - time.Date(2022, 3, 5, 17, 10, 27, 0, time.UTC), - }, - Expires: tsclient.Time{ - time.Date(2022, 9, 1, 17, 10, 27, 0, time.UTC), - }, - Hostname: "foo", - ID: "50053", - IsExternal: false, - KeyExpiryDisabled: true, - LastSeen: tsclient.Time{ - time.Date(2022, 4, 15, 13, 25, 21, 0, time.UTC), - }, - MachineKey: "mkey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", - Name: "foo.example.com", - NodeKey: "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", - OS: "linux", - UpdateAvailable: false, - User: "foo@example.com", - }, - }, - }, - } - - for _, tc := range tt { - t.Run(tc.Name, func(t *testing.T) { - actual := make(map[string][]tsclient.Device) - - assert.NoError(t, tc.UnmarshalFunc(tc.DevicesContent, &actual)) - assert.EqualValues(t, tc.Expected, actual["devices"]) - }) - } -} - -func TestClient_DeleteDevice(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - ctx := context.Background() - - deviceID := "deviceTestId" - assert.NoError(t, client.Devices().Delete(ctx, deviceID)) - assert.Equal(t, http.MethodDelete, server.Method) - assert.Equal(t, "/api/v2/device/deviceTestId", server.Path) -} - -func TestClient_DeviceSubnetRoutes(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - server.ResponseBody = &tsclient.DeviceRoutes{ - Advertised: []string{"127.0.0.1"}, - Enabled: []string{"127.0.0.1"}, - } - - const deviceID = "test" - - routes, err := client.Devices().SubnetRoutes(context.Background(), deviceID) - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/device/test/routes", server.Path) - assert.Equal(t, server.ResponseBody, routes) -} - -func TestClient_SetDeviceAuthorized(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - const deviceID = "test" - - for _, value := range []bool{true, false} { - assert.NoError(t, client.Devices().SetAuthorized(context.Background(), deviceID, value)) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/device/test/authorized", server.Path) - - body := make(map[string]bool) - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) - assert.EqualValues(t, value, body["authorized"]) - } -} - -func TestClient_SetDeviceName(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - const deviceID = "test" - name := "test" - - assert.NoError(t, client.Devices().SetName(context.Background(), deviceID, name)) - assert.EqualValues(t, http.MethodPost, server.Method) - assert.EqualValues(t, "/api/v2/device/"+deviceID+"/name", server.Path) - - body := make(map[string]string) - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) - assert.EqualValues(t, name, body["name"]) -} - -func TestClient_SetDeviceTags(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - const deviceID = "test" - tags := []string{"a:b", "b:c"} - - assert.NoError(t, client.Devices().SetTags(context.Background(), deviceID, tags)) - assert.EqualValues(t, http.MethodPost, server.Method) - assert.EqualValues(t, "/api/v2/device/"+deviceID+"/tags", server.Path) - - body := make(map[string][]string) - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) - assert.EqualValues(t, tags, body["tags"]) -} - -func TestClient_SetDevicePostureAttributes(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - server.ResponseBody = nil - - const deviceID = "test" - const attributeKey = "custom:test" - - setRequest := tsclient.DevicePostureAttributeRequest{ - Value: "value", - Expiry: tsclient.Time{time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC)}, - Comment: "test", - } - - assert.NoError(t, client.Devices().SetPostureAttribute(context.Background(), deviceID, attributeKey, setRequest)) - assert.EqualValues(t, http.MethodPost, server.Method) - assert.EqualValues(t, "/api/v2/device/"+deviceID+"/attributes/"+attributeKey, server.Path) - - var receivedRequest tsclient.DevicePostureAttributeRequest - err := json.Unmarshal(server.Body.Bytes(), &receivedRequest) - assert.NoError(t, err) - assert.EqualValues(t, setRequest, receivedRequest) -} - -func TestClient_SetDeviceKey(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - const deviceID = "test" - expected := tsclient.DeviceKey{ - KeyExpiryDisabled: true, - } - - assert.NoError(t, client.Devices().SetKey(context.Background(), deviceID, expected)) - - assert.EqualValues(t, http.MethodPost, server.Method) - assert.EqualValues(t, "/api/v2/device/"+deviceID+"/key", server.Path) - - var actual tsclient.DeviceKey - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actual)) - assert.EqualValues(t, expected, actual) -} - -func TestClient_SetDeviceIPv4Address(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - const deviceID = "test" - address := "100.64.0.1" - - assert.NoError(t, client.Devices().SetIPv4Address(context.Background(), deviceID, address)) - assert.Equal(t, http.MethodPost, server.Method) - assert.EqualValues(t, "/api/v2/device/"+deviceID+"/ip", server.Path) -} - -func TestClient_UserAgent(t *testing.T) { - t.Parallel() - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - // Check the default user-agent. - assert.NoError(t, client.Devices().SetAuthorized(context.Background(), "test", true)) - assert.Equal(t, "tailscale-client-go", server.Header.Get("User-Agent")) - - // Check a custom user-agent. - client = &tsclient.Client{ - APIKey: "fake key", - BaseURL: server.BaseURL, - UserAgent: "custom-user-agent", - } - assert.NoError(t, client.Devices().SetAuthorized(context.Background(), "test", true)) - assert.Equal(t, "custom-user-agent", server.Header.Get("User-Agent")) -} diff --git a/v2/dns.go b/v2/dns.go deleted file mode 100644 index f2f71cc..0000000 --- a/v2/dns.go +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - "context" - "net/http" -) - -// DNSResource provides access to https://tailscale.com/api#tag/dns. -type DNSResource struct { - *Client -} - -// SplitDNSRequest is a map from domain names to a list of nameservers. -type SplitDNSRequest map[string][]string - -// SplitDNSResponse is a map from domain names to a list of nameservers. -type SplitDNSResponse SplitDNSRequest - -type DNSPreferences struct { - MagicDNS bool `json:"magicDNS"` -} - -// SetSearchPaths replaces the list of search paths with the list supplied by the user and returns an error otherwise. -func (dr *DNSResource) SetSearchPaths(ctx context.Context, searchPaths []string) error { - req, err := dr.buildRequest(ctx, http.MethodPost, dr.buildTailnetURL("dns", "searchpaths"), requestBody(map[string][]string{ - "searchPaths": searchPaths, - })) - if err != nil { - return err - } - - return dr.do(req, nil) -} - -// SearchPaths retrieves the list of search paths that is currently set for the given tailnet. -func (dr *DNSResource) SearchPaths(ctx context.Context) ([]string, error) { - req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildTailnetURL("dns", "searchpaths")) - if err != nil { - return nil, err - } - - resp := make(map[string][]string) - if err = dr.do(req, &resp); err != nil { - return nil, err - } - - return resp["searchPaths"], nil -} - -// SetNameservers replaces the list of DNS nameservers for the given tailnet with the list supplied by the user. Note -// that changing the list of DNS nameservers may also affect the status of MagicDNS (if MagicDNS is on). -func (dr *DNSResource) SetNameservers(ctx context.Context, dns []string) error { - req, err := dr.buildRequest(ctx, http.MethodPost, dr.buildTailnetURL("dns", "nameservers"), requestBody(map[string][]string{ - "dns": dns, - })) - if err != nil { - return err - } - - return dr.do(req, nil) -} - -// Nameservers lists the DNS nameservers for the tailnet -func (dr *DNSResource) Nameservers(ctx context.Context) ([]string, error) { - req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildTailnetURL("dns", "nameservers")) - if err != nil { - return nil, err - } - - resp := make(map[string][]string) - if err = dr.do(req, &resp); err != nil { - return nil, err - } - - return resp["dns"], nil -} - -// UpdateSplitDNS updates the split DNS settings for the tailnet using the -// provided [SplitDNSRequest] object. This is a PATCH operation that performs -// partial updates of the underlying data structure. -// -// Mapping a domain to a nil slice in the request will unset the nameservers -// associated with that domain. Values provided for domains will overwrite the -// current value associated with the domain. Domains not included in the request -// will remain unchanged. -func (dr *DNSResource) UpdateSplitDNS(ctx context.Context, request SplitDNSRequest) (SplitDNSResponse, error) { - req, err := dr.buildRequest(ctx, http.MethodPatch, dr.buildTailnetURL("dns", "split-dns"), requestBody(request)) - if err != nil { - return nil, err - } - - var resp SplitDNSResponse - if err := dr.do(req, &resp); err != nil { - return nil, err - } - return resp, nil -} - -// SetSplitDNS sets the split DNS settings for the tailnet using the provided -// [SplitDNSRequest] object. This is a PUT operation that fully replaces the underlying -// data structure. -// -// Passing in an empty [SplitDNSRequest] will unset all split DNS mappings for the tailnet. -func (dr *DNSResource) SetSplitDNS(ctx context.Context, request SplitDNSRequest) error { - req, err := dr.buildRequest(ctx, http.MethodPut, dr.buildTailnetURL("dns", "split-dns"), requestBody(request)) - if err != nil { - return err - } - - return dr.do(req, nil) -} - -// SplitDNS retrieves the split DNS configuration for the tailnet. -func (dr *DNSResource) SplitDNS(ctx context.Context) (SplitDNSResponse, error) { - req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildTailnetURL("dns", "split-dns")) - if err != nil { - return nil, err - } - - var resp SplitDNSResponse - if err := dr.do(req, &resp); err != nil { - return nil, err - } - return resp, nil -} - -// Preferences retrieves the DNS preferences that are currently set for the given tailnet. -func (dr *DNSResource) Preferences(ctx context.Context) (*DNSPreferences, error) { - req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildTailnetURL("dns", "preferences")) - if err != nil { - return nil, err - } - - return body[DNSPreferences](dr, req) -} - -// SetPreferences replaces the DNS preferences for the tailnet, specifically, the MagicDNS setting. Note that MagicDNS -// is dependent on DNS servers. -func (dr *DNSResource) SetPreferences(ctx context.Context, preferences DNSPreferences) error { - req, err := dr.buildRequest(ctx, http.MethodPost, dr.buildTailnetURL("dns", "preferences"), requestBody(preferences)) - if err != nil { - return nil - } - - return dr.do(req, nil) -} diff --git a/v2/dns_test.go b/v2/dns_test.go deleted file mode 100644 index 562c0f1..0000000 --- a/v2/dns_test.go +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient_test - -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -func TestClient_DNSNameservers(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - expectedNameservers := map[string][]string{ - "dns": {"127.0.0.1"}, - } - - server.ResponseBody = expectedNameservers - nameservers, err := client.DNS().Nameservers(context.Background()) - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/dns/nameservers", server.Path) - assert.Equal(t, expectedNameservers["dns"], nameservers) -} - -func TestClient_DNSPreferences(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - server.ResponseBody = &tsclient.DNSPreferences{ - MagicDNS: true, - } - - preferences, err := client.DNS().Preferences(context.Background()) - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/dns/preferences", server.Path) - assert.Equal(t, server.ResponseBody, preferences) -} - -func TestClient_DNSSearchPaths(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - expectedPaths := map[string][]string{ - "searchPaths": {"test"}, - } - - server.ResponseBody = expectedPaths - - paths, err := client.DNS().SearchPaths(context.Background()) - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/dns/searchpaths", server.Path) - assert.Equal(t, expectedPaths["searchPaths"], paths) -} - -func TestClient_SplitDNS(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - expectedNameservers := tsclient.SplitDNSResponse{ - "example.com": {"1.1.1.1", "1.2.3.4"}, - } - - server.ResponseBody = expectedNameservers - nameservers, err := client.DNS().SplitDNS(context.Background()) - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/dns/split-dns", server.Path) - assert.EqualValues(t, expectedNameservers, nameservers) -} - -func TestClient_SetDNSNameservers(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - nameservers := []string{"127.0.0.1"} - - assert.NoError(t, client.DNS().SetNameservers(context.Background(), nameservers)) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/dns/nameservers", server.Path) - - body := make(map[string][]string) - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) - assert.EqualValues(t, nameservers, body["dns"]) -} - -func TestClient_SetDNSPreferences(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - preferences := tsclient.DNSPreferences{ - MagicDNS: true, - } - - assert.NoError(t, client.DNS().SetPreferences(context.Background(), preferences)) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/dns/preferences", server.Path) - - var body tsclient.DNSPreferences - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) - assert.EqualValues(t, preferences, body) -} - -func TestClient_SetDNSSearchPaths(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - paths := []string{"test"} - - assert.NoError(t, client.DNS().SetSearchPaths(context.Background(), paths)) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/dns/searchpaths", server.Path) - - body := make(map[string][]string) - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) - assert.EqualValues(t, paths, body["searchPaths"]) -} - -func TestClient_UpdateSplitDNS(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - nameservers := []string{"1.1.2.1", "3.3.3.4"} - request := tsclient.SplitDNSRequest{ - "example.com": nameservers, - } - - expectedNameservers := tsclient.SplitDNSResponse{ - "example.com": nameservers, - } - server.ResponseBody = expectedNameservers - - resp, err := client.DNS().UpdateSplitDNS(context.Background(), request) - assert.NoError(t, err) - assert.Equal(t, http.MethodPatch, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/dns/split-dns", server.Path) - - body := make(tsclient.SplitDNSResponse) - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) - assert.EqualValues(t, nameservers, body["example.com"]) - assert.EqualValues(t, expectedNameservers, resp) -} - -func TestClient_SetSplitDNS(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - nameservers := []string{"1.1.2.1", "3.3.3.4"} - request := tsclient.SplitDNSRequest{ - "example.com": nameservers, - } - - assert.NoError(t, client.DNS().SetSplitDNS(context.Background(), request)) - assert.Equal(t, http.MethodPut, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/dns/split-dns", server.Path) - - body := make(tsclient.SplitDNSResponse) - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) - assert.EqualValues(t, nameservers, body["example.com"]) -} diff --git a/v2/go.mod b/v2/go.mod deleted file mode 100644 index 1de477b..0000000 --- a/v2/go.mod +++ /dev/null @@ -1,15 +0,0 @@ -module github.com/tailscale/tailscale-client-go/v2 - -go 1.22.0 - -require ( - github.com/stretchr/testify v1.9.0 - github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 - golang.org/x/oauth2 v0.21.0 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/v2/go.sum b/v2/go.sum deleted file mode 100644 index 66acc2a..0000000 --- a/v2/go.sum +++ /dev/null @@ -1,16 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 h1:erxeiTyq+nw4Cz5+hLDkOwNF5/9IQWCQPv0gpb3+QHU= -github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/v2/keys.go b/v2/keys.go deleted file mode 100644 index 890b447..0000000 --- a/v2/keys.go +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - "context" - "net/http" - "time" -) - -// KeysResource provides access to https://tailscale.com/api#tag/keys. -type KeysResource struct { - *Client -} - -// KeyCapabilities describes the capabilities of an authentication key. -type KeyCapabilities struct { - Devices struct { - Create struct { - Reusable bool `json:"reusable"` - Ephemeral bool `json:"ephemeral"` - Tags []string `json:"tags"` - Preauthorized bool `json:"preauthorized"` - } `json:"create"` - } `json:"devices"` -} - -// CreateKeyRequest describes the definition of an authentication key to create. -type CreateKeyRequest struct { - Capabilities KeyCapabilities `json:"capabilities"` - ExpirySeconds int64 `json:"expirySeconds"` - Description string `json:"description"` -} - -// Key describes an authentication key within the tailnet. -type Key struct { - ID string `json:"id"` - Key string `json:"key"` - Description string `json:"description"` - Created time.Time `json:"created"` - Expires time.Time `json:"expires"` - Revoked time.Time `json:"revoked"` - Invalid bool `json:"invalid"` - Capabilities KeyCapabilities `json:"capabilities"` - UserID string `json:"userId"` -} - -// Create creates a new authentication key. Returns the generated [Key] if successful. -func (kr *KeysResource) Create(ctx context.Context, ckr CreateKeyRequest) (*Key, error) { - req, err := kr.buildRequest(ctx, http.MethodPost, kr.buildTailnetURL("keys"), requestBody(ckr)) - if err != nil { - return nil, err - } - - return body[Key](kr, req) -} - -// Get returns all information on a [Key] whose identifier matches the one provided. This will not return the -// authentication key itself, just the metadata. -func (kr *KeysResource) Get(ctx context.Context, id string) (*Key, error) { - req, err := kr.buildRequest(ctx, http.MethodGet, kr.buildTailnetURL("keys", id)) - if err != nil { - return nil, err - } - - return body[Key](kr, req) -} - -// List returns every [Key] within the tailnet. The only fields set for each [Key] will be its identifier. -// The keys returned are relative to the user that owns the API key used to authenticate the client. -// -// Specify all to list both user and tailnet level keys. -func (kr *KeysResource) List(ctx context.Context, all bool) ([]Key, error) { - url := kr.buildTailnetURL("keys") - if all { - url.RawQuery = "all=true" - } - req, err := kr.buildRequest(ctx, http.MethodGet, url) - if err != nil { - return nil, err - } - - resp := make(map[string][]Key) - if err = kr.do(req, &resp); err != nil { - return nil, err - } - - return resp["keys"], nil -} - -// Delete removes an authentication key from the tailnet. -func (kr *KeysResource) Delete(ctx context.Context, id string) error { - req, err := kr.buildRequest(ctx, http.MethodDelete, kr.buildTailnetURL("keys", id)) - if err != nil { - return err - } - - return kr.do(req, nil) -} diff --git a/v2/keys_test.go b/v2/keys_test.go deleted file mode 100644 index 7c7f4af..0000000 --- a/v2/keys_test.go +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient_test - -import ( - "context" - "encoding/json" - "net/http" - "testing" - "time" - - "github.com/stretchr/testify/assert" - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -func TestClient_CreateKey(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - capabilities := tsclient.KeyCapabilities{} - capabilities.Devices.Create.Ephemeral = true - capabilities.Devices.Create.Reusable = true - capabilities.Devices.Create.Preauthorized = true - capabilities.Devices.Create.Tags = []string{"test:test"} - - expected := &tsclient.Key{ - ID: "test", - Key: "thisisatestkey", - Created: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Expires: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Capabilities: capabilities, - Description: "", - } - - server.ResponseBody = expected - - actual, err := client.Keys().Create(context.Background(), tsclient.CreateKeyRequest{ - Capabilities: capabilities, - }) - assert.NoError(t, err) - assert.EqualValues(t, expected, actual) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/keys", server.Path) - - var actualReq tsclient.CreateKeyRequest - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualReq)) - assert.EqualValues(t, capabilities, actualReq.Capabilities) - assert.EqualValues(t, 0, actualReq.ExpirySeconds) - assert.EqualValues(t, "", actualReq.Description) -} - -func TestClient_CreateKeyWithExpirySeconds(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - capabilities := tsclient.KeyCapabilities{} - capabilities.Devices.Create.Ephemeral = true - capabilities.Devices.Create.Reusable = true - capabilities.Devices.Create.Preauthorized = true - capabilities.Devices.Create.Tags = []string{"test:test"} - - expected := &tsclient.Key{ - ID: "test", - Key: "thisisatestkey", - Created: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Expires: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Capabilities: capabilities, - Description: "", - } - - server.ResponseBody = expected - - actual, err := client.Keys().Create(context.Background(), tsclient.CreateKeyRequest{ - Capabilities: capabilities, - ExpirySeconds: 1440, - }) - assert.NoError(t, err) - assert.EqualValues(t, expected, actual) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/keys", server.Path) - - var actualReq tsclient.CreateKeyRequest - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualReq)) - assert.EqualValues(t, capabilities, actualReq.Capabilities) - assert.EqualValues(t, 1440, actualReq.ExpirySeconds) - assert.EqualValues(t, "", actualReq.Description) -} - -func TestClient_CreateKeyWithDescription(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - capabilities := tsclient.KeyCapabilities{} - capabilities.Devices.Create.Ephemeral = true - capabilities.Devices.Create.Reusable = true - capabilities.Devices.Create.Preauthorized = true - capabilities.Devices.Create.Tags = []string{"test:test"} - - expected := &tsclient.Key{ - ID: "test", - Key: "thisisatestkey", - Created: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Expires: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Capabilities: capabilities, - Description: "key description", - } - - server.ResponseBody = expected - - actual, err := client.Keys().Create(context.Background(), tsclient.CreateKeyRequest{ - Capabilities: capabilities, - Description: "key description", - }) - assert.NoError(t, err) - assert.EqualValues(t, expected, actual) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/keys", server.Path) - - var actualReq tsclient.CreateKeyRequest - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualReq)) - assert.EqualValues(t, capabilities, actualReq.Capabilities) - assert.EqualValues(t, 0, actualReq.ExpirySeconds) - assert.EqualValues(t, "key description", actualReq.Description) -} - -func TestClient_GetKey(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - capabilities := tsclient.KeyCapabilities{} - capabilities.Devices.Create.Ephemeral = true - capabilities.Devices.Create.Reusable = true - capabilities.Devices.Create.Preauthorized = true - capabilities.Devices.Create.Tags = []string{"test:test"} - - expected := &tsclient.Key{ - ID: "test", - Created: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Expires: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), - Capabilities: capabilities, - Description: "", - } - - server.ResponseBody = expected - - actual, err := client.Keys().Get(context.Background(), expected.ID) - assert.NoError(t, err) - assert.EqualValues(t, expected, actual) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/keys/"+expected.ID, server.Path) -} - -func TestClient_Keys(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - expected := []tsclient.Key{ - {ID: "key-a"}, - {ID: "key-b"}, - } - - server.ResponseBody = map[string][]tsclient.Key{ - "keys": expected, - } - - actual, err := client.Keys().List(context.Background(), false) - assert.NoError(t, err) - assert.EqualValues(t, expected, actual) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/keys", server.Path) -} - -func TestClient_DeleteKey(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - const keyID = "test" - - assert.NoError(t, client.Keys().Delete(context.Background(), keyID)) - assert.Equal(t, http.MethodDelete, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/keys/"+keyID, server.Path) -} diff --git a/v2/logging.go b/v2/logging.go deleted file mode 100644 index 6cf0c64..0000000 --- a/v2/logging.go +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - "context" - "net/http" -) - -// LoggingResource provides access to https://tailscale.com/api#tag/logging. -type LoggingResource struct { - *Client -} - -const ( - LogstreamSplunkEndpoint LogstreamEndpointType = "splunk" - LogstreamElasticEndpoint LogstreamEndpointType = "elastic" - LogstreamPantherEndpoint LogstreamEndpointType = "panther" - LogstreamCriblEndpoint LogstreamEndpointType = "cribl" - LogstreamDatadogEndpoint LogstreamEndpointType = "datadog" - LogstreamAxiomEndpoint LogstreamEndpointType = "axiom" - LogstreamS3Endpoint LogstreamEndpointType = "s3" -) - -const ( - LogTypeConfig LogType = "configuration" - LogTypeNetwork LogType = "network" -) - -const ( - S3AccessKeyAuthentication S3AuthenticationType = "accesskey" - S3RoleARNAuthentication S3AuthenticationType = "rolearn" -) - -// LogstreamConfiguration type defines a log stream entity in tailscale. -type LogstreamConfiguration struct { - LogType LogType `json:"logType,omitempty"` - DestinationType LogstreamEndpointType `json:"destinationType,omitempty"` - URL string `json:"url,omitempty"` - User string `json:"user,omitempty"` - S3Bucket string `json:"s3Bucket,omitempty"` - S3Region string `json:"s3Region,omitempty"` - S3KeyPrefix string `json:"s3KeyPrefix,omitempty"` - S3AuthenticationType S3AuthenticationType `json:"s3AuthenticationType,omitempty"` - S3AccessKeyID string `json:"s3AccessKeyId,omitempty"` - S3RoleARN string `json:"s3RoleArn,omitempty"` - S3ExternalID string `json:"s3ExternalId,omitempty"` -} - -// SetLogstreamConfigurationRequest type defines a request for setting a LogstreamConfiguration. -type SetLogstreamConfigurationRequest struct { - DestinationType LogstreamEndpointType `json:"destinationType,omitempty"` - URL string `json:"url,omitempty"` - User string `json:"user,omitempty"` - Token string `json:"token,omitempty"` - S3Bucket string `json:"s3Bucket,omitempty"` - S3Region string `json:"s3Region,omitempty"` - S3KeyPrefix string `json:"s3KeyPrefix,omitempty"` - S3AuthenticationType S3AuthenticationType `json:"s3AuthenticationType,omitempty"` - S3AccessKeyID string `json:"s3AccessKeyId,omitempty"` - S3SecretAccessKey string `json:"s3SecretAccessKey,omitempty"` - S3RoleARN string `json:"s3RoleArn,omitempty"` - S3ExternalID string `json:"s3ExternalId,omitempty"` -} - -// LogstreamEndpointType describes the type of the endpoint. -type LogstreamEndpointType string - -// LogType describes the type of logging. -type LogType string - -// S3AuthenticationType describes the type of authentication used to stream logs to a LogstreamS3Endpoint. -type S3AuthenticationType string - -// LogstreamConfiguration retrieves the tailnet's [LogstreamConfiguration] for the given [LogType]. -func (lr *LoggingResource) LogstreamConfiguration(ctx context.Context, logType LogType) (*LogstreamConfiguration, error) { - req, err := lr.buildRequest(ctx, http.MethodGet, lr.buildTailnetURL("logging", logType, "stream")) - if err != nil { - return nil, err - } - - return body[LogstreamConfiguration](lr, req) -} - -// SetLogstreamConfiguration sets the tailnet's [LogstreamConfiguration] for the given [LogType]. -func (lr *LoggingResource) SetLogstreamConfiguration(ctx context.Context, logType LogType, request SetLogstreamConfigurationRequest) error { - req, err := lr.buildRequest(ctx, http.MethodPut, lr.buildTailnetURL("logging", logType, "stream"), requestBody(request)) - if err != nil { - return err - } - - return lr.do(req, nil) -} - -// DeleteLogstreamConfiguration deletes the tailnet's [LogstreamConfiguration] for the given [LogType]. -func (lr *LoggingResource) DeleteLogstreamConfiguration(ctx context.Context, logType LogType) error { - req, err := lr.buildRequest(ctx, http.MethodDelete, lr.buildTailnetURL("logging", logType, "stream")) - if err != nil { - return err - } - - return lr.do(req, nil) -} - -// AWSExternalID represents an AWS External ID that Tailscale can use to stream logs from a -// particular Tailscale AWS account to a LogstreamS3Endpoint that uses S3RoleARNAuthentication. -type AWSExternalID struct { - ExternalID string `json:"externalId,omitempty"` - TailscaleAWSAccountID string `json:"tailscaleAwsAccountId,omitempty"` -} - -// CreateOrGetAwsExternalId gets an AWS External ID that Tailscale can use to stream logs to -// a LogstreamS3Endpoint using S3RoleARNAuthentication, creating a new one for this tailnet -// when necessary. -func (lr *LoggingResource) CreateOrGetAwsExternalId(ctx context.Context, reusable bool) (*AWSExternalID, error) { - req, err := lr.buildRequest(ctx, http.MethodPost, lr.buildTailnetURL("aws-external-id"), requestBody(map[string]bool{ - "reusable": reusable, - })) - if err != nil { - return nil, err - } - return body[AWSExternalID](lr, req) -} - -// ValidateAWSTrustPolicy validates that Tailscale can assume your AWS IAM role with (and only -// with) the given AWS External ID. -func (lr *LoggingResource) ValidateAWSTrustPolicy(ctx context.Context, awsExternalID string, roleARN string) error { - req, err := lr.buildRequest(ctx, http.MethodPost, lr.buildTailnetURL("aws-external-id", awsExternalID, "validate-aws-trust-policy"), requestBody(map[string]string{ - "roleArn": roleARN, - })) - if err != nil { - return err - } - return lr.do(req, nil) -} diff --git a/v2/logging_test.go b/v2/logging_test.go deleted file mode 100644 index 9b4b8ae..0000000 --- a/v2/logging_test.go +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient_test - -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -func TestClient_LogstreamConfiguration(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - expectedLogstream := &tsclient.LogstreamConfiguration{} - server.ResponseBody = expectedLogstream - - actualWebhook, err := client.Logging().LogstreamConfiguration(context.Background(), tsclient.LogTypeConfig) - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/logging/configuration/stream", server.Path) - assert.Equal(t, expectedLogstream, actualWebhook) -} - -func TestClient_SetLogstreamConfiguration(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - logstreamRequest := tsclient.SetLogstreamConfigurationRequest{ - DestinationType: tsclient.LogstreamCriblEndpoint, - URL: "http://example.com", - User: "my-user", - Token: "my-token", - S3Bucket: "my-bucket", - S3Region: "us-west-2", - S3KeyPrefix: "logs/", - S3AuthenticationType: tsclient.S3AccessKeyAuthentication, - S3AccessKeyID: "my-access-key-id", - S3SecretAccessKey: "my-secret-access-key", - S3RoleARN: "my-role-arn", - S3ExternalID: "my-external-id", - } - server.ResponseBody = nil - - err := client.Logging().SetLogstreamConfiguration(context.Background(), tsclient.LogTypeNetwork, logstreamRequest) - assert.NoError(t, err) - assert.Equal(t, http.MethodPut, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/logging/network/stream", server.Path) - var receivedRequest tsclient.SetLogstreamConfigurationRequest - err = json.Unmarshal(server.Body.Bytes(), &receivedRequest) - assert.NoError(t, err) - assert.EqualValues(t, logstreamRequest, receivedRequest) -} - -func TestClient_DeleteLogstream(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - err := client.Logging().DeleteLogstreamConfiguration(context.Background(), tsclient.LogTypeConfig) - assert.NoError(t, err) - assert.Equal(t, http.MethodDelete, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/logging/configuration/stream", server.Path) -} - -func TestClient_CreateOrGetAwsExternalId(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - wantExternalID := &tsclient.AWSExternalID{ - ExternalID: "external-id", - TailscaleAWSAccountID: "account-id", - } - server.ResponseBody = wantExternalID - - gotExternalID, err := client.Logging().CreateOrGetAwsExternalId(context.Background(), true) - assert.NoError(t, err) - assert.Equal(t, server.Method, http.MethodPost) - assert.Equal(t, server.Path, "/api/v2/tailnet/example.com/aws-external-id") - assert.Equal(t, gotExternalID, wantExternalID) - - gotRequest := make(map[string]bool) - err = json.Unmarshal(server.Body.Bytes(), &gotRequest) - assert.NoError(t, err) - assert.EqualValues(t, gotRequest, map[string]bool{"reusable": true}) -} - -func TestClient_ValidateAWSTrustPolicy(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - roleARN := "arn:aws:iam::123456789012:role/example-role" - - err := client.Logging().ValidateAWSTrustPolicy(context.Background(), "external-id-0000-0000", roleARN) - assert.NoError(t, err) - assert.Equal(t, server.Method, http.MethodPost) - assert.Equal(t, server.Path, "/api/v2/tailnet/example.com/aws-external-id/external-id-0000-0000/validate-aws-trust-policy") - - gotRequest := make(map[string]string) - err = json.Unmarshal(server.Body.Bytes(), &gotRequest) - assert.NoError(t, err) - assert.EqualValues(t, gotRequest, map[string]string{"roleArn": roleARN}) -} diff --git a/v2/oauth.go b/v2/oauth.go deleted file mode 100644 index 133977b..0000000 --- a/v2/oauth.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - "context" - "net/http" - - "golang.org/x/oauth2/clientcredentials" -) - -// OAuthConfig provides a mechanism for configuring OAuth authentication. -type OAuthConfig struct { - // ClientID is the client ID of the OAuth client. - ClientID string - // ClientSecret is the client secret of the OAuth client. - ClientSecret string - // Scopes are the scopes to request when generating tokens for this OAuth client. - Scopes []string - // BaseURL is an optional base URL for the API server to which we'll connect. Defaults to https://api.tailscale.com. - BaseURL string -} - -// HTTPClient constructs an HTTP client that authenticates using OAuth. -func (ocfg OAuthConfig) HTTPClient() *http.Client { - baseURL := ocfg.BaseURL - if baseURL == "" { - baseURL = defaultBaseURL.String() - } - oauthConfig := clientcredentials.Config{ - ClientID: ocfg.ClientID, - ClientSecret: ocfg.ClientSecret, - Scopes: ocfg.Scopes, - TokenURL: baseURL + "/api/v2/oauth/token", - } - - // Use context.Background() here, since this is used to refresh the token in the future. - client := oauthConfig.Client(context.Background()) - client.Timeout = defaultHttpClientTimeout - return client -} diff --git a/v2/policyfile.go b/v2/policyfile.go deleted file mode 100644 index f66e799..0000000 --- a/v2/policyfile.go +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - "context" - "fmt" - "net/http" -) - -// PolicyFileResource provides access to https://tailscale.com/api#tag/policyfile. -type PolicyFileResource struct { - *Client -} - -// ACL contains the schema for a tailnet policy file. More details: https://tailscale.com/kb/1018/acls/ -type ACL struct { - ACLs []ACLEntry `json:"acls,omitempty" hujson:"ACLs,omitempty"` - AutoApprovers *ACLAutoApprovers `json:"autoApprovers,omitempty" hujson:"AutoApprovers,omitempty"` - Groups map[string][]string `json:"groups,omitempty" hujson:"Groups,omitempty"` - Hosts map[string]string `json:"hosts,omitempty" hujson:"Hosts,omitempty"` - TagOwners map[string][]string `json:"tagOwners,omitempty" hujson:"TagOwners,omitempty"` - DERPMap *ACLDERPMap `json:"derpMap,omitempty" hujson:"DerpMap,omitempty"` - Tests []ACLTest `json:"tests,omitempty" hujson:"Tests,omitempty"` - SSH []ACLSSH `json:"ssh,omitempty" hujson:"SSH,omitempty"` - NodeAttrs []NodeAttrGrant `json:"nodeAttrs,omitempty" hujson:"NodeAttrs,omitempty"` - DisableIPv4 bool `json:"disableIPv4,omitempty" hujson:"DisableIPv4,omitempty"` - OneCGNATRoute string `json:"oneCGNATRoute,omitempty" hujson:"OneCGNATRoute,omitempty"` - RandomizeClientPort bool `json:"randomizeClientPort,omitempty" hujson:"RandomizeClientPort,omitempty"` - - // Postures and DefaultSourcePosture are for an experimental feature and not yet public or documented as of 2023-08-17. - // This API is subject to change. Internal bug: corp/13986 - Postures map[string][]string `json:"postures,omitempty" hujson:"Postures,omitempty"` - DefaultSourcePosture []string `json:"defaultSrcPosture,omitempty" hujson:"DefaultSrcPosture,omitempty"` - - // ETag is the etag corresponding to this version of the ACL - ETag string `json:"-"` -} - -// RawACL contains a raw HuJSON ACL and its associated ETag. -type RawACL struct { - // HuJSON is the raw HuJSON ACL string - HuJSON string - - // ETag is the etag corresponding to this version of the ACL - ETag string -} - -type ACLAutoApprovers struct { - Routes map[string][]string `json:"routes,omitempty" hujson:"Routes,omitempty"` - ExitNode []string `json:"exitNode,omitempty" hujson:"ExitNode,omitempty"` -} - -type ACLEntry struct { - Action string `json:"action,omitempty" hujson:"Action,omitempty"` - Ports []string `json:"ports,omitempty" hujson:"Ports,omitempty"` - Users []string `json:"users,omitempty" hujson:"Users,omitempty"` - Source []string `json:"src,omitempty" hujson:"Src,omitempty"` - Destination []string `json:"dst,omitempty" hujson:"Dst,omitempty"` - Protocol string `json:"proto,omitempty" hujson:"Proto,omitempty"` - - // SourcePosture is for an experimental feature and not yet public or documented as of 2023-08-17. - SourcePosture []string `json:"srcPosture,omitempty" hujson:"SrcPosture,omitempty"` -} - -type ACLTest struct { - User string `json:"user,omitempty" hujson:"User,omitempty"` - Allow []string `json:"allow,omitempty" hujson:"Allow,omitempty"` - Deny []string `json:"deny,omitempty" hujson:"Deny,omitempty"` - Source string `json:"src,omitempty" hujson:"Src,omitempty"` - Accept []string `json:"accept,omitempty" hujson:"Accept,omitempty"` -} - -type ACLDERPMap struct { - Regions map[int]*ACLDERPRegion `json:"regions" hujson:"Regions"` - OmitDefaultRegions bool `json:"omitDefaultRegions,omitempty" hujson:"OmitDefaultRegions,omitempty"` -} - -type ACLDERPRegion struct { - RegionID int `json:"regionID" hujson:"RegionID"` - RegionCode string `json:"regionCode" hujson:"RegionCode"` - RegionName string `json:"regionName" hujson:"RegionName"` - Avoid bool `json:"avoid,omitempty" hujson:"Avoid,omitempty"` - Nodes []*ACLDERPNode `json:"nodes" hujson:"Nodes"` -} - -type ACLDERPNode struct { - Name string `json:"name" hujson:"Name"` - RegionID int `json:"regionID" hujson:"RegionID"` - HostName string `json:"hostName" hujson:"HostName"` - CertName string `json:"certName,omitempty" hujson:"CertName,omitempty"` - IPv4 string `json:"ipv4,omitempty" hujson:"IPv4,omitempty"` - IPv6 string `json:"ipv6,omitempty" hujson:"IPv6,omitempty"` - STUNPort int `json:"stunPort,omitempty" hujson:"STUNPort,omitempty"` - STUNOnly bool `json:"stunOnly,omitempty" hujson:"STUNOnly,omitempty"` - DERPPort int `json:"derpPort,omitempty" hujson:"DERPPort,omitempty"` -} - -type ACLSSH struct { - Action string `json:"action,omitempty" hujson:"Action,omitempty"` - Users []string `json:"users,omitempty" hujson:"Users,omitempty"` - Source []string `json:"src,omitempty" hujson:"Src,omitempty"` - Destination []string `json:"dst,omitempty" hujson:"Dst,omitempty"` - CheckPeriod Duration `json:"checkPeriod,omitempty" hujson:"CheckPeriod,omitempty"` - Recorder []string `json:"recorder,omitempty" hujson:"Recorder,omitempty"` - EnforceRecorder bool `json:"enforceRecorder,omitempty" hujson:"EnforceRecorder,omitempty"` -} - -type NodeAttrGrant struct { - Target []string `json:"target,omitempty" hujson:"Target,omitempty"` - Attr []string `json:"attr,omitempty" hujson:"Attr,omitempty"` - App map[string][]*NodeAttrGrantApp `json:"app,omitempty" hujson:"App,omitempty"` -} - -type NodeAttrGrantApp struct { - Name string `json:"name,omitempty" hujson:"Name,omitempty"` - Connectors []string `json:"connectors,omitempty" hujson:"Connectors,omitempty"` - Domains []string `json:"domains,omitempty" hujson:"Domains,omitempty"` -} - -// Get retrieves the [ACL] that is currently set for the tailnet. -func (pr *PolicyFileResource) Get(ctx context.Context) (*ACL, error) { - req, err := pr.buildRequest(ctx, http.MethodGet, pr.buildTailnetURL("acl")) - if err != nil { - return nil, err - } - - acl, header, err := bodyWithResponseHeader[ACL](pr, req) - if err != nil { - return nil, err - } - acl.ETag = header.Get("Etag") - return acl, nil -} - -// Raw retrieves the [ACL] that is currently set for the tailnet as a HuJSON string. -func (pr *PolicyFileResource) Raw(ctx context.Context) (*RawACL, error) { - req, err := pr.buildRequest(ctx, http.MethodGet, pr.buildTailnetURL("acl"), requestContentType("application/hujson")) - if err != nil { - return nil, err - } - - var resp []byte - header, err := pr.doWithResponseHeaders(req, &resp) - if err != nil { - return nil, err - } - - return &RawACL{ - HuJSON: string(resp), - ETag: header.Get("Etag"), - }, nil -} - -// Set sets the [ACL] for the tailnet. acl can either be an [ACL], or a HuJSON string. -// etag is an optional value that, if supplied, will be used in the "If-Match" HTTP request header. -func (pr *PolicyFileResource) Set(ctx context.Context, acl any, etag string) error { - headers := make(map[string]string) - if etag != "" { - headers["If-Match"] = fmt.Sprintf("%q", etag) - } - - reqOpts := []requestOption{ - requestHeaders(headers), - requestBody(acl), - } - switch v := acl.(type) { - case ACL: - case string: - reqOpts = append(reqOpts, requestContentType("application/hujson")) - default: - return fmt.Errorf("expected ACL content as a string or as ACL struct; got %T", v) - } - - req, err := pr.buildRequest(ctx, http.MethodPost, pr.buildTailnetURL("acl"), reqOpts...) - if err != nil { - return err - } - - return pr.do(req, nil) -} - -// Validate validates the provided ACL via the API. acl can either be an [ACL], or a HuJSON string. -func (pr *PolicyFileResource) Validate(ctx context.Context, acl any) error { - reqOpts := []requestOption{ - requestBody(acl), - } - switch v := acl.(type) { - case ACL: - case string: - reqOpts = append(reqOpts, requestContentType("application/hujson")) - default: - return fmt.Errorf("expected ACL content as a string or as ACL struct; got %T", v) - } - - req, err := pr.buildRequest(ctx, http.MethodPost, pr.buildTailnetURL("acl", "validate"), reqOpts...) - if err != nil { - return err - } - - var response APIError - if err := pr.do(req, &response); err != nil { - return err - } - if response.Message != "" { - return fmt.Errorf("ACL validation failed: %s; %v", response.Message, response.Data) - } - return nil -} diff --git a/v2/policyfile_test.go b/v2/policyfile_test.go deleted file mode 100644 index 30f6938..0000000 --- a/v2/policyfile_test.go +++ /dev/null @@ -1,387 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient_test - -import ( - "context" - _ "embed" - "encoding/json" - "net/http" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/tailscale/hujson" - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -var ( - //go:embed testdata/acl.json - jsonACL []byte - //go:embed testdata/acl.hujson - huJSONACL []byte -) - -func TestACL_Unmarshal(t *testing.T) { - t.Parallel() - - tt := []struct { - Name string - ACLContent []byte - Expected tsclient.ACL - UnmarshalFunc func(data []byte, v interface{}) error - }{ - { - Name: "It should handle JSON ACLs", - ACLContent: jsonACL, - UnmarshalFunc: json.Unmarshal, - Expected: tsclient.ACL{ - ACLs: []tsclient.ACLEntry{ - { - Action: "accept", - Ports: []string(nil), - Users: []string(nil), - Source: []string{"autogroup:members"}, - Destination: []string{"autogroup:self:*"}, - Protocol: "", - }, - { - Action: "accept", - Ports: []string(nil), - Users: []string(nil), - Source: []string{"group:dev"}, - Destination: []string{"tag:dev:*"}, - Protocol: "", - }, - { - Action: "accept", - Ports: []string(nil), - Users: []string(nil), - Source: []string{"group:devops"}, - Destination: []string{"tag:prod:*"}, - Protocol: "", - }, - { - Action: "accept", - Ports: []string(nil), - Users: []string(nil), - Source: []string{"autogroup:members"}, - Destination: []string{"tag:monitoring:80,443"}, - Protocol: "", - }, - }, - Groups: map[string][]string{ - "group:dev": {"alice@example.com", "bob@example.com"}, - "group:devops": {"carl@example.com"}, - }, - Hosts: map[string]string(nil), - TagOwners: map[string][]string{ - "tag:dev": {"group:devops"}, - "tag:monitoring": {"group:devops"}, - "tag:prod": {"group:devops"}, - }, - DERPMap: (*tsclient.ACLDERPMap)(nil), - Tests: []tsclient.ACLTest{ - { - User: "", - Allow: []string(nil), - Deny: []string(nil), - Source: "carl@example.com", - Accept: []string{"tag:prod:80"}, - }, - { - User: "", - Allow: []string(nil), - Deny: []string{"tag:prod:80"}, - Source: "alice@example.com", - Accept: []string{"tag:dev:80"}}, - }, - SSH: []tsclient.ACLSSH{ - { - Action: "accept", - Source: []string{"autogroup:members"}, - Destination: []string{"autogroup:self"}, - Users: []string{"root", "autogroup:nonroot"}, - }, - { - Action: "accept", - Source: []string{"autogroup:members"}, - Destination: []string{"tag:prod"}, - Users: []string{"root", "autogroup:nonroot"}, - }, - { - Action: "accept", - Source: []string{"tag:logging"}, - Destination: []string{"tag:prod"}, - Users: []string{"root", "autogroup:nonroot"}, - CheckPeriod: tsclient.Duration(time.Hour * 20), - }, - }, - }, - }, - { - Name: "It should handle HuJSON ACLs", - ACLContent: huJSONACL, - UnmarshalFunc: func(b []byte, v interface{}) error { - b = append([]byte{}, b...) - b, err := hujson.Standardize(b) - if err != nil { - return err - } - return json.Unmarshal(b, v) - }, - Expected: tsclient.ACL{ - ACLs: []tsclient.ACLEntry{ - { - Action: "accept", - Ports: []string(nil), - Users: []string(nil), - Source: []string{"autogroup:members"}, - Destination: []string{"autogroup:self:*"}, - Protocol: "", - }, - { - Action: "accept", - Ports: []string(nil), - Users: []string(nil), - Source: []string{"group:dev"}, - Destination: []string{"tag:dev:*"}, - Protocol: "", - }, - { - Action: "accept", - Ports: []string(nil), - Users: []string(nil), - Source: []string{"group:devops"}, - Destination: []string{"tag:prod:*"}, - Protocol: "", - }, - { - Action: "accept", - Ports: []string(nil), - Users: []string(nil), - Source: []string{"autogroup:members"}, - Destination: []string{"tag:monitoring:80,443"}, - Protocol: "", - }, - }, - Groups: map[string][]string{ - "group:dev": {"alice@example.com", "bob@example.com"}, - "group:devops": {"carl@example.com"}, - }, - Hosts: map[string]string(nil), - TagOwners: map[string][]string{ - "tag:dev": {"group:devops"}, - "tag:monitoring": {"group:devops"}, - "tag:prod": {"group:devops"}, - }, - DERPMap: (*tsclient.ACLDERPMap)(nil), - SSH: []tsclient.ACLSSH{ - { - Action: "accept", - Source: []string{"autogroup:members"}, - Destination: []string{"autogroup:self"}, - Users: []string{"root", "autogroup:nonroot"}, - }, - { - Action: "accept", - Source: []string{"autogroup:members"}, - Destination: []string{"tag:prod"}, - Users: []string{"root", "autogroup:nonroot"}, - }, - { - Action: "accept", - Source: []string{"tag:logging"}, - Destination: []string{"tag:prod"}, - Users: []string{"root", "autogroup:nonroot"}, - CheckPeriod: tsclient.Duration(time.Hour * 20), - }, - }, - Tests: []tsclient.ACLTest{ - { - User: "", - Allow: []string(nil), - Deny: []string(nil), - Source: "carl@example.com", - Accept: []string{"tag:prod:80"}, - }, - { - User: "", - Allow: []string(nil), - Deny: []string{"tag:prod:80"}, - Source: "alice@example.com", - Accept: []string{"tag:dev:80"}}, - }, - }, - }, - } - - for _, tc := range tt { - t.Run(tc.Name, func(t *testing.T) { - var actual tsclient.ACL - - assert.NoError(t, tc.UnmarshalFunc(tc.ACLContent, &actual)) - assert.EqualValues(t, tc.Expected, actual) - }) - } -} - -func TestClient_SetACL(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - expectedACL := tsclient.ACL{ - ACLs: []tsclient.ACLEntry{ - { - Action: "accept", - Ports: []string{"*:*"}, - Users: []string{"*"}, - }, - }, - TagOwners: map[string][]string{ - "tag:example": {"group:example"}, - }, - Hosts: map[string]string{ - "example-host-1": "100.100.100.100", - "example-host-2": "100.100.101.100/24", - }, - Groups: map[string][]string{ - "group:example": { - "user1@example.com", - "user2@example.com", - }, - }, - Tests: []tsclient.ACLTest{ - { - User: "user1@example.com", - Allow: []string{"example-host-1:22", "example-host-2:80"}, - Deny: []string{"exapmle-host-2:100"}, - }, - { - User: "user2@example.com", - Allow: []string{"100.60.3.4:22"}, - }, - }, - } - - assert.NoError(t, client.PolicyFile().Set(context.Background(), expectedACL, "")) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/acl", server.Path) - assert.Equal(t, "", server.Header.Get("If-Match")) - assert.EqualValues(t, "application/json", server.Header.Get("Content-Type")) - - var actualACL tsclient.ACL - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualACL)) - assert.EqualValues(t, expectedACL, actualACL) -} -func TestClient_SetACL_HuJSON(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - assert.NoError(t, client.PolicyFile().Set(context.Background(), string(huJSONACL), "")) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/acl", server.Path) - assert.Equal(t, "", server.Header.Get("If-Match")) - assert.EqualValues(t, "application/hujson", server.Header.Get("Content-Type")) - assert.EqualValues(t, huJSONACL, server.Body.Bytes()) -} - -func TestClient_SetACLWithETag(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - expectedACL := tsclient.ACL{ - ACLs: []tsclient.ACLEntry{ - { - Action: "accept", - Ports: []string{"*:*"}, - Users: []string{"*"}, - }, - }, - } - - assert.NoError(t, client.PolicyFile().Set(context.Background(), expectedACL, "test-etag")) - assert.Equal(t, http.MethodPost, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/acl", server.Path) - assert.Equal(t, `"test-etag"`, server.Header.Get("If-Match")) - - var actualACL tsclient.ACL - assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualACL)) - assert.EqualValues(t, expectedACL, actualACL) -} - -func TestClient_ACL(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - - server.ResponseCode = http.StatusOK - server.ResponseBody = &tsclient.ACL{ - ACLs: []tsclient.ACLEntry{ - { - Action: "accept", - Ports: []string{"*:*"}, - Users: []string{"*"}, - }, - }, - TagOwners: map[string][]string{ - "tag:example": {"group:example"}, - }, - Hosts: map[string]string{ - "example-host-1": "100.100.100.100", - "example-host-2": "100.100.101.100/24", - }, - Groups: map[string][]string{ - "group:example": { - "user1@example.com", - "user2@example.com", - }, - }, - Tests: []tsclient.ACLTest{ - { - User: "user1@example.com", - Allow: []string{"example-host-1:22", "example-host-2:80"}, - Deny: []string{"exapmle-host-2:100"}, - }, - { - User: "user2@example.com", - Allow: []string{"100.60.3.4:22"}, - }, - }, - ETag: "myetag", - } - server.ResponseHeader.Add("ETag", "myetag") - - acl, err := client.PolicyFile().Get(context.Background()) - assert.NoError(t, err) - assert.EqualValues(t, server.ResponseBody, acl) - assert.EqualValues(t, http.MethodGet, server.Method) - assert.EqualValues(t, "application/json", server.Header.Get("Accept")) - assert.EqualValues(t, "/api/v2/tailnet/example.com/acl", server.Path) -} - -func TestClient_RawACL(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - - server.ResponseCode = http.StatusOK - server.ResponseBody = huJSONACL - server.ResponseHeader.Add("ETag", "myetag") - - expectedRawACL := &tsclient.RawACL{ - HuJSON: string(huJSONACL), - ETag: "myetag", - } - acl, err := client.PolicyFile().Raw(context.Background()) - assert.NoError(t, err) - assert.EqualValues(t, expectedRawACL, acl) - assert.EqualValues(t, http.MethodGet, server.Method) - assert.EqualValues(t, "application/hujson", server.Header.Get("Accept")) - assert.EqualValues(t, "/api/v2/tailnet/example.com/acl", server.Path) -} diff --git a/v2/tailnet_settings.go b/v2/tailnet_settings.go deleted file mode 100644 index cadad14..0000000 --- a/v2/tailnet_settings.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - "context" - "net/http" -) - -// TailnetSettingsResource provides access to https://tailscale.com/api#tag/tailnetsettings. -type TailnetSettingsResource struct { - *Client -} - -// TailnetSettings represents the current settings of a tailnet. -// See https://tailscale.com/api#model/tailnetsettings. -type TailnetSettings struct { - DevicesApprovalOn bool `json:"devicesApprovalOn"` - DevicesAutoUpdatesOn bool `json:"devicesAutoUpdatesOn"` - DevicesKeyDurationDays int `json:"devicesKeyDurationDays"` // days before device key expiry - - UsersApprovalOn bool `json:"usersApprovalOn"` - UsersRoleAllowedToJoinExternalTailnets RoleAllowedToJoinExternalTailnets `json:"usersRoleAllowedToJoinExternalTailnets"` - - NetworkFlowLoggingOn bool `json:"networkFlowLoggingOn"` - RegionalRoutingOn bool `json:"regionalRoutingOn"` - PostureIdentityCollectionOn bool `json:"postureIdentityCollectionOn"` -} - -// UpdateTailnetSettingsRequest is a request to update the settings of a tailnet. -// Nil values indicate that the existing setting should be left unchanged. -type UpdateTailnetSettingsRequest struct { - DevicesApprovalOn *bool `json:"devicesApprovalOn,omitempty"` - DevicesAutoUpdatesOn *bool `json:"devicesAutoUpdatesOn,omitempty"` - DevicesKeyDurationDays *int `json:"devicesKeyDurationDays,omitempty"` // days before device key expiry - - UsersApprovalOn *bool `json:"usersApprovalOn,omitempty"` - UsersRoleAllowedToJoinExternalTailnets *RoleAllowedToJoinExternalTailnets `json:"usersRoleAllowedToJoinExternalTailnets,omitempty"` - - NetworkFlowLoggingOn *bool `json:"networkFlowLoggingOn,omitempty"` - RegionalRoutingOn *bool `json:"regionalRoutingOn,omitempty"` - PostureIdentityCollectionOn *bool `json:"postureIdentityCollectionOn,omitempty"` -} - -// RoleAllowedToJoinExternalTailnets constrains which users are allowed to join external tailnets -// based on their role. -type RoleAllowedToJoinExternalTailnets string - -const ( - RoleAllowedToJoinExternalTailnetsNone RoleAllowedToJoinExternalTailnets = "none" - RoleAllowedToJoinExternalTailnetsAdmin RoleAllowedToJoinExternalTailnets = "admin" - RoleAllowedToJoinExternalTailnetsMember RoleAllowedToJoinExternalTailnets = "member" -) - -// Get retrieves the current [TailnetSettings]. -// See https://tailscale.com/api#tag/tailnetsettings/GET/tailnet/{tailnet}/settings. -func (tsr *TailnetSettingsResource) Get(ctx context.Context) (*TailnetSettings, error) { - req, err := tsr.buildRequest(ctx, http.MethodGet, tsr.buildTailnetURL("settings")) - if err != nil { - return nil, err - } - - return body[TailnetSettings](tsr, req) -} - -// Update updates the tailnet settings. -// See https://tailscale.com/api#tag/tailnetsettings/PATCH/tailnet/{tailnet}/settings. -func (tsr *TailnetSettingsResource) Update(ctx context.Context, request UpdateTailnetSettingsRequest) error { - req, err := tsr.buildRequest(ctx, http.MethodPatch, tsr.buildTailnetURL("settings"), requestBody(request)) - if err != nil { - return err - } - - return tsr.do(req, nil) -} diff --git a/v2/tailnet_settings_test.go b/v2/tailnet_settings_test.go deleted file mode 100644 index 393676c..0000000 --- a/v2/tailnet_settings_test.go +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient_test - -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -func TestClient_TailnetSettings_Get(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - expected := tsclient.TailnetSettings{ - DevicesApprovalOn: true, - DevicesAutoUpdatesOn: true, - DevicesKeyDurationDays: 5, - UsersApprovalOn: true, - UsersRoleAllowedToJoinExternalTailnets: tsclient.RoleAllowedToJoinExternalTailnetsMember, - NetworkFlowLoggingOn: true, - RegionalRoutingOn: true, - PostureIdentityCollectionOn: true, - } - server.ResponseBody = expected - - actual, err := client.TailnetSettings().Get(context.Background()) - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/settings", server.Path) - assert.Equal(t, &expected, actual) -} - -func TestClient_TailnetSettings_Update(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - server.ResponseBody = nil - - updateRequest := tsclient.UpdateTailnetSettingsRequest{ - DevicesApprovalOn: tsclient.PointerTo(true), - DevicesAutoUpdatesOn: tsclient.PointerTo(true), - DevicesKeyDurationDays: tsclient.PointerTo(5), - UsersApprovalOn: tsclient.PointerTo(true), - UsersRoleAllowedToJoinExternalTailnets: tsclient.PointerTo(tsclient.RoleAllowedToJoinExternalTailnetsMember), - NetworkFlowLoggingOn: tsclient.PointerTo(true), - RegionalRoutingOn: tsclient.PointerTo(true), - PostureIdentityCollectionOn: tsclient.PointerTo(true), - } - err := client.TailnetSettings().Update(context.Background(), updateRequest) - assert.NoError(t, err) - assert.Equal(t, http.MethodPatch, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/settings", server.Path) - var receivedRequest tsclient.UpdateTailnetSettingsRequest - err = json.Unmarshal(server.Body.Bytes(), &receivedRequest) - assert.NoError(t, err) - assert.EqualValues(t, updateRequest, receivedRequest) -} diff --git a/v2/tailscale_test.go b/v2/tailscale_test.go deleted file mode 100644 index c8594c2..0000000 --- a/v2/tailscale_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient_test - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "maps" - "net" - "net/http" - "net/url" - "testing" - - "github.com/stretchr/testify/assert" - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -type TestServer struct { - t *testing.T - - BaseURL *url.URL - - Method string - Path string - Query url.Values - Body *bytes.Buffer - Header http.Header - - ResponseCode int - ResponseBody interface{} - ResponseHeader http.Header -} - -func NewTestHarness(t *testing.T) (*tsclient.Client, *TestServer) { - t.Helper() - - testServer := &TestServer{ - t: t, - ResponseHeader: make(http.Header), - } - - mux := http.NewServeMux() - mux.Handle("/", testServer) - svr := &http.Server{ - Handler: mux, - } - - // Start a listener on a random port - listener, err := net.Listen("tcp", ":0") - assert.NoError(t, err) - - go func() { - _ = svr.Serve(listener) - }() - - // When the test is over, close the server - t.Cleanup(func() { - assert.NoError(t, svr.Close()) - }) - - baseURL := fmt.Sprintf("http://localhost:%v", listener.Addr().(*net.TCPAddr).Port) - testServer.BaseURL, err = url.Parse(baseURL) - assert.NoError(t, err) - client := &tsclient.Client{ - BaseURL: testServer.BaseURL, - APIKey: "not a real key", - Tailnet: "example.com", - } - - return client, testServer -} - -func (t *TestServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - t.Method = r.Method - t.Path = r.URL.Path - t.Query = r.URL.Query() - t.Header = r.Header - - t.Body = bytes.NewBuffer([]byte{}) - _, err := io.Copy(t.Body, r.Body) - assert.NoError(t.t, err) - - maps.Copy(w.Header(), t.ResponseHeader) - w.WriteHeader(t.ResponseCode) - if t.ResponseBody != nil { - switch body := t.ResponseBody.(type) { - case []byte: - _, err := w.Write(body) - assert.NoError(t.t, err) - default: - assert.NoError(t.t, json.NewEncoder(w).Encode(body)) - } - } -} diff --git a/v2/testdata/acl.hujson b/v2/testdata/acl.hujson deleted file mode 100644 index 372a063..0000000 --- a/v2/testdata/acl.hujson +++ /dev/null @@ -1,62 +0,0 @@ -{ - "groups": { - // Alice and Bob are in group:dev - "group:dev": ["alice@example.com", "bob@example.com",], - // Carl is in group:devops - "group:devops": ["carl@example.com",], - }, - "acls": [ - // all employees can access their own devices - { "action": "accept", "src": ["autogroup:members"], "dst": ["autogroup:self:*"] }, - // users in group:dev can access devices tagged tag:dev - { "action": "accept", "src": ["group:dev"], "dst": ["tag:dev:*"] }, - // users in group:devops can access devices tagged tag:prod - { "action": "accept", "src": ["group:devops"], "dst": ["tag:prod:*"] }, - // all employees can access devices tagged tag:monitoring on - // ports 80 and 443 - { "action": "accept", "src": ["autogroup:members"], "dst": ["tag:monitoring:80,443"] }, - ], - "tagOwners": { - // users in group:devops can apply the tag tag:monitoring - "tag:monitoring": ["group:devops"], - // users in group:devops can apply the tag tag:dev - "tag:dev": ["group:devops"], - // users in group:devops can apply the tag tag:prod - "tag:prod": ["group:devops"], - }, - "tests": [ - { - "src": "carl@example.com", - // test that Carl can access devices tagged tag:prod on port 80 - "accept": ["tag:prod:80"], - }, - { - "src": "alice@example.com", - // test that Alice can access devices tagged tag:dev on port 80 - "accept": ["tag:dev:80"], - // test that Alice cannot access devices tagged tag:prod on port 80 - "deny": ["tag:prod:80"], - }, - ], - "ssh": [ - { - "action": "accept", - "src": ["autogroup:members"], - "dst": ["autogroup:self"], - "users": ["root", "autogroup:nonroot"] - }, - { - "action": "accept", - "src": ["autogroup:members"], - "dst": ["tag:prod"], - "users": ["root", "autogroup:nonroot"] - }, - { - "action": "accept", - "src": ["tag:logging"], - "dst": ["tag:prod"], - "users": ["root", "autogroup:nonroot"], - "checkPeriod": "20h" - }, - ] -} diff --git a/v2/testdata/acl.json b/v2/testdata/acl.json deleted file mode 100644 index ea00dac..0000000 --- a/v2/testdata/acl.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "groups": { - "group:dev": ["alice@example.com", "bob@example.com"], - "group:devops": ["carl@example.com"] - }, - "acls": [ - { "action": "accept", "src": ["autogroup:members"], "dst": ["autogroup:self:*"] }, - { "action": "accept", "src": ["group:dev"], "dst": ["tag:dev:*"] }, - { "action": "accept", "src": ["group:devops"], "dst": ["tag:prod:*"] }, - { "action": "accept", "src": ["autogroup:members"], "dst": ["tag:monitoring:80,443"] } - ], - "tagOwners": { - "tag:monitoring": ["group:devops"], - "tag:dev": ["group:devops"], - "tag:prod": ["group:devops"] - }, - "tests": [ - { - "src": "carl@example.com", - "accept": ["tag:prod:80"] - }, - { - "src": "alice@example.com", - "accept": ["tag:dev:80"], - "deny": ["tag:prod:80"] - } - ], - "ssh": [ - { - "action": "accept", - "src": ["autogroup:members"], - "dst": ["autogroup:self"], - "users": ["root", "autogroup:nonroot"] - }, - { - "action": "accept", - "src": ["autogroup:members"], - "dst": ["tag:prod"], - "users": ["root", "autogroup:nonroot"] - }, - { - "action": "accept", - "src": ["tag:logging"], - "dst": ["tag:prod"], - "users": ["root", "autogroup:nonroot"], - "checkPeriod": "20h" - } - ] -} diff --git a/v2/testdata/devices.json b/v2/testdata/devices.json deleted file mode 100644 index 71a4675..0000000 --- a/v2/testdata/devices.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "devices": [ - { - "addresses": [ - "100.101.102.103", - "fd7a:115c:a1e0:ab12:4843:cd96:6265:6667" - ], - "authorized": true, - "blocksIncomingConnections": false, - "clientVersion": "", - "created": "", - "expires": "0001-01-01T00:00:00Z", - "hostname": "hello", - "id": "50052", - "isExternal": true, - "keyExpiryDisabled": true, - "lastSeen": "2022-04-15T13:24:40Z", - "machineKey": "", - "name": "hello.example.com", - "nodeKey": "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", - "os": "linux", - "updateAvailable": false, - "user": "services@example.com" - }, - { - "addresses": [ - "100.121.200.21", - "fd7a:115c:a1e0:ab12:4843:cd96:6265:e618" - ], - "authorized": true, - "blocksIncomingConnections": false, - "clientVersion": "1.22.2-t60b671955-gecc5d9846", - "created": "2022-03-05T17:10:27Z", - "expires": "2022-09-01T17:10:27Z", - "hostname": "foo", - "id": "50053", - "isExternal": false, - "keyExpiryDisabled": true, - "lastSeen": "2022-04-15T13:25:21Z", - "machineKey": "mkey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", - "name": "foo.example.com", - "nodeKey": "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", - "os": "linux", - "updateAvailable": false, - "user": "foo@example.com" - } - ] -} diff --git a/v2/users.go b/v2/users.go deleted file mode 100644 index d80f6a6..0000000 --- a/v2/users.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - "context" - "net/http" - "time" -) - -// UsersResource provides access to https://tailscale.com/api#tag/users. -type UsersResource struct { - *Client -} - -const ( - UserTypeMember UserType = "member" - UserTypeShared UserType = "shared" -) - -const ( - UserRoleOwner UserRole = "owner" - UserRoleMember UserRole = "member" - UserRoleAdmin UserRole = "admin" - UserRoleITAdmin UserRole = "it-admin" - UserRoleNetworkAdmin UserRole = "network-admin" - UserRoleBillingAdmin UserRole = "billing-admin" - UserRoleAuditor UserRole = "auditor" -) - -const ( - UserStatusActive UserStatus = "active" - UserStatusIdle UserStatus = "idle" - UserStatusSuspended UserStatus = "suspended" - UserStatusNeedsApproval UserStatus = "needs-approval" - UserStatusOverBillingLimit UserStatus = "over-billing-limit" -) - -// UserType is the type of relation this user has to the tailnet associated with the request. -type UserType string - -// UserRole is the role of the user. -type UserRole string - -// UserStatus is the status of the user. -type UserStatus string - -// User is a representation of a user within a tailnet. -type User struct { - ID string `json:"id"` - DisplayName string `json:"displayName"` - LoginName string `json:"loginName"` - ProfilePicURL string `json:"profilePicUrl"` - TailnetID string `json:"tailnetId"` - Created time.Time `json:"created"` - Type UserType `json:"type"` - Role UserRole `json:"role"` - Status UserStatus `json:"status"` - DeviceCount int `json:"deviceCount"` - LastSeen time.Time `json:"lastSeen"` - CurrentlyConnected bool `json:"currentlyConnected"` -} - -// List lists every [User] of the tailnet. If userType and/or role are provided, -// the list of users will be filtered by those. -func (ur *UsersResource) List(ctx context.Context, userType *UserType, role *UserRole) ([]User, error) { - u := ur.buildTailnetURL("users") - q := u.Query() - if userType != nil { - q.Add("type", string(*userType)) - } - if role != nil { - q.Add("role", string(*role)) - } - u.RawQuery = q.Encode() - - req, err := ur.buildRequest(ctx, http.MethodGet, u) - if err != nil { - return nil, err - } - - resp := make(map[string][]User) - if err = ur.do(req, &resp); err != nil { - return nil, err - } - - return resp["users"], nil -} - -// Get retrieves the [User] identified by the given id. -func (ur *UsersResource) Get(ctx context.Context, id string) (*User, error) { - req, err := ur.buildRequest(ctx, http.MethodGet, ur.buildURL("users", id)) - if err != nil { - return nil, err - } - - return body[User](ur, req) -} diff --git a/v2/users_test.go b/v2/users_test.go deleted file mode 100644 index 4bb94cd..0000000 --- a/v2/users_test.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient_test - -import ( - "context" - "net/http" - "net/url" - "testing" - "time" - - "github.com/stretchr/testify/assert" - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -func TestClient_Users_List(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - expectedUsers := map[string][]tsclient.User{ - "users": { - { - ID: "12345", - DisplayName: "Jane Doe", - LoginName: "janedoe", - ProfilePicURL: "http://example.com/users/janedoe", - TailnetID: "1", - Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), - Type: tsclient.UserTypeMember, - Role: tsclient.UserRoleOwner, - Status: tsclient.UserStatusActive, - DeviceCount: 2, - LastSeen: time.Date(2022, 2, 10, 12, 50, 23, 0, time.UTC), - CurrentlyConnected: true, - }, - { - ID: "12346", - DisplayName: "John Doe", - LoginName: "johndoe", - ProfilePicURL: "http://example.com/users/johndoe", - TailnetID: "2", - Created: time.Date(2022, 2, 10, 11, 50, 23, 12, time.UTC), - Type: tsclient.UserTypeShared, - Role: tsclient.UserRoleMember, - Status: tsclient.UserStatusIdle, - DeviceCount: 2, - LastSeen: time.Date(2022, 2, 10, 12, 50, 23, 12, time.UTC), - CurrentlyConnected: true, - }, - }, - } - server.ResponseBody = expectedUsers - - actualUsers, err := client.Users().List( - context.Background(), - tsclient.PointerTo(tsclient.UserTypeMember), - tsclient.PointerTo(tsclient.UserRoleAdmin)) - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/tailnet/example.com/users", server.Path) - assert.Equal(t, url.Values{"type": []string{"member"}, "role": []string{"admin"}}, server.Query) - assert.Equal(t, expectedUsers["users"], actualUsers) -} - -func TestClient_Users_Get(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - expectedUser := &tsclient.User{ - ID: "12345", - DisplayName: "Jane Doe", - LoginName: "janedoe", - ProfilePicURL: "http://example.com/users/janedoe", - TailnetID: "1", - Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), - Type: tsclient.UserTypeMember, - Role: tsclient.UserRoleOwner, - Status: tsclient.UserStatusActive, - DeviceCount: 2, - LastSeen: time.Date(2022, 2, 10, 12, 50, 23, 0, time.UTC), - CurrentlyConnected: true, - } - server.ResponseBody = expectedUser - - actualUser, err := client.Users().Get(context.Background(), "12345") - assert.NoError(t, err) - assert.Equal(t, http.MethodGet, server.Method) - assert.Equal(t, "/api/v2/users/12345", server.Path) - assert.Equal(t, expectedUser, actualUser) -} diff --git a/v2/webhooks.go b/v2/webhooks.go deleted file mode 100644 index 782393c..0000000 --- a/v2/webhooks.go +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient - -import ( - "context" - "net/http" - "time" -) - -// WebhooksResource provides access to https://tailscale.com/api#tag/webhooks. -type WebhooksResource struct { - *Client -} - -const ( - WebhookEmptyProviderType WebhookProviderType = "" - WebhookSlackProviderType WebhookProviderType = "slack" - WebhookMattermostProviderType WebhookProviderType = "mattermost" - WebhookGoogleChatProviderType WebhookProviderType = "googlechat" - WebhookDiscordProviderType WebhookProviderType = "discord" -) - -const ( - // WebhookCategoryTailnetManagement implies the entire group of events below. - // Note that subscribing to WebhookCategoryTailnetManagement will include any - // future events added below. - WebhookCategoryTailnetManagement WebhookSubscriptionType = "categoryTailnetManagement" - 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" -) - -const ( - // WebhookCategoryDeviceMisconfigurations implies the entire group of events below. - // Note that subscribing to WebhookCategoryDeviceMisconfigurations will include any - // future events added below. - WebhookCategoryDeviceMisconfigurations WebhookSubscriptionType = "categoryDeviceMisconfigurations" - WebhookSubnetIPForwardingNotEnabled WebhookSubscriptionType = "subnetIPForwardingNotEnabled" - WebhookExitNodeIPForwardingNotEnabled WebhookSubscriptionType = "exitNodeIPForwardingNotEnabled" -) - -// WebhookProviderType defines the provider type for a Webhook destination. -type WebhookProviderType string - -// WebhookSubscriptionType defines events in tailscale to subscribe a Webhook to. -type WebhookSubscriptionType string - -// Webhook type defines a webhook endpoint within a tailnet. -type 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. -type CreateWebhookRequest struct { - EndpointURL string `json:"endpointUrl"` - ProviderType WebhookProviderType `json:"providerType"` - Subscriptions []WebhookSubscriptionType `json:"subscriptions"` -} - -// Create creates a new [Webhook] with the specifications provided in the [CreateWebhookRequest]. -// Returns the created [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 - } - - return body[Webhook](wr, req) -} - -// List lists every [Webhook] in the 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 - } - - return body[Webhook](wr, req) -} - -// Update updates an existing webhook's subscriptions. Returns the updated [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 - } - - return body[Webhook](wr, req) -} - -// 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 - } - - return body[Webhook](wr, req) -} diff --git a/v2/webhooks_test.go b/v2/webhooks_test.go deleted file mode 100644 index 4508f82..0000000 --- a/v2/webhooks_test.go +++ /dev/null @@ -1,183 +0,0 @@ -// Copyright (c) David Bond, Tailscale Inc, & Contributors -// SPDX-License-Identifier: MIT - -package tsclient_test - -import ( - "context" - "net/http" - "testing" - "time" - - "github.com/stretchr/testify/assert" - tsclient "github.com/tailscale/tailscale-client-go/v2" -) - -func TestClient_CreateWebhook(t *testing.T) { - t.Parallel() - - client, server := NewTestHarness(t) - server.ResponseCode = http.StatusOK - - req := tsclient.CreateWebhookRequest{ - EndpointURL: "https://example.com/my/endpoint", - ProviderType: tsclient.WebhookDiscordProviderType, - Subscriptions: []tsclient.WebhookSubscriptionType{tsclient.WebhookNodeCreated, tsclient.WebhookNodeApproved}, - } - - expectedSecret := "my-secret" - expectedWebhook := &tsclient.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][]tsclient.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: []tsclient.WebhookSubscriptionType{tsclient.WebhookNodeCreated, tsclient.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: []tsclient.WebhookSubscriptionType{tsclient.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 := &tsclient.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: []tsclient.WebhookSubscriptionType{tsclient.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 := []tsclient.WebhookSubscriptionType{tsclient.WebhookNodeCreated, tsclient.WebhookNodeApproved, tsclient.WebhookNodeNeedsApproval} - - expectedWebhook := &tsclient.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 := &tsclient.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: []tsclient.WebhookSubscriptionType{tsclient.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) -}