api/v2: add device shields-up, SSH, distro and connectivity fields

Same read shapes as SaaS. Latency stays empty: headscale drops it on arrival.
This commit is contained in:
Kristoffer Dalby committed 2026-11-03 17:05:00 +01:00
1 parent 9ba1c5e5e2
commit 7063c04581
3 files changed
+152 -2

No files matched your search

+67 -2
View File
@@ -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
+56
View File
@@ -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)
}
+29
View File
@@ -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)