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
|
||||
// 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
|
||||
|
||||
@@ -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"`
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user