From 931531d3fb7e519ba406e060d818235a72039e8a Mon Sep 17 00:00:00 2001 From: Vitor Pellegrino Date: Mon, 18 Apr 2022 22:08:37 +0200 Subject: [PATCH] fix: allow for parsing json parsing when timestamps are empty (#15) * fix: allow for parsing json parsing when timestamps are empty The tailscale API returns blank strings for non existing timestamps, such as the "created" timestamp for Tailscale managed devices. One example is the "hello" device * Apply suggestions from code review Co-authored-by: David Bond * refactor: changes MaybeEmptyTime to Time Co-authored-by: David Bond --- tailscale/client.go | 71 ++++++++++++------ tailscale/client_test.go | 124 ++++++++++++++++++++++++++------ tailscale/testdata/devices.json | 48 +++++++++++++ tailscale/time_test.go | 57 +++++++++++++++ 4 files changed, 254 insertions(+), 46 deletions(-) create mode 100644 tailscale/testdata/devices.json create mode 100644 tailscale/time_test.go diff --git a/tailscale/client.go b/tailscale/client.go index aea6d78..75b97be 100644 --- a/tailscale/client.go +++ b/tailscale/client.go @@ -5,6 +5,7 @@ package tailscale import ( "bytes" "context" + "encoding/json" "errors" "fmt" "net/http" @@ -220,11 +221,11 @@ type ACLEntry struct { } 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"` + 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 { @@ -359,25 +360,49 @@ func (c *Client) DeviceSubnetRoutes(ctx context.Context, deviceID string) (*Devi return &resp, nil } +// Time wraps a time and allows for unmarshalling timestamps that represent an empty time as an empty string (e.g "") +// this is used by the tailscale API when it returns devices that have no created date, such as its hello service. +type Time struct { + time.Time +} + +// MarshalJSON is an implementation of json.Marshal. +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. +func (t *Time) UnmarshalJSON(data []byte) error { + if string(data) == `""` { + return nil + } + + if err := json.Unmarshal(data, &t.Time); err != nil { + return err + } + + return nil +} + type Device struct { - Addresses []string `json:"addresses"` - Name string `json:"name"` - ID string `json:"id"` - Authorized bool `json:"authorized"` - User string `json:"user"` - Tags []string `json:"tags"` - KeyExpiryDisabled bool `json:"keyExpiryDisabled"` - BlocksIncomingConnections bool `json:"blocksIncomingConnections"` - ClientVersion string `json:"clientVersion"` - Created time.Time `json:"created"` - Expires time.Time `json:"expires"` - Hostname string `json:"hostname"` - IsExternal bool `json:"isExternal"` - LastSeen time.Time `json:"lastSeen"` - MachineKey string `json:"machineKey"` - NodeKey string `json:"nodeKey"` - OS string `json:"os"` - UpdateAvailable bool `json:"updateAvailable"` + Addresses []string `json:"addresses"` + Name string `json:"name"` + ID string `json:"id"` + Authorized bool `json:"authorized"` + User string `json:"user"` + Tags []string `json:"tags"` + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` + BlocksIncomingConnections bool `json:"blocksIncomingConnections"` + ClientVersion string `json:"clientVersion"` + Created Time `json:"created"` + Expires Time `json:"expires"` + Hostname string `json:"hostname"` + IsExternal bool `json:"isExternal"` + LastSeen Time `json:"lastSeen"` + MachineKey string `json:"machineKey"` + NodeKey string `json:"nodeKey"` + OS string `json:"os"` + UpdateAvailable bool `json:"updateAvailable"` } // Devices lists the devices in a tailnet. diff --git a/tailscale/client_test.go b/tailscale/client_test.go index 78f667d..9b9b65f 100644 --- a/tailscale/client_test.go +++ b/tailscale/client_test.go @@ -20,6 +20,8 @@ var ( jsonACL []byte //go:embed testdata/acl.hujson huJSONACL []byte + //go:embed testdata/devices.json + jsonDevices []byte ) func TestACL_Unmarshal(t *testing.T) { @@ -83,18 +85,18 @@ func TestACL_Unmarshal(t *testing.T) { DERPMap: (*tailscale.ACLDERPMap)(nil), Tests: []tailscale.ACLTest{ { - User: "", - Allow: []string(nil), - Deny: []string(nil), - Source: "carl@example.com", - Accept: []string{"tag:prod:80"}, + 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"}}, + User: "", + Allow: []string(nil), + Deny: []string{"tag:prod:80"}, + Source: "alice@example.com", + Accept: []string{"tag:dev:80"}}, }, }, }, @@ -150,18 +152,18 @@ func TestACL_Unmarshal(t *testing.T) { DERPMap: (*tailscale.ACLDERPMap)(nil), Tests: []tailscale.ACLTest{ { - User: "", - Allow: []string(nil), - Deny: []string(nil), - Source: "carl@example.com", - Accept: []string{"tag:prod:80"}, + 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"}}, + User: "", + Allow: []string(nil), + Deny: []string{"tag:prod:80"}, + Source: "alice@example.com", + Accept: []string{"tag:dev:80"}}, }, }, }, @@ -307,11 +309,11 @@ func TestClient_Devices(t *testing.T) { }, BlocksIncomingConnections: false, ClientVersion: "1.22.1", - Created: time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC), - Expires: time.Date(2022, 8, 9, 11, 50, 23, 0, time.UTC), + Created: tailscale.Time{time.Date(2022, 2, 10, 11, 50, 23, 0, time.UTC)}, + Expires: tailscale.Time{time.Date(2022, 8, 9, 11, 50, 23, 0, time.UTC)}, Hostname: "test", IsExternal: false, - LastSeen: time.Date(2022, 3, 9, 20, 3, 42, 0, time.UTC), + LastSeen: tailscale.Time{time.Date(2022, 3, 9, 20, 3, 42, 0, time.UTC)}, MachineKey: "mkey:test", NodeKey: "nodekey:test", OS: "windows", @@ -331,6 +333,82 @@ func TestClient_Devices(t *testing.T) { assert.EqualValues(t, expectedDevices["devices"], actualDevices) } +func TestDevices_Unmarshal(t *testing.T) { + t.Parallel() + + tt := []struct { + Name string + DevicesContent []byte + Expected []tailscale.Device + UnmarshalFunc func(data []byte, v interface{}) error + }{ + { + Name: "It should handle badly formed devices", + DevicesContent: jsonDevices, + UnmarshalFunc: json.Unmarshal, + Expected: []tailscale.Device{ + { + Addresses: []string{"100.101.102.103", "fd7a:115c:a1e0:ab12:4843:cd96:6265:6667"}, + Authorized: true, + BlocksIncomingConnections: false, + ClientVersion: "", + Created: tailscale.Time{}, + Expires: tailscale.Time{ + time.Date(1, 1, 1, 00, 00, 00, 0, time.UTC), + }, + Hostname: "hello", + ID: "50052", + IsExternal: true, + KeyExpiryDisabled: true, + LastSeen: tailscale.Time{ + time.Date(2022, 4, 15, 13, 24, 40, 0, time.UTC), + }, + MachineKey: "", + Name: "hello.tailscale.com", + NodeKey: "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", + OS: "linux", + UpdateAvailable: false, + User: "services@tailscale.com", + }, + { + Addresses: []string{"100.121.200.21", "fd7a:115c:a1e0:ab12:4843:cd96:6265:e618"}, + Authorized: true, + BlocksIncomingConnections: false, + ClientVersion: "1.22.2-t60b671955-gecc5d9846", + Created: tailscale.Time{ + time.Date(2022, 3, 5, 17, 10, 27, 0, time.UTC), + }, + Expires: tailscale.Time{ + time.Date(2022, 9, 1, 17, 10, 27, 0, time.UTC), + }, + Hostname: "foo", + ID: "50053", + IsExternal: false, + KeyExpiryDisabled: true, + LastSeen: tailscale.Time{ + time.Date(2022, 4, 15, 13, 25, 21, 0, time.UTC), + }, + MachineKey: "mkey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", + Name: "foo.example.com", + NodeKey: "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", + OS: "linux", + UpdateAvailable: false, + User: "foo@example.com", + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + actual := make(map[string][]tailscale.Device) + + assert.NoError(t, tc.UnmarshalFunc(tc.DevicesContent, &actual)) + assert.EqualValues(t, tc.Expected, actual["devices"]) + }) + } +} + func TestClient_DeleteDevice(t *testing.T) { t.Parallel() diff --git a/tailscale/testdata/devices.json b/tailscale/testdata/devices.json new file mode 100644 index 0000000..a09ee3a --- /dev/null +++ b/tailscale/testdata/devices.json @@ -0,0 +1,48 @@ +{ + "devices": [ + { + "addresses": [ + "100.101.102.103", + "fd7a:115c:a1e0:ab12:4843:cd96:6265:6667" + ], + "authorized": true, + "blocksIncomingConnections": false, + "clientVersion": "", + "created": "", + "expires": "0001-01-01T00:00:00Z", + "hostname": "hello", + "id": "50052", + "isExternal": true, + "keyExpiryDisabled": true, + "lastSeen": "2022-04-15T13:24:40Z", + "machineKey": "", + "name": "hello.tailscale.com", + "nodeKey": "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", + "os": "linux", + "updateAvailable": false, + "user": "services@tailscale.com" + }, + { + "addresses": [ + "100.121.200.21", + "fd7a:115c:a1e0:ab12:4843:cd96:6265:e618" + ], + "authorized": true, + "blocksIncomingConnections": false, + "clientVersion": "1.22.2-t60b671955-gecc5d9846", + "created": "2022-03-05T17:10:27Z", + "expires": "2022-09-01T17:10:27Z", + "hostname": "foo", + "id": "50053", + "isExternal": false, + "keyExpiryDisabled": true, + "lastSeen": "2022-04-15T13:25:21Z", + "machineKey": "mkey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", + "name": "foo.example.com", + "nodeKey": "nodekey:30dc3c061ac8b33fdc6d88a4a67b053b01b56930d78cae0cf7a164411d424c0d", + "os": "linux", + "updateAvailable": false, + "user": "foo@example.com" + } + ] +} diff --git a/tailscale/time_test.go b/tailscale/time_test.go new file mode 100644 index 0000000..a19bc21 --- /dev/null +++ b/tailscale/time_test.go @@ -0,0 +1,57 @@ +package tailscale_test + +import ( + "testing" + "time" + + "github.com/davidsbond/tailscale-client-go/tailscale" + "github.com/stretchr/testify/assert" +) + +func TestWrapsStdTime(t *testing.T) { + expectedTime := tailscale.Time{} + newTime := time.Time{} + assert.Equal(t, expectedTime.Time.UTC(), newTime.UTC()) +} + +func TestFailsToParseInvalidTimestamps(t *testing.T) { + ti := tailscale.Time{} + invalidIso8601Date := []byte("\"2022-13-05T17:10:27Z\"") + assert.Error(t, ti.UnmarshalJSON(invalidIso8601Date)) +} + +func TestMarshalingTimestamps(t *testing.T) { + t.Parallel() + utcMinusFour := time.FixedZone("UTC-4", -60*60*4) + + tt := []struct { + Name string + Expected time.Time + TimeContent string + }{ + { + Name: "It should handle empty strings as null-value times", + Expected: time.Time{}, + TimeContent: "\"\"", + }, + { + Name: "It should parse timestamp strings", + Expected: time.Date(2022, 3, 5, 17, 10, 27, 0, time.UTC), + TimeContent: "\"2022-03-05T17:10:27Z\"", + }, + { + Name: "It should handle different timezones", + TimeContent: "\"2006-01-02T15:04:05-04:00\"", + Expected: time.Date(2006, 1, 2, 15, 04, 5, 0, utcMinusFour), + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + actual := tailscale.Time{} + + assert.NoError(t, actual.UnmarshalJSON([]byte(tc.TimeContent))) + assert.Equal(t, tc.Expected.UTC(), actual.Time.UTC()) + }) + } +}