diff --git a/client/tailscale/acl.go b/client/tailscale/acl.go new file mode 100644 index 000000000..4a1584866 --- /dev/null +++ b/client/tailscale/acl.go @@ -0,0 +1,469 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.18 +// +build go1.18 + +package tailscale + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "inet.af/netaddr" +) + +// ACLRow defines a rule that grants access by a set of users or groups to a set of servers and ports. +type ACLRow struct { + Action string `json:"action,omitempty"` // valid values: "accept" + Users []string `json:"users,omitempty"` + Ports []string `json:"ports,omitempty"` +} + +// ACLTest defines a test for your ACLs to prevent accidental exposure or revoking of access to key servers and ports. +type ACLTest struct { + User string `json:"user,omitempty"` // source + Allow []string `json:"allow,omitempty"` // expected destination ip:port that user can access + Deny []string `json:"deny,omitempty"` // expected destination ip:port that user cannot access +} + +// ACLDetails contains all the details for an ACL. +type ACLDetails struct { + Tests []ACLTest `json:"tests,omitempty"` + ACLs []ACLRow `json:"acls,omitempty"` + Groups map[string][]string `json:"groups,omitempty"` + TagOwners map[string][]string `json:"tagowners,omitempty"` + Hosts map[string]string `json:"hosts,omitempty"` +} + +// ACL contains an ACLDetails and metadata. +type ACL struct { + ACL ACLDetails + ETag string // to check with version on server +} + +// ACLHuJSON contains the HuJSON string of the ACL and metadata. +type ACLHuJSON struct { + ACL string + Warnings []string + ETag string // to check with version on server +} + +// ACL makes a call to the Tailscale server to get a JSON-parsed version of the ACL. +// The JSON-parsed version of the ACL contains no comments as proper JSON does not support +// comments. +func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.ACL: %w", err) + } + }() + + path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.BaseURL, c.Tailnet) + req, err := http.NewRequestWithContext(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + b, resp, err := c.sendRequest(req) + if err != nil { + return nil, err + } + + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(b, resp) + } + + // Otherwise, try to decode the response. + var aclDetails ACLDetails + if err = json.Unmarshal(b, &aclDetails); err != nil { + return nil, err + } + acl = &ACL{ + ACL: aclDetails, + ETag: resp.Header.Get("ETag"), + } + return acl, nil +} + +// ACLHuJSON makes a call to the Tailscale server to get the ACL HuJSON and returns +// it as a string. +// HuJSON is JSON with a few modifications to make it more human-friendly. The primary +// changes are allowing comments and trailing comments. See the following links for more info: +// https://tailscale.com/kb/1018/acls?q=acl#tailscale-acl-policy-format +// https://github.com/tailscale/hujson +func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.ACLHuJSON: %w", err) + } + }() + + path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl?details=1", c.BaseURL, c.Tailnet) + req, err := http.NewRequestWithContext(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/hujson") + b, resp, err := c.sendRequest(req) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(b, resp) + } + + data := struct { + ACL []byte `json:"acl"` + Warnings []string `json:"warnings"` + }{} + if err := json.Unmarshal(b, &data); err != nil { + return nil, err + } + + acl = &ACLHuJSON{ + ACL: string(data.ACL), + Warnings: data.Warnings, + ETag: resp.Header.Get("ETag"), + } + return acl, nil +} + +// ACLTestFailureSummary specifies a user for which ACL tests +// failed and the related user-friendly error messages. +// +// ACLTestFailureSummary specifies the JSON format sent to the +// JavaScript client to be rendered in the HTML. +type ACLTestFailureSummary struct { + User string `json:"user"` + Errors []string `json:"errors"` +} + +// ACLTestError is ErrResponse but with an extra field to account for ACLTestFailureSummary. +type ACLTestError struct { + ErrResponse + Data []ACLTestFailureSummary `json:"data"` +} + +func (e ACLTestError) Error() string { + return fmt.Sprintf("%s, Data: %+v", e.ErrResponse.Error(), e.Data) +} + +func (c *Client) aclPOSTRequest(ctx context.Context, body []byte, avoidCollisions bool, etag, acceptHeader string) ([]byte, string, error) { + path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.BaseURL, c.Tailnet) + req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body)) + if err != nil { + return nil, "", err + } + + if avoidCollisions { + req.Header.Set("If-Match", etag) + } + req.Header.Set("Accept", acceptHeader) + req.Header.Set("Content-Type", "application/hujson") + b, resp, err := c.sendRequest(req) + if err != nil { + return nil, "", err + } + + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + // check if test error + var ate ACLTestError + if err := json.Unmarshal(b, &ate); err != nil { + return nil, "", err + } + ate.Status = resp.StatusCode + return nil, "", ate + } + return b, resp.Header.Get("ETag"), nil +} + +// SetACL sends a POST request to update the ACL according to the provided ACL object. If +// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match +// header to check if the previously obtained ACL was the latest version and that no updates +// were missed. +// +// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true. +// Returns error if ACL has tests that fail. +// Returns error if there are other errors with the ACL. +func (c *Client) SetACL(ctx context.Context, acl ACL, avoidCollisions bool) (res *ACL, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.SetACL: %w", err) + } + }() + postData, err := json.Marshal(acl.ACL) + if err != nil { + return nil, err + } + b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/json") + if err != nil { + return nil, err + } + + // Otherwise, try to decode the response. + var aclDetails ACLDetails + if err = json.Unmarshal(b, &aclDetails); err != nil { + return nil, err + } + res = &ACL{ + ACL: aclDetails, + ETag: etag, + } + return res, nil +} + +// SetACLHuJSON sends a POST request to update the ACL according to the provided ACL object. If +// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match +// header to check if the previously obtained ACL was the latest version and that no updates +// were missed. +// +// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true. +// Returns error if the HuJSON is invalid. +// Returns error if ACL has tests that fail. +// Returns error if there are other errors with the ACL. +func (c *Client) SetACLHuJSON(ctx context.Context, acl ACLHuJSON, avoidCollisions bool) (res *ACLHuJSON, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.SetACLHuJSON: %w", err) + } + }() + + postData := []byte(acl.ACL) + b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/hujson") + if err != nil { + return nil, err + } + + res = &ACLHuJSON{ + ACL: string(b), + ETag: etag, + } + return res, nil +} + +// UserRuleMatch specifies the source users/groups/hosts that a rule targets +// and the destination ports that they can access. +// LineNumber is only useful for requests provided in HuJSON form. +// While JSON requests will have LineNumber, the value is not useful. +type UserRuleMatch struct { + Users []string `json:"users"` + Ports []string `json:"ports"` + LineNumber int `json:"lineNumber"` +} + +// ACLPreviewResponse is the response type of previewACLPostRequest +type ACLPreviewResponse struct { + Matches []UserRuleMatch `json:"matches"` // ACL rules that match the specified user or ipport. + Type string `json:"type"` // The request type: currently only "user" or "ipport". + PreviewFor string `json:"previewFor"` // A specific user or ipport. +} + +// ACLPreview is the response type of PreviewACLForUser, PreviewACLForIPPort, PreviewACLHuJSONForUser, and PreviewACLHuJSONForIPPort +type ACLPreview struct { + Matches []UserRuleMatch `json:"matches"` + User string `json:"user,omitempty"` // Filled if response of PreviewACLForUser or PreviewACLHuJSONForUser + IPPort string `json:"ipport,omitempty"` // Filled if response of PreviewACLForIPPort or PreviewACLHuJSONForIPPort +} + +func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) { + path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/preview", c.BaseURL, c.Tailnet) + req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Add("type", previewType) + q.Add("previewFor", previewFor) + req.URL.RawQuery = q.Encode() + + req.Header.Set("Content-Type", "application/hujson") + req.SetBasicAuth(c.APIKey, "") + + b, resp, err := c.sendRequest(req) + if err != nil { + return nil, err + } + + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(b, resp) + } + if err = json.Unmarshal(b, &res); err != nil { + return nil, err + } + + return res, nil +} + +// PreviewACLForUser determines what rules match a given ACL for a user. +// The ACL can be a locally modified or clean ACL obtained from server. +// +// Returns ACLPreview on success with matches in a slice. If there are no matches, +// the call is still successful but Matches will be an empty slice. +// Returns error if the provided ACL is invalid. +func (c *Client) PreviewACLForUser(ctx context.Context, acl ACL, user string) (res *ACLPreview, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.PreviewACLForUser: %w", err) + } + }() + postData, err := json.Marshal(acl.ACL) + if err != nil { + return nil, err + } + b, err := c.previewACLPostRequest(ctx, postData, "user", user) + if err != nil { + return nil, err + } + + return &ACLPreview{ + Matches: b.Matches, + User: b.PreviewFor, + }, nil +} + +// PreviewACLForIPPort determines what rules match a given ACL for a ipport. +// The ACL can be a locally modified or clean ACL obtained from server. +// +// Returns ACLPreview on success with matches in a slice. If there are no matches, +// the call is still successful but Matches will be an empty slice. +// Returns error if the provided ACL is invalid. +func (c *Client) PreviewACLForIPPort(ctx context.Context, acl ACL, ipport netaddr.IPPort) (res *ACLPreview, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.PreviewACLForIPPort: %w", err) + } + }() + postData, err := json.Marshal(acl.ACL) + if err != nil { + return nil, err + } + b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport.String()) + if err != nil { + return nil, err + } + + return &ACLPreview{ + Matches: b.Matches, + IPPort: b.PreviewFor, + }, nil +} + +// PreviewACLHuJSONForUser determines what rules match a given ACL for a user. +// The ACL can be a locally modified or clean ACL obtained from server. +// +// Returns ACLPreview on success with matches in a slice. If there are no matches, +// the call is still successful but Matches will be an empty slice. +// Returns error if the provided ACL is invalid. +func (c *Client) PreviewACLHuJSONForUser(ctx context.Context, acl ACLHuJSON, user string) (res *ACLPreview, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.PreviewACLHuJSONForUser: %w", err) + } + }() + postData := []byte(acl.ACL) + b, err := c.previewACLPostRequest(ctx, postData, "user", user) + if err != nil { + return nil, err + } + + return &ACLPreview{ + Matches: b.Matches, + User: b.PreviewFor, + }, nil +} + +// PreviewACLHuJSONForIPPort determines what rules match a given ACL for a ipport. +// The ACL can be a locally modified or clean ACL obtained from server. +// +// Returns ACLPreview on success with matches in a slice. If there are no matches, +// the call is still successful but Matches will be an empty slice. +// Returns error if the provided ACL is invalid. +func (c *Client) PreviewACLHuJSONForIPPort(ctx context.Context, acl ACLHuJSON, ipport string) (res *ACLPreview, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.PreviewACLHuJSONForIPPort: %w", err) + } + }() + postData := []byte(acl.ACL) + b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport) + if err != nil { + return nil, err + } + + return &ACLPreview{ + Matches: b.Matches, + IPPort: b.PreviewFor, + }, nil +} + +// ValidateACLJSON takes in the given source and destination (in this situation, +// it is assumed that you are checking whether the source can connect to destination) +// and creates an ACLTest from that. It then sends the ACLTest to the control api acl +// validate endpoint, where the test is run. It returns a nil ACLTestError pointer if +// no test errors occur. +func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (testErr *ACLTestError, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.ValidateACLJSON: %w", err) + } + }() + + tests := []ACLTest{ACLTest{User: source, Allow: []string{dest}}} + postData, err := json.Marshal(tests) + if err != nil { + return nil, err + } + + path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/validate", c.BaseURL, c.Tailnet) + req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(postData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(c.APIKey, "") + + b, resp, err := c.sendRequest(req) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("control api responsed with %d status code", resp.StatusCode) + } + + // The test ran without fail + if len(b) == 0 { + return nil, nil + } + + var res ACLTestError + // The test returned errors. + if err = json.Unmarshal(b, &res); err != nil { + // failed to unmarshal + return nil, err + } + return &res, nil +} diff --git a/client/tailscale/apitype/controltype.go b/client/tailscale/apitype/controltype.go new file mode 100644 index 000000000..1c19bf655 --- /dev/null +++ b/client/tailscale/apitype/controltype.go @@ -0,0 +1,20 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package apitype + +type DNSConfig struct { + Resolvers []DNSResolver `json:"resolvers"` + FallbackResolvers []DNSResolver `json:"fallbackResolvers"` + Routes map[string][]DNSResolver `json:"routes"` + Domains []string `json:"domains"` + Nameservers []string `json:"nameservers"` + Proxied bool `json:"proxied"` + PerDomain bool `json:",omitempty"` +} + +type DNSResolver struct { + Addr string `json:"addr"` + BootstrapResolution []string `json:"bootstrapResolution,omitempty"` +} diff --git a/client/tailscale/devices.go b/client/tailscale/devices.go new file mode 100644 index 000000000..b909ae309 --- /dev/null +++ b/client/tailscale/devices.go @@ -0,0 +1,262 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.18 +// +build go1.18 + +package tailscale + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "tailscale.com/types/opt" +) + +type GetDevicesResponse struct { + Devices []*Device `json:"devices"` +} + +type DerpRegion struct { + Preferred bool `json:"preferred,omitempty"` + LatencyMilliseconds float64 `json:"latencyMs"` +} + +type ClientConnectivity struct { + Endpoints []string `json:"endpoints"` + DERP string `json:"derp"` + MappingVariesByDestIP opt.Bool `json:"mappingVariesByDestIP"` + // DERPLatency is mapped by region name (e.g. "New York City", "Seattle"). + DERPLatency map[string]DerpRegion `json:"latency"` + ClientSupports map[string]opt.Bool `json:"clientSupports"` +} + +type Device struct { + // Addresses is a list of the devices's Tailscale IP addresses. + // It's currently just 1 element, the 100.x.y.z Tailscale IP. + Addresses []string `json:"addresses"` + DeviceID string `json:"id"` + User string `json:"user"` + Name string `json:"name"` + Hostname string `json:"hostname"` + + ClientVersion string `json:"clientVersion"` // Empty for external devices. + UpdateAvailable bool `json:"updateAvailable"` // Empty for external devices. + OS string `json:"os"` + Created string `json:"created"` // Empty for external devices. + LastSeen string `json:"lastSeen"` + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` + Expires string `json:"expires"` + Authorized bool `json:"authorized"` + IsExternal bool `json:"isExternal"` + MachineKey string `json:"machineKey"` // Empty for external devices. + NodeKey string `json:"nodeKey"` + + // BlocksIncomingConnections is configured via the device's + // Tailscale client preferences. This field is only reported + // to the API starting with Tailscale 1.3.x clients. + BlocksIncomingConnections bool `json:"blocksIncomingConnections"` + + // The following fields are not included by default: + + // EnabledRoutes are the previously-approved subnet routes + // (e.g. "192.168.4.16/24", "10.5.2.4/32"). + EnabledRoutes []string `json:"enabledRoutes"` // Empty for external devices. + // AdvertisedRoutes are the subnets (both enabled and not enabled) + // being requested from the node. + AdvertisedRoutes []string `json:"advertisedRoutes"` // Empty for external devices. + + ClientConnectivity *ClientConnectivity `json:"clientConnectivity"` +} + +// DeviceFieldsOpts determines which fields should be returned in the response. +// +// Please only use DeviceAllFields and DeviceDefaultFields. +// Other DeviceFieldsOpts are not supported. +// +// TODO: Support other DeviceFieldsOpts. +// In the future, users should be able to create their own DeviceFieldsOpts +// as valid arguments by setting the fields they want returned to a "non-nil" +// value. For example, DeviceFieldsOpts{NodeID: "true"} should only return NodeIDs. +type DeviceFieldsOpts Device + +func (d *DeviceFieldsOpts) addFieldsToQueryParameter() string { + if d == DeviceDefaultFields || d == nil { + return "default" + } + if d == DeviceAllFields { + return "all" + } + + return "" +} + +var ( + DeviceAllFields = &DeviceFieldsOpts{} + + // DeviceDefaultFields specifies that the following fields are returned: + // Addresses, NodeID, User, Name, Hostname, ClientVersion, UpdateAvailable, + // OS, Created, LastSeen, KeyExpiryDisabled, Expires, Authorized, IsExternal + // MachineKey, NodeKey, BlocksIncomingConnections. + DeviceDefaultFields = &DeviceFieldsOpts{} +) + +// Devices retrieves the list of devices for a tailnet. +// +// See the Device structure for the list of fields hidden for external devices. +// The optional fields parameter specifies which fields of the devices to return; currently +// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported. +// Other values are currently undefined. +func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceList []*Device, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.Devices: %w", err) + } + }() + + path := fmt.Sprintf("%s/api/v2/tailnet/%s/devices", c.BaseURL, c.Tailnet) + req, err := http.NewRequestWithContext(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + // Add fields. + fieldStr := fields.addFieldsToQueryParameter() + q := req.URL.Query() + q.Add("fields", fieldStr) + req.URL.RawQuery = q.Encode() + + b, resp, err := c.sendRequest(req) + if err != nil { + return nil, err + } + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(b, resp) + } + + var devices GetDevicesResponse + err = json.Unmarshal(b, &devices) + return devices.Devices, err +} + +// Device retrieved the details for a specific device. +// +// See the Device structure for the list of fields hidden for an external device. +// The optional fields parameter specifies which fields of the devices to return; currently +// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported. +// Other values are currently undefined. +func (c *Client) Device(ctx context.Context, deviceID string, fields *DeviceFieldsOpts) (device *Device, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.Device: %w", err) + } + }() + path := fmt.Sprintf("%s/api/v2/device/%s", c.BaseURL, deviceID) + req, err := http.NewRequestWithContext(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + + // Add fields. + fieldStr := fields.addFieldsToQueryParameter() + q := req.URL.Query() + q.Add("fields", fieldStr) + req.URL.RawQuery = q.Encode() + + b, resp, err := c.sendRequest(req) + if err != nil { + return nil, err + } + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(b, resp) + } + + err = json.Unmarshal(b, &device) + return device, err +} + +// DeleteDevice deletes the specified device from the Client's tailnet. +// NOTE: Only devices that belong to the Client's tailnet can be deleted. +// Deleting external devices is not supported. +func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error) { + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.DeleteDevice: %w", err) + } + }() + + path := fmt.Sprintf("%s/api/v2/device/%s", c.BaseURL, url.PathEscape(deviceID)) + req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil) + if err != nil { + return err + } + + b, resp, err := c.sendRequest(req) + if err != nil { + return err + } + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(b, resp) + } + return nil +} + +// AuthorizeDevice marks a device as authorized. +func (c *Client) AuthorizeDevice(ctx context.Context, deviceID string) error { + path := fmt.Sprintf("%s/api/v2/device/%s/authorized", c.BaseURL, url.PathEscape(deviceID)) + req, err := http.NewRequestWithContext(ctx, "POST", path, strings.NewReader(`{"authorized":true}`)) + if err != nil { + return err + } + + b, resp, err := c.sendRequest(req) + if err != nil { + return err + } + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(b, resp) + } + + return nil +} + +// SetTags updates the ACL tags on a device. +func (c *Client) SetTags(ctx context.Context, deviceID string, tags []string) error { + params := &struct { + Tags []string `json:"tags"` + }{Tags: tags} + data, err := json.Marshal(params) + if err != nil { + return err + } + path := fmt.Sprintf("%s/api/v2/device/%s/tags", c.BaseURL, url.PathEscape(deviceID)) + req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data)) + if err != nil { + return err + } + + b, resp, err := c.sendRequest(req) + if err != nil { + return err + } + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(b, resp) + } + + return nil +} diff --git a/client/tailscale/dns.go b/client/tailscale/dns.go new file mode 100644 index 000000000..7b156bb48 --- /dev/null +++ b/client/tailscale/dns.go @@ -0,0 +1,235 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.18 +// +build go1.18 + +package tailscale + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "tailscale.com/client/tailscale/apitype" +) + +// DNSNameServers is returned when retrieving the list of nameservers. +// It is also the structure provided when setting nameservers. +type DNSNameServers struct { + DNS []string `json:"dns"` // DNS name servers +} + +// DNSNameServersPostResponse is returned when setting the list of DNS nameservers. +// +// It includes the MagicDNS status since nameservers changes may affect MagicDNS. +type DNSNameServersPostResponse struct { + DNS []string `json:"dns"` // DNS name servers + MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers) +} + +// DNSSearchpaths is the list of search paths for a given domain. +type DNSSearchPaths struct { + SearchPaths []string `json:"searchPaths"` // DNS search paths +} + +// DNSPreferences is the preferences set for a given tailnet. +// +// It includes MagicDNS which can be turned on or off. To enable MagicDNS, +// there must be at least one nameserver. When all nameservers are removed, +// MagicDNS is disabled. +type DNSPreferences struct { + MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers) +} + +func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) { + path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.BaseURL, c.Tailnet, endpoint) + req, err := http.NewRequestWithContext(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + b, resp, err := c.sendRequest(req) + if err != nil { + return nil, err + } + + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(b, resp) + } + + return b, nil +} + +func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData interface{}) ([]byte, error) { + path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.BaseURL, c.Tailnet, endpoint) + data, err := json.Marshal(&postData) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data)) + req.Header.Set("Content-Type", "application/json") + if err != nil { + return nil, err + } + + b, resp, err := c.sendRequest(req) + if err != nil { + return nil, err + } + + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(b, resp) + } + + return b, nil +} + +// DNSConfig retrieves the DNSConfig settings for a domain. +func (c *Client) DNSConfig(ctx context.Context) (cfg *apitype.DNSConfig, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.DNSConfig: %w", err) + } + }() + b, err := c.dnsGETRequest(ctx, "config") + if err != nil { + return nil, err + } + var dnsResp apitype.DNSConfig + err = json.Unmarshal(b, &dnsResp) + return &dnsResp, err +} + +func (c *Client) SetDNSConfig(ctx context.Context, cfg apitype.DNSConfig) (resp *apitype.DNSConfig, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.SetDNSConfig: %w", err) + } + }() + var dnsResp apitype.DNSConfig + b, err := c.dnsPOSTRequest(ctx, "config", cfg) + if err != nil { + return nil, err + } + err = json.Unmarshal(b, &dnsResp) + return &dnsResp, err +} + +// NameServers retrieves the list of nameservers set for a domain. +func (c *Client) NameServers(ctx context.Context) (nameservers []string, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.NameServers: %w", err) + } + }() + b, err := c.dnsGETRequest(ctx, "nameservers") + if err != nil { + return nil, err + } + var dnsResp DNSNameServers + err = json.Unmarshal(b, &dnsResp) + return dnsResp.DNS, err +} + +// SetNameServers sets the list of nameservers for a tailnet to the list provided +// by the user. +// +// It returns the new list of nameservers and the MagicDNS status in case it was +// affected by the change. For example, removing all nameservers will turn off +// MagicDNS. +func (c *Client) SetNameServers(ctx context.Context, nameservers []string) (dnsResp *DNSNameServersPostResponse, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.SetNameServers: %w", err) + } + }() + dnsReq := DNSNameServers{DNS: nameservers} + b, err := c.dnsPOSTRequest(ctx, "nameservers", dnsReq) + if err != nil { + return nil, err + } + err = json.Unmarshal(b, &dnsResp) + return dnsResp, err +} + +// DNSPreferences retrieves the DNS preferences set for a tailnet. +// +// It returns the status of MagicDNS. +func (c *Client) DNSPreferences(ctx context.Context) (dnsResp *DNSPreferences, err error) { + // Format return errors to be descriptive. + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.DNSPreferences: %w", err) + } + }() + b, err := c.dnsGETRequest(ctx, "preferences") + if err != nil { + return nil, err + } + err = json.Unmarshal(b, &dnsResp) + return dnsResp, err +} + +// SetDNSPreferences sets the DNS preferences for a tailnet. +// +// MagicDNS can only be enabled when there is at least one nameserver provided. +// When all nameservers are removed, MagicDNS is disabled and will stay disabled, +// unless explicitly enabled by a user again. +func (c *Client) SetDNSPreferences(ctx context.Context, magicDNS bool) (dnsResp *DNSPreferences, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.SetDNSPreferences: %w", err) + } + }() + dnsReq := DNSPreferences{MagicDNS: magicDNS} + b, err := c.dnsPOSTRequest(ctx, "preferences", dnsReq) + if err != nil { + return + } + err = json.Unmarshal(b, &dnsResp) + return dnsResp, err +} + +// SearchPaths retrieves the list of searchpaths set for a tailnet. +func (c *Client) SearchPaths(ctx context.Context) (searchpaths []string, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.SearchPaths: %w", err) + } + }() + b, err := c.dnsGETRequest(ctx, "searchpaths") + if err != nil { + return nil, err + } + var dnsResp *DNSSearchPaths + err = json.Unmarshal(b, &dnsResp) + return dnsResp.SearchPaths, err +} + +// SetSearchPaths sets the list of searchpaths for a tailnet. +func (c *Client) SetSearchPaths(ctx context.Context, searchpaths []string) (newSearchPaths []string, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.SetSearchPaths: %w", err) + } + }() + dnsReq := DNSSearchPaths{SearchPaths: searchpaths} + b, err := c.dnsPOSTRequest(ctx, "searchpaths", dnsReq) + if err != nil { + return nil, err + } + var dnsResp DNSSearchPaths + err = json.Unmarshal(b, &dnsResp) + return dnsResp.SearchPaths, err +} diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 5f8d15456..e694a0646 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -5,7 +5,6 @@ //go:build go1.18 // +build go1.18 -// Package tailscale contains Tailscale client code. package tailscale import ( diff --git a/client/tailscale/routes.go b/client/tailscale/routes.go new file mode 100644 index 000000000..c00138d3d --- /dev/null +++ b/client/tailscale/routes.go @@ -0,0 +1,98 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.18 +// +build go1.18 + +package tailscale + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "inet.af/netaddr" +) + +// Routes contains the lists of subnet routes that are currently advertised by a device, +// as well as the subnets that are enabled to be routed by the device. +type Routes struct { + AdvertisedRoutes []netaddr.IPPrefix `json:"advertisedRoutes"` + EnabledRoutes []netaddr.IPPrefix `json:"enabledRoutes"` +} + +// Routes retrieves the list of subnet routes that have been enabled for a device. +// The routes that are returned are not necessarily advertised by the device, +// they have only been preapproved. +func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.Routes: %w", err) + } + }() + + path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.BaseURL, deviceID) + req, err := http.NewRequestWithContext(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + + b, resp, err := c.sendRequest(req) + if err != nil { + return nil, err + } + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(b, resp) + } + + var sr Routes + err = json.Unmarshal(b, &sr) + return &sr, err +} + +type postRoutesParams struct { + Routes []netaddr.IPPrefix `json:"routes"` +} + +// SetRoutes updates the list of subnets that are enabled for a device. +// Subnets must be parsable by inet.af/netaddr.ParseIPPrefix. +// Subnets do not have to be currently advertised by a device, they may be pre-enabled. +// Returns the updated list of enabled and advertised subnet routes in a *Routes object. +func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netaddr.IPPrefix) (routes *Routes, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.SetRoutes: %w", err) + } + }() + params := &postRoutesParams{Routes: subnets} + data, err := json.Marshal(params) + if err != nil { + return nil, err + } + path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.BaseURL, deviceID) + req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + b, resp, err := c.sendRequest(req) + if err != nil { + return nil, err + } + // If status code was not successful, return the error. + // TODO: Change the check for the StatusCode to include other 2XX success codes. + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(b, resp) + } + + var srr *Routes + if err := json.Unmarshal(b, &srr); err != nil { + return nil, err + } + return srr, err +} diff --git a/client/tailscale/tailnet.go b/client/tailscale/tailnet.go new file mode 100644 index 000000000..79542364d --- /dev/null +++ b/client/tailscale/tailnet.go @@ -0,0 +1,42 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.18 +// +build go1.18 + +package tailscale + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control. +func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (err error) { + defer func() { + if err != nil { + err = fmt.Errorf("tailscale.DeleteTailnet: %w", err) + } + }() + + path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.BaseURL, url.PathEscape(string(tailnetID))) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil) + if err != nil { + return err + } + + req.SetBasicAuth(c.APIKey, "") + b, resp, err := c.sendRequest(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(b, resp) + } + + return nil +} diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go new file mode 100644 index 000000000..2f21f570d --- /dev/null +++ b/client/tailscale/tailscale.go @@ -0,0 +1,112 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.18 +// +build go1.18 + +// Package tailscale contains Go clients for the Tailscale Local API and +// Tailscale control plane API. +// +// Warning: this package is in development and makes no API compatibility +// promises as of 2022-04-29. It is subject to change at any time. +package tailscale + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" +) + +// I_Acknowledge_This_API_Is_Unstable must be set true to use this package +// for now. It was added 2022-04-29 when it was moved to this git repo +// and will be removed when the public API has settled. +// +// TODO(bradfitz): remove this after the we're happy with the public API. +var I_Acknowledge_This_API_Is_Unstable = false + +// TODO: use url.PathEscape() for deviceID and tailnets when constructing requests. + +// DefaultURL is the default base URL used for API calls. +const DefaultURL = "https://api.tailscale.com" + +// maxSize is the maximum read size (10MB) of responses from the server. +const maxReadSize int64 = 10 * 1024 * 1024 + +// Client is needed to make different API calls to the Tailscale server. +// It holds all the necessary information so that it can be reused to make +// multiple requests for the same user. +// Unless overridden, "api.tailscale.com" is the default BaseURL. +type Client struct { + // Tailnet is the globally unique identifier for a Tailscale network, such + // as "example.com" or "user@gmail.com". + Tailnet string + APIKey string + BaseURL string + HTTPClient *http.Client +} + +// New is a convenience method for instantiating a new Client. +// +// tailnet is the globally unique identifier for a Tailscale network, such +// as "example.com" or "user@gmail.com". +// If httpClient is nil, then http.DefaultClient is used. +// "api.tailscale.com" is set as the BaseURL for the returned client +// and can be changed manually by the user. +func New(tailnet string, key string, httpClient *http.Client) *Client { + c := &Client{ + Tailnet: tailnet, + APIKey: key, + BaseURL: DefaultURL, + HTTPClient: httpClient, + } + + if httpClient == nil { + c.HTTPClient = http.DefaultClient + } + + return c +} + +// sendRequest add the authenication key to the request and sends it. It +// receives the response and reads up to 10MB of it. +func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error) { + if !I_Acknowledge_This_API_Is_Unstable { + return nil, nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable") + } + req.SetBasicAuth(c.APIKey, "") + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, resp, err + } + defer resp.Body.Close() + + // Read response. Limit the response to 10MB. + body := io.LimitReader(resp.Body, maxReadSize) + b, err := ioutil.ReadAll(body) + return b, resp, err +} + +// ErrResponse is the HTTP error returned by the Tailscale server. +type ErrResponse struct { + Status int + Message string +} + +func (e ErrResponse) Error() string { + return fmt.Sprintf("Status: %d, Message: %q", e.Status, e.Message) +} + +// handleErrorResponse decodes the error message from the server and returns +// an ErrResponse from it. +func handleErrorResponse(b []byte, resp *http.Response) error { + var errResp ErrResponse + if err := json.Unmarshal(b, &errResp); err != nil { + return err + } + errResp.Status = resp.StatusCode + return errResp +}