diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go index 58088a0d0..0cae15a5c 100644 --- a/hscontrol/api/v2/devices.go +++ b/hscontrol/api/v2/devices.go @@ -24,24 +24,27 @@ func init() { // Headscale does not track are emitted as their zero value. The route slices // are only populated for the fields=all variant, matching Tailscale. type Device struct { - Addresses []string `json:"addresses" nullable:"false"` + Addresses []string `json:"addresses" nullable:"false"` Name string `json:"name"` ID string `json:"id"` NodeID string `json:"nodeId"` Authorized bool `json:"authorized"` User string `json:"user"` - Tags []string `json:"tags" nullable:"false"` + Tags []string `json:"tags" nullable:"false"` KeyExpiryDisabled bool `json:"keyExpiryDisabled"` Created time.Time `json:"created"` Expires *time.Time `json:"expires,omitempty"` Hostname string `json:"hostname"` IsEphemeral bool `json:"isEphemeral"` - LastSeen *time.Time `json:"lastSeen,omitempty"` - MachineKey string `json:"machineKey"` - NodeKey string `json:"nodeKey"` - OS string `json:"os"` - ClientVersion string `json:"clientVersion"` - UpdateAvailable bool `json:"updateAvailable"` + // ConnectedToControl and LastSeen follow what Tailscale sends, not its SDK's + // comment: every connected device has both. + ConnectedToControl bool `json:"connectedToControl"` + LastSeen *time.Time `json:"lastSeen,omitempty"` + MachineKey string `json:"machineKey"` + NodeKey string `json:"nodeKey"` + OS string `json:"os"` + ClientVersion string `json:"clientVersion"` + UpdateAvailable bool `json:"updateAvailable"` // OAuthClientID names the auth key the device joined with. For a key a // token made, Tailscale names the token's OAuth client, which headscale // does not record, so it is left out. @@ -455,21 +458,22 @@ func deviceFromView(view types.NodeView, allFields bool, baseDomain string) Devi } d := Device{ - Addresses: emptyIfNil(view.IPsAsString()), - Name: name, - ID: id, - NodeID: id, - Authorized: true, // Headscale has no post-registration de-auth state. - User: deviceUser(view), - Tags: emptyIfNil(view.Tags().AsSlice()), - KeyExpiryDisabled: !view.Expiry().Valid(), - Created: view.CreatedAt(), - Hostname: view.Hostname(), - IsEphemeral: view.IsEphemeral(), - MachineKey: view.MachineKey().String(), - NodeKey: view.NodeKey().String(), - OS: hostinfoOS(view), - ClientVersion: hostinfoVersion(view), + Addresses: emptyIfNil(view.IPsAsString()), + Name: name, + ID: id, + NodeID: id, + Authorized: true, // Headscale has no post-registration de-auth state. + User: deviceUser(view), + Tags: emptyIfNil(view.Tags().AsSlice()), + KeyExpiryDisabled: !view.Expiry().Valid(), + Created: view.CreatedAt(), + Hostname: view.Hostname(), + IsEphemeral: view.IsEphemeral(), + ConnectedToControl: view.IsOnline().Valid() && view.IsOnline().Get(), + MachineKey: view.MachineKey().String(), + NodeKey: view.NodeKey().String(), + OS: hostinfoOS(view), + ClientVersion: hostinfoVersion(view), } if key := view.AuthKey(); key.Valid() && !key.CreatedByToken() { @@ -481,9 +485,13 @@ func deviceFromView(view types.NodeView, allFields bool, baseDomain string) Devi d.Expires = &exp } - // LastSeen is reported only when the device is offline, matching tailcfg. - hasOfflineLastSeen := view.LastSeen().Valid() && view.IsOnline().Valid() && !view.IsOnline().Get() - if hasOfflineLastSeen { + // The stored time does not move while a client idles. A connected device + // reads now, so a "not seen for a while" script cannot pick a live device. + switch { + case d.ConnectedToControl: + now := time.Now().UTC() + d.LastSeen = &now + case view.LastSeen().Valid(): ls := view.LastSeen().Get() d.LastSeen = &ls } diff --git a/hscontrol/servertest/apiv2_devices_test.go b/hscontrol/servertest/apiv2_devices_test.go index b244f7671..04f8d1d46 100644 --- a/hscontrol/servertest/apiv2_devices_test.go +++ b/hscontrol/servertest/apiv2_devices_test.go @@ -553,8 +553,64 @@ func TestNoChangePolicyWrite(t *testing.T) { assert.False(t, kept, "a changed policy drops SSH check approvals") } +// TestAPIV2DevicePresence: Tailscale's SDK says lastSeen is nil while connected, +// but SaaS sends it on every device, so a tool reads it without a nil check. +func TestAPIV2DevicePresence(t *testing.T) { + t.Parallel() + + // Clients dial the in-memory network, so the SDK gets its own listener. + srv := servertest.NewServer(t) + api := httptest.NewServer(srv.App.HTTPHandler()) + t.Cleanup(api.Close) + + owner := srv.CreateUser(t, "apiv2") + apiKey := srv.CreateAPIKey(t, owner) + client := servertest.NewClient(t, srv, "presence", servertest.WithUser(owner)) + + read := func(c *assert.CollectT) *tsclient.Device { + devices, err := goClient(t, api.URL, apiKey).Devices().List(t.Context()) + if err != nil { + assert.Fail(c, "listing devices", err.Error()) + + return nil + } + + if !assert.Len(c, devices, 1) { + return nil + } + + return &devices[0] + } + + require.EventuallyWithT(t, func(c *assert.CollectT) { + d := read(c) + if d == nil { + return + } + + assert.True(c, d.ConnectedToControl) + + if assert.NotNil(c, d.LastSeen) { + assert.WithinDuration(c, time.Now(), d.LastSeen.Time, time.Minute) + } + }, 20*time.Second, 200*time.Millisecond, "connected device") + + client.Disconnect(t) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + d := read(c) + if d == nil { + return + } + + assert.False(c, d.ConnectedToControl) + assert.NotNil(c, d.LastSeen) + }, 30*time.Second, 200*time.Millisecond, "device that left") +} + // deviceListChecks compares, per device in the step's device list, its user, -// SaaS emails mapped to user names, and its tags. +// SaaS emails mapped to user names, its tags, whether it is connected, and +// whether it has a last seen time. func deviceListChecks(t *testing.T, r *stepReplay, step testcapture.Step) []stepCheck { t.Helper() @@ -584,7 +640,7 @@ func deviceListChecks(t *testing.T, r *stepReplay, step testcapture.Step) []step } devices := stepDevices(t, step) - checks := make([]stepCheck, 0, 2*len(devices)) + checks := make([]stepCheck, 0, 4*len(devices)) for _, d := range devices { x := deviceLabelAlias(d.ID) @@ -602,6 +658,16 @@ func deviceListChecks(t *testing.T, r *stepReplay, step testcapture.Step) []step return cmp.Or(missing, sortedStrings(got.Tags)) }}, + stepCheck{x + "/device-connected", strconv.FormatBool(d.ConnectedToControl), func() string { + got, missing := hsDevice(x) + + return cmp.Or(missing, strconv.FormatBool(got.ConnectedToControl)) + }}, + stepCheck{x + "/device-last-seen", strconv.FormatBool(d.LastSeen != nil), func() string { + got, missing := hsDevice(x) + + return cmp.Or(missing, strconv.FormatBool(got.LastSeen != nil)) + }}, ) } diff --git a/hscontrol/servertest/lifecycle_compat_test.go b/hscontrol/servertest/lifecycle_compat_test.go index 6c7c38503..188ddd302 100644 --- a/hscontrol/servertest/lifecycle_compat_test.go +++ b/hscontrol/servertest/lifecycle_compat_test.go @@ -418,6 +418,9 @@ type saasDevice struct { Expires string `json:"expires"` KeyExpiryDisabled bool `json:"keyExpiryDisabled"` IsEphemeral bool `json:"isEphemeral"` + + ConnectedToControl bool `json:"connectedToControl"` + LastSeen *string `json:"lastSeen"` } // lastRoutesRead returns the step's last routes read: the device it named