From 7063c045815d628f2fa845ddb9ba5f44d6cbc653 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 31 Oct 2026 14:23:27 +0000 Subject: [PATCH] api/v2: add device shields-up, SSH, distro and connectivity fields Same read shapes as SaaS. Latency stays empty: headscale drops it on arrival. --- hscontrol/api/v2/devices.go | 69 +++++++++++++++++++++++++++++++- hscontrol/api/v2/devices_test.go | 56 ++++++++++++++++++++++++++ hscontrol/apiv2_compat_test.go | 29 ++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 hscontrol/api/v2/devices_test.go diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go index 0cae15a5c..8e2534705 100644 --- a/hscontrol/api/v2/devices.go +++ b/hscontrol/api/v2/devices.go @@ -21,8 +21,9 @@ func init() { } // Device is the Tailscale device response. Headscale nodes map onto it; fields -// Headscale does not track are emitted as their zero value. The route slices -// are only populated for the fields=all variant, matching Tailscale. +// Headscale does not track are left out, apart from UpdateAvailable. The route +// slices and the host detail are only populated for the fields=all variant, +// matching Tailscale. type Device struct { Addresses []string `json:"addresses" nullable:"false"` Name string `json:"name"` @@ -45,6 +46,9 @@ type Device struct { OS string `json:"os"` ClientVersion string `json:"clientVersion"` UpdateAvailable bool `json:"updateAvailable"` + + BlocksIncomingConnections bool `json:"blocksIncomingConnections"` + // 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. @@ -53,6 +57,37 @@ type Device struct { // Populated only when fields=all is requested, then as [] when empty. AdvertisedRoutes []string `json:"advertisedRoutes,omitzero"` EnabledRoutes []string `json:"enabledRoutes,omitzero"` + + SSHEnabled *bool `json:"sshEnabled,omitempty"` + Distro *Distro `json:"distro,omitempty"` + ClientConnectivity *ClientConnectivity `json:"clientConnectivity,omitempty"` +} + +// Distro is the Linux distribution a device reports. +type Distro struct { + Name string `json:"name"` + Version string `json:"version"` + CodeName string `json:"codeName"` +} + +// ClientConnectivity is how a device reaches the network, from what it reports. +// Latency stays empty: headscale drops a client's DERP latency on arrival. +type ClientConnectivity struct { + Endpoints []string `json:"endpoints" nullable:"false"` + MappingVariesByDestIP bool `json:"mappingVariesByDestIP"` + Latency map[string]any `json:"latency" nullable:"false"` + ClientSupports ClientSupports `json:"clientSupports"` +} + +// ClientSupports is what a device's network allows. HairPinning stays null: +// clients no longer report it. +type ClientSupports struct { + HairPinning *bool `json:"hairPinning"` + IPv6 bool `json:"ipv6"` + PCP bool `json:"pcp"` + PMP bool `json:"pmp"` + UDP bool `json:"udp"` + UPnP bool `json:"upnp"` } // DeviceRoutes is the GET /device/{id}/routes response: what the node announces @@ -496,9 +531,39 @@ func deviceFromView(view types.NodeView, allFields bool, baseDomain string) Devi d.LastSeen = &ls } + hi := view.Hostinfo() + d.BlocksIncomingConnections = hi.Valid() && hi.ShieldsUp() + if allFields { d.AdvertisedRoutes = emptyIfNil(util.PrefixesToString(view.AnnouncedRoutes())) d.EnabledRoutes = emptyIfNil(util.PrefixesToString(view.ApprovedRoutes().AsSlice())) + + sshEnabled := hi.Valid() && hi.TailscaleSSHEnabled() + d.SSHEnabled = &sshEnabled + d.Distro = &Distro{} + d.ClientConnectivity = &ClientConnectivity{ + Endpoints: make([]string, 0, view.Endpoints().Len()), + Latency: map[string]any{}, + } + + for _, ep := range view.Endpoints().All() { + d.ClientConnectivity.Endpoints = append(d.ClientConnectivity.Endpoints, ep.String()) + } + + if hi.Valid() { + d.Distro = &Distro{Name: hi.Distro(), Version: hi.DistroVersion(), CodeName: hi.DistroCodeName()} + + if ni := hi.NetInfo(); ni.Valid() { + d.ClientConnectivity.MappingVariesByDestIP = ni.MappingVariesByDestIP().EqualBool(true) + d.ClientConnectivity.ClientSupports = ClientSupports{ + IPv6: ni.WorkingIPv6().EqualBool(true), + PCP: ni.PCP().EqualBool(true), + PMP: ni.PMP().EqualBool(true), + UDP: ni.WorkingUDP().EqualBool(true), + UPnP: ni.UPnP().EqualBool(true), + } + } + } } return d diff --git a/hscontrol/api/v2/devices_test.go b/hscontrol/api/v2/devices_test.go new file mode 100644 index 000000000..c8a0cea8a --- /dev/null +++ b/hscontrol/api/v2/devices_test.go @@ -0,0 +1,56 @@ +package apiv2 + +import ( + "net/netip" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "tailscale.com/tailcfg" + "tailscale.com/types/opt" +) + +// TestDeviceHostDetail pins where each host detail comes from, and that only +// blocksIncomingConnections leaves the fields=all shape. +func TestDeviceHostDetail(t *testing.T) { + t.Parallel() + + node := &types.Node{ + GivenName: "host", + Endpoints: []netip.AddrPort{netip.MustParseAddrPort("192.0.2.1:41641")}, + Hostinfo: &tailcfg.Hostinfo{ + ShieldsUp: true, + Distro: "nixos", + DistroVersion: "26.05", + DistroCodeName: "yarara", + SSH_HostKeys: []string{"ssh-ed25519 AAAA"}, + NetInfo: &tailcfg.NetInfo{ + MappingVariesByDestIP: opt.NewBool(true), + WorkingUDP: opt.NewBool(true), + UPnP: opt.NewBool(true), + }, + }, + } + + short := deviceFromView(node.View(), false, "") + assert.True(t, short.BlocksIncomingConnections) + assert.Nil(t, short.SSHEnabled) + assert.Nil(t, short.Distro) + assert.Nil(t, short.ClientConnectivity) + + all := deviceFromView(node.View(), true, "") + assert.True(t, *all.SSHEnabled) + assert.Equal(t, Distro{Name: "nixos", Version: "26.05", CodeName: "yarara"}, *all.Distro) + assert.Equal(t, ClientConnectivity{ + Endpoints: []string{"192.0.2.1:41641"}, + MappingVariesByDestIP: true, + Latency: map[string]any{}, + ClientSupports: ClientSupports{UDP: true, UPnP: true}, + }, *all.ClientConnectivity) + + // A node that has reported nothing still has the fields=all shape. + bare := deviceFromView((&types.Node{GivenName: "bare"}).View(), true, "") + assert.False(t, *bare.SSHEnabled) + assert.Equal(t, Distro{}, *bare.Distro) + assert.Equal(t, []string{}, bare.ClientConnectivity.Endpoints) +} diff --git a/hscontrol/apiv2_compat_test.go b/hscontrol/apiv2_compat_test.go index b6e8515e1..706f3bd8c 100644 --- a/hscontrol/apiv2_compat_test.go +++ b/hscontrol/apiv2_compat_test.go @@ -689,6 +689,12 @@ type apiBody struct { EnabledRoutes json.RawMessage `json:"enabledRoutes"` OAuthClientID *string `json:"oauthClientId"` + ConnectedToControl json.RawMessage `json:"connectedToControl"` + BlocksIncomingConnections json.RawMessage `json:"blocksIncomingConnections"` + SSHEnabled json.RawMessage `json:"sshEnabled"` + Distro json.RawMessage `json:"distro"` + ClientConnectivity json.RawMessage `json:"clientConnectivity"` + Keys json.RawMessage `json:"keys"` Devices []apiBody `json:"devices"` } @@ -890,6 +896,7 @@ func (r *apiReplay) deviceChecks(prefix, alias string, all bool, want, got apiBo checks := []apiCheck{ {prefix + "tags", sortedStrings(want.Tags), sortedStrings(got.Tags)}, {prefix + "owner", deviceOwner(want.Tags, r.userName(want.User)), deviceOwner(got.Tags, got.User)}, + {prefix + "fields", deviceFields(want), deviceFields(got)}, } if all { @@ -911,6 +918,28 @@ func (r *apiReplay) userName(email string) string { return email } +// deviceFields names the fields a read shape must carry whatever their value. +func deviceFields(b apiBody) string { + var out []string + + for _, f := range []struct { + name string + raw json.RawMessage + }{ + {"connectedToControl", b.ConnectedToControl}, + {"blocksIncomingConnections", b.BlocksIncomingConnections}, + {"sshEnabled", b.SSHEnabled}, + {"distro", b.Distro}, + {"clientConnectivity", b.ClientConnectivity}, + } { + if f.raw != nil { + out = append(out, f.name) + } + } + + return strings.Join(out, ",") +} + func deviceOwner(tags []string, user string) string { if len(tags) > 0 { return "tags " + sortedStrings(tags)