diff --git a/v2/client.go b/v2/client.go index e681c34..d8559c9 100644 --- a/v2/client.go +++ b/v2/client.go @@ -38,11 +38,11 @@ type ( initOnce sync.Once // Specific resources - contacts *ContactsResource - keys *KeysResource - devices *DevicesResource - keys *KeysResource - webhooks *WebhooksResource + contacts *ContactsResource + devices *DevicesResource + keys *KeysResource + policyFile *PolicyFileResource + webhooks *WebhooksResource } // APIError type describes an error as returned by the Tailscale API. @@ -97,6 +97,7 @@ func (c *Client) init() { c.contacts = &ContactsResource{c} c.devices = &DevicesResource{c} c.keys = &KeysResource{c} + c.policyFile = &PolicyFileResource{c} c.webhooks = &WebhooksResource{c} }) } @@ -130,6 +131,11 @@ func (c *Client) Keys() *KeysResource { return c.keys } +func (c *Client) PolicyFile() *PolicyFileResource { + c.init() + return c.policyFile +} + func (c *Client) Webhooks() *WebhooksResource { c.init() return c.webhooks diff --git a/v2/policyfile.go b/v2/policyfile.go new file mode 100644 index 0000000..91ebd8a --- /dev/null +++ b/v2/policyfile.go @@ -0,0 +1,193 @@ +package tsclient + +import ( + "context" + "fmt" + "net/http" +) + +type PolicyFileResource struct { + *Client +} + +type ( + // ACL contains the schema for a tailnet policy file. More details: https://tailscale.com/kb/1018/acls/ + ACL struct { + ACLs []ACLEntry `json:"acls,omitempty" hujson:"ACLs,omitempty"` + AutoApprovers *ACLAutoApprovers `json:"autoApprovers,omitempty" hujson:"AutoApprovers,omitempty"` + Groups map[string][]string `json:"groups,omitempty" hujson:"Groups,omitempty"` + Hosts map[string]string `json:"hosts,omitempty" hujson:"Hosts,omitempty"` + TagOwners map[string][]string `json:"tagOwners,omitempty" hujson:"TagOwners,omitempty"` + DERPMap *ACLDERPMap `json:"derpMap,omitempty" hujson:"DerpMap,omitempty"` + Tests []ACLTest `json:"tests,omitempty" hujson:"Tests,omitempty"` + SSH []ACLSSH `json:"ssh,omitempty" hujson:"SSH,omitempty"` + NodeAttrs []NodeAttrGrant `json:"nodeAttrs,omitempty" hujson:"NodeAttrs,omitempty"` + DisableIPv4 bool `json:"disableIPv4,omitempty" hujson:"DisableIPv4,omitempty"` + OneCGNATRoute string `json:"oneCGNATRoute,omitempty" hujson:"OneCGNATRoute,omitempty"` + RandomizeClientPort bool `json:"randomizeClientPort,omitempty" hujson:"RandomizeClientPort,omitempty"` + + // Postures and DefaultSourcePosture are for an experimental feature and not yet public or documented as of 2023-08-17. + // This API is subject to change. Internal bug: corp/13986 + Postures map[string][]string `json:"postures,omitempty" hujson:"Postures,omitempty"` + DefaultSourcePosture []string `json:"defaultSrcPosture,omitempty" hujson:"DefaultSrcPosture,omitempty"` + } + + ACLAutoApprovers struct { + Routes map[string][]string `json:"routes,omitempty" hujson:"Routes,omitempty"` + ExitNode []string `json:"exitNode,omitempty" hujson:"ExitNode,omitempty"` + } + + ACLEntry struct { + Action string `json:"action,omitempty" hujson:"Action,omitempty"` + Ports []string `json:"ports,omitempty" hujson:"Ports,omitempty"` + Users []string `json:"users,omitempty" hujson:"Users,omitempty"` + Source []string `json:"src,omitempty" hujson:"Src,omitempty"` + Destination []string `json:"dst,omitempty" hujson:"Dst,omitempty"` + Protocol string `json:"proto,omitempty" hujson:"Proto,omitempty"` + + // SourcePosture is for an experimental feature and not yet public or documented as of 2023-08-17. + SourcePosture []string `json:"srcPosture,omitempty" hujson:"SrcPosture,omitempty"` + } + + ACLTest struct { + User string `json:"user,omitempty" hujson:"User,omitempty"` + Allow []string `json:"allow,omitempty" hujson:"Allow,omitempty"` + Deny []string `json:"deny,omitempty" hujson:"Deny,omitempty"` + Source string `json:"src,omitempty" hujson:"Src,omitempty"` + Accept []string `json:"accept,omitempty" hujson:"Accept,omitempty"` + } + + ACLDERPMap struct { + Regions map[int]*ACLDERPRegion `json:"regions" hujson:"Regions"` + OmitDefaultRegions bool `json:"omitDefaultRegions,omitempty" hujson:"OmitDefaultRegions,omitempty"` + } + + ACLDERPRegion struct { + RegionID int `json:"regionID" hujson:"RegionID"` + RegionCode string `json:"regionCode" hujson:"RegionCode"` + RegionName string `json:"regionName" hujson:"RegionName"` + Avoid bool `json:"avoid,omitempty" hujson:"Avoid,omitempty"` + Nodes []*ACLDERPNode `json:"nodes" hujson:"Nodes"` + } + + ACLDERPNode struct { + Name string `json:"name" hujson:"Name"` + RegionID int `json:"regionID" hujson:"RegionID"` + HostName string `json:"hostName" hujson:"HostName"` + CertName string `json:"certName,omitempty" hujson:"CertName,omitempty"` + IPv4 string `json:"ipv4,omitempty" hujson:"IPv4,omitempty"` + IPv6 string `json:"ipv6,omitempty" hujson:"IPv6,omitempty"` + STUNPort int `json:"stunPort,omitempty" hujson:"STUNPort,omitempty"` + STUNOnly bool `json:"stunOnly,omitempty" hujson:"STUNOnly,omitempty"` + DERPPort int `json:"derpPort,omitempty" hujson:"DERPPort,omitempty"` + InsecureForTests bool `json:"insecureForRests,omitempty" hujson:"InsecureForTests,omitempty"` + STUNTestIP string `json:"stunTestIP,omitempty" hujson:"STUNTestIP,omitempty"` + } + + ACLSSH struct { + Action string `json:"action,omitempty" hujson:"Action,omitempty"` + Users []string `json:"users,omitempty" hujson:"Users,omitempty"` + Source []string `json:"src,omitempty" hujson:"Src,omitempty"` + Destination []string `json:"dst,omitempty" hujson:"Dst,omitempty"` + CheckPeriod Duration `json:"checkPeriod,omitempty" hujson:"CheckPeriod,omitempty"` + Recorder []string `json:"recorder,omitempty" hujson:"Recorder,omitempty"` + EnforceRecorder bool `json:"enforceRecorder,omitempty" hujson:"EnforceRecorder,omitempty"` + } + + NodeAttrGrant struct { + Target []string `json:"target,omitempty" hujson:"Target,omitempty"` + Attr []string `json:"attr,omitempty" hujson:"Attr,omitempty"` + App map[string][]*NodeAttrGrantApp `json:"app,omitempty" hujson:"App,omitempty"` + } + + NodeAttrGrantApp struct { + Name string `json:"name,omitempty" hujson:"Name,omitempty"` + Connectors []string `json:"connectors,omitempty" hujson:"Connectors,omitempty"` + Domains []string `json:"domains,omitempty" hujson:"Domains,omitempty"` + } +) + +// Get retrieves the Get that is currently set for the given tailnet. +func (pr *PolicyFileResource) Get(ctx context.Context) (*ACL, error) { + req, err := pr.buildRequest(ctx, http.MethodGet, pr.buildTailnetURL("acl")) + if err != nil { + return nil, err + } + + var resp ACL + return &resp, pr.do(req, &resp) +} + +// Raw retrieves the ACL that is currently set for the given tailnet +// as a HuJSON string. +func (pr *PolicyFileResource) Raw(ctx context.Context) (string, error) { + req, err := pr.buildRequest(ctx, http.MethodGet, pr.buildTailnetURL("acl"), requestContentType("application/hujson")) + if err != nil { + return "", err + } + + var resp []byte + if err = pr.do(req, &resp); err != nil { + return "", err + } + + return string(resp), nil +} + +// Set sets the ACL for the given tailnet. `acl` can either be an [ACL], +// or a HuJSON string. etag is an optional value that, if supplied, will be used in the +// `If-Match` HTTP request header. +func (pr *PolicyFileResource) Set(ctx context.Context, acl any, etag string) error { + headers := make(map[string]string) + if etag != "" { + headers["If-Match"] = fmt.Sprintf("%q", etag) + } + + reqOpts := []requestOption{ + requestHeaders(headers), + requestBody(acl), + } + switch v := acl.(type) { + case ACL: + case string: + reqOpts = append(reqOpts, requestContentType("application/hujson")) + default: + return fmt.Errorf("expected ACL content as a string or as ACL struct; got %T", v) + } + + req, err := pr.buildRequest(ctx, http.MethodPost, pr.buildTailnetURL("acl"), reqOpts...) + if err != nil { + return err + } + + return pr.do(req, nil) +} + +// Validate validates the provided ACL via the API. `acl` can either be an [ACL], +// or a HuJSON string. +func (pr *PolicyFileResource) Validate(ctx context.Context, acl any) error { + reqOpts := []requestOption{ + requestBody(acl), + } + switch v := acl.(type) { + case ACL: + case string: + reqOpts = append(reqOpts, requestContentType("application/hujson")) + default: + return fmt.Errorf("expected ACL content as a string or as ACL struct; got %T", v) + } + + req, err := pr.buildRequest(ctx, http.MethodPost, pr.buildTailnetURL("acl", "validate"), reqOpts...) + if err != nil { + return err + } + + var response APIError + if err := pr.do(req, &response); err != nil { + return err + } + if response.Message != "" { + return fmt.Errorf("ACL validation failed: %s; %v", response.Message, response.Data) + } + return nil +} diff --git a/v2/policyfile_test.go b/v2/policyfile_test.go new file mode 100644 index 0000000..8aa51e9 --- /dev/null +++ b/v2/policyfile_test.go @@ -0,0 +1,377 @@ +package tsclient_test + +import ( + "context" + _ "embed" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/tailscale/hujson" + tsclient "github.com/tailscale/tailscale-client-go/v2" +) + +var ( + //go:embed testdata/acl.json + jsonACL []byte + //go:embed testdata/acl.hujson + huJSONACL []byte +) + +func TestACL_Unmarshal(t *testing.T) { + t.Parallel() + + tt := []struct { + Name string + ACLContent []byte + Expected tsclient.ACL + UnmarshalFunc func(data []byte, v interface{}) error + }{ + { + Name: "It should handle JSON ACLs", + ACLContent: jsonACL, + UnmarshalFunc: json.Unmarshal, + Expected: tsclient.ACL{ + ACLs: []tsclient.ACLEntry{ + { + Action: "accept", + Ports: []string(nil), + Users: []string(nil), + Source: []string{"autogroup:members"}, + Destination: []string{"autogroup:self:*"}, + Protocol: "", + }, + { + Action: "accept", + Ports: []string(nil), + Users: []string(nil), + Source: []string{"group:dev"}, + Destination: []string{"tag:dev:*"}, + Protocol: "", + }, + { + Action: "accept", + Ports: []string(nil), + Users: []string(nil), + Source: []string{"group:devops"}, + Destination: []string{"tag:prod:*"}, + Protocol: "", + }, + { + Action: "accept", + Ports: []string(nil), + Users: []string(nil), + Source: []string{"autogroup:members"}, + Destination: []string{"tag:monitoring:80,443"}, + Protocol: "", + }, + }, + Groups: map[string][]string{ + "group:dev": {"alice@example.com", "bob@example.com"}, + "group:devops": {"carl@example.com"}, + }, + Hosts: map[string]string(nil), + TagOwners: map[string][]string{ + "tag:dev": {"group:devops"}, + "tag:monitoring": {"group:devops"}, + "tag:prod": {"group:devops"}, + }, + DERPMap: (*tsclient.ACLDERPMap)(nil), + Tests: []tsclient.ACLTest{ + { + User: "", + Allow: []string(nil), + Deny: []string(nil), + Source: "carl@example.com", + Accept: []string{"tag:prod:80"}, + }, + { + User: "", + Allow: []string(nil), + Deny: []string{"tag:prod:80"}, + Source: "alice@example.com", + Accept: []string{"tag:dev:80"}}, + }, + SSH: []tsclient.ACLSSH{ + { + Action: "accept", + Source: []string{"autogroup:members"}, + Destination: []string{"autogroup:self"}, + Users: []string{"root", "autogroup:nonroot"}, + }, + { + Action: "accept", + Source: []string{"autogroup:members"}, + Destination: []string{"tag:prod"}, + Users: []string{"root", "autogroup:nonroot"}, + }, + { + Action: "accept", + Source: []string{"tag:logging"}, + Destination: []string{"tag:prod"}, + Users: []string{"root", "autogroup:nonroot"}, + CheckPeriod: tsclient.Duration(time.Hour * 20), + }, + }, + }, + }, + { + Name: "It should handle HuJSON ACLs", + ACLContent: huJSONACL, + UnmarshalFunc: func(b []byte, v interface{}) error { + b = append([]byte{}, b...) + b, err := hujson.Standardize(b) + if err != nil { + return err + } + return json.Unmarshal(b, v) + }, + Expected: tsclient.ACL{ + ACLs: []tsclient.ACLEntry{ + { + Action: "accept", + Ports: []string(nil), + Users: []string(nil), + Source: []string{"autogroup:members"}, + Destination: []string{"autogroup:self:*"}, + Protocol: "", + }, + { + Action: "accept", + Ports: []string(nil), + Users: []string(nil), + Source: []string{"group:dev"}, + Destination: []string{"tag:dev:*"}, + Protocol: "", + }, + { + Action: "accept", + Ports: []string(nil), + Users: []string(nil), + Source: []string{"group:devops"}, + Destination: []string{"tag:prod:*"}, + Protocol: "", + }, + { + Action: "accept", + Ports: []string(nil), + Users: []string(nil), + Source: []string{"autogroup:members"}, + Destination: []string{"tag:monitoring:80,443"}, + Protocol: "", + }, + }, + Groups: map[string][]string{ + "group:dev": {"alice@example.com", "bob@example.com"}, + "group:devops": {"carl@example.com"}, + }, + Hosts: map[string]string(nil), + TagOwners: map[string][]string{ + "tag:dev": {"group:devops"}, + "tag:monitoring": {"group:devops"}, + "tag:prod": {"group:devops"}, + }, + DERPMap: (*tsclient.ACLDERPMap)(nil), + SSH: []tsclient.ACLSSH{ + { + Action: "accept", + Source: []string{"autogroup:members"}, + Destination: []string{"autogroup:self"}, + Users: []string{"root", "autogroup:nonroot"}, + }, + { + Action: "accept", + Source: []string{"autogroup:members"}, + Destination: []string{"tag:prod"}, + Users: []string{"root", "autogroup:nonroot"}, + }, + { + Action: "accept", + Source: []string{"tag:logging"}, + Destination: []string{"tag:prod"}, + Users: []string{"root", "autogroup:nonroot"}, + CheckPeriod: tsclient.Duration(time.Hour * 20), + }, + }, + Tests: []tsclient.ACLTest{ + { + User: "", + Allow: []string(nil), + Deny: []string(nil), + Source: "carl@example.com", + Accept: []string{"tag:prod:80"}, + }, + { + User: "", + Allow: []string(nil), + Deny: []string{"tag:prod:80"}, + Source: "alice@example.com", + Accept: []string{"tag:dev:80"}}, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + var actual tsclient.ACL + + assert.NoError(t, tc.UnmarshalFunc(tc.ACLContent, &actual)) + assert.EqualValues(t, tc.Expected, actual) + }) + } +} + +func TestClient_SetACL(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + server.ResponseCode = http.StatusOK + expectedACL := tsclient.ACL{ + ACLs: []tsclient.ACLEntry{ + { + Action: "accept", + Ports: []string{"*:*"}, + Users: []string{"*"}, + }, + }, + TagOwners: map[string][]string{ + "tag:example": {"group:example"}, + }, + Hosts: map[string]string{ + "example-host-1": "100.100.100.100", + "example-host-2": "100.100.101.100/24", + }, + Groups: map[string][]string{ + "group:example": { + "user1@example.com", + "user2@example.com", + }, + }, + Tests: []tsclient.ACLTest{ + { + User: "user1@example.com", + Allow: []string{"example-host-1:22", "example-host-2:80"}, + Deny: []string{"exapmle-host-2:100"}, + }, + { + User: "user2@example.com", + Allow: []string{"100.60.3.4:22"}, + }, + }, + } + + assert.NoError(t, client.PolicyFile().Set(context.Background(), expectedACL, "")) + assert.Equal(t, http.MethodPost, server.Method) + assert.Equal(t, "/api/v2/tailnet/example.com/acl", server.Path) + assert.Equal(t, "", server.Header.Get("If-Match")) + assert.EqualValues(t, "application/json", server.Header.Get("Content-Type")) + + var actualACL tsclient.ACL + assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualACL)) + assert.EqualValues(t, expectedACL, actualACL) +} +func TestClient_SetACL_HuJSON(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + server.ResponseCode = http.StatusOK + + assert.NoError(t, client.PolicyFile().Set(context.Background(), string(huJSONACL), "")) + assert.Equal(t, http.MethodPost, server.Method) + assert.Equal(t, "/api/v2/tailnet/example.com/acl", server.Path) + assert.Equal(t, "", server.Header.Get("If-Match")) + assert.EqualValues(t, "application/hujson", server.Header.Get("Content-Type")) + assert.EqualValues(t, huJSONACL, server.Body.Bytes()) +} + +func TestClient_SetACLWithETag(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + server.ResponseCode = http.StatusOK + expectedACL := tsclient.ACL{ + ACLs: []tsclient.ACLEntry{ + { + Action: "accept", + Ports: []string{"*:*"}, + Users: []string{"*"}, + }, + }, + } + + assert.NoError(t, client.PolicyFile().Set(context.Background(), expectedACL, "test-etag")) + assert.Equal(t, http.MethodPost, server.Method) + assert.Equal(t, "/api/v2/tailnet/example.com/acl", server.Path) + assert.Equal(t, `"test-etag"`, server.Header.Get("If-Match")) + + var actualACL tsclient.ACL + assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualACL)) + assert.EqualValues(t, expectedACL, actualACL) +} + +func TestClient_ACL(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + + server.ResponseCode = http.StatusOK + server.ResponseBody = &tsclient.ACL{ + ACLs: []tsclient.ACLEntry{ + { + Action: "accept", + Ports: []string{"*:*"}, + Users: []string{"*"}, + }, + }, + TagOwners: map[string][]string{ + "tag:example": {"group:example"}, + }, + Hosts: map[string]string{ + "example-host-1": "100.100.100.100", + "example-host-2": "100.100.101.100/24", + }, + Groups: map[string][]string{ + "group:example": { + "user1@example.com", + "user2@example.com", + }, + }, + Tests: []tsclient.ACLTest{ + { + User: "user1@example.com", + Allow: []string{"example-host-1:22", "example-host-2:80"}, + Deny: []string{"exapmle-host-2:100"}, + }, + { + User: "user2@example.com", + Allow: []string{"100.60.3.4:22"}, + }, + }, + } + + acl, err := client.PolicyFile().Get(context.Background()) + assert.NoError(t, err) + assert.EqualValues(t, acl, server.ResponseBody) + assert.EqualValues(t, http.MethodGet, server.Method) + assert.EqualValues(t, "application/json", server.Header.Get("Accept")) + assert.EqualValues(t, "/api/v2/tailnet/example.com/acl", server.Path) +} + +func TestClient_RawACL(t *testing.T) { + t.Parallel() + + client, server := NewTestHarness(t) + + server.ResponseCode = http.StatusOK + server.ResponseBody = huJSONACL + + acl, err := client.PolicyFile().Raw(context.Background()) + assert.NoError(t, err) + assert.EqualValues(t, string(huJSONACL), acl) + assert.EqualValues(t, http.MethodGet, server.Method) + assert.EqualValues(t, "application/hujson", server.Header.Get("Accept")) + assert.EqualValues(t, "/api/v2/tailnet/example.com/acl", server.Path) +} diff --git a/v2/testdata/acl.hujson b/v2/testdata/acl.hujson new file mode 100644 index 0000000..372a063 --- /dev/null +++ b/v2/testdata/acl.hujson @@ -0,0 +1,62 @@ +{ + "groups": { + // Alice and Bob are in group:dev + "group:dev": ["alice@example.com", "bob@example.com",], + // Carl is in group:devops + "group:devops": ["carl@example.com",], + }, + "acls": [ + // all employees can access their own devices + { "action": "accept", "src": ["autogroup:members"], "dst": ["autogroup:self:*"] }, + // users in group:dev can access devices tagged tag:dev + { "action": "accept", "src": ["group:dev"], "dst": ["tag:dev:*"] }, + // users in group:devops can access devices tagged tag:prod + { "action": "accept", "src": ["group:devops"], "dst": ["tag:prod:*"] }, + // all employees can access devices tagged tag:monitoring on + // ports 80 and 443 + { "action": "accept", "src": ["autogroup:members"], "dst": ["tag:monitoring:80,443"] }, + ], + "tagOwners": { + // users in group:devops can apply the tag tag:monitoring + "tag:monitoring": ["group:devops"], + // users in group:devops can apply the tag tag:dev + "tag:dev": ["group:devops"], + // users in group:devops can apply the tag tag:prod + "tag:prod": ["group:devops"], + }, + "tests": [ + { + "src": "carl@example.com", + // test that Carl can access devices tagged tag:prod on port 80 + "accept": ["tag:prod:80"], + }, + { + "src": "alice@example.com", + // test that Alice can access devices tagged tag:dev on port 80 + "accept": ["tag:dev:80"], + // test that Alice cannot access devices tagged tag:prod on port 80 + "deny": ["tag:prod:80"], + }, + ], + "ssh": [ + { + "action": "accept", + "src": ["autogroup:members"], + "dst": ["autogroup:self"], + "users": ["root", "autogroup:nonroot"] + }, + { + "action": "accept", + "src": ["autogroup:members"], + "dst": ["tag:prod"], + "users": ["root", "autogroup:nonroot"] + }, + { + "action": "accept", + "src": ["tag:logging"], + "dst": ["tag:prod"], + "users": ["root", "autogroup:nonroot"], + "checkPeriod": "20h" + }, + ] +} diff --git a/v2/testdata/acl.json b/v2/testdata/acl.json new file mode 100644 index 0000000..ea00dac --- /dev/null +++ b/v2/testdata/acl.json @@ -0,0 +1,49 @@ +{ + "groups": { + "group:dev": ["alice@example.com", "bob@example.com"], + "group:devops": ["carl@example.com"] + }, + "acls": [ + { "action": "accept", "src": ["autogroup:members"], "dst": ["autogroup:self:*"] }, + { "action": "accept", "src": ["group:dev"], "dst": ["tag:dev:*"] }, + { "action": "accept", "src": ["group:devops"], "dst": ["tag:prod:*"] }, + { "action": "accept", "src": ["autogroup:members"], "dst": ["tag:monitoring:80,443"] } + ], + "tagOwners": { + "tag:monitoring": ["group:devops"], + "tag:dev": ["group:devops"], + "tag:prod": ["group:devops"] + }, + "tests": [ + { + "src": "carl@example.com", + "accept": ["tag:prod:80"] + }, + { + "src": "alice@example.com", + "accept": ["tag:dev:80"], + "deny": ["tag:prod:80"] + } + ], + "ssh": [ + { + "action": "accept", + "src": ["autogroup:members"], + "dst": ["autogroup:self"], + "users": ["root", "autogroup:nonroot"] + }, + { + "action": "accept", + "src": ["autogroup:members"], + "dst": ["tag:prod"], + "users": ["root", "autogroup:nonroot"] + }, + { + "action": "accept", + "src": ["tag:logging"], + "dst": ["tag:prod"], + "users": ["root", "autogroup:nonroot"], + "checkPeriod": "20h" + } + ] +}