Skip to content

Commit

Permalink
v2: added support for DevicesResource (#90)
Browse files Browse the repository at this point in the history
Updates tailscale/corp#21867

Signed-off-by: Percy Wegmann <[email protected]>
  • Loading branch information
oxtoacart authored Aug 2, 2024
1 parent d273e41 commit 7518e66
Show file tree
Hide file tree
Showing 4 changed files with 568 additions and 14 deletions.
40 changes: 26 additions & 14 deletions v2/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ type (

// http is the http client to use for requests to the API server. If specified, this supercedes the above configuration.
http *http.Client
// tailnetPathEscaped is the value of tailnet passed to url.PathEscape.
// This value should be used when formatting paths that have tailnet as a segment.
tailnetPathEscaped string

initOnce sync.Once

// Specific resources
devices *DevicesResource
}

// APIError type describes an error as returned by the Tailscale API.
Expand Down Expand Up @@ -84,13 +84,13 @@ func (c *Client) init() {
if c.BaseURL == nil {
c.BaseURL = defaultBaseURL
}
c.tailnetPathEscaped = url.PathEscape(c.Tailnet)
if c.UserAgent == "" {
c.UserAgent = defaultUserAgent
}
if c.http == nil {
c.http = &http.Client{Timeout: defaultHttpClientTimeout}
}
c.devices = &DevicesResource{c}
})
}

Expand All @@ -108,6 +108,11 @@ func (c *Client) UseOAuth(clientID, clientSecret string, scopes []string) {
c.http.Timeout = defaultHttpClientTimeout
}

func (c *Client) Devices() *DevicesResource {
c.init()
return c.devices
}

type requestParams struct {
headers map[string]string
body any
Expand All @@ -134,19 +139,26 @@ func requestContentType(ct string) requestOption {
}
}

func (c *Client) buildRequest(ctx context.Context, method, uri string, opts ...requestOption) (*http.Request, error) {
// buildURL builds a url to /api/v2/... using the given pathElements. It
// url escapes each path element, so the caller doesn't need to worry about
// that.
func (c *Client) buildURL(pathElements ...any) *url.URL {
elem := make([]string, 1, len(pathElements)+1)
elem[0] = "/api/v2"
for _, pathElement := range pathElements {
elem = append(elem, fmt.Sprint(pathElement))
}
return c.BaseURL.JoinPath(elem...)
}

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)
}

u, err := c.BaseURL.Parse(uri)
if err != nil {
return nil, err
}

var bodyBytes []byte
if rof.body != nil {
switch body := rof.body.(type) {
Expand All @@ -155,14 +167,15 @@ func (c *Client) buildRequest(ctx context.Context, method, uri string, opts ...r
case []byte:
bodyBytes = body
default:
var err error
bodyBytes, err = json.MarshalIndent(rof.body, "", " ")
if err != nil {
return nil, err
}
}
}

req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewBuffer(bodyBytes))
req, err := http.NewRequestWithContext(ctx, method, uri.String(), bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, err
}
Expand All @@ -182,15 +195,14 @@ func (c *Client) buildRequest(ctx context.Context, method, uri string, opts ...r
req.Header.Set("Content-Type", rof.contentType)
}

// c.apiKey will not be set on the client was configured with WithOAuthClientCredentials()
if c.APIKey != "" {
req.SetBasicAuth(c.APIKey, "")
}

return req, nil
}

func (c *Client) performRequest(req *http.Request, out interface{}) error {
func (c *Client) do(req *http.Request, out interface{}) error {
res, err := c.http.Do(req)
if err != nil {
return err
Expand Down Expand Up @@ -229,7 +241,7 @@ func (c *Client) performRequest(req *http.Request, out interface{}) error {

if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated {
var apiErr APIError
if err = json.Unmarshal(body, &apiErr); err != nil {
if err := json.Unmarshal(body, &apiErr); err != nil {
return err
}

Expand Down
181 changes: 181 additions & 0 deletions v2/devices.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package tailscale

import (
"context"
"encoding/json"
"net/http"
"time"
)

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"`
UpdateAvailable bool `json:"updateAvailable"`
}

// Get gets a single device
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
}

var result Device
return &result, dr.do(req, &result)
}

// List lists the devices in a tailnet.
func (dr *DevicesResource) List(ctx context.Context) ([]Device, error) {
req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildURL("tailnet", dr.Tailnet, "devices"))
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 given its 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)
}

// SetTags updates the tags of a target device.
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)
}

type (
// DeviceKey type represents the properties of the key of an individual device within
// the tailnet.
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) SetDeviceIPv4Address(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
}

var result DeviceRoutes
return &result, dr.do(req, &result)
}
Loading

0 comments on commit 7518e66

Please sign in to comment.