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)
}