api/v2: send connectedToControl and lastSeen on connected devices

SaaS sends both on every device. The SDK reported live devices as
disconnected, and tscli delete devices panicked on the missing lastSeen.
This commit is contained in:
Kristoffer Dalby committed 2026-11-03 17:05:00 +01:00
1 parent 9841280d11
commit f1788c1ce4
3 files changed
+105 -28

No files matched your search

+34 -26
View File
@@ -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
}
+68 -2
View File
@@ -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))
}},
)
}
@@ -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