mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 04:34:07 +09:00
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:
1 parent
9841280d11
commit
f1788c1ce4
3 files changed
+105
-28
No files matched your search
+34
-26
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user