mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 04:34:07 +09:00
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:
1 parent
9ba1c5e5e2
commit
7063c04581
3 files changed
+152
-2
No files matched your search
@@ -21,8 +21,9 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Device is the Tailscale device response. Headscale nodes map onto it; fields
|
// 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
|
// Headscale does not track are left out, apart from UpdateAvailable. The route
|
||||||
// are only populated for the fields=all variant, matching Tailscale.
|
// slices and the host detail are only populated for the fields=all variant,
|
||||||
|
// matching Tailscale.
|
||||||
type Device struct {
|
type Device struct {
|
||||||
Addresses []string `json:"addresses" nullable:"false"`
|
Addresses []string `json:"addresses" nullable:"false"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -45,6 +46,9 @@ type Device struct {
|
|||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
ClientVersion string `json:"clientVersion"`
|
ClientVersion string `json:"clientVersion"`
|
||||||
UpdateAvailable bool `json:"updateAvailable"`
|
UpdateAvailable bool `json:"updateAvailable"`
|
||||||
|
|
||||||
|
BlocksIncomingConnections bool `json:"blocksIncomingConnections"`
|
||||||
|
|
||||||
// OAuthClientID names the auth key the device joined with. For a key a
|
// OAuthClientID names the auth key the device joined with. For a key a
|
||||||
// token made, Tailscale names the token's OAuth client, which headscale
|
// token made, Tailscale names the token's OAuth client, which headscale
|
||||||
// does not record, so it is left out.
|
// 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.
|
// Populated only when fields=all is requested, then as [] when empty.
|
||||||
AdvertisedRoutes []string `json:"advertisedRoutes,omitzero"`
|
AdvertisedRoutes []string `json:"advertisedRoutes,omitzero"`
|
||||||
EnabledRoutes []string `json:"enabledRoutes,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
|
// 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
|
d.LastSeen = &ls
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hi := view.Hostinfo()
|
||||||
|
d.BlocksIncomingConnections = hi.Valid() && hi.ShieldsUp()
|
||||||
|
|
||||||
if allFields {
|
if allFields {
|
||||||
d.AdvertisedRoutes = emptyIfNil(util.PrefixesToString(view.AnnouncedRoutes()))
|
d.AdvertisedRoutes = emptyIfNil(util.PrefixesToString(view.AnnouncedRoutes()))
|
||||||
d.EnabledRoutes = emptyIfNil(util.PrefixesToString(view.ApprovedRoutes().AsSlice()))
|
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
|
return d
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -689,6 +689,12 @@ type apiBody struct {
|
|||||||
EnabledRoutes json.RawMessage `json:"enabledRoutes"`
|
EnabledRoutes json.RawMessage `json:"enabledRoutes"`
|
||||||
OAuthClientID *string `json:"oauthClientId"`
|
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"`
|
Keys json.RawMessage `json:"keys"`
|
||||||
Devices []apiBody `json:"devices"`
|
Devices []apiBody `json:"devices"`
|
||||||
}
|
}
|
||||||
@@ -890,6 +896,7 @@ func (r *apiReplay) deviceChecks(prefix, alias string, all bool, want, got apiBo
|
|||||||
checks := []apiCheck{
|
checks := []apiCheck{
|
||||||
{prefix + "tags", sortedStrings(want.Tags), sortedStrings(got.Tags)},
|
{prefix + "tags", sortedStrings(want.Tags), sortedStrings(got.Tags)},
|
||||||
{prefix + "owner", deviceOwner(want.Tags, r.userName(want.User)), deviceOwner(got.Tags, got.User)},
|
{prefix + "owner", deviceOwner(want.Tags, r.userName(want.User)), deviceOwner(got.Tags, got.User)},
|
||||||
|
{prefix + "fields", deviceFields(want), deviceFields(got)},
|
||||||
}
|
}
|
||||||
|
|
||||||
if all {
|
if all {
|
||||||
@@ -911,6 +918,28 @@ func (r *apiReplay) userName(email string) string {
|
|||||||
return email
|
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 {
|
func deviceOwner(tags []string, user string) string {
|
||||||
if len(tags) > 0 {
|
if len(tags) > 0 {
|
||||||
return "tags " + sortedStrings(tags)
|
return "tags " + sortedStrings(tags)
|
||||||
|
|||||||
Reference in new issue
Block a user