From c5c40c9b1caa1068ba5c9acce4caf29adbceaea7 Mon Sep 17 00:00:00 2001 From: David Bond Date: Wed, 22 Jun 2022 21:20:51 +0100 Subject: [PATCH] Add support for Tailscale SSH (#22) This commit adds the `ssh` section to the ACL type, allowing users of the package to specify their SSH configuration for the Tailscale SSH feature: https://tailscale.com/blog/tailscale-ssh/ Signed-off-by: David Bond --- tailscale/client.go | 160 +++++++++++++++++++++------------- tailscale/client_test.go | 42 +++++++++ tailscale/testdata/acl.hujson | 21 +++++ tailscale/testdata/acl.json | 21 +++++ tailscale/time_test.go | 9 +- 5 files changed, 193 insertions(+), 60 deletions(-) diff --git a/tailscale/client.go b/tailscale/client.go index 9852372..ac82717 100644 --- a/tailscale/client.go +++ b/tailscale/client.go @@ -223,64 +223,75 @@ func (c *Client) DNSNameservers(ctx context.Context) ([]string, error) { return resp["dns"], nil } -type ACL struct { - ACLs []ACLEntry `json:"acls" 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"` -} - -type ACLAutoApprovers struct { - Routes map[string][]string `json:"routes" hujson:"Routes"` - ExitNode []string `json:"exitNode" hujson:"ExitNode"` -} - -type ACLEntry struct { - Action string `json:"action" hujson:"Action"` - Ports []string `json:"ports" hujson:"Ports"` - Users []string `json:"users" hujson:"Users"` - Source []string `json:"src" hujson:"Src"` - Destination []string `json:"dst" hujson:"Dst"` - Protocol string `json:"proto" hujson:"Proto"` -} - -type ACLTest struct { - User string `json:"user" hujson:"User"` - Allow []string `json:"allow" hujson:"Allow"` - Deny []string `json:"deny" hujson:"Deny"` - Source string `json:"src" hujson:"Src"` - Accept []string `json:"accept" hujson:"Accept"` -} - -type ACLDERPMap struct { - Regions map[int]*ACLDERPRegion `json:"regions" hujson:"Regions"` - OmitDefaultRegions bool `json:"omitDefaultRegions,omitempty" hujson:"OmitDefaultRegions,omitempty"` -} - -type 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"` -} - -type 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"` -} +type ( + ACL struct { + ACLs []ACLEntry `json:"acls" 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"` + } + + ACLAutoApprovers struct { + Routes map[string][]string `json:"routes" hujson:"Routes"` + ExitNode []string `json:"exitNode" hujson:"ExitNode"` + } + + ACLEntry struct { + Action string `json:"action" hujson:"Action"` + Ports []string `json:"ports" hujson:"Ports"` + Users []string `json:"users" hujson:"Users"` + Source []string `json:"src" hujson:"Src"` + Destination []string `json:"dst" hujson:"Dst"` + Protocol string `json:"proto" hujson:"Proto"` + } + + ACLTest struct { + User string `json:"user" hujson:"User"` + Allow []string `json:"allow" hujson:"Allow"` + Deny []string `json:"deny" hujson:"Deny"` + Source string `json:"src" hujson:"Src"` + Accept []string `json:"accept" hujson:"Accept"` + } + + 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" hujson:"Action"` + Users []string `json:"users" hujson:"Users"` + Source []string `json:"src" hujson:"Src"` + Destination []string `json:"dst" hujson:"Dst"` + CheckPeriod Duration `json:"checkPeriod" hujson:"CheckPeriod"` + } +) // ACL retrieves the ACL that is currently set for the given tailnet. func (c *Client) ACL(ctx context.Context) (*ACL, error) { @@ -398,7 +409,7 @@ func (t Time) MarshalJSON() ([]byte, error) { return json.Marshal(t.Time) } -// MarshalJSON unmarshals the content of data as a time.Duration, a blank string will keep the time at its zero value. +// UnmarshalJSON unmarshals the content of data as a time.Time, a blank string will keep the time at its zero value. func (t *Time) UnmarshalJSON(data []byte) error { if string(data) == `""` { return nil @@ -593,3 +604,34 @@ func ErrorData(err error) []APIErrorData { return nil } + +// The Duration type wraps a time.Duration, allowing it to be JSON marshalled as a string like "20h" rather than +// a numeric value. +type Duration struct { + time.Duration +} + +// MarshalJSON is an implementation of json.Marshal. +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Duration.String()) +} + +// UnmarshalJSON unmarshals the content of data as a time.Duration, a blank string will keep the duration at its zero value. +func (d *Duration) UnmarshalJSON(data []byte) error { + if string(data) == `""` { + return nil + } + + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + + dur, err := time.ParseDuration(str) + if err != nil { + return err + } + + d.Duration = dur + return nil +} diff --git a/tailscale/client_test.go b/tailscale/client_test.go index 5a1ea43..5aa835a 100644 --- a/tailscale/client_test.go +++ b/tailscale/client_test.go @@ -98,6 +98,27 @@ func TestACL_Unmarshal(t *testing.T) { Source: "alice@example.com", Accept: []string{"tag:dev:80"}}, }, + SSH: []tailscale.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: tailscale.Duration{Duration: time.Hour * 20}, + }, + }, }, }, { @@ -156,6 +177,27 @@ func TestACL_Unmarshal(t *testing.T) { "tag:prod": {"group:devops"}, }, DERPMap: (*tailscale.ACLDERPMap)(nil), + SSH: []tailscale.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: tailscale.Duration{Duration: time.Hour * 20}, + }, + }, Tests: []tailscale.ACLTest{ { User: "", diff --git a/tailscale/testdata/acl.hujson b/tailscale/testdata/acl.hujson index a715174..372a063 100644 --- a/tailscale/testdata/acl.hujson +++ b/tailscale/testdata/acl.hujson @@ -38,4 +38,25 @@ "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/tailscale/testdata/acl.json b/tailscale/testdata/acl.json index b35f724..ea00dac 100644 --- a/tailscale/testdata/acl.json +++ b/tailscale/testdata/acl.json @@ -24,5 +24,26 @@ "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" + } ] } diff --git a/tailscale/time_test.go b/tailscale/time_test.go index a19bc21..4186b52 100644 --- a/tailscale/time_test.go +++ b/tailscale/time_test.go @@ -4,8 +4,9 @@ import ( "testing" "time" - "github.com/davidsbond/tailscale-client-go/tailscale" "github.com/stretchr/testify/assert" + + "github.com/davidsbond/tailscale-client-go/tailscale" ) func TestWrapsStdTime(t *testing.T) { @@ -55,3 +56,9 @@ func TestMarshalingTimestamps(t *testing.T) { }) } } + +func TestWrapsStdDuration(t *testing.T) { + expectedDuration := tailscale.Duration{} + newDuration := time.Duration(0) + assert.Equal(t, expectedDuration.Duration, newDuration) +}