Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v2: added support for DevicesResource #90

Merged
merged 1 commit into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
oxtoacart marked this conversation as resolved.
Show resolved Hide resolved
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...)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does JoinPath handle URL encoding / escaping the strings under the hood? e.g, if we end up with an /tailnet/mario@example.com/ URL does the @ get properly escaped?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that was my reason for using it.

}

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit x 2 if the above is accepted to bring it back to how it was:

Suggested change
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) {
oxtoacart marked this conversation as resolved.
Show resolved Hide resolved
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
Loading