Skip to content

Commit b40c082

Browse files
authored
tailscale: support reading & writing ACL content as HuJSON (#70)
Updates tailscale/terraform-provider-tailscale#227 Signed-off-by: Anton Tolchanov <[email protected]>
1 parent 6b54b06 commit b40c082

File tree

6 files changed

+186
-44
lines changed

6 files changed

+186
-44
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
jobs:
99
test:
1010
runs-on: ubuntu-latest
11-
container: golang:1.19
11+
container: golang:1.22
1212
steps:
1313
- name: Checkout
1414
uses: actions/checkout@v3

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/tailscale/tailscale-client-go
22

3-
go 1.19
3+
go 1.22.0
44

55
require (
66
github.com/stretchr/testify v1.8.4

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg
66
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
77
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
88
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
9+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
910
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1011
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1112
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=

tailscale/client.go

Lines changed: 128 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ type (
4646
)
4747

4848
const baseURL = "https://api.tailscale.com"
49-
const contentType = "application/json"
49+
const defaultContentType = "application/json"
5050
const defaultHttpClientTimeout = time.Minute
5151
const defaultUserAgent = "tailscale-client-go"
5252

@@ -134,18 +134,57 @@ func WithUserAgent(ua string) ClientOption {
134134
}
135135
}
136136

137-
// TODO: consider setting `headers` and `body` via opts to decrease the number of arguments.
138-
func (c *Client) buildRequest(ctx context.Context, method, uri string, headers map[string]string, body interface{}) (*http.Request, error) {
137+
type requestParams struct {
138+
headers map[string]string
139+
body any
140+
contentType string
141+
}
142+
143+
type requestOption func(*requestParams)
144+
145+
func requestBody(body any) requestOption {
146+
return func(rof *requestParams) {
147+
rof.body = body
148+
}
149+
}
150+
151+
func requestHeaders(headers map[string]string) requestOption {
152+
return func(rof *requestParams) {
153+
rof.headers = headers
154+
}
155+
}
156+
157+
func requestContentType(ct string) requestOption {
158+
return func(rof *requestParams) {
159+
rof.contentType = ct
160+
}
161+
}
162+
163+
func (c *Client) buildRequest(ctx context.Context, method, uri string, opts ...requestOption) (*http.Request, error) {
164+
rof := &requestParams{
165+
contentType: defaultContentType,
166+
}
167+
for _, opt := range opts {
168+
opt(rof)
169+
}
170+
139171
u, err := c.baseURL.Parse(uri)
140172
if err != nil {
141173
return nil, err
142174
}
143175

144176
var bodyBytes []byte
145-
if body != nil {
146-
bodyBytes, err = json.MarshalIndent(body, "", " ")
147-
if err != nil {
148-
return nil, err
177+
if rof.body != nil {
178+
switch body := rof.body.(type) {
179+
case string:
180+
bodyBytes = []byte(body)
181+
case []byte:
182+
bodyBytes = body
183+
default:
184+
bodyBytes, err = json.MarshalIndent(rof.body, "", " ")
185+
if err != nil {
186+
return nil, err
187+
}
149188
}
150189
}
151190

@@ -158,15 +197,15 @@ func (c *Client) buildRequest(ctx context.Context, method, uri string, headers m
158197
req.Header.Set("User-Agent", c.userAgent)
159198
}
160199

161-
for k, v := range headers {
200+
for k, v := range rof.headers {
162201
req.Header.Set(k, v)
163202
}
164203

165204
switch {
166-
case body == nil:
167-
req.Header.Set("Accept", contentType)
205+
case rof.body == nil:
206+
req.Header.Set("Accept", rof.contentType)
168207
default:
169-
req.Header.Set("Content-Type", contentType)
208+
req.Header.Set("Content-Type", rof.contentType)
170209
}
171210

172211
// c.apiKey will not be set on the client was configured with WithOAuthClientCredentials()
@@ -197,6 +236,12 @@ func (c *Client) performRequest(req *http.Request, out interface{}) error {
197236
return nil
198237
}
199238

239+
// If we're expected to write result into a []byte, do not attempt to parse it.
240+
if o, ok := out.(*[]byte); ok {
241+
*o = bytes.Clone(body)
242+
return nil
243+
}
244+
200245
// If we've got hujson back, convert it to JSON, so we can natively parse it.
201246
if !json.Valid(body) {
202247
body, err = hujson.Standardize(body)
@@ -229,9 +274,9 @@ func (err APIError) Error() string {
229274
func (c *Client) SetDNSSearchPaths(ctx context.Context, searchPaths []string) error {
230275
const uriFmt = "/api/v2/tailnet/%v/dns/searchpaths"
231276

232-
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), nil, map[string][]string{
277+
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), requestBody(map[string][]string{
233278
"searchPaths": searchPaths,
234-
})
279+
}))
235280
if err != nil {
236281
return err
237282
}
@@ -243,7 +288,7 @@ func (c *Client) SetDNSSearchPaths(ctx context.Context, searchPaths []string) er
243288
func (c *Client) DNSSearchPaths(ctx context.Context) ([]string, error) {
244289
const uriFmt = "/api/v2/tailnet/%v/dns/searchpaths"
245290

246-
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet), nil, nil)
291+
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet))
247292
if err != nil {
248293
return nil, err
249294
}
@@ -261,9 +306,9 @@ func (c *Client) DNSSearchPaths(ctx context.Context) ([]string, error) {
261306
func (c *Client) SetDNSNameservers(ctx context.Context, dns []string) error {
262307
const uriFmt = "/api/v2/tailnet/%v/dns/nameservers"
263308

264-
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), nil, map[string][]string{
309+
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), requestBody(map[string][]string{
265310
"dns": dns,
266-
})
311+
}))
267312
if err != nil {
268313
return err
269314
}
@@ -275,7 +320,7 @@ func (c *Client) SetDNSNameservers(ctx context.Context, dns []string) error {
275320
func (c *Client) DNSNameservers(ctx context.Context) ([]string, error) {
276321
const uriFmt = "/api/v2/tailnet/%v/dns/nameservers"
277322

278-
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet), nil, nil)
323+
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet))
279324
if err != nil {
280325
return nil, err
281326
}
@@ -389,7 +434,7 @@ type (
389434
func (c *Client) ACL(ctx context.Context) (*ACL, error) {
390435
const uriFmt = "/api/v2/tailnet/%s/acl"
391436

392-
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet), nil, nil)
437+
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet))
393438
if err != nil {
394439
return nil, err
395440
}
@@ -402,6 +447,24 @@ func (c *Client) ACL(ctx context.Context) (*ACL, error) {
402447
return &resp, nil
403448
}
404449

450+
// RawACL retrieves the ACL that is currently set for the given tailnet
451+
// as a HuJSON string.
452+
func (c *Client) RawACL(ctx context.Context) (string, error) {
453+
const uriFmt = "/api/v2/tailnet/%s/acl"
454+
455+
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet), requestContentType("application/hujson"))
456+
if err != nil {
457+
return "", err
458+
}
459+
460+
var resp []byte
461+
if err = c.performRequest(req, &resp); err != nil {
462+
return "", err
463+
}
464+
465+
return string(resp), nil
466+
}
467+
405468
type setACLParams struct {
406469
headers map[string]string
407470
}
@@ -415,28 +478,53 @@ func WithETag(etag string) SetACLOption {
415478
}
416479
}
417480

418-
// SetACL sets the ACL for the given tailnet.
419-
func (c *Client) SetACL(ctx context.Context, acl ACL, opts ...SetACLOption) error {
481+
// SetACL sets the ACL for the given tailnet. `acl` can either be an [ACL],
482+
// or a HuJSON string.
483+
func (c *Client) SetACL(ctx context.Context, acl any, opts ...SetACLOption) error {
420484
const uriFmt = "/api/v2/tailnet/%s/acl"
421485

422486
p := &setACLParams{headers: make(map[string]string)}
423487
for _, opt := range opts {
424488
opt(p)
425489
}
426490

427-
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), p.headers, acl)
491+
reqOpts := []requestOption{
492+
requestHeaders(p.headers),
493+
requestBody(acl),
494+
}
495+
switch v := acl.(type) {
496+
case ACL:
497+
case string:
498+
reqOpts = append(reqOpts, requestContentType("application/hujson"))
499+
default:
500+
return fmt.Errorf("expected ACL content as a string or as ACL struct; got %T", v)
501+
}
502+
503+
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), reqOpts...)
428504
if err != nil {
429505
return err
430506
}
431507

432508
return c.performRequest(req, nil)
433509
}
434510

435-
// ValidateACL validates the provided ACL via the API.
436-
func (c *Client) ValidateACL(ctx context.Context, acl ACL) error {
511+
// ValidateACL validates the provided ACL via the API. `acl` can either be an [ACL],
512+
// or a HuJSON string.
513+
func (c *Client) ValidateACL(ctx context.Context, acl any) error {
437514
const uriFmt = "/api/v2/tailnet/%s/acl/validate"
438515

439-
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), nil, acl)
516+
reqOpts := []requestOption{
517+
requestBody(acl),
518+
}
519+
switch v := acl.(type) {
520+
case ACL:
521+
case string:
522+
reqOpts = append(reqOpts, requestContentType("application/hujson"))
523+
default:
524+
return fmt.Errorf("expected ACL content as a string or as ACL struct; got %T", v)
525+
}
526+
527+
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), reqOpts...)
440528
if err != nil {
441529
return err
442530
}
@@ -460,7 +548,7 @@ type DNSPreferences struct {
460548
func (c *Client) DNSPreferences(ctx context.Context) (*DNSPreferences, error) {
461549
const uriFmt = "/api/v2/tailnet/%s/dns/preferences"
462550

463-
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet), nil, nil)
551+
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet))
464552
if err != nil {
465553
return nil, err
466554
}
@@ -478,7 +566,7 @@ func (c *Client) DNSPreferences(ctx context.Context) (*DNSPreferences, error) {
478566
func (c *Client) SetDNSPreferences(ctx context.Context, preferences DNSPreferences) error {
479567
const uriFmt = "/api/v2/tailnet/%s/dns/preferences"
480568

481-
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), nil, preferences)
569+
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), requestBody(preferences))
482570
if err != nil {
483571
return nil
484572
}
@@ -498,9 +586,9 @@ type (
498586
func (c *Client) SetDeviceSubnetRoutes(ctx context.Context, deviceID string, routes []string) error {
499587
const uriFmt = "/api/v2/device/%s/routes"
500588

501-
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), nil, map[string][]string{
589+
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), requestBody(map[string][]string{
502590
"routes": routes,
503-
})
591+
}))
504592
if err != nil {
505593
return err
506594
}
@@ -514,7 +602,7 @@ func (c *Client) SetDeviceSubnetRoutes(ctx context.Context, deviceID string, rou
514602
func (c *Client) DeviceSubnetRoutes(ctx context.Context, deviceID string) (*DeviceRoutes, error) {
515603
const uriFmt = "/api/v2/device/%s/routes"
516604

517-
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, deviceID), nil, nil)
605+
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, deviceID))
518606
if err != nil {
519607
return nil, err
520608
}
@@ -576,7 +664,7 @@ type Device struct {
576664
func (c *Client) Devices(ctx context.Context) ([]Device, error) {
577665
const uriFmt = "/api/v2/tailnet/%s/devices"
578666

579-
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet), nil, nil)
667+
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet))
580668
if err != nil {
581669
return nil, err
582670
}
@@ -598,9 +686,9 @@ func (c *Client) AuthorizeDevice(ctx context.Context, deviceID string) error {
598686
func (c *Client) SetDeviceAuthorized(ctx context.Context, deviceID string, authorized bool) error {
599687
const uriFmt = "/api/v2/device/%s/authorized"
600688

601-
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), nil, map[string]bool{
689+
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), requestBody(map[string]bool{
602690
"authorized": authorized,
603-
})
691+
}))
604692
if err != nil {
605693
return err
606694
}
@@ -611,7 +699,7 @@ func (c *Client) SetDeviceAuthorized(ctx context.Context, deviceID string, autho
611699
// DeleteDevice deletes the device given its deviceID.
612700
func (c *Client) DeleteDevice(ctx context.Context, deviceID string) error {
613701
const uriFmt = "/api/v2/device/%s"
614-
req, err := c.buildRequest(ctx, http.MethodDelete, fmt.Sprintf(uriFmt, deviceID), nil, nil)
702+
req, err := c.buildRequest(ctx, http.MethodDelete, fmt.Sprintf(uriFmt, deviceID))
615703
if err != nil {
616704
return err
617705
}
@@ -686,7 +774,7 @@ func (c *Client) CreateKey(ctx context.Context, capabilities KeyCapabilities, op
686774
}
687775
}
688776

689-
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), nil, ckr)
777+
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, c.tailnet), requestBody(ckr))
690778
if err != nil {
691779
return Key{}, err
692780
}
@@ -700,7 +788,7 @@ func (c *Client) CreateKey(ctx context.Context, capabilities KeyCapabilities, op
700788
func (c *Client) GetKey(ctx context.Context, id string) (Key, error) {
701789
const uriFmt = "/api/v2/tailnet/%s/keys/%s"
702790

703-
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet, id), nil, nil)
791+
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet, id))
704792
if err != nil {
705793
return Key{}, err
706794
}
@@ -714,7 +802,7 @@ func (c *Client) GetKey(ctx context.Context, id string) (Key, error) {
714802
func (c *Client) Keys(ctx context.Context) ([]Key, error) {
715803
const uriFmt = "/api/v2/tailnet/%s/keys"
716804

717-
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet), nil, nil)
805+
req, err := c.buildRequest(ctx, http.MethodGet, fmt.Sprintf(uriFmt, c.tailnet))
718806
if err != nil {
719807
return nil, err
720808
}
@@ -731,7 +819,7 @@ func (c *Client) Keys(ctx context.Context) ([]Key, error) {
731819
func (c *Client) DeleteKey(ctx context.Context, id string) error {
732820
const uriFmt = "/api/v2/tailnet/%s/keys/%s"
733821

734-
req, err := c.buildRequest(ctx, http.MethodDelete, fmt.Sprintf(uriFmt, c.tailnet, id), nil, nil)
822+
req, err := c.buildRequest(ctx, http.MethodDelete, fmt.Sprintf(uriFmt, c.tailnet, id))
735823
if err != nil {
736824
return err
737825
}
@@ -743,9 +831,9 @@ func (c *Client) DeleteKey(ctx context.Context, id string) error {
743831
func (c *Client) SetDeviceTags(ctx context.Context, deviceID string, tags []string) error {
744832
const uriFmt = "/api/v2/device/%s/tags"
745833

746-
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), nil, map[string][]string{
834+
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), requestBody(map[string][]string{
747835
"tags": tags,
748-
})
836+
}))
749837
if err != nil {
750838
return err
751839
}
@@ -765,7 +853,7 @@ type (
765853
func (c *Client) SetDeviceKey(ctx context.Context, deviceID string, key DeviceKey) error {
766854
const uriFmt = "/api/v2/device/%s/key"
767855

768-
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), nil, key)
856+
req, err := c.buildRequest(ctx, http.MethodPost, fmt.Sprintf(uriFmt, deviceID), requestBody(key))
769857
if err != nil {
770858
return err
771859
}

0 commit comments

Comments
 (0)