Skip to content

Commit

Permalink
fix: allow for parsing json parsing when timestamps are empty (tailsc…
Browse files Browse the repository at this point in the history
…ale#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 <davidsbond@users.noreply.github.com>

* refactor: changes MaybeEmptyTime to Time

Co-authored-by: David Bond <davidsbond@users.noreply.github.com>
  • Loading branch information
pellegrino and davidsbond committed Apr 18, 2022
1 parent aeb163a commit 931531d
Show file tree
Hide file tree
Showing 4 changed files with 254 additions and 46 deletions.
71 changes: 48 additions & 23 deletions tailscale/client.go
Expand Up @@ -5,6 +5,7 @@ package tailscale
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
124 changes: 101 additions & 23 deletions tailscale/client_test.go
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"}},
},
},
},
Expand Down Expand Up @@ -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"}},
},
},
},
Expand Down Expand Up @@ -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",
Expand All @@ -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()

Expand Down
48 changes: 48 additions & 0 deletions 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"
}
]
}
57 changes: 57 additions & 0 deletions 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())
})
}
}

0 comments on commit 931531d

Please sign in to comment.