├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── scripts └── check_license_headers.sh ├── tailscale ├── client.go ├── client_test.go ├── tailscale_test.go ├── testdata │ ├── acl.hujson │ ├── acl.json │ └── devices.json └── time_test.go └── v2 └── client.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: {} 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | container: golang:1.22 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | 16 | - name: Get cache paths 17 | id: cache 18 | run: | 19 | echo "build=$(go env GOCACHE)" | tee -a $GITHUB_OUTPUT 20 | echo "module=$(go env GOMODCACHE)" | tee -a $GITHUB_OUTPUT 21 | 22 | - name: Set up cache 23 | uses: actions/cache@v2 24 | with: 25 | path: | 26 | ${{ steps.cache.outputs.build }} 27 | ${{ steps.cache.outputs.module }} 28 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 29 | restore-keys: | 30 | ${{ runner.os }}-go- 31 | 32 | - name: Run tests 33 | run: go test -race ./... 34 | 35 | licenses: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: checkout 39 | uses: actions/checkout@v4 40 | - name: check licenses 41 | run: ./scripts/check_license_headers.sh . 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 David Bond 4 | Copyright (c) 2024 Tailscale Inc & Contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tailscale-client-go 2 | 3 | > [!IMPORTANT] 4 | > This implementation is no longer maintained. The replacement is available at [tailscale-client-go-v2](https://github.com/tailscale/tailscale-client-go-v2). 5 | 6 | A Go client implementation for the [Tailscale API](https://tailscale.com/api). 7 | 8 | ## Old version 9 | 10 | * V1: `import "github.com/tailscale/tailscale-client-go"` 11 | 12 | Deprecated, no longer maintained. 13 | 14 | [![Go Reference](https://pkg.go.dev/badge/github.com/tailscale/tailscale-client-go.svg)](https://pkg.go.dev/github.com/tailscale/tailscale-client-go) 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tailscale/tailscale-client-go 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/stretchr/testify v1.9.0 7 | github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 8 | golang.org/x/oauth2 v0.23.0 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 4 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 8 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 9 | github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 h1:erxeiTyq+nw4Cz5+hLDkOwNF5/9IQWCQPv0gpb3+QHU= 10 | github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= 11 | golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= 12 | golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 16 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /scripts/check_license_headers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Copyright (c) David Bond, Tailscale Inc, & Contributors 4 | # SPDX-License-Identifier: MIT 5 | 6 | # check_license_headers.sh checks that all Go files in the given 7 | # directory tree have a correct-looking license header. 8 | 9 | check_file() { 10 | got=$1 11 | 12 | want=$(cat <&2 25 | exit 1 26 | fi 27 | 28 | fail=0 29 | for file in $(find $1 \( -name '*.go' -or -name '*.tsx' -or -name '*.ts' -not -name '*.config.ts' \) -not -path '*/.git/*' -not -path '*/node_modules/*'); do 30 | case $file in 31 | $1/tempfork/*) 32 | # Skip, tempfork of third-party code 33 | ;; 34 | $1/wgengine/router/ifconfig_windows.go) 35 | # WireGuard copyright. 36 | ;; 37 | $1/cmd/tailscale/cli/authenticode_windows.go) 38 | # WireGuard copyright. 39 | ;; 40 | *_string.go) 41 | # Generated file from go:generate stringer 42 | ;; 43 | $1/control/controlbase/noiseexplorer_test.go) 44 | # Noiseexplorer.com copyright. 45 | ;; 46 | */zsyscall_windows.go) 47 | # Generated syscall wrappers 48 | ;; 49 | $1/util/winutil/subprocess_windows_test.go) 50 | # Subprocess test harness code 51 | ;; 52 | $1/util/winutil/testdata/testrestartableprocesses/main.go) 53 | # Subprocess test harness code 54 | ;; 55 | *$1/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go) 56 | # Generated kube deepcopy funcs file starts with a Go build tag + an empty line 57 | header="$(head -5 $file | tail -n+3 )" 58 | ;; 59 | $1/derp/xdp/bpf_bpfe*.go) 60 | # Generated eBPF management code 61 | ;; 62 | *) 63 | header="$(head -2 $file)" 64 | ;; 65 | esac 66 | if [ ! -z "$header" ]; then 67 | if ! check_file "$header"; then 68 | fail=1 69 | echo "${file#$1/} doesn't have the right copyright header:" 70 | echo "$header" | sed -e 's/^/ /g' 71 | fi 72 | fi 73 | done 74 | 75 | if [ $fail -ne 0 ]; then 76 | exit 1 77 | fi 78 | -------------------------------------------------------------------------------- /tailscale/client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package tailscale contains a basic implementation of a client for the Tailscale HTTP API. 5 | // 6 | // Documentation is at https://tailscale.com/api 7 | package tailscale 8 | 9 | import ( 10 | "bytes" 11 | "context" 12 | "encoding/json" 13 | "errors" 14 | "fmt" 15 | "io" 16 | "net/http" 17 | "net/url" 18 | "time" 19 | 20 | "github.com/tailscale/hujson" 21 | "golang.org/x/oauth2/clientcredentials" 22 | ) 23 | 24 | type ( 25 | // Client type is used to perform actions against the Tailscale API. 26 | Client struct { 27 | apiKey string 28 | http *http.Client 29 | baseURL *url.URL 30 | tailnet string 31 | // tailnetPathEscaped is the value of tailnet passed to url.PathEscape. 32 | // This value should be used when formatting paths that have tailnet as a segment. 33 | tailnetPathEscaped string 34 | userAgent string // empty string means Go's default value. 35 | } 36 | 37 | // APIError type describes an error as returned by the Tailscale API. 38 | APIError struct { 39 | Message string `json:"message"` 40 | Data []APIErrorData `json:"data"` 41 | status int 42 | } 43 | 44 | // APIErrorData type describes elements of the data field within errors returned by the Tailscale API. 45 | APIErrorData struct { 46 | User string `json:"user"` 47 | Errors []string `json:"errors"` 48 | } 49 | 50 | // ClientOption type is a function that is used to modify a Client. 51 | ClientOption func(c *Client) error 52 | ) 53 | 54 | const baseURL = "https://api.tailscale.com" 55 | const defaultContentType = "application/json" 56 | const defaultHttpClientTimeout = time.Minute 57 | const defaultUserAgent = "tailscale-client-go" 58 | 59 | // NewClient returns a new instance of the Client type that will perform operations against a chosen tailnet and will 60 | // provide the apiKey for authorization. Additional options can be provided, see ClientOption for more details. 61 | // 62 | // To use OAuth Client credentials pass an empty string as apiKey and use WithOAuthClientCredentials() as below: 63 | // 64 | // client, err := tailscale.NewClient( 65 | // "", 66 | // tailnet, 67 | // tailscale.WithOAuthClientCredentials(oauthClientID, oauthClientSecret, oauthScopes), 68 | // ) 69 | func NewClient(apiKey, tailnet string, options ...ClientOption) (*Client, error) { 70 | u, err := url.Parse(baseURL) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | c := &Client{ 76 | baseURL: u, 77 | tailnet: tailnet, 78 | tailnetPathEscaped: url.PathEscape(tailnet), 79 | userAgent: defaultUserAgent, 80 | } 81 | 82 | if apiKey != "" { 83 | c.apiKey = apiKey 84 | c.http = &http.Client{Timeout: defaultHttpClientTimeout} 85 | } 86 | 87 | for _, option := range options { 88 | if err = option(c); err != nil { 89 | return nil, err 90 | } 91 | } 92 | 93 | // apiKey or WithOAuthClientCredentials will initialize the http client. Fail here if both are not set. 94 | if c.apiKey == "" && c.http == nil { 95 | return nil, errors.New("no authentication credentials provided") 96 | } 97 | 98 | return c, nil 99 | } 100 | 101 | // WithBaseURL sets a custom baseURL for the Tailscale API, this is primarily used for testing purposes. 102 | func WithBaseURL(baseURL string) ClientOption { 103 | return func(c *Client) error { 104 | u, err := url.Parse(baseURL) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | c.baseURL = u 110 | return nil 111 | } 112 | } 113 | 114 | // WithOAuthClientCredentials sets the OAuth Client Credentials to use for the Tailscale API. 115 | func WithOAuthClientCredentials(clientID, clientSecret string, scopes []string) ClientOption { 116 | return func(c *Client) error { 117 | relTokenURL, err := url.Parse("/api/v2/oauth/token") 118 | if err != nil { 119 | return err 120 | } 121 | oauthConfig := clientcredentials.Config{ 122 | ClientID: clientID, 123 | ClientSecret: clientSecret, 124 | TokenURL: c.baseURL.ResolveReference(relTokenURL).String(), 125 | Scopes: scopes, 126 | } 127 | 128 | // use context.Background() here, since this is used to refresh the token in the future 129 | c.http = oauthConfig.Client(context.Background()) 130 | c.http.Timeout = defaultHttpClientTimeout 131 | return nil 132 | } 133 | } 134 | 135 | // WithUserAgent sets a custom User-Agent header in HTTP requests. 136 | // Passing an empty string will make the client use Go's default value. 137 | func WithUserAgent(ua string) ClientOption { 138 | return func(c *Client) error { 139 | c.userAgent = ua 140 | return nil 141 | } 142 | } 143 | 144 | type requestParams struct { 145 | headers map[string]string 146 | body any 147 | contentType string 148 | } 149 | 150 | type requestOption func(*requestParams) 151 | 152 | func requestBody(body any) requestOption { 153 | return func(rof *requestParams) { 154 | rof.body = body 155 | } 156 | } 157 | 158 | func requestHeaders(headers map[string]string) requestOption { 159 | return func(rof *requestParams) { 160 | rof.headers = headers 161 | } 162 | } 163 | 164 | func requestContentType(ct string) requestOption { 165 | return func(rof *requestParams) { 166 | rof.contentType = ct 167 | } 168 | } 169 | 170 | func (c *Client) buildRequest(ctx context.Context, method, uri string, opts ...requestOption) (*http.Request, error) { 171 | rof := &requestParams{ 172 | contentType: defaultContentType, 173 | } 174 | for _, opt := range opts { 175 | opt(rof) 176 | } 177 | 178 | u, err := c.baseURL.Parse(uri) 179 | if err != nil { 180 | return nil, err 181 | } 182 | 183 | var bodyBytes []byte 184 | if rof.body != nil { 185 | switch body := rof.body.(type) { 186 | case string: 187 | bodyBytes = []byte(body) 188 | case []byte: 189 | bodyBytes = body 190 | default: 191 | bodyBytes, err = json.MarshalIndent(rof.body, "", " ") 192 | if err != nil { 193 | return nil, err 194 | } 195 | } 196 | } 197 | 198 | req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewBuffer(bodyBytes)) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | if c.userAgent != "" { 204 | req.Header.Set("User-Agent", c.userAgent) 205 | } 206 | 207 | for k, v := range rof.headers { 208 | req.Header.Set(k, v) 209 | } 210 | 211 | switch { 212 | case rof.body == nil: 213 | req.Header.Set("Accept", rof.contentType) 214 | default: 215 | req.Header.Set("Content-Type", rof.contentType) 216 | } 217 | 218 | // c.apiKey will not be set on the client was configured with WithOAuthClientCredentials() 219 | if c.apiKey != "" { 220 | req.SetBasicAuth(c.apiKey, "") 221 | } 222 | 223 | return req, nil 224 | } 225 | 226 | func (c *Client) performRequest(req *http.Request, out interface{}) error { 227 | res, err := c.http.Do(req) 228 | if err != nil { 229 | return err 230 | } 231 | defer res.Body.Close() 232 | 233 | body, err := io.ReadAll(res.Body) 234 | if err != nil { 235 | return err 236 | } 237 | 238 | if res.StatusCode >= http.StatusOK && res.StatusCode < http.StatusMultipleChoices { 239 | // If we don't care about the response body, leave. This check is required as some 240 | // API responses have empty bodies, so we don't want to try and standardize them for 241 | // parsing. 242 | if out == nil { 243 | return nil 244 | } 245 | 246 | // If we're expected to write result into a []byte, do not attempt to parse it. 247 | if o, ok := out.(*[]byte); ok { 248 | *o = bytes.Clone(body) 249 | return nil 250 | } 251 | 252 | // If we've got hujson back, convert it to JSON, so we can natively parse it. 253 | if !json.Valid(body) { 254 | body, err = hujson.Standardize(body) 255 | if err != nil { 256 | return err 257 | } 258 | } 259 | 260 | return json.Unmarshal(body, out) 261 | } 262 | 263 | if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated { 264 | var apiErr APIError 265 | if err = json.Unmarshal(body, &apiErr); err != nil { 266 | return err 267 | } 268 | 269 | apiErr.status = res.StatusCode 270 | return apiErr 271 | } 272 | 273 | return nil 274 | } 275 | 276 | func (err APIError) Error() string { 277 | return fmt.Sprintf("%s (%v)", err.Message, err.status) 278 | } 279 | 280 | // SetDNSSearchPaths replaces the list of search paths with the list supplied by the user and returns an error otherwise. 281 | func (c *Client) SetDNSSearchPaths(ctx context.Context, searchPaths []string) error { 282 | const uriFmt = "/api/v2/tailnet/%v/dns/searchpaths" 283 | 284 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnetPathEscaped), requestBody(map[string][]string{ 285 | "searchPaths": searchPaths, 286 | })) 287 | if err != nil { 288 | return err 289 | } 290 | 291 | return c.performRequest(req, nil) 292 | } 293 | 294 | // DNSSearchPaths retrieves the list of search paths that is currently set for the given tailnet. 295 | func (c *Client) DNSSearchPaths(ctx context.Context) ([]string, error) { 296 | const uriFmt = "/api/v2/tailnet/%v/dns/searchpaths" 297 | 298 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnetPathEscaped)) 299 | if err != nil { 300 | return nil, err 301 | } 302 | 303 | resp := make(map[string][]string) 304 | if err = c.performRequest(req, &resp); err != nil { 305 | return nil, err 306 | } 307 | 308 | return resp["searchPaths"], nil 309 | } 310 | 311 | // SetDNSNameservers replaces the list of DNS nameservers for the given tailnet with the list supplied by the user. Note 312 | // that changing the list of DNS nameservers may also affect the status of MagicDNS (if MagicDNS is on). 313 | func (c *Client) SetDNSNameservers(ctx context.Context, dns []string) error { 314 | const uriFmt = "/api/v2/tailnet/%v/dns/nameservers" 315 | 316 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnetPathEscaped), requestBody(map[string][]string{ 317 | "dns": dns, 318 | })) 319 | if err != nil { 320 | return err 321 | } 322 | 323 | return c.performRequest(req, nil) 324 | } 325 | 326 | // DNSNameservers lists the DNS nameservers for a tailnet 327 | func (c *Client) DNSNameservers(ctx context.Context) ([]string, error) { 328 | const uriFmt = "/api/v2/tailnet/%v/dns/nameservers" 329 | 330 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnetPathEscaped)) 331 | if err != nil { 332 | return nil, err 333 | } 334 | 335 | resp := make(map[string][]string) 336 | if err = c.performRequest(req, &resp); err != nil { 337 | return nil, err 338 | } 339 | 340 | return resp["dns"], nil 341 | } 342 | 343 | // SplitDnsRequest is a map from domain names to a list of nameservers. 344 | type SplitDnsRequest map[string][]string 345 | 346 | // SplitDnsResponse is a map from domain names to a list of nameservers. 347 | type SplitDnsResponse SplitDnsRequest 348 | 349 | // UpdateSplitDNS updates the split DNS settings for a tailnet using the 350 | // provided SplitDnsRequest object. This is a PATCH operation that performs 351 | // partial updates of the underlying data structure. 352 | // 353 | // Mapping a domain to a nil slice in the request will unset the nameservers 354 | // associated with that domain. Values provided for domains will overwrite the 355 | // current value associated with the domain. Domains not included in the request 356 | // will remain unchanged. 357 | func (c *Client) UpdateSplitDNS(ctx context.Context, request SplitDnsRequest) (SplitDnsResponse, error) { 358 | const uriFmt = "/api/v2/tailnet/%v/dns/split-dns" 359 | 360 | req, err := c.buildRequest(ctx, http.MethodPatch, fmt.Sprintf(uriFmt, c.tailnetPathEscaped), requestBody(request)) 361 | if err != nil { 362 | return nil, err 363 | } 364 | 365 | var resp SplitDnsResponse 366 | if err = c.performRequest(req, &resp); err != nil { 367 | return nil, err 368 | } 369 | 370 | return resp, nil 371 | } 372 | 373 | // SetSplitDNS sets the split DNS settings for a tailnet using the provided 374 | // SplitDnsRequest object. This is a PUT operation that fully replaces the underlying 375 | // data structure. 376 | // 377 | // Passing in an empty SplitDnsRequest will unset all split DNS mappings for the tailnet. 378 | func (c *Client) SetSplitDNS(ctx context.Context, request SplitDnsRequest) error { 379 | const uriFmt = "/api/v2/tailnet/%v/dns/split-dns" 380 | 381 | req, err := c.buildRequest(ctx, http.MethodPut, fmt.Sprintf(uriFmt, c.tailnetPathEscaped), requestBody(request)) 382 | if err != nil { 383 | return err 384 | } 385 | 386 | return c.performRequest(req, nil) 387 | } 388 | 389 | // SplitDNS retrieves the split DNS configuration for a tailnet. 390 | func (c *Client) SplitDNS(ctx context.Context) (SplitDnsResponse, error) { 391 | const uriFmt = "/api/v2/tailnet/%v/dns/split-dns" 392 | 393 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnetPathEscaped)) 394 | if err != nil { 395 | return nil, err 396 | } 397 | 398 | var resp SplitDnsResponse 399 | if err = c.performRequest(req, &resp); err != nil { 400 | return nil, err 401 | } 402 | 403 | return resp, nil 404 | } 405 | 406 | type ( 407 | // ACL contains the schema for a tailnet policy file. More details: https://tailscale.com/kb/1018/acls/ 408 | ACL struct { 409 | ACLs []ACLEntry `json:"acls,omitempty" hujson:"ACLs,omitempty"` 410 | AutoApprovers *ACLAutoApprovers `json:"autoApprovers,omitempty" hujson:"AutoApprovers,omitempty"` 411 | Groups map[string][]string `json:"groups,omitempty" hujson:"Groups,omitempty"` 412 | Hosts map[string]string `json:"hosts,omitempty" hujson:"Hosts,omitempty"` 413 | TagOwners map[string][]string `json:"tagOwners,omitempty" hujson:"TagOwners,omitempty"` 414 | DERPMap *ACLDERPMap `json:"derpMap,omitempty" hujson:"DerpMap,omitempty"` 415 | Tests []ACLTest `json:"tests,omitempty" hujson:"Tests,omitempty"` 416 | SSH []ACLSSH `json:"ssh,omitempty" hujson:"SSH,omitempty"` 417 | NodeAttrs []NodeAttrGrant `json:"nodeAttrs,omitempty" hujson:"NodeAttrs,omitempty"` 418 | DisableIPv4 bool `json:"disableIPv4,omitempty" hujson:"DisableIPv4,omitempty"` 419 | OneCGNATRoute string `json:"oneCGNATRoute,omitempty" hujson:"OneCGNATRoute,omitempty"` 420 | RandomizeClientPort bool `json:"randomizeClientPort,omitempty" hujson:"RandomizeClientPort,omitempty"` 421 | 422 | // Postures and DefaultSourcePosture are for an experimental feature and not yet public or documented as of 2023-08-17. 423 | // This API is subject to change. Internal bug: corp/13986 424 | Postures map[string][]string `json:"postures,omitempty" hujson:"Postures,omitempty"` 425 | DefaultSourcePosture []string `json:"defaultSrcPosture,omitempty" hujson:"DefaultSrcPosture,omitempty"` 426 | } 427 | 428 | ACLAutoApprovers struct { 429 | Routes map[string][]string `json:"routes,omitempty" hujson:"Routes,omitempty"` 430 | ExitNode []string `json:"exitNode,omitempty" hujson:"ExitNode,omitempty"` 431 | } 432 | 433 | ACLEntry struct { 434 | Action string `json:"action,omitempty" hujson:"Action,omitempty"` 435 | Ports []string `json:"ports,omitempty" hujson:"Ports,omitempty"` 436 | Users []string `json:"users,omitempty" hujson:"Users,omitempty"` 437 | Source []string `json:"src,omitempty" hujson:"Src,omitempty"` 438 | Destination []string `json:"dst,omitempty" hujson:"Dst,omitempty"` 439 | Protocol string `json:"proto,omitempty" hujson:"Proto,omitempty"` 440 | 441 | // SourcePosture is for an experimental feature and not yet public or documented as of 2023-08-17. 442 | SourcePosture []string `json:"srcPosture,omitempty" hujson:"SrcPosture,omitempty"` 443 | } 444 | 445 | ACLTest struct { 446 | User string `json:"user,omitempty" hujson:"User,omitempty"` 447 | Allow []string `json:"allow,omitempty" hujson:"Allow,omitempty"` 448 | Deny []string `json:"deny,omitempty" hujson:"Deny,omitempty"` 449 | Source string `json:"src,omitempty" hujson:"Src,omitempty"` 450 | Accept []string `json:"accept,omitempty" hujson:"Accept,omitempty"` 451 | } 452 | 453 | ACLDERPMap struct { 454 | Regions map[int]*ACLDERPRegion `json:"regions" hujson:"Regions"` 455 | OmitDefaultRegions bool `json:"omitDefaultRegions,omitempty" hujson:"OmitDefaultRegions,omitempty"` 456 | } 457 | 458 | ACLDERPRegion struct { 459 | RegionID int `json:"regionID" hujson:"RegionID"` 460 | RegionCode string `json:"regionCode" hujson:"RegionCode"` 461 | RegionName string `json:"regionName" hujson:"RegionName"` 462 | Avoid bool `json:"avoid,omitempty" hujson:"Avoid,omitempty"` 463 | Nodes []*ACLDERPNode `json:"nodes" hujson:"Nodes"` 464 | } 465 | 466 | ACLDERPNode struct { 467 | Name string `json:"name" hujson:"Name"` 468 | RegionID int `json:"regionID" hujson:"RegionID"` 469 | HostName string `json:"hostName" hujson:"HostName"` 470 | CertName string `json:"certName,omitempty" hujson:"CertName,omitempty"` 471 | IPv4 string `json:"ipv4,omitempty" hujson:"IPv4,omitempty"` 472 | IPv6 string `json:"ipv6,omitempty" hujson:"IPv6,omitempty"` 473 | STUNPort int `json:"stunPort,omitempty" hujson:"STUNPort,omitempty"` 474 | STUNOnly bool `json:"stunOnly,omitempty" hujson:"STUNOnly,omitempty"` 475 | DERPPort int `json:"derpPort,omitempty" hujson:"DERPPort,omitempty"` 476 | } 477 | 478 | ACLSSH struct { 479 | Action string `json:"action,omitempty" hujson:"Action,omitempty"` 480 | Users []string `json:"users,omitempty" hujson:"Users,omitempty"` 481 | Source []string `json:"src,omitempty" hujson:"Src,omitempty"` 482 | Destination []string `json:"dst,omitempty" hujson:"Dst,omitempty"` 483 | CheckPeriod Duration `json:"checkPeriod,omitempty" hujson:"CheckPeriod,omitempty"` 484 | Recorder []string `json:"recorder,omitempty" hujson:"Recorder,omitempty"` 485 | EnforceRecorder bool `json:"enforceRecorder,omitempty" hujson:"EnforceRecorder,omitempty"` 486 | } 487 | 488 | NodeAttrGrant struct { 489 | Target []string `json:"target,omitempty" hujson:"Target,omitempty"` 490 | Attr []string `json:"attr,omitempty" hujson:"Attr,omitempty"` 491 | App map[string][]*NodeAttrGrantApp `json:"app,omitempty" hujson:"App,omitempty"` 492 | } 493 | 494 | NodeAttrGrantApp struct { 495 | Name string `json:"name,omitempty" hujson:"Name,omitempty"` 496 | Connectors []string `json:"connectors,omitempty" hujson:"Connectors,omitempty"` 497 | Domains []string `json:"domains,omitempty" hujson:"Domains,omitempty"` 498 | } 499 | ) 500 | 501 | // ACL retrieves the ACL that is currently set for the given tailnet. 502 | func (c *Client) ACL(ctx context.Context) (*ACL, error) { 503 | const uriFmt = "/api/v2/tailnet/%s/acl" 504 | 505 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnetPathEscaped)) 506 | if err != nil { 507 | return nil, err 508 | } 509 | 510 | var resp ACL 511 | if err = c.performRequest(req, &resp); err != nil { 512 | return nil, err 513 | } 514 | 515 | return &resp, nil 516 | } 517 | 518 | // RawACL retrieves the ACL that is currently set for the given tailnet 519 | // as a HuJSON string. 520 | func (c *Client) RawACL(ctx context.Context) (string, error) { 521 | const uriFmt = "/api/v2/tailnet/%s/acl" 522 | 523 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnetPathEscaped), requestContentType("application/hujson")) 524 | if err != nil { 525 | return "", err 526 | } 527 | 528 | var resp []byte 529 | if err = c.performRequest(req, &resp); err != nil { 530 | return "", err 531 | } 532 | 533 | return string(resp), nil 534 | } 535 | 536 | type setACLParams struct { 537 | headers map[string]string 538 | } 539 | type SetACLOption func(p *setACLParams) 540 | 541 | // WithETag allows passing an ETag value with Set ACL API call that 542 | // will be used in the "If-Match" HTTP request header. 543 | func WithETag(etag string) SetACLOption { 544 | return func(p *setACLParams) { 545 | p.headers["If-Match"] = fmt.Sprintf("%q", etag) 546 | } 547 | } 548 | 549 | // SetACL sets the ACL for the given tailnet. "acl" can either be an [ACL], 550 | // or a HuJSON string. 551 | func (c *Client) SetACL(ctx context.Context, acl any, opts ...SetACLOption) error { 552 | const uriFmt = "/api/v2/tailnet/%s/acl" 553 | 554 | p := &setACLParams{headers: make(map[string]string)} 555 | for _, opt := range opts { 556 | opt(p) 557 | } 558 | 559 | reqOpts := []requestOption{ 560 | requestHeaders(p.headers), 561 | requestBody(acl), 562 | } 563 | switch v := acl.(type) { 564 | case ACL: 565 | case string: 566 | reqOpts = append(reqOpts, requestContentType("application/hujson")) 567 | default: 568 | return fmt.Errorf("expected ACL content as a string or as ACL struct; got %T", v) 569 | } 570 | 571 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnetPathEscaped), reqOpts...) 572 | if err != nil { 573 | return err 574 | } 575 | 576 | return c.performRequest(req, nil) 577 | } 578 | 579 | // ValidateACL validates the provided ACL via the API. "acl" can either be an [ACL], 580 | // or a HuJSON string. 581 | func (c *Client) ValidateACL(ctx context.Context, acl any) error { 582 | const uriFmt = "/api/v2/tailnet/%s/acl/validate" 583 | 584 | reqOpts := []requestOption{ 585 | requestBody(acl), 586 | } 587 | switch v := acl.(type) { 588 | case ACL: 589 | case string: 590 | reqOpts = append(reqOpts, requestContentType("application/hujson")) 591 | default: 592 | return fmt.Errorf("expected ACL content as a string or as ACL struct; got %T", v) 593 | } 594 | 595 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnetPathEscaped), reqOpts...) 596 | if err != nil { 597 | return err 598 | } 599 | 600 | var response APIError 601 | if err := c.performRequest(req, &response); err != nil { 602 | return err 603 | } 604 | if response.Message != "" { 605 | return fmt.Errorf("ACL validation failed: %s; %v", response.Message, response.Data) 606 | } 607 | return nil 608 | } 609 | 610 | type DNSPreferences struct { 611 | MagicDNS bool `json:"magicDNS"` 612 | } 613 | 614 | // DNSPreferences retrieves the DNS preferences that are currently set for the given tailnet. Supply the tailnet of 615 | // interest in the path. 616 | func (c *Client) DNSPreferences(ctx context.Context) (*DNSPreferences, error) { 617 | const uriFmt = "/api/v2/tailnet/%s/dns/preferences" 618 | 619 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnetPathEscaped)) 620 | if err != nil { 621 | return nil, err 622 | } 623 | 624 | var resp DNSPreferences 625 | if err = c.performRequest(req, &resp); err != nil { 626 | return nil, err 627 | } 628 | 629 | return &resp, nil 630 | } 631 | 632 | // SetDNSPreferences replaces the DNS preferences for a tailnet, specifically, the MagicDNS setting. Note that MagicDNS 633 | // is dependent on DNS servers. 634 | func (c *Client) SetDNSPreferences(ctx context.Context, preferences DNSPreferences) error { 635 | const uriFmt = "/api/v2/tailnet/%s/dns/preferences" 636 | 637 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnetPathEscaped), requestBody(preferences)) 638 | if err != nil { 639 | return nil 640 | } 641 | 642 | return c.performRequest(req, nil) 643 | } 644 | 645 | type ( 646 | DeviceRoutes struct { 647 | Advertised []string `json:"advertisedRoutes"` 648 | Enabled []string `json:"enabledRoutes"` 649 | } 650 | ) 651 | 652 | // SetDeviceSubnetRoutes sets which subnet routes are enabled to be routed by a device by replacing the existing list 653 | // of subnet routes with the supplied routes. Routes can be enabled without a device advertising them (e.g. for preauth). 654 | func (c *Client) SetDeviceSubnetRoutes(ctx context.Context, deviceID string, routes []string) error { 655 | const uriFmt = "/api/v2/device/%s/routes" 656 | 657 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), requestBody(map[string][]string{ 658 | "routes": routes, 659 | })) 660 | if err != nil { 661 | return err 662 | } 663 | 664 | return c.performRequest(req, nil) 665 | } 666 | 667 | // DeviceSubnetRoutes Retrieves the list of subnet routes that a device is advertising, as well as those that are 668 | // enabled for it. Enabled routes are not necessarily advertised (e.g. for pre-enabling), and likewise, advertised 669 | // routes are not necessarily enabled. 670 | func (c *Client) DeviceSubnetRoutes(ctx context.Context, deviceID string) (*DeviceRoutes, error) { 671 | const uriFmt = "/api/v2/device/%s/routes" 672 | 673 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, deviceID)) 674 | if err != nil { 675 | return nil, err 676 | } 677 | 678 | var resp DeviceRoutes 679 | if err = c.performRequest(req, &resp); err != nil { 680 | return nil, err 681 | } 682 | 683 | return &resp, nil 684 | } 685 | 686 | // Time wraps a time and allows for unmarshalling timestamps that represent an empty time as an empty string (e.g "") 687 | // this is used by the tailscale API when it returns devices that have no created date, such as its hello service. 688 | type Time struct { 689 | time.Time 690 | } 691 | 692 | // MarshalJSON is an implementation of json.Marshal. 693 | func (t Time) MarshalJSON() ([]byte, error) { 694 | return json.Marshal(t.Time) 695 | } 696 | 697 | // UnmarshalJSON unmarshals the content of data as a time.Time, a blank string will keep the time at its zero value. 698 | func (t *Time) UnmarshalJSON(data []byte) error { 699 | if string(data) == `""` { 700 | return nil 701 | } 702 | 703 | if err := json.Unmarshal(data, &t.Time); err != nil { 704 | return err 705 | } 706 | 707 | return nil 708 | } 709 | 710 | type Device struct { 711 | Addresses []string `json:"addresses"` 712 | Name string `json:"name"` 713 | ID string `json:"id"` 714 | Authorized bool `json:"authorized"` 715 | User string `json:"user"` 716 | Tags []string `json:"tags"` 717 | KeyExpiryDisabled bool `json:"keyExpiryDisabled"` 718 | BlocksIncomingConnections bool `json:"blocksIncomingConnections"` 719 | ClientVersion string `json:"clientVersion"` 720 | Created Time `json:"created"` 721 | Expires Time `json:"expires"` 722 | Hostname string `json:"hostname"` 723 | IsExternal bool `json:"isExternal"` 724 | LastSeen Time `json:"lastSeen"` 725 | MachineKey string `json:"machineKey"` 726 | NodeKey string `json:"nodeKey"` 727 | OS string `json:"os"` 728 | UpdateAvailable bool `json:"updateAvailable"` 729 | } 730 | 731 | // Devices lists the devices in a tailnet. 732 | func (c *Client) Devices(ctx context.Context) ([]Device, error) { 733 | const uriFmt = "/api/v2/tailnet/%s/devices" 734 | 735 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnetPathEscaped)) 736 | if err != nil { 737 | return nil, err 738 | } 739 | 740 | resp := make(map[string][]Device) 741 | if err = c.performRequest(req, &resp); err != nil { 742 | return nil, err 743 | } 744 | 745 | return resp["devices"], nil 746 | } 747 | 748 | // AuthorizeDevice marks the specified device identifier as authorized to join the tailnet. 749 | func (c *Client) AuthorizeDevice(ctx context.Context, deviceID string) error { 750 | return c.SetDeviceAuthorized(ctx, deviceID, true) 751 | } 752 | 753 | // SetDeviceAuthorized marks the specified device as authorized or not. 754 | func (c *Client) SetDeviceAuthorized(ctx context.Context, deviceID string, authorized bool) error { 755 | const uriFmt = "/api/v2/device/%s/authorized" 756 | 757 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), requestBody(map[string]bool{ 758 | "authorized": authorized, 759 | })) 760 | if err != nil { 761 | return err 762 | } 763 | 764 | return c.performRequest(req, nil) 765 | } 766 | 767 | // DeleteDevice deletes the device given its deviceID. 768 | func (c *Client) DeleteDevice(ctx context.Context, deviceID string) error { 769 | const uriFmt = "/api/v2/device/%s" 770 | req, err := c.buildRequest(ctx, http.MethodDelete, fmt.Sprintf(uriFmt, deviceID)) 771 | if err != nil { 772 | return err 773 | } 774 | 775 | return c.performRequest(req, nil) 776 | } 777 | 778 | type ( 779 | // KeyCapabilities type describes the capabilities of an authentication key. 780 | KeyCapabilities struct { 781 | Devices struct { 782 | Create struct { 783 | Reusable bool `json:"reusable"` 784 | Ephemeral bool `json:"ephemeral"` 785 | Tags []string `json:"tags"` 786 | Preauthorized bool `json:"preauthorized"` 787 | } `json:"create"` 788 | } `json:"devices"` 789 | } 790 | 791 | // CreateKeyRequest type describes the definition of an authentication key to create. 792 | CreateKeyRequest struct { 793 | Capabilities KeyCapabilities `json:"capabilities"` 794 | ExpirySeconds int64 `json:"expirySeconds"` 795 | Description string `json:"description"` 796 | } 797 | 798 | // CreateKeyOption type is a function that is used to modify a CreateKeyRequest. 799 | CreateKeyOption func(c *CreateKeyRequest) error 800 | 801 | // Key type describes an authentication key within the tailnet. 802 | Key struct { 803 | ID string `json:"id"` 804 | Key string `json:"key"` 805 | Description string `json:"description"` 806 | Created time.Time `json:"created"` 807 | Expires time.Time `json:"expires"` 808 | Revoked time.Time `json:"revoked"` 809 | Invalid bool `json:"invalid"` 810 | Capabilities KeyCapabilities `json:"capabilities"` 811 | } 812 | ) 813 | 814 | // WithKeyExpiry sets how long the key is valid for. 815 | func WithKeyExpiry(e time.Duration) CreateKeyOption { 816 | return func(c *CreateKeyRequest) error { 817 | c.ExpirySeconds = int64(e.Seconds()) 818 | return nil 819 | } 820 | } 821 | 822 | // WithKeyDescription sets the description for the key. 823 | func WithKeyDescription(desc string) CreateKeyOption { 824 | return func(c *CreateKeyRequest) error { 825 | c.Description = desc 826 | return nil 827 | } 828 | } 829 | 830 | // CreateKey creates a new authentication key with the capabilities selected via the KeyCapabilities type. Returns 831 | // the generated key if successful. 832 | func (c *Client) CreateKey(ctx context.Context, capabilities KeyCapabilities, opts ...CreateKeyOption) (Key, error) { 833 | const uriFmt = "/api/v2/tailnet/%s/keys" 834 | 835 | ckr := &CreateKeyRequest{ 836 | Capabilities: capabilities, 837 | } 838 | 839 | for _, opt := range opts { 840 | if err := opt(ckr); err != nil { 841 | return Key{}, err 842 | } 843 | } 844 | 845 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnetPathEscaped), requestBody(ckr)) 846 | if err != nil { 847 | return Key{}, err 848 | } 849 | 850 | var key Key 851 | return key, c.performRequest(req, &key) 852 | } 853 | 854 | // GetKey returns all information on a key whose identifier matches the one provided. This will not return the 855 | // authentication key itself, just the metadata. 856 | func (c *Client) GetKey(ctx context.Context, id string) (Key, error) { 857 | const uriFmt = "/api/v2/tailnet/%s/keys/%s" 858 | 859 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnetPathEscaped, id)) 860 | if err != nil { 861 | return Key{}, err 862 | } 863 | 864 | var key Key 865 | return key, c.performRequest(req, &key) 866 | } 867 | 868 | // Keys returns all keys within the tailnet. The only fields set for each key will be its identifier. The keys returned 869 | // are relative to the user that owns the API key used to authenticate the client. 870 | func (c *Client) Keys(ctx context.Context) ([]Key, error) { 871 | const uriFmt = "/api/v2/tailnet/%s/keys" 872 | 873 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnetPathEscaped)) 874 | if err != nil { 875 | return nil, err 876 | } 877 | 878 | resp := make(map[string][]Key) 879 | if err = c.performRequest(req, &resp); err != nil { 880 | return nil, err 881 | } 882 | 883 | return resp["keys"], nil 884 | } 885 | 886 | // DeleteKey removes an authentication key from the tailnet. 887 | func (c *Client) DeleteKey(ctx context.Context, id string) error { 888 | const uriFmt = "/api/v2/tailnet/%s/keys/%s" 889 | 890 | req, err := c.buildRequest(ctx, http.MethodDelete, fmt.Sprintf(uriFmt, c.tailnetPathEscaped, id)) 891 | if err != nil { 892 | return err 893 | } 894 | 895 | return c.performRequest(req, nil) 896 | } 897 | 898 | // SetDeviceTags updates the tags of a target device. 899 | func (c *Client) SetDeviceTags(ctx context.Context, deviceID string, tags []string) error { 900 | const uriFmt = "/api/v2/device/%s/tags" 901 | 902 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), requestBody(map[string][]string{ 903 | "tags": tags, 904 | })) 905 | if err != nil { 906 | return err 907 | } 908 | 909 | return c.performRequest(req, nil) 910 | } 911 | 912 | type ( 913 | // DeviceKey type represents the properties of the key of an individual device within 914 | // the tailnet. 915 | DeviceKey struct { 916 | KeyExpiryDisabled bool `json:"keyExpiryDisabled"` // Whether or not this device's key will ever expire. 917 | } 918 | ) 919 | 920 | // SetDeviceKey updates the properties of a device's key. 921 | func (c *Client) SetDeviceKey(ctx context.Context, deviceID string, key DeviceKey) error { 922 | const uriFmt = "/api/v2/device/%s/key" 923 | 924 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), requestBody(key)) 925 | if err != nil { 926 | return err 927 | } 928 | 929 | return c.performRequest(req, nil) 930 | } 931 | 932 | // SetDeviceIPv4Address sets the Tailscale IPv4 address of the device. 933 | func (c *Client) SetDeviceIPv4Address(ctx context.Context, deviceID string, ipv4Address string) error { 934 | const uriFmt = "/api/v2/device/%s/ip" 935 | 936 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), requestBody(map[string]string{ 937 | "ipv4": ipv4Address, 938 | })) 939 | if err != nil { 940 | return err 941 | } 942 | 943 | return c.performRequest(req, nil) 944 | } 945 | 946 | const ( 947 | WebhookEmptyProviderType WebhookProviderType = "" 948 | WebhookSlackProviderType WebhookProviderType = "slack" 949 | WebhookMattermostProviderType WebhookProviderType = "mattermost" 950 | WebhookGoogleChatProviderType WebhookProviderType = "googlechat" 951 | WebhookDiscordProviderType WebhookProviderType = "discord" 952 | ) 953 | 954 | const ( 955 | WebhookNodeCreated WebhookSubscriptionType = "nodeCreated" 956 | WebhookNodeNeedsApproval WebhookSubscriptionType = "nodeNeedsApproval" 957 | WebhookNodeApproved WebhookSubscriptionType = "nodeApproved" 958 | WebhookNodeKeyExpiringInOneDay WebhookSubscriptionType = "nodeKeyExpiringInOneDay" 959 | WebhookNodeKeyExpired WebhookSubscriptionType = "nodeKeyExpired" 960 | WebhookNodeDeleted WebhookSubscriptionType = "nodeDeleted" 961 | WebhookPolicyUpdate WebhookSubscriptionType = "policyUpdate" 962 | WebhookUserCreated WebhookSubscriptionType = "userCreated" 963 | WebhookUserNeedsApproval WebhookSubscriptionType = "userNeedsApproval" 964 | WebhookUserSuspended WebhookSubscriptionType = "userSuspended" 965 | WebhookUserRestored WebhookSubscriptionType = "userRestored" 966 | WebhookUserDeleted WebhookSubscriptionType = "userDeleted" 967 | WebhookUserApproved WebhookSubscriptionType = "userApproved" 968 | WebhookUserRoleUpdated WebhookSubscriptionType = "userRoleUpdated" 969 | WebhookSubnetIPForwardingNotEnabled WebhookSubscriptionType = "subnetIPForwardingNotEnabled" 970 | WebhookExitNodeIPForwardingNotEnabled WebhookSubscriptionType = "exitNodeIPForwardingNotEnabled" 971 | ) 972 | 973 | type ( 974 | // WebhookProviderType defines the provider type for a Webhook destination. 975 | WebhookProviderType string 976 | 977 | // WebhookSubscriptionType defines events in tailscale to subscribe a Webhook to. 978 | WebhookSubscriptionType string 979 | 980 | // Webhook type defines a webhook endpoint within a tailnet. 981 | Webhook struct { 982 | EndpointID string `json:"endpointId"` 983 | EndpointURL string `json:"endpointUrl"` 984 | ProviderType WebhookProviderType `json:"providerType"` 985 | CreatorLoginName string `json:"creatorLoginName"` 986 | Created time.Time `json:"created"` 987 | LastModified time.Time `json:"lastModified"` 988 | Subscriptions []WebhookSubscriptionType `json:"subscriptions"` 989 | // Secret is only populated on Webhook creation and after secret rotation. 990 | Secret *string `json:"secret,omitempty"` 991 | } 992 | 993 | // CreateWebhookRequest type describes the configuration for creating a Webhook. 994 | CreateWebhookRequest struct { 995 | EndpointURL string `json:"endpointUrl"` 996 | ProviderType WebhookProviderType `json:"providerType"` 997 | Subscriptions []WebhookSubscriptionType `json:"subscriptions"` 998 | } 999 | ) 1000 | 1001 | // CreateWebhook creates a new webhook with the specifications provided in the CreateWebhookRequest. 1002 | // Returns a Webhook if successful. 1003 | func (c *Client) CreateWebhook(ctx context.Context, request CreateWebhookRequest) (*Webhook, error) { 1004 | const uriFmt = "/api/v2/tailnet/%s/webhooks" 1005 | 1006 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnetPathEscaped), requestBody(request)) 1007 | if err != nil { 1008 | return nil, err 1009 | } 1010 | 1011 | var webhook Webhook 1012 | return &webhook, c.performRequest(req, &webhook) 1013 | } 1014 | 1015 | // Webhooks lists the webhooks in a tailnet. 1016 | func (c *Client) Webhooks(ctx context.Context) ([]Webhook, error) { 1017 | const uriFmt = "/api/v2/tailnet/%s/webhooks" 1018 | 1019 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnetPathEscaped)) 1020 | if err != nil { 1021 | return nil, err 1022 | } 1023 | 1024 | resp := make(map[string][]Webhook) 1025 | if err = c.performRequest(req, &resp); err != nil { 1026 | return nil, err 1027 | } 1028 | 1029 | return resp["webhooks"], nil 1030 | } 1031 | 1032 | // Webhook retrieves a specific webhook. 1033 | func (c *Client) Webhook(ctx context.Context, endpointID string) (*Webhook, error) { 1034 | const uriFmt = "/api/v2/webhooks/%s" 1035 | 1036 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, endpointID)) 1037 | if err != nil { 1038 | return nil, err 1039 | } 1040 | 1041 | var webhook Webhook 1042 | return &webhook, c.performRequest(req, &webhook) 1043 | } 1044 | 1045 | // UpdateWebhook updates an existing webhook's subscriptions. 1046 | // Returns a Webhook on success. 1047 | func (c *Client) UpdateWebhook(ctx context.Context, endpointID string, subscriptions []WebhookSubscriptionType) (*Webhook, error) { 1048 | const uriFmt = "/api/v2/webhooks/%s" 1049 | 1050 | req, err := c.buildRequest(ctx, http.MethodPatch, fmt.Sprintf(uriFmt, endpointID), requestBody(map[string][]WebhookSubscriptionType{ 1051 | "subscriptions": subscriptions, 1052 | })) 1053 | if err != nil { 1054 | return nil, err 1055 | } 1056 | 1057 | var webhook Webhook 1058 | return &webhook, c.performRequest(req, &webhook) 1059 | } 1060 | 1061 | // DeleteWebhook deletes a specific webhook. 1062 | func (c *Client) DeleteWebhook(ctx context.Context, endpointID string) error { 1063 | const uriFmt = "/api/v2/webhooks/%s" 1064 | 1065 | req, err := c.buildRequest(ctx, http.MethodDelete, fmt.Sprintf(uriFmt, endpointID)) 1066 | if err != nil { 1067 | return err 1068 | } 1069 | 1070 | return c.performRequest(req, nil) 1071 | } 1072 | 1073 | // TestWebhook queues a test event to be sent to a specific webhook. 1074 | // Sending the test event is an asynchronous operation which will 1075 | // typically happen a few seconds after using this method. 1076 | func (c *Client) TestWebhook(ctx context.Context, endpointID string) error { 1077 | const uriFmt = "/api/v2/webhooks/%s/test" 1078 | 1079 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, endpointID)) 1080 | if err != nil { 1081 | return err 1082 | } 1083 | 1084 | return c.performRequest(req, nil) 1085 | } 1086 | 1087 | // RotateWebhookSecret rotates the secret associated with a webhook. 1088 | // A new secret will be generated and set on the returned Webhook. 1089 | func (c *Client) RotateWebhookSecret(ctx context.Context, endpointID string) (*Webhook, error) { 1090 | const uriFmt = "/api/v2/webhooks/%s/rotate" 1091 | 1092 | req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, endpointID)) 1093 | if err != nil { 1094 | return nil, err 1095 | } 1096 | 1097 | var webhook Webhook 1098 | return &webhook, c.performRequest(req, &webhook) 1099 | } 1100 | 1101 | const ( 1102 | ContactAccount ContactType = "account" 1103 | ContactSupport ContactType = "support" 1104 | ContactSecurity ContactType = "security" 1105 | ) 1106 | 1107 | type ( 1108 | // ContactType defines the type of contact. 1109 | ContactType string 1110 | 1111 | // Contacts type defines the object returned when retrieving contacts. 1112 | Contacts struct { 1113 | Account Contact `json:"account"` 1114 | Support Contact `json:"support"` 1115 | Security Contact `json:"security"` 1116 | } 1117 | 1118 | // Contact type defines the structure of an individual contact for the tailnet. 1119 | Contact struct { 1120 | Email string `json:"email"` 1121 | // FallbackEmail is the email used when Email has not been verified. 1122 | FallbackEmail string `json:"fallbackEmail,omitempty"` 1123 | // NeedsVerification is true if Email needs to be verified. 1124 | NeedsVerification bool `json:"needsVerification"` 1125 | } 1126 | 1127 | // UpdateContactRequest type defines the structure of a request to update a Contact. 1128 | UpdateContactRequest struct { 1129 | Email *string `json:"email,omitempty"` 1130 | } 1131 | ) 1132 | 1133 | // Contacts retieves the contact information for a tailnet. 1134 | func (c *Client) Contacts(ctx context.Context) (*Contacts, error) { 1135 | const uriFmt = "/api/v2/tailnet/%s/contacts" 1136 | 1137 | req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnetPathEscaped)) 1138 | if err != nil { 1139 | return nil, err 1140 | } 1141 | 1142 | var contacts Contacts 1143 | return &contacts, c.performRequest(req, &contacts) 1144 | } 1145 | 1146 | // UpdateContact updates the email for the specified ContactType within the tailnet. 1147 | // If the email address changes, the system will send a verification email to confirm the change. 1148 | func (c *Client) UpdateContact(ctx context.Context, contactType ContactType, contact UpdateContactRequest) error { 1149 | const uriFmt = "/api/v2/tailnet/%s/contacts/%s" 1150 | 1151 | req, err := c.buildRequest(ctx, http.MethodPatch, fmt.Sprintf(uriFmt, c.tailnetPathEscaped, contactType), requestBody(contact)) 1152 | if err != nil { 1153 | return err 1154 | } 1155 | 1156 | return c.performRequest(req, nil) 1157 | } 1158 | 1159 | // IsNotFound returns true if the provided error implementation is an APIError with a status of 404. 1160 | func IsNotFound(err error) bool { 1161 | var apiErr APIError 1162 | if errors.As(err, &apiErr) { 1163 | return apiErr.status == http.StatusNotFound 1164 | } 1165 | 1166 | return false 1167 | } 1168 | 1169 | // ErrorData returns the contents of the APIError.Data field from the provided error if it is of type APIError. Returns 1170 | // a nil slice if the given error is not of type APIError. 1171 | func ErrorData(err error) []APIErrorData { 1172 | var apiErr APIError 1173 | if errors.As(err, &apiErr) { 1174 | return apiErr.Data 1175 | } 1176 | 1177 | return nil 1178 | } 1179 | 1180 | // Duration type wraps a time.Duration, allowing it to be JSON marshalled as a string like "20h" rather than 1181 | // a numeric value. 1182 | type Duration time.Duration 1183 | 1184 | func (d Duration) String() string { 1185 | return time.Duration(d).String() 1186 | } 1187 | 1188 | func (d Duration) MarshalText() ([]byte, error) { 1189 | return []byte(d.String()), nil 1190 | } 1191 | 1192 | func (d *Duration) UnmarshalText(b []byte) error { 1193 | text := string(b) 1194 | if text == "" { 1195 | text = "0s" 1196 | } 1197 | pd, err := time.ParseDuration(text) 1198 | if err != nil { 1199 | return err 1200 | } 1201 | *d = Duration(pd) 1202 | return nil 1203 | } 1204 | -------------------------------------------------------------------------------- /tailscale/client_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale_test 5 | 6 | import ( 7 | "context" 8 | _ "embed" 9 | "encoding/json" 10 | "io" 11 | "net/http" 12 | "testing" 13 | "time" 14 | 15 | "github.com/stretchr/testify/assert" 16 | "github.com/tailscale/hujson" 17 | 18 | "github.com/tailscale/tailscale-client-go/tailscale" 19 | ) 20 | 21 | var ( 22 | //go:embed testdata/acl.json 23 | jsonACL []byte 24 | //go:embed testdata/acl.hujson 25 | huJSONACL []byte 26 | //go:embed testdata/devices.json 27 | jsonDevices []byte 28 | ) 29 | 30 | func TestACL_Unmarshal(t *testing.T) { 31 | t.Parallel() 32 | 33 | tt := []struct { 34 | Name string 35 | ACLContent []byte 36 | Expected tailscale.ACL 37 | UnmarshalFunc func(data []byte, v interface{}) error 38 | }{ 39 | { 40 | Name: "It should handle JSON ACLs", 41 | ACLContent: jsonACL, 42 | UnmarshalFunc: json.Unmarshal, 43 | Expected: tailscale.ACL{ 44 | ACLs: []tailscale.ACLEntry{ 45 | { 46 | Action: "accept", 47 | Ports: []string(nil), 48 | Users: []string(nil), 49 | Source: []string{"autogroup:members"}, 50 | Destination: []string{"autogroup:self:*"}, 51 | Protocol: "", 52 | }, 53 | { 54 | Action: "accept", 55 | Ports: []string(nil), 56 | Users: []string(nil), 57 | Source: []string{"group:dev"}, 58 | Destination: []string{"tag:dev:*"}, 59 | Protocol: "", 60 | }, 61 | { 62 | Action: "accept", 63 | Ports: []string(nil), 64 | Users: []string(nil), 65 | Source: []string{"group:devops"}, 66 | Destination: []string{"tag:prod:*"}, 67 | Protocol: "", 68 | }, 69 | { 70 | Action: "accept", 71 | Ports: []string(nil), 72 | Users: []string(nil), 73 | Source: []string{"autogroup:members"}, 74 | Destination: []string{"tag:monitoring:80,443"}, 75 | Protocol: "", 76 | }, 77 | }, 78 | Groups: map[string][]string{ 79 | "group:dev": {"alice@example.com", "bob@example.com"}, 80 | "group:devops": {"carl@example.com"}, 81 | }, 82 | Hosts: map[string]string(nil), 83 | TagOwners: map[string][]string{ 84 | "tag:dev": {"group:devops"}, 85 | "tag:monitoring": {"group:devops"}, 86 | "tag:prod": {"group:devops"}, 87 | }, 88 | DERPMap: (*tailscale.ACLDERPMap)(nil), 89 | Tests: []tailscale.ACLTest{ 90 | { 91 | User: "", 92 | Allow: []string(nil), 93 | Deny: []string(nil), 94 | Source: "carl@example.com", 95 | Accept: []string{"tag:prod:80"}, 96 | }, 97 | { 98 | User: "", 99 | Allow: []string(nil), 100 | Deny: []string{"tag:prod:80"}, 101 | Source: "alice@example.com", 102 | Accept: []string{"tag:dev:80"}}, 103 | }, 104 | SSH: []tailscale.ACLSSH{ 105 | { 106 | Action: "accept", 107 | Source: []string{"autogroup:members"}, 108 | Destination: []string{"autogroup:self"}, 109 | Users: []string{"root", "autogroup:nonroot"}, 110 | }, 111 | { 112 | Action: "accept", 113 | Source: []string{"autogroup:members"}, 114 | Destination: []string{"tag:prod"}, 115 | Users: []string{"root", "autogroup:nonroot"}, 116 | }, 117 | { 118 | Action: "accept", 119 | Source: []string{"tag:logging"}, 120 | Destination: []string{"tag:prod"}, 121 | Users: []string{"root", "autogroup:nonroot"}, 122 | CheckPeriod: tailscale.Duration(time.Hour * 20), 123 | }, 124 | }, 125 | }, 126 | }, 127 | { 128 | Name: "It should handle HuJSON ACLs", 129 | ACLContent: huJSONACL, 130 | UnmarshalFunc: func(b []byte, v interface{}) error { 131 | b = append([]byte{}, b...) 132 | b, err := hujson.Standardize(b) 133 | if err != nil { 134 | return err 135 | } 136 | return json.Unmarshal(b, v) 137 | }, 138 | Expected: tailscale.ACL{ 139 | ACLs: []tailscale.ACLEntry{ 140 | { 141 | Action: "accept", 142 | Ports: []string(nil), 143 | Users: []string(nil), 144 | Source: []string{"autogroup:members"}, 145 | Destination: []string{"autogroup:self:*"}, 146 | Protocol: "", 147 | }, 148 | { 149 | Action: "accept", 150 | Ports: []string(nil), 151 | Users: []string(nil), 152 | Source: []string{"group:dev"}, 153 | Destination: []string{"tag:dev:*"}, 154 | Protocol: "", 155 | }, 156 | { 157 | Action: "accept", 158 | Ports: []string(nil), 159 | Users: []string(nil), 160 | Source: []string{"group:devops"}, 161 | Destination: []string{"tag:prod:*"}, 162 | Protocol: "", 163 | }, 164 | { 165 | Action: "accept", 166 | Ports: []string(nil), 167 | Users: []string(nil), 168 | Source: []string{"autogroup:members"}, 169 | Destination: []string{"tag:monitoring:80,443"}, 170 | Protocol: "", 171 | }, 172 | }, 173 | Groups: map[string][]string{ 174 | "group:dev": {"alice@example.com", "bob@example.com"}, 175 | "group:devops": {"carl@example.com"}, 176 | }, 177 | Hosts: map[string]string(nil), 178 | TagOwners: map[string][]string{ 179 | "tag:dev": {"group:devops"}, 180 | "tag:monitoring": {"group:devops"}, 181 | "tag:prod": {"group:devops"}, 182 | }, 183 | DERPMap: (*tailscale.ACLDERPMap)(nil), 184 | SSH: []tailscale.ACLSSH{ 185 | { 186 | Action: "accept", 187 | Source: []string{"autogroup:members"}, 188 | Destination: []string{"autogroup:self"}, 189 | Users: []string{"root", "autogroup:nonroot"}, 190 | }, 191 | { 192 | Action: "accept", 193 | Source: []string{"autogroup:members"}, 194 | Destination: []string{"tag:prod"}, 195 | Users: []string{"root", "autogroup:nonroot"}, 196 | }, 197 | { 198 | Action: "accept", 199 | Source: []string{"tag:logging"}, 200 | Destination: []string{"tag:prod"}, 201 | Users: []string{"root", "autogroup:nonroot"}, 202 | CheckPeriod: tailscale.Duration(time.Hour * 20), 203 | }, 204 | }, 205 | Tests: []tailscale.ACLTest{ 206 | { 207 | User: "", 208 | Allow: []string(nil), 209 | Deny: []string(nil), 210 | Source: "carl@example.com", 211 | Accept: []string{"tag:prod:80"}, 212 | }, 213 | { 214 | User: "", 215 | Allow: []string(nil), 216 | Deny: []string{"tag:prod:80"}, 217 | Source: "alice@example.com", 218 | Accept: []string{"tag:dev:80"}}, 219 | }, 220 | }, 221 | }, 222 | } 223 | 224 | for _, tc := range tt { 225 | t.Run(tc.Name, func(t *testing.T) { 226 | var actual tailscale.ACL 227 | 228 | assert.NoError(t, tc.UnmarshalFunc(tc.ACLContent, &actual)) 229 | assert.EqualValues(t, tc.Expected, actual) 230 | }) 231 | } 232 | } 233 | 234 | func TestClient_SetACL(t *testing.T) { 235 | t.Parallel() 236 | 237 | client, server := NewTestHarness(t) 238 | server.ResponseCode = http.StatusOK 239 | expectedACL := tailscale.ACL{ 240 | ACLs: []tailscale.ACLEntry{ 241 | { 242 | Action: "accept", 243 | Ports: []string{"*:*"}, 244 | Users: []string{"*"}, 245 | }, 246 | }, 247 | TagOwners: map[string][]string{ 248 | "tag:example": {"group:example"}, 249 | }, 250 | Hosts: map[string]string{ 251 | "example-host-1": "100.100.100.100", 252 | "example-host-2": "100.100.101.100/24", 253 | }, 254 | Groups: map[string][]string{ 255 | "group:example": { 256 | "user1@example.com", 257 | "user2@example.com", 258 | }, 259 | }, 260 | Tests: []tailscale.ACLTest{ 261 | { 262 | User: "user1@example.com", 263 | Allow: []string{"example-host-1:22", "example-host-2:80"}, 264 | Deny: []string{"exapmle-host-2:100"}, 265 | }, 266 | { 267 | User: "user2@example.com", 268 | Allow: []string{"100.60.3.4:22"}, 269 | }, 270 | }, 271 | } 272 | 273 | assert.NoError(t, client.SetACL(context.Background(), expectedACL)) 274 | assert.Equal(t, http.MethodPost, server.Method) 275 | assert.Equal(t, "/api/v2/tailnet/example.com/acl", server.Path) 276 | assert.Equal(t, "", server.Header.Get("If-Match")) 277 | assert.EqualValues(t, "application/json", server.Header.Get("Content-Type")) 278 | 279 | var actualACL tailscale.ACL 280 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualACL)) 281 | assert.EqualValues(t, expectedACL, actualACL) 282 | } 283 | func TestClient_SetACL_HuJSON(t *testing.T) { 284 | t.Parallel() 285 | 286 | client, server := NewTestHarness(t) 287 | server.ResponseCode = http.StatusOK 288 | 289 | assert.NoError(t, client.SetACL(context.Background(), string(huJSONACL))) 290 | assert.Equal(t, http.MethodPost, server.Method) 291 | assert.Equal(t, "/api/v2/tailnet/example.com/acl", server.Path) 292 | assert.Equal(t, "", server.Header.Get("If-Match")) 293 | assert.EqualValues(t, "application/hujson", server.Header.Get("Content-Type")) 294 | assert.EqualValues(t, huJSONACL, server.Body.Bytes()) 295 | } 296 | 297 | func TestClient_SetACLWithETag(t *testing.T) { 298 | t.Parallel() 299 | 300 | client, server := NewTestHarness(t) 301 | server.ResponseCode = http.StatusOK 302 | expectedACL := tailscale.ACL{ 303 | ACLs: []tailscale.ACLEntry{ 304 | { 305 | Action: "accept", 306 | Ports: []string{"*:*"}, 307 | Users: []string{"*"}, 308 | }, 309 | }, 310 | } 311 | 312 | assert.NoError(t, client.SetACL(context.Background(), expectedACL, tailscale.WithETag("test-etag"))) 313 | assert.Equal(t, http.MethodPost, server.Method) 314 | assert.Equal(t, "/api/v2/tailnet/example.com/acl", server.Path) 315 | assert.Equal(t, `"test-etag"`, server.Header.Get("If-Match")) 316 | 317 | var actualACL tailscale.ACL 318 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualACL)) 319 | assert.EqualValues(t, expectedACL, actualACL) 320 | } 321 | 322 | func TestClient_ACL(t *testing.T) { 323 | t.Parallel() 324 | 325 | client, server := NewTestHarness(t) 326 | 327 | server.ResponseCode = http.StatusOK 328 | server.ResponseBody = &tailscale.ACL{ 329 | ACLs: []tailscale.ACLEntry{ 330 | { 331 | Action: "accept", 332 | Ports: []string{"*:*"}, 333 | Users: []string{"*"}, 334 | }, 335 | }, 336 | TagOwners: map[string][]string{ 337 | "tag:example": {"group:example"}, 338 | }, 339 | Hosts: map[string]string{ 340 | "example-host-1": "100.100.100.100", 341 | "example-host-2": "100.100.101.100/24", 342 | }, 343 | Groups: map[string][]string{ 344 | "group:example": { 345 | "user1@example.com", 346 | "user2@example.com", 347 | }, 348 | }, 349 | Tests: []tailscale.ACLTest{ 350 | { 351 | User: "user1@example.com", 352 | Allow: []string{"example-host-1:22", "example-host-2:80"}, 353 | Deny: []string{"exapmle-host-2:100"}, 354 | }, 355 | { 356 | User: "user2@example.com", 357 | Allow: []string{"100.60.3.4:22"}, 358 | }, 359 | }, 360 | } 361 | 362 | acl, err := client.ACL(context.Background()) 363 | assert.NoError(t, err) 364 | assert.EqualValues(t, acl, server.ResponseBody) 365 | assert.EqualValues(t, http.MethodGet, server.Method) 366 | assert.EqualValues(t, "application/json", server.Header.Get("Accept")) 367 | assert.EqualValues(t, "/api/v2/tailnet/example.com/acl", server.Path) 368 | } 369 | 370 | func TestClient_RawACL(t *testing.T) { 371 | t.Parallel() 372 | 373 | client, server := NewTestHarness(t) 374 | 375 | server.ResponseCode = http.StatusOK 376 | server.ResponseBody = huJSONACL 377 | 378 | acl, err := client.RawACL(context.Background()) 379 | assert.NoError(t, err) 380 | assert.EqualValues(t, string(huJSONACL), acl) 381 | assert.EqualValues(t, http.MethodGet, server.Method) 382 | assert.EqualValues(t, "application/hujson", server.Header.Get("Accept")) 383 | assert.EqualValues(t, "/api/v2/tailnet/example.com/acl", server.Path) 384 | } 385 | 386 | func TestClient_SetDeviceSubnetRoutes(t *testing.T) { 387 | t.Parallel() 388 | 389 | client, server := NewTestHarness(t) 390 | server.ResponseCode = http.StatusOK 391 | 392 | const deviceID = "test" 393 | routes := []string{"127.0.0.1"} 394 | 395 | assert.NoError(t, client.SetDeviceSubnetRoutes(context.Background(), deviceID, routes)) 396 | assert.Equal(t, http.MethodPost, server.Method) 397 | assert.Equal(t, "/api/v2/device/test/routes", server.Path) 398 | 399 | body := make(map[string][]string) 400 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) 401 | assert.EqualValues(t, routes, body["routes"]) 402 | } 403 | 404 | func TestClient_Devices(t *testing.T) { 405 | t.Parallel() 406 | 407 | expectedDevices := map[string][]tailscale.Device{ 408 | "devices": { 409 | { 410 | Addresses: []string{"127.0.0.1"}, 411 | Name: "test", 412 | ID: "test", 413 | Authorized: true, 414 | KeyExpiryDisabled: true, 415 | User: "test@example.com", 416 | Tags: []string{ 417 | "tag:value", 418 | }, 419 | BlocksIncomingConnections: false, 420 | ClientVersion: "1.22.1", 421 | Created: tailscale.Time{time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC)}, 422 | Expires: tailscale.Time{time.Date(2022, 8, 9, 11, 50, 23, 0, time.UTC)}, 423 | Hostname: "test", 424 | IsExternal: false, 425 | LastSeen: tailscale.Time{time.Date(2022, 3, 9, 20, 3, 42, 0, time.UTC)}, 426 | MachineKey: "mkey:test", 427 | NodeKey: "nodekey:test", 428 | OS: "windows", 429 | UpdateAvailable: true, 430 | }, 431 | }, 432 | } 433 | 434 | client, server := NewTestHarness(t) 435 | server.ResponseCode = http.StatusOK 436 | server.ResponseBody = expectedDevices 437 | 438 | actualDevices, err := client.Devices(context.Background()) 439 | assert.NoError(t, err) 440 | assert.Equal(t, http.MethodGet, server.Method) 441 | assert.Equal(t, "/api/v2/tailnet/example.com/devices", server.Path) 442 | assert.EqualValues(t, expectedDevices["devices"], actualDevices) 443 | } 444 | 445 | func TestDevices_Unmarshal(t *testing.T) { 446 | t.Parallel() 447 | 448 | tt := []struct { 449 | Name string 450 | DevicesContent []byte 451 | Expected []tailscale.Device 452 | UnmarshalFunc func(data []byte, v interface{}) error 453 | }{ 454 | { 455 | Name: "It should handle badly formed devices", 456 | DevicesContent: jsonDevices, 457 | UnmarshalFunc: json.Unmarshal, 458 | Expected: []tailscale.Device{ 459 | { 460 | Addresses: []string{"100.101.102.103", "fd7a:115c:a1e0:ab12:4843:cd96:6265:6667"}, 461 | Authorized: true, 462 | BlocksIncomingConnections: false, 463 | ClientVersion: "", 464 | Created: tailscale.Time{}, 465 | Expires: tailscale.Time{ 466 | time.Date(1, 1, 1, 00, 00, 00, 0, time.UTC), 467 | }, 468 | Hostname: "hello", 469 | ID: "50052", 470 | IsExternal: true, 471 | KeyExpiryDisabled: true, 472 | LastSeen: tailscale.Time{ 473 | time.Date(2022, 4, 15, 13, 24, 40, 0, time.UTC), 474 | }, 475 | MachineKey: "", 476 | Name: "hello.tailscale.com", 477 | NodeKey: "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", 478 | OS: "linux", 479 | UpdateAvailable: false, 480 | User: "services@tailscale.com", 481 | }, 482 | { 483 | Addresses: []string{"100.121.200.21", "fd7a:115c:a1e0:ab12:4843:cd96:6265:e618"}, 484 | Authorized: true, 485 | BlocksIncomingConnections: false, 486 | ClientVersion: "1.22.2-t60b671955-gecc5d9846", 487 | Created: tailscale.Time{ 488 | time.Date(2022, 3, 5, 17, 10, 27, 0, time.UTC), 489 | }, 490 | Expires: tailscale.Time{ 491 | time.Date(2022, 9, 1, 17, 10, 27, 0, time.UTC), 492 | }, 493 | Hostname: "foo", 494 | ID: "50053", 495 | IsExternal: false, 496 | KeyExpiryDisabled: true, 497 | LastSeen: tailscale.Time{ 498 | time.Date(2022, 4, 15, 13, 25, 21, 0, time.UTC), 499 | }, 500 | MachineKey: "mkey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", 501 | Name: "foo.example.com", 502 | NodeKey: "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", 503 | OS: "linux", 504 | UpdateAvailable: false, 505 | User: "foo@example.com", 506 | }, 507 | }, 508 | }, 509 | } 510 | 511 | for _, tc := range tt { 512 | t.Run(tc.Name, func(t *testing.T) { 513 | actual := make(map[string][]tailscale.Device) 514 | 515 | assert.NoError(t, tc.UnmarshalFunc(tc.DevicesContent, &actual)) 516 | assert.EqualValues(t, tc.Expected, actual["devices"]) 517 | }) 518 | } 519 | } 520 | 521 | func TestClient_DeleteDevice(t *testing.T) { 522 | t.Parallel() 523 | 524 | client, server := NewTestHarness(t) 525 | server.ResponseCode = http.StatusOK 526 | ctx := context.Background() 527 | 528 | deviceID := "deviceTestId" 529 | assert.NoError(t, client.DeleteDevice(ctx, deviceID)) 530 | assert.Equal(t, http.MethodDelete, server.Method) 531 | assert.Equal(t, "/api/v2/device/deviceTestId", server.Path) 532 | } 533 | 534 | func TestClient_DeviceSubnetRoutes(t *testing.T) { 535 | t.Parallel() 536 | 537 | client, server := NewTestHarness(t) 538 | server.ResponseCode = http.StatusOK 539 | server.ResponseBody = &tailscale.DeviceRoutes{ 540 | Advertised: []string{"127.0.0.1"}, 541 | Enabled: []string{"127.0.0.1"}, 542 | } 543 | 544 | const deviceID = "test" 545 | 546 | routes, err := client.DeviceSubnetRoutes(context.Background(), deviceID) 547 | assert.NoError(t, err) 548 | assert.Equal(t, http.MethodGet, server.Method) 549 | assert.Equal(t, "/api/v2/device/test/routes", server.Path) 550 | assert.Equal(t, server.ResponseBody, routes) 551 | } 552 | 553 | func TestClient_DNSNameservers(t *testing.T) { 554 | t.Parallel() 555 | 556 | client, server := NewTestHarness(t) 557 | server.ResponseCode = http.StatusOK 558 | 559 | expectedNameservers := map[string][]string{ 560 | "dns": {"127.0.0.1"}, 561 | } 562 | 563 | server.ResponseBody = expectedNameservers 564 | nameservers, err := client.DNSNameservers(context.Background()) 565 | assert.NoError(t, err) 566 | assert.Equal(t, http.MethodGet, server.Method) 567 | assert.Equal(t, "/api/v2/tailnet/example.com/dns/nameservers", server.Path) 568 | assert.Equal(t, expectedNameservers["dns"], nameservers) 569 | } 570 | 571 | func TestClient_DNSPreferences(t *testing.T) { 572 | t.Parallel() 573 | 574 | client, server := NewTestHarness(t) 575 | server.ResponseCode = http.StatusOK 576 | server.ResponseBody = &tailscale.DNSPreferences{ 577 | MagicDNS: true, 578 | } 579 | 580 | preferences, err := client.DNSPreferences(context.Background()) 581 | assert.NoError(t, err) 582 | assert.Equal(t, http.MethodGet, server.Method) 583 | assert.Equal(t, "/api/v2/tailnet/example.com/dns/preferences", server.Path) 584 | assert.Equal(t, server.ResponseBody, preferences) 585 | } 586 | 587 | func TestClient_DNSSearchPaths(t *testing.T) { 588 | t.Parallel() 589 | 590 | client, server := NewTestHarness(t) 591 | server.ResponseCode = http.StatusOK 592 | 593 | expectedPaths := map[string][]string{ 594 | "searchPaths": {"test"}, 595 | } 596 | 597 | server.ResponseBody = expectedPaths 598 | 599 | paths, err := client.DNSSearchPaths(context.Background()) 600 | assert.NoError(t, err) 601 | assert.Equal(t, http.MethodGet, server.Method) 602 | assert.Equal(t, "/api/v2/tailnet/example.com/dns/searchpaths", server.Path) 603 | assert.Equal(t, expectedPaths["searchPaths"], paths) 604 | } 605 | 606 | func TestClient_SplitDNS(t *testing.T) { 607 | t.Parallel() 608 | 609 | client, server := NewTestHarness(t) 610 | server.ResponseCode = http.StatusOK 611 | 612 | expectedNameservers := tailscale.SplitDnsResponse{ 613 | "example.com": {"1.1.1.1", "1.2.3.4"}, 614 | } 615 | 616 | server.ResponseBody = expectedNameservers 617 | nameservers, err := client.SplitDNS(context.Background()) 618 | assert.NoError(t, err) 619 | assert.Equal(t, http.MethodGet, server.Method) 620 | assert.Equal(t, "/api/v2/tailnet/example.com/dns/split-dns", server.Path) 621 | assert.Equal(t, expectedNameservers, nameservers) 622 | } 623 | 624 | func TestClient_SetDNSNameservers(t *testing.T) { 625 | t.Parallel() 626 | 627 | client, server := NewTestHarness(t) 628 | server.ResponseCode = http.StatusOK 629 | 630 | nameservers := []string{"127.0.0.1"} 631 | 632 | assert.NoError(t, client.SetDNSNameservers(context.Background(), nameservers)) 633 | assert.Equal(t, http.MethodPost, server.Method) 634 | assert.Equal(t, "/api/v2/tailnet/example.com/dns/nameservers", server.Path) 635 | 636 | body := make(map[string][]string) 637 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) 638 | assert.EqualValues(t, nameservers, body["dns"]) 639 | } 640 | 641 | func TestClient_SetDNSPreferences(t *testing.T) { 642 | t.Parallel() 643 | 644 | client, server := NewTestHarness(t) 645 | server.ResponseCode = http.StatusOK 646 | 647 | preferences := tailscale.DNSPreferences{ 648 | MagicDNS: true, 649 | } 650 | 651 | assert.NoError(t, client.SetDNSPreferences(context.Background(), preferences)) 652 | assert.Equal(t, http.MethodPost, server.Method) 653 | assert.Equal(t, "/api/v2/tailnet/example.com/dns/preferences", server.Path) 654 | 655 | var body tailscale.DNSPreferences 656 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) 657 | assert.EqualValues(t, preferences, body) 658 | } 659 | 660 | func TestClient_SetDNSSearchPaths(t *testing.T) { 661 | t.Parallel() 662 | 663 | client, server := NewTestHarness(t) 664 | server.ResponseCode = http.StatusOK 665 | 666 | paths := []string{"test"} 667 | 668 | assert.NoError(t, client.SetDNSSearchPaths(context.Background(), paths)) 669 | assert.Equal(t, http.MethodPost, server.Method) 670 | assert.Equal(t, "/api/v2/tailnet/example.com/dns/searchpaths", server.Path) 671 | 672 | body := make(map[string][]string) 673 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) 674 | assert.EqualValues(t, paths, body["searchPaths"]) 675 | } 676 | 677 | func TestClient_UpdateSplitDNS(t *testing.T) { 678 | t.Parallel() 679 | 680 | client, server := NewTestHarness(t) 681 | server.ResponseCode = http.StatusOK 682 | 683 | nameservers := []string{"1.1.2.1", "3.3.3.4"} 684 | request := tailscale.SplitDnsRequest{ 685 | "example.com": nameservers, 686 | } 687 | 688 | expectedNameservers := tailscale.SplitDnsResponse{ 689 | "example.com": nameservers, 690 | } 691 | server.ResponseBody = expectedNameservers 692 | 693 | resp, err := client.UpdateSplitDNS(context.Background(), request) 694 | assert.NoError(t, err) 695 | assert.Equal(t, http.MethodPatch, server.Method) 696 | assert.Equal(t, "/api/v2/tailnet/example.com/dns/split-dns", server.Path) 697 | 698 | body := make(tailscale.SplitDnsResponse) 699 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) 700 | assert.EqualValues(t, nameservers, body["example.com"]) 701 | assert.Equal(t, expectedNameservers, resp) 702 | } 703 | 704 | func TestClient_SetSplitDNS(t *testing.T) { 705 | t.Parallel() 706 | 707 | client, server := NewTestHarness(t) 708 | server.ResponseCode = http.StatusOK 709 | 710 | nameservers := []string{"1.1.2.1", "3.3.3.4"} 711 | request := tailscale.SplitDnsRequest{ 712 | "example.com": nameservers, 713 | } 714 | 715 | assert.NoError(t, client.SetSplitDNS(context.Background(), request)) 716 | assert.Equal(t, http.MethodPut, server.Method) 717 | assert.Equal(t, "/api/v2/tailnet/example.com/dns/split-dns", server.Path) 718 | 719 | body := make(tailscale.SplitDnsResponse) 720 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) 721 | assert.EqualValues(t, nameservers, body["example.com"]) 722 | } 723 | 724 | func TestClient_AuthorizeDevice(t *testing.T) { 725 | t.Parallel() 726 | 727 | client, server := NewTestHarness(t) 728 | server.ResponseCode = http.StatusOK 729 | 730 | const deviceID = "test" 731 | 732 | assert.NoError(t, client.AuthorizeDevice(context.Background(), deviceID)) 733 | assert.Equal(t, http.MethodPost, server.Method) 734 | assert.Equal(t, "/api/v2/device/test/authorized", server.Path) 735 | 736 | body := make(map[string]bool) 737 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) 738 | assert.EqualValues(t, true, body["authorized"]) 739 | } 740 | 741 | func TestClient_SetDeviceAuthorized(t *testing.T) { 742 | t.Parallel() 743 | 744 | client, server := NewTestHarness(t) 745 | server.ResponseCode = http.StatusOK 746 | 747 | const deviceID = "test" 748 | 749 | for _, value := range []bool{true, false} { 750 | assert.NoError(t, client.SetDeviceAuthorized(context.Background(), deviceID, value)) 751 | assert.Equal(t, http.MethodPost, server.Method) 752 | assert.Equal(t, "/api/v2/device/test/authorized", server.Path) 753 | 754 | body := make(map[string]bool) 755 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) 756 | assert.EqualValues(t, value, body["authorized"]) 757 | } 758 | } 759 | 760 | func TestClient_CreateKey(t *testing.T) { 761 | t.Parallel() 762 | 763 | client, server := NewTestHarness(t) 764 | server.ResponseCode = http.StatusOK 765 | 766 | capabilities := tailscale.KeyCapabilities{} 767 | capabilities.Devices.Create.Ephemeral = true 768 | capabilities.Devices.Create.Reusable = true 769 | capabilities.Devices.Create.Preauthorized = true 770 | capabilities.Devices.Create.Tags = []string{"test:test"} 771 | 772 | expected := tailscale.Key{ 773 | ID: "test", 774 | Key: "thisisatestkey", 775 | Created: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 776 | Expires: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 777 | Capabilities: capabilities, 778 | Description: "", 779 | } 780 | 781 | server.ResponseBody = expected 782 | 783 | actual, err := client.CreateKey(context.Background(), capabilities) 784 | assert.NoError(t, err) 785 | assert.EqualValues(t, expected, actual) 786 | assert.Equal(t, http.MethodPost, server.Method) 787 | assert.Equal(t, "/api/v2/tailnet/example.com/keys", server.Path) 788 | 789 | var actualReq tailscale.CreateKeyRequest 790 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualReq)) 791 | assert.EqualValues(t, capabilities, actualReq.Capabilities) 792 | assert.EqualValues(t, 0, actualReq.ExpirySeconds) 793 | assert.EqualValues(t, "", actualReq.Description) 794 | } 795 | 796 | func TestClient_CreateKeyWithExpirySeconds(t *testing.T) { 797 | t.Parallel() 798 | 799 | client, server := NewTestHarness(t) 800 | server.ResponseCode = http.StatusOK 801 | 802 | capabilities := tailscale.KeyCapabilities{} 803 | capabilities.Devices.Create.Ephemeral = true 804 | capabilities.Devices.Create.Reusable = true 805 | capabilities.Devices.Create.Preauthorized = true 806 | capabilities.Devices.Create.Tags = []string{"test:test"} 807 | 808 | expected := tailscale.Key{ 809 | ID: "test", 810 | Key: "thisisatestkey", 811 | Created: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 812 | Expires: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 813 | Capabilities: capabilities, 814 | Description: "", 815 | } 816 | 817 | server.ResponseBody = expected 818 | 819 | actual, err := client.CreateKey(context.Background(), capabilities, tailscale.WithKeyExpiry(1440*time.Second)) 820 | assert.NoError(t, err) 821 | assert.EqualValues(t, expected, actual) 822 | assert.Equal(t, http.MethodPost, server.Method) 823 | assert.Equal(t, "/api/v2/tailnet/example.com/keys", server.Path) 824 | 825 | var actualReq tailscale.CreateKeyRequest 826 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualReq)) 827 | assert.EqualValues(t, capabilities, actualReq.Capabilities) 828 | assert.EqualValues(t, 1440, actualReq.ExpirySeconds) 829 | assert.EqualValues(t, "", actualReq.Description) 830 | } 831 | 832 | func TestClient_CreateKeyWithDescription(t *testing.T) { 833 | t.Parallel() 834 | 835 | client, server := NewTestHarness(t) 836 | server.ResponseCode = http.StatusOK 837 | 838 | capabilities := tailscale.KeyCapabilities{} 839 | capabilities.Devices.Create.Ephemeral = true 840 | capabilities.Devices.Create.Reusable = true 841 | capabilities.Devices.Create.Preauthorized = true 842 | capabilities.Devices.Create.Tags = []string{"test:test"} 843 | 844 | expected := tailscale.Key{ 845 | ID: "test", 846 | Key: "thisisatestkey", 847 | Created: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 848 | Expires: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 849 | Capabilities: capabilities, 850 | Description: "key description", 851 | } 852 | 853 | server.ResponseBody = expected 854 | 855 | actual, err := client.CreateKey(context.Background(), capabilities, tailscale.WithKeyDescription("key description")) 856 | assert.NoError(t, err) 857 | assert.EqualValues(t, expected, actual) 858 | assert.Equal(t, http.MethodPost, server.Method) 859 | assert.Equal(t, "/api/v2/tailnet/example.com/keys", server.Path) 860 | 861 | var actualReq tailscale.CreateKeyRequest 862 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualReq)) 863 | assert.EqualValues(t, capabilities, actualReq.Capabilities) 864 | assert.EqualValues(t, 0, actualReq.ExpirySeconds) 865 | assert.EqualValues(t, "key description", actualReq.Description) 866 | } 867 | 868 | func TestClient_GetKey(t *testing.T) { 869 | t.Parallel() 870 | 871 | client, server := NewTestHarness(t) 872 | server.ResponseCode = http.StatusOK 873 | 874 | capabilities := tailscale.KeyCapabilities{} 875 | capabilities.Devices.Create.Ephemeral = true 876 | capabilities.Devices.Create.Reusable = true 877 | capabilities.Devices.Create.Preauthorized = true 878 | capabilities.Devices.Create.Tags = []string{"test:test"} 879 | 880 | expected := tailscale.Key{ 881 | ID: "test", 882 | Created: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 883 | Expires: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 884 | Capabilities: capabilities, 885 | Description: "", 886 | } 887 | 888 | server.ResponseBody = expected 889 | 890 | actual, err := client.GetKey(context.Background(), expected.ID) 891 | assert.NoError(t, err) 892 | assert.EqualValues(t, expected, actual) 893 | assert.Equal(t, http.MethodGet, server.Method) 894 | assert.Equal(t, "/api/v2/tailnet/example.com/keys/"+expected.ID, server.Path) 895 | } 896 | 897 | func TestClient_Keys(t *testing.T) { 898 | t.Parallel() 899 | 900 | client, server := NewTestHarness(t) 901 | server.ResponseCode = http.StatusOK 902 | 903 | expected := []tailscale.Key{ 904 | {ID: "key-a"}, 905 | {ID: "key-b"}, 906 | } 907 | 908 | server.ResponseBody = map[string][]tailscale.Key{ 909 | "keys": expected, 910 | } 911 | 912 | actual, err := client.Keys(context.Background()) 913 | assert.NoError(t, err) 914 | assert.EqualValues(t, expected, actual) 915 | assert.Equal(t, http.MethodGet, server.Method) 916 | assert.Equal(t, "/api/v2/tailnet/example.com/keys", server.Path) 917 | } 918 | 919 | func TestClient_DeleteKey(t *testing.T) { 920 | t.Parallel() 921 | 922 | client, server := NewTestHarness(t) 923 | server.ResponseCode = http.StatusOK 924 | 925 | const keyID = "test" 926 | 927 | assert.NoError(t, client.DeleteKey(context.Background(), keyID)) 928 | assert.Equal(t, http.MethodDelete, server.Method) 929 | assert.Equal(t, "/api/v2/tailnet/example.com/keys/"+keyID, server.Path) 930 | } 931 | 932 | func TestIsNotFound(t *testing.T) { 933 | t.Parallel() 934 | 935 | client, server := NewTestHarness(t) 936 | server.ResponseCode = http.StatusNotFound 937 | server.ResponseBody = tailscale.APIError{Message: "error"} 938 | 939 | _, err := client.GetKey(context.Background(), "test") 940 | assert.True(t, tailscale.IsNotFound(err)) 941 | } 942 | 943 | func TestClient_SetDeviceTags(t *testing.T) { 944 | t.Parallel() 945 | 946 | client, server := NewTestHarness(t) 947 | server.ResponseCode = http.StatusOK 948 | 949 | const deviceID = "test" 950 | tags := []string{"a:b", "b:c"} 951 | 952 | assert.NoError(t, client.SetDeviceTags(context.Background(), deviceID, tags)) 953 | assert.EqualValues(t, http.MethodPost, server.Method) 954 | assert.EqualValues(t, "/api/v2/device/"+deviceID+"/tags", server.Path) 955 | 956 | body := make(map[string][]string) 957 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &body)) 958 | assert.EqualValues(t, tags, body["tags"]) 959 | } 960 | 961 | func TestClient_SetDeviceKey(t *testing.T) { 962 | t.Parallel() 963 | 964 | client, server := NewTestHarness(t) 965 | server.ResponseCode = http.StatusOK 966 | 967 | const deviceID = "test" 968 | expected := tailscale.DeviceKey{ 969 | KeyExpiryDisabled: true, 970 | } 971 | 972 | assert.NoError(t, client.SetDeviceKey(context.Background(), deviceID, expected)) 973 | 974 | assert.EqualValues(t, http.MethodPost, server.Method) 975 | assert.EqualValues(t, "/api/v2/device/"+deviceID+"/key", server.Path) 976 | 977 | var actual tailscale.DeviceKey 978 | assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actual)) 979 | assert.EqualValues(t, expected, actual) 980 | 981 | } 982 | func TestClient_SetDeviceIPv4Address(t *testing.T) { 983 | t.Parallel() 984 | 985 | client, server := NewTestHarness(t) 986 | server.ResponseCode = http.StatusOK 987 | 988 | const deviceID = "test" 989 | address := "100.64.0.1" 990 | 991 | assert.NoError(t, client.SetDeviceIPv4Address(context.Background(), deviceID, address)) 992 | assert.Equal(t, http.MethodPost, server.Method) 993 | assert.EqualValues(t, "/api/v2/device/"+deviceID+"/ip", server.Path) 994 | } 995 | 996 | func TestErrorData(t *testing.T) { 997 | t.Parallel() 998 | 999 | t.Run("It should return the data element from a valid error", func(t *testing.T) { 1000 | expected := tailscale.APIError{ 1001 | Data: []tailscale.APIErrorData{ 1002 | { 1003 | User: "user1@example.com", 1004 | Errors: []string{ 1005 | "address \"user2@example.com:400\": want: Accept, got: Drop", 1006 | }, 1007 | }, 1008 | }, 1009 | } 1010 | 1011 | actual := tailscale.ErrorData(expected) 1012 | assert.EqualValues(t, expected.Data, actual) 1013 | }) 1014 | 1015 | t.Run("It should return an empty slice for any other error", func(t *testing.T) { 1016 | assert.Empty(t, tailscale.ErrorData(io.EOF)) 1017 | }) 1018 | } 1019 | 1020 | func TestClient_ValidateACL(t *testing.T) { 1021 | t.Parallel() 1022 | 1023 | client, server := NewTestHarness(t) 1024 | 1025 | acl := tailscale.ACL{ 1026 | ACLs: []tailscale.ACLEntry{ 1027 | { 1028 | Action: "accept", 1029 | Ports: []string{"*:*"}, 1030 | Users: []string{"*"}, 1031 | }, 1032 | }, 1033 | TagOwners: map[string][]string{ 1034 | "tag:example": {"group:example"}, 1035 | }, 1036 | Hosts: map[string]string{ 1037 | "example-host-1": "10.0.0.0/8", 1038 | "example-host-2": "10.0.0.1", 1039 | }, 1040 | Groups: map[string][]string{ 1041 | "group:example": { 1042 | "user1@example.com", 1043 | "user2@example.com", 1044 | }, 1045 | }, 1046 | Tests: []tailscale.ACLTest{ 1047 | { 1048 | User: "user1@example.com", 1049 | Allow: []string{"example-host-1:22", "example-host-2:80"}, 1050 | Deny: []string{"exapmle-host-2:100"}, 1051 | }, 1052 | { 1053 | User: "user2@example.com", 1054 | Allow: []string{"100.64.0.1:22"}, 1055 | }, 1056 | }, 1057 | } 1058 | 1059 | server.ResponseCode = http.StatusOK 1060 | server.ResponseBody = acl 1061 | 1062 | err := client.ValidateACL(context.Background(), acl) 1063 | assert.NoError(t, err) 1064 | assert.EqualValues(t, server.ResponseBody, acl) 1065 | assert.EqualValues(t, http.MethodPost, server.Method) 1066 | assert.EqualValues(t, "application/json", server.Header.Get("Content-Type")) 1067 | assert.EqualValues(t, "/api/v2/tailnet/example.com/acl/validate", server.Path) 1068 | 1069 | tests := []struct { 1070 | name string 1071 | responseCode int 1072 | responseBody any 1073 | wantErr string 1074 | }{ 1075 | { 1076 | name: "403_response", 1077 | responseCode: 403, 1078 | responseBody: tailscale.APIError{Message: "access denied"}, 1079 | wantErr: "access denied", 1080 | }, 1081 | { 1082 | name: "200_response_with_error", 1083 | responseCode: 200, 1084 | responseBody: tailscale.APIError{Message: "validation failed"}, 1085 | wantErr: "validation failed", 1086 | }, 1087 | } 1088 | for _, tt := range tests { 1089 | t.Run(tt.name, func(t *testing.T) { 1090 | server.ResponseCode = tt.responseCode 1091 | server.ResponseBody = tt.responseBody 1092 | err := client.ValidateACL(context.Background(), acl) 1093 | assert.ErrorContains(t, err, tt.wantErr) 1094 | }) 1095 | } 1096 | } 1097 | 1098 | func TestClient_ValidateACL_HuJSON(t *testing.T) { 1099 | t.Parallel() 1100 | 1101 | client, server := NewTestHarness(t) 1102 | 1103 | server.ResponseCode = http.StatusOK 1104 | server.ResponseBody = huJSONACL 1105 | 1106 | err := client.ValidateACL(context.Background(), string(huJSONACL)) 1107 | assert.NoError(t, err) 1108 | assert.EqualValues(t, server.ResponseBody, huJSONACL) 1109 | assert.EqualValues(t, http.MethodPost, server.Method) 1110 | assert.EqualValues(t, "application/hujson", server.Header.Get("Content-Type")) 1111 | assert.EqualValues(t, "/api/v2/tailnet/example.com/acl/validate", server.Path) 1112 | } 1113 | 1114 | func TestClient_UserAgent(t *testing.T) { 1115 | t.Parallel() 1116 | client, server := NewTestHarness(t) 1117 | server.ResponseCode = http.StatusOK 1118 | 1119 | // Check the default user-agent. 1120 | assert.NoError(t, client.SetDeviceAuthorized(context.Background(), "test", true)) 1121 | assert.Equal(t, "tailscale-client-go", server.Header.Get("User-Agent")) 1122 | 1123 | // Check a custom user-agent. 1124 | client, err := tailscale.NewClient("fake key", "", tailscale.WithBaseURL(server.BaseURL), tailscale.WithUserAgent("custom-user-agent")) 1125 | assert.NoError(t, err) 1126 | assert.NoError(t, client.SetDeviceAuthorized(context.Background(), "test", true)) 1127 | assert.Equal(t, "custom-user-agent", server.Header.Get("User-Agent")) 1128 | 1129 | // Overriding with an empty string uses runtime's default value. 1130 | client, err = tailscale.NewClient("fake key", "", tailscale.WithBaseURL(server.BaseURL), tailscale.WithUserAgent("")) 1131 | assert.NoError(t, err) 1132 | assert.NoError(t, client.SetDeviceAuthorized(context.Background(), "test", true)) 1133 | assert.Contains(t, server.Header.Get("User-Agent"), "Go-http-client") 1134 | } 1135 | 1136 | func TestClient_CreateWebhook(t *testing.T) { 1137 | t.Parallel() 1138 | 1139 | client, server := NewTestHarness(t) 1140 | server.ResponseCode = http.StatusOK 1141 | 1142 | req := tailscale.CreateWebhookRequest{ 1143 | EndpointURL: "https://example.com/my/endpoint", 1144 | ProviderType: tailscale.WebhookDiscordProviderType, 1145 | Subscriptions: []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeCreated, tailscale.WebhookNodeApproved}, 1146 | } 1147 | 1148 | expectedSecret := "my-secret" 1149 | expectedWebhook := &tailscale.Webhook{ 1150 | EndpointID: "12345", 1151 | EndpointURL: req.EndpointURL, 1152 | ProviderType: req.ProviderType, 1153 | CreatorLoginName: "pretend@example.com", 1154 | Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1155 | LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1156 | Subscriptions: req.Subscriptions, 1157 | Secret: &expectedSecret, 1158 | } 1159 | server.ResponseBody = expectedWebhook 1160 | 1161 | webhook, err := client.CreateWebhook(context.Background(), req) 1162 | assert.NoError(t, err) 1163 | assert.Equal(t, http.MethodPost, server.Method) 1164 | assert.Equal(t, "/api/v2/tailnet/example.com/webhooks", server.Path) 1165 | assert.Equal(t, expectedWebhook, webhook) 1166 | } 1167 | 1168 | func TestClient_Webhooks(t *testing.T) { 1169 | t.Parallel() 1170 | 1171 | client, server := NewTestHarness(t) 1172 | server.ResponseCode = http.StatusOK 1173 | 1174 | expectedWebhooks := map[string][]tailscale.Webhook{ 1175 | "webhooks": { 1176 | { 1177 | EndpointID: "12345", 1178 | EndpointURL: "https://example.com/my/endpoint", 1179 | ProviderType: "", 1180 | CreatorLoginName: "pretend@example.com", 1181 | Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1182 | LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1183 | Subscriptions: []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeCreated, tailscale.WebhookNodeApproved}, 1184 | }, 1185 | { 1186 | EndpointID: "54321", 1187 | EndpointURL: "https://example.com/my/endpoint/other", 1188 | ProviderType: "slack", 1189 | CreatorLoginName: "pretend2@example.com", 1190 | Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1191 | LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1192 | Subscriptions: []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeApproved}, 1193 | }, 1194 | }, 1195 | } 1196 | server.ResponseBody = expectedWebhooks 1197 | 1198 | actualWebhooks, err := client.Webhooks(context.Background()) 1199 | assert.NoError(t, err) 1200 | assert.Equal(t, http.MethodGet, server.Method) 1201 | assert.Equal(t, "/api/v2/tailnet/example.com/webhooks", server.Path) 1202 | assert.Equal(t, expectedWebhooks["webhooks"], actualWebhooks) 1203 | } 1204 | 1205 | func TestClient_Webhook(t *testing.T) { 1206 | t.Parallel() 1207 | 1208 | client, server := NewTestHarness(t) 1209 | server.ResponseCode = http.StatusOK 1210 | 1211 | expectedWebhook := &tailscale.Webhook{ 1212 | EndpointID: "54321", 1213 | EndpointURL: "https://example.com/my/endpoint/other", 1214 | ProviderType: "slack", 1215 | CreatorLoginName: "pretend2@example.com", 1216 | Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1217 | LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1218 | Subscriptions: []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeApproved}, 1219 | } 1220 | server.ResponseBody = expectedWebhook 1221 | 1222 | actualWebhook, err := client.Webhook(context.Background(), "54321") 1223 | assert.NoError(t, err) 1224 | assert.Equal(t, http.MethodGet, server.Method) 1225 | assert.Equal(t, "/api/v2/webhooks/54321", server.Path) 1226 | assert.Equal(t, expectedWebhook, actualWebhook) 1227 | } 1228 | 1229 | func TestClient_UpdateWebhook(t *testing.T) { 1230 | t.Parallel() 1231 | 1232 | client, server := NewTestHarness(t) 1233 | server.ResponseCode = http.StatusOK 1234 | 1235 | subscriptions := []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeCreated, tailscale.WebhookNodeApproved, tailscale.WebhookNodeNeedsApproval} 1236 | 1237 | expectedWebhook := &tailscale.Webhook{ 1238 | EndpointID: "54321", 1239 | EndpointURL: "https://example.com/my/endpoint/other", 1240 | ProviderType: "slack", 1241 | CreatorLoginName: "pretend2@example.com", 1242 | Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1243 | LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1244 | Subscriptions: subscriptions, 1245 | } 1246 | server.ResponseBody = expectedWebhook 1247 | 1248 | actualWebhook, err := client.UpdateWebhook(context.Background(), "54321", subscriptions) 1249 | assert.NoError(t, err) 1250 | assert.Equal(t, http.MethodPatch, server.Method) 1251 | assert.Equal(t, "/api/v2/webhooks/54321", server.Path) 1252 | assert.Equal(t, expectedWebhook, actualWebhook) 1253 | } 1254 | 1255 | func TestClient_DeleteWebhook(t *testing.T) { 1256 | t.Parallel() 1257 | 1258 | client, server := NewTestHarness(t) 1259 | server.ResponseCode = http.StatusOK 1260 | 1261 | err := client.DeleteWebhook(context.Background(), "54321") 1262 | assert.NoError(t, err) 1263 | assert.Equal(t, http.MethodDelete, server.Method) 1264 | assert.Equal(t, "/api/v2/webhooks/54321", server.Path) 1265 | } 1266 | 1267 | func TestClient_TestWebhook(t *testing.T) { 1268 | t.Parallel() 1269 | 1270 | client, server := NewTestHarness(t) 1271 | server.ResponseCode = http.StatusAccepted 1272 | 1273 | err := client.TestWebhook(context.Background(), "54321") 1274 | assert.NoError(t, err) 1275 | assert.Equal(t, http.MethodPost, server.Method) 1276 | assert.Equal(t, "/api/v2/webhooks/54321/test", server.Path) 1277 | } 1278 | 1279 | func TestClient_RotateWebhookSecret(t *testing.T) { 1280 | t.Parallel() 1281 | 1282 | client, server := NewTestHarness(t) 1283 | server.ResponseCode = http.StatusOK 1284 | 1285 | expectedSecret := "my-new-secret" 1286 | expectedWebhook := &tailscale.Webhook{ 1287 | EndpointID: "54321", 1288 | EndpointURL: "https://example.com/my/endpoint/other", 1289 | ProviderType: "slack", 1290 | CreatorLoginName: "pretend2@example.com", 1291 | Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1292 | LastModified: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), 1293 | Subscriptions: []tailscale.WebhookSubscriptionType{tailscale.WebhookNodeApproved}, 1294 | Secret: &expectedSecret, 1295 | } 1296 | server.ResponseBody = expectedWebhook 1297 | 1298 | actualWebhook, err := client.RotateWebhookSecret(context.Background(), "54321") 1299 | assert.NoError(t, err) 1300 | assert.Equal(t, http.MethodPost, server.Method) 1301 | assert.Equal(t, "/api/v2/webhooks/54321/rotate", server.Path) 1302 | assert.Equal(t, expectedWebhook, actualWebhook) 1303 | } 1304 | 1305 | func TestClient_Contacts(t *testing.T) { 1306 | t.Parallel() 1307 | 1308 | client, server := NewTestHarness(t) 1309 | server.ResponseCode = http.StatusOK 1310 | 1311 | expectedContacts := &tailscale.Contacts{ 1312 | Account: tailscale.Contact{ 1313 | Email: "test@example.com", 1314 | FallbackEmail: "test2@example.com", 1315 | NeedsVerification: false, 1316 | }, 1317 | Support: tailscale.Contact{ 1318 | Email: "test3@example.com", 1319 | NeedsVerification: false, 1320 | }, 1321 | Security: tailscale.Contact{ 1322 | Email: "test4@example.com", 1323 | FallbackEmail: "test5@example.com", 1324 | NeedsVerification: true, 1325 | }, 1326 | } 1327 | server.ResponseBody = expectedContacts 1328 | 1329 | actualContacts, err := client.Contacts(context.Background()) 1330 | assert.NoError(t, err) 1331 | assert.Equal(t, http.MethodGet, server.Method) 1332 | assert.Equal(t, "/api/v2/tailnet/example.com/contacts", server.Path) 1333 | assert.Equal(t, expectedContacts, actualContacts) 1334 | } 1335 | 1336 | func TestClient_UpdateContact(t *testing.T) { 1337 | t.Parallel() 1338 | 1339 | client, server := NewTestHarness(t) 1340 | server.ResponseCode = http.StatusOK 1341 | server.ResponseBody = nil 1342 | 1343 | email := "new@example.com" 1344 | updateRequest := tailscale.UpdateContactRequest{ 1345 | Email: &email, 1346 | } 1347 | err := client.UpdateContact(context.Background(), tailscale.ContactAccount, updateRequest) 1348 | assert.NoError(t, err) 1349 | assert.Equal(t, http.MethodPatch, server.Method) 1350 | assert.Equal(t, "/api/v2/tailnet/example.com/contacts/account", server.Path) 1351 | var receivedRequest tailscale.UpdateContactRequest 1352 | err = json.Unmarshal(server.Body.Bytes(), &receivedRequest) 1353 | assert.NoError(t, err) 1354 | assert.EqualValues(t, updateRequest, receivedRequest) 1355 | } 1356 | -------------------------------------------------------------------------------- /tailscale/tailscale_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale_test 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "net" 12 | "net/http" 13 | "testing" 14 | 15 | "github.com/stretchr/testify/assert" 16 | 17 | "github.com/tailscale/tailscale-client-go/tailscale" 18 | ) 19 | 20 | type TestServer struct { 21 | t *testing.T 22 | 23 | BaseURL string 24 | 25 | Method string 26 | Path string 27 | Body *bytes.Buffer 28 | Header http.Header 29 | 30 | ResponseCode int 31 | ResponseBody interface{} 32 | } 33 | 34 | func NewTestHarness(t *testing.T) (*tailscale.Client, *TestServer) { 35 | t.Helper() 36 | 37 | testServer := &TestServer{ 38 | t: t, 39 | } 40 | 41 | mux := http.NewServeMux() 42 | mux.Handle("/", testServer) 43 | svr := &http.Server{ 44 | Handler: mux, 45 | } 46 | 47 | // Start a listener on a random port 48 | listener, err := net.Listen("tcp", ":0") 49 | assert.NoError(t, err) 50 | 51 | go func() { 52 | _ = svr.Serve(listener) 53 | }() 54 | 55 | // When the test is over, close the server 56 | t.Cleanup(func() { 57 | assert.NoError(t, svr.Close()) 58 | }) 59 | 60 | baseURL := fmt.Sprintf("http://localhost:%v", listener.Addr().(*net.TCPAddr).Port) 61 | testServer.BaseURL = baseURL 62 | client, err := tailscale.NewClient("not a real key", "example.com", tailscale.WithBaseURL(baseURL)) 63 | assert.NoError(t, err) 64 | 65 | return client, testServer 66 | } 67 | 68 | func (t *TestServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 69 | t.Method = r.Method 70 | t.Path = r.URL.Path 71 | t.Header = r.Header 72 | 73 | t.Body = bytes.NewBuffer([]byte{}) 74 | _, err := io.Copy(t.Body, r.Body) 75 | assert.NoError(t.t, err) 76 | 77 | w.WriteHeader(t.ResponseCode) 78 | if t.ResponseBody != nil { 79 | switch body := t.ResponseBody.(type) { 80 | case []byte: 81 | _, err := w.Write(body) 82 | assert.NoError(t.t, err) 83 | default: 84 | assert.NoError(t.t, json.NewEncoder(w).Encode(body)) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tailscale/testdata/acl.hujson: -------------------------------------------------------------------------------- 1 | { 2 | "groups": { 3 | // Alice and Bob are in group:dev 4 | "group:dev": ["alice@example.com", "bob@example.com",], 5 | // Carl is in group:devops 6 | "group:devops": ["carl@example.com",], 7 | }, 8 | "acls": [ 9 | // all employees can access their own devices 10 | { "action": "accept", "src": ["autogroup:members"], "dst": ["autogroup:self:*"] }, 11 | // users in group:dev can access devices tagged tag:dev 12 | { "action": "accept", "src": ["group:dev"], "dst": ["tag:dev:*"] }, 13 | // users in group:devops can access devices tagged tag:prod 14 | { "action": "accept", "src": ["group:devops"], "dst": ["tag:prod:*"] }, 15 | // all employees can access devices tagged tag:monitoring on 16 | // ports 80 and 443 17 | { "action": "accept", "src": ["autogroup:members"], "dst": ["tag:monitoring:80,443"] }, 18 | ], 19 | "tagOwners": { 20 | // users in group:devops can apply the tag tag:monitoring 21 | "tag:monitoring": ["group:devops"], 22 | // users in group:devops can apply the tag tag:dev 23 | "tag:dev": ["group:devops"], 24 | // users in group:devops can apply the tag tag:prod 25 | "tag:prod": ["group:devops"], 26 | }, 27 | "tests": [ 28 | { 29 | "src": "carl@example.com", 30 | // test that Carl can access devices tagged tag:prod on port 80 31 | "accept": ["tag:prod:80"], 32 | }, 33 | { 34 | "src": "alice@example.com", 35 | // test that Alice can access devices tagged tag:dev on port 80 36 | "accept": ["tag:dev:80"], 37 | // test that Alice cannot access devices tagged tag:prod on port 80 38 | "deny": ["tag:prod:80"], 39 | }, 40 | ], 41 | "ssh": [ 42 | { 43 | "action": "accept", 44 | "src": ["autogroup:members"], 45 | "dst": ["autogroup:self"], 46 | "users": ["root", "autogroup:nonroot"] 47 | }, 48 | { 49 | "action": "accept", 50 | "src": ["autogroup:members"], 51 | "dst": ["tag:prod"], 52 | "users": ["root", "autogroup:nonroot"] 53 | }, 54 | { 55 | "action": "accept", 56 | "src": ["tag:logging"], 57 | "dst": ["tag:prod"], 58 | "users": ["root", "autogroup:nonroot"], 59 | "checkPeriod": "20h" 60 | }, 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /tailscale/testdata/acl.json: -------------------------------------------------------------------------------- 1 | { 2 | "groups": { 3 | "group:dev": ["alice@example.com", "bob@example.com"], 4 | "group:devops": ["carl@example.com"] 5 | }, 6 | "acls": [ 7 | { "action": "accept", "src": ["autogroup:members"], "dst": ["autogroup:self:*"] }, 8 | { "action": "accept", "src": ["group:dev"], "dst": ["tag:dev:*"] }, 9 | { "action": "accept", "src": ["group:devops"], "dst": ["tag:prod:*"] }, 10 | { "action": "accept", "src": ["autogroup:members"], "dst": ["tag:monitoring:80,443"] } 11 | ], 12 | "tagOwners": { 13 | "tag:monitoring": ["group:devops"], 14 | "tag:dev": ["group:devops"], 15 | "tag:prod": ["group:devops"] 16 | }, 17 | "tests": [ 18 | { 19 | "src": "carl@example.com", 20 | "accept": ["tag:prod:80"] 21 | }, 22 | { 23 | "src": "alice@example.com", 24 | "accept": ["tag:dev:80"], 25 | "deny": ["tag:prod:80"] 26 | } 27 | ], 28 | "ssh": [ 29 | { 30 | "action": "accept", 31 | "src": ["autogroup:members"], 32 | "dst": ["autogroup:self"], 33 | "users": ["root", "autogroup:nonroot"] 34 | }, 35 | { 36 | "action": "accept", 37 | "src": ["autogroup:members"], 38 | "dst": ["tag:prod"], 39 | "users": ["root", "autogroup:nonroot"] 40 | }, 41 | { 42 | "action": "accept", 43 | "src": ["tag:logging"], 44 | "dst": ["tag:prod"], 45 | "users": ["root", "autogroup:nonroot"], 46 | "checkPeriod": "20h" 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /tailscale/testdata/devices.json: -------------------------------------------------------------------------------- 1 | { 2 | "devices": [ 3 | { 4 | "addresses": [ 5 | "100.101.102.103", 6 | "fd7a:115c:a1e0:ab12:4843:cd96:6265:6667" 7 | ], 8 | "authorized": true, 9 | "blocksIncomingConnections": false, 10 | "clientVersion": "", 11 | "created": "", 12 | "expires": "0001-01-01T00:00:00Z", 13 | "hostname": "hello", 14 | "id": "50052", 15 | "isExternal": true, 16 | "keyExpiryDisabled": true, 17 | "lastSeen": "2022-04-15T13:24:40Z", 18 | "machineKey": "", 19 | "name": "hello.tailscale.com", 20 | "nodeKey": "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", 21 | "os": "linux", 22 | "updateAvailable": false, 23 | "user": "services@tailscale.com" 24 | }, 25 | { 26 | "addresses": [ 27 | "100.121.200.21", 28 | "fd7a:115c:a1e0:ab12:4843:cd96:6265:e618" 29 | ], 30 | "authorized": true, 31 | "blocksIncomingConnections": false, 32 | "clientVersion": "1.22.2-t60b671955-gecc5d9846", 33 | "created": "2022-03-05T17:10:27Z", 34 | "expires": "2022-09-01T17:10:27Z", 35 | "hostname": "foo", 36 | "id": "50053", 37 | "isExternal": false, 38 | "keyExpiryDisabled": true, 39 | "lastSeen": "2022-04-15T13:25:21Z", 40 | "machineKey": "mkey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", 41 | "name": "foo.example.com", 42 | "nodeKey": "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", 43 | "os": "linux", 44 | "updateAvailable": false, 45 | "user": "foo@example.com" 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /tailscale/time_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale_test 5 | 6 | import ( 7 | "encoding/json" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/tailscale/tailscale-client-go/tailscale" 14 | ) 15 | 16 | func TestWrapsStdTime(t *testing.T) { 17 | expectedTime := tailscale.Time{} 18 | newTime := time.Time{} 19 | assert.Equal(t, expectedTime.Time.UTC(), newTime.UTC()) 20 | } 21 | 22 | func TestFailsToParseInvalidTimestamps(t *testing.T) { 23 | ti := tailscale.Time{} 24 | invalidIso8601Date := []byte("\"2022-13-05T17:10:27Z\"") 25 | assert.Error(t, ti.UnmarshalJSON(invalidIso8601Date)) 26 | } 27 | 28 | func TestMarshalingTimestamps(t *testing.T) { 29 | t.Parallel() 30 | utcMinusFour := time.FixedZone("UTC-4", -60*60*4) 31 | 32 | tt := []struct { 33 | Name string 34 | Expected time.Time 35 | TimeContent string 36 | }{ 37 | { 38 | Name: "It should handle empty strings as null-value times", 39 | Expected: time.Time{}, 40 | TimeContent: "\"\"", 41 | }, 42 | { 43 | Name: "It should parse timestamp strings", 44 | Expected: time.Date(2022, 3, 5, 17, 10, 27, 0, time.UTC), 45 | TimeContent: "\"2022-03-05T17:10:27Z\"", 46 | }, 47 | { 48 | Name: "It should handle different timezones", 49 | TimeContent: "\"2006-01-02T15:04:05-04:00\"", 50 | Expected: time.Date(2006, 1, 2, 15, 04, 5, 0, utcMinusFour), 51 | }, 52 | } 53 | 54 | for _, tc := range tt { 55 | t.Run(tc.Name, func(t *testing.T) { 56 | actual := tailscale.Time{} 57 | 58 | assert.NoError(t, actual.UnmarshalJSON([]byte(tc.TimeContent))) 59 | assert.Equal(t, tc.Expected.UTC(), actual.Time.UTC()) 60 | }) 61 | } 62 | } 63 | 64 | func TestDurationUnmarshal(t *testing.T) { 65 | t.Parallel() 66 | 67 | tt := []struct { 68 | Name string 69 | Content string 70 | Expected tailscale.Duration 71 | }{ 72 | { 73 | Name: "It should handle empty string as zero value", 74 | Content: `""`, 75 | Expected: tailscale.Duration(0), 76 | }, 77 | { 78 | Name: "It should handle null as zero value", 79 | Content: `null`, 80 | Expected: tailscale.Duration(0), 81 | }, 82 | { 83 | Name: "It should handle 0s as zero value", 84 | Content: `"0s"`, 85 | Expected: tailscale.Duration(0), 86 | }, 87 | { 88 | Name: "It should parse duration strings", 89 | Content: `"15s"`, 90 | Expected: tailscale.Duration(15 * time.Second), 91 | }, 92 | } 93 | 94 | for _, tc := range tt { 95 | t.Run(tc.Name, func(t *testing.T) { 96 | var actual tailscale.Duration 97 | 98 | assert.NoError(t, json.Unmarshal([]byte(tc.Content), &actual)) 99 | assert.Equal(t, tc.Expected, actual) 100 | }) 101 | } 102 | } 103 | 104 | func TestDurationMarshal(t *testing.T) { 105 | t.Parallel() 106 | 107 | tt := []struct { 108 | Name string 109 | Content any 110 | Expected string 111 | }{ 112 | { 113 | Name: "It should marshal zero duration as 0s", 114 | Content: struct{ D tailscale.Duration }{tailscale.Duration(0)}, 115 | Expected: `{"D":"0s"}`, 116 | }, 117 | { 118 | Name: "It should not marshal zero duration if omitempty", 119 | Content: struct { 120 | D tailscale.Duration `json:"d,omitempty"` 121 | }{tailscale.Duration(0)}, 122 | Expected: `{}`, 123 | }, 124 | { 125 | Name: "It should marshal duration correctly", 126 | Content: struct{ D tailscale.Duration }{tailscale.Duration(15 * time.Second)}, 127 | Expected: `{"D":"15s"}`, 128 | }, 129 | } 130 | 131 | for _, tc := range tt { 132 | t.Run(tc.Name, func(t *testing.T) { 133 | actual, err := json.Marshal(tc.Content) 134 | 135 | assert.NoError(t, err) 136 | assert.Equal(t, tc.Expected, string(actual)) 137 | }) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /v2/client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package tsclient used to contain a basic implementation of a client for the Tailscale HTTP API. 5 | // It has been superseded by tailscale.com/client/tailscale/v2. 6 | // 7 | // Deprecated: use tailscale.com/client/tailscale/v2 instead. 8 | package tsclient 9 | --------------------------------------------------------------------------------