diff --git a/README.md b/README.md index 37a078b..eea32e5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A client implementation for the [Tailscale](https://tailscale.com) HTTP API. For more details, please see [API documentation](https://github.com/tailscale/tailscale/blob/main/api.md). +A [V2](v2) implementation of the client is under active development, use at your own risk. + # Example ```go diff --git a/v2/client.go b/v2/client.go new file mode 100644 index 0000000..d6dbabc --- /dev/null +++ b/v2/client.go @@ -0,0 +1,291 @@ +// Package tailscale contains a basic implementation of a client for the Tailscale HTTP api. Documentation is here: +// https://github.com/tailscale/tailscale/blob/main/api.md +// +// WARNING - this v2 implementation is under active development, use at your own risk. +package tailscale + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + "github.com/tailscale/hujson" + "golang.org/x/oauth2/clientcredentials" +) + +type ( + // Client type is used to perform actions against the Tailscale API. + 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. + 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 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 + } + + // APIError type describes an error as returned by the Tailscale API. + 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. + APIErrorData struct { + User string `json:"user"` + Errors []string `json:"errors"` + } +) + +var defaultBaseURL *url.URL +var oauthRelTokenURL *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)) + } + + oauthRelTokenURL, err = url.Parse("/api/v2/oauth/token") + if err != nil { + panic(fmt.Errorf("failed to parse oauthRelTokenURL: %s", err)) + } +} + +const defaultContentType = "application/json" +const defaultHttpClientTimeout = time.Minute +const defaultUserAgent = "tailscale-client-go" + +// NewClient returns a new instance of the Client type that will perform operations against a chosen tailnet and will +// provide the apiKey for authorization. Additional options can be provided, see ClientOption for more details. +// +// To use OAuth Client credentials, call [UseOAuth]. +func (c *Client) init() { + c.initOnce.Do(func() { + 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} + } + }) +} + +// UseOAuth configures the client to use the specified OAuth credentials. +func (c *Client) UseOAuth(clientID, clientSecret string, scopes []string) { + oauthConfig := clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: c.BaseURL.ResolveReference(oauthRelTokenURL).String(), + Scopes: scopes, + } + + // use context.Background() here, since this is used to refresh the token in the future + c.http = oauthConfig.Client(context.Background()) + c.http.Timeout = defaultHttpClientTimeout +} + +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 + } +} + +func (c *Client) buildRequest(ctx context.Context, method, uri string, 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) { + case string: + bodyBytes = []byte(body) + case []byte: + bodyBytes = body + default: + bodyBytes, err = json.MarshalIndent(rof.body, "", " ") + if err != nil { + return nil, err + } + } + } + + req, err := http.NewRequestWithContext(ctx, method, u.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) + } + + // 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 { + res, err := c.http.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return 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 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 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 err + } + } + + return json.Unmarshal(body, out) + } + + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated { + var apiErr APIError + if err = json.Unmarshal(body, &apiErr); err != nil { + return err + } + + apiErr.status = res.StatusCode + return apiErr + } + + return 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 type 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 +} diff --git a/v2/client_test.go b/v2/client_test.go new file mode 100644 index 0000000..6149e7d --- /dev/null +++ b/v2/client_test.go @@ -0,0 +1,35 @@ +package tailscale_test + +import ( + _ "embed" + "io" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/tailscale/tailscale-client-go/v2" +) + +func TestErrorData(t *testing.T) { + t.Parallel() + + t.Run("It should return the data element from a valid error", func(t *testing.T) { + expected := tailscale.APIError{ + Data: []tailscale.APIErrorData{ + { + User: "user1@example.com", + Errors: []string{ + "address \"user2@example.com:400\": want: Accept, got: Drop", + }, + }, + }, + } + + actual := tailscale.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, tailscale.ErrorData(io.EOF)) + }) +} diff --git a/v2/go.mod b/v2/go.mod new file mode 100644 index 0000000..1de477b --- /dev/null +++ b/v2/go.mod @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..66acc2a --- /dev/null +++ b/v2/go.sum @@ -0,0 +1,16 @@ +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/tailscale_test.go b/v2/tailscale_test.go new file mode 100644 index 0000000..2b0e600 --- /dev/null +++ b/v2/tailscale_test.go @@ -0,0 +1,89 @@ +package tailscale_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/tailscale/tailscale-client-go/v2" +) + +type TestServer struct { + t *testing.T + + BaseURL *url.URL + + Method string + Path string + Body *bytes.Buffer + Header http.Header + + ResponseCode int + ResponseBody interface{} +} + +func NewTestHarness(t *testing.T) (*tailscale.Client, *TestServer) { + t.Helper() + + testServer := &TestServer{ + t: t, + } + + 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 := &tailscale.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.Header = r.Header + + t.Body = bytes.NewBuffer([]byte{}) + _, err := io.Copy(t.Body, r.Body) + assert.NoError(t.t, err) + + 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)) + } + } +}