mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-23 18:48:42 +09:00
Tailscale stamps tailcfg.NodeAttrDefaultAutoUpdate on every node's CapMap with a JSON bool reflecting the tailnet-wide auto-update default. Headscale grows an auto_update.enabled config option and emits the cap accordingly from TailNode -- the cap leaves the unmodelledTailnetStateCaps strip list and is compared in full by the nodeAttrs compat suite. testNodeAttrsSuccess drives cfg.AutoUpdate.Enabled from tf.Input.Tailnet.Settings.DevicesAutoUpdatesOn so each capture's expected emission matches the SaaS state it was taken under. Two captures cover both branches: - nodeattrs-tailnet-devices-auto-updates-on -> [true] - nodeattrs-tailnet-devices-auto-updates-off -> [false] The Tailscale v2 TailnetSettings API does not expose the Send Files toggle, so the compat suite cannot vary cfg.Taildrop.Enabled per capture. TestTaildropDisabledWithholdsFileSharingCap covers the off path directly in servertest.
418 lines
11 KiB
Go
418 lines
11 KiB
Go
package mapper
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/netip"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"tailscale.com/net/tsaddr"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/key"
|
|
)
|
|
|
|
func TestTailNode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mustNK := func(str string) key.NodePublic {
|
|
var k key.NodePublic
|
|
|
|
_ = k.UnmarshalText([]byte(str))
|
|
|
|
return k
|
|
}
|
|
|
|
mustDK := func(str string) key.DiscoPublic {
|
|
var k key.DiscoPublic
|
|
|
|
_ = k.UnmarshalText([]byte(str))
|
|
|
|
return k
|
|
}
|
|
|
|
mustMK := func(str string) key.MachinePublic {
|
|
var k key.MachinePublic
|
|
|
|
_ = k.UnmarshalText([]byte(str))
|
|
|
|
return k
|
|
}
|
|
|
|
hiview := func(hoin tailcfg.Hostinfo) tailcfg.HostinfoView {
|
|
return hoin.View()
|
|
}
|
|
|
|
created := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
|
|
lastSeen := time.Date(2009, time.November, 10, 23, 9, 0, 0, time.UTC)
|
|
expire := time.Date(2500, time.November, 11, 23, 0, 0, 0, time.UTC)
|
|
|
|
tests := []struct {
|
|
name string
|
|
node *types.Node
|
|
dnsConfig *tailcfg.DNSConfig
|
|
baseDomain string
|
|
want *tailcfg.Node
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "empty-node",
|
|
node: &types.Node{
|
|
GivenName: "empty",
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
},
|
|
dnsConfig: &tailcfg.DNSConfig{},
|
|
baseDomain: "",
|
|
want: &tailcfg.Node{
|
|
Name: "empty",
|
|
StableID: "0",
|
|
HomeDERP: 0,
|
|
LegacyDERPString: "127.3.3.40:0",
|
|
Hostinfo: hiview(tailcfg.Hostinfo{}),
|
|
MachineAuthorized: true,
|
|
|
|
CapMap: tailcfg.NodeCapMap{
|
|
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
|
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "minimal-node",
|
|
node: &types.Node{
|
|
ID: 0,
|
|
MachineKey: mustMK(
|
|
"mkey:f08305b4ee4250b95a70f3b7504d048d75d899993c624a26d422c67af0422507",
|
|
),
|
|
NodeKey: mustNK(
|
|
"nodekey:9b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306fe",
|
|
),
|
|
DiscoKey: mustDK(
|
|
"discokey:cf7b0fd05da556fdc3bab365787b506fd82d64a70745db70e00e86c1b1c03084",
|
|
),
|
|
IPv4: iap("100.64.0.1"),
|
|
Hostname: "mini",
|
|
GivenName: "mini",
|
|
UserID: new(uint(0)),
|
|
User: &types.User{
|
|
Name: "mini",
|
|
},
|
|
Tags: []string{},
|
|
AuthKey: &types.PreAuthKey{},
|
|
LastSeen: &lastSeen,
|
|
Expiry: &expire,
|
|
Hostinfo: &tailcfg.Hostinfo{
|
|
RoutableIPs: []netip.Prefix{
|
|
tsaddr.AllIPv4(),
|
|
tsaddr.AllIPv6(),
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
netip.MustParsePrefix("172.0.0.0/10"),
|
|
},
|
|
},
|
|
ApprovedRoutes: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6(), netip.MustParsePrefix("192.168.0.0/24")},
|
|
CreatedAt: created,
|
|
},
|
|
dnsConfig: &tailcfg.DNSConfig{},
|
|
baseDomain: "",
|
|
want: &tailcfg.Node{
|
|
ID: 0,
|
|
StableID: "0",
|
|
Name: "mini",
|
|
|
|
User: 0,
|
|
|
|
Key: mustNK(
|
|
"nodekey:9b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306fe",
|
|
),
|
|
KeyExpiry: expire,
|
|
|
|
Machine: mustMK(
|
|
"mkey:f08305b4ee4250b95a70f3b7504d048d75d899993c624a26d422c67af0422507",
|
|
),
|
|
DiscoKey: mustDK(
|
|
"discokey:cf7b0fd05da556fdc3bab365787b506fd82d64a70745db70e00e86c1b1c03084",
|
|
),
|
|
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
|
AllowedIPs: []netip.Prefix{
|
|
tsaddr.AllIPv4(),
|
|
netip.MustParsePrefix("100.64.0.1/32"),
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
tsaddr.AllIPv6(),
|
|
},
|
|
PrimaryRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
},
|
|
HomeDERP: 0,
|
|
LegacyDERPString: "127.3.3.40:0",
|
|
Hostinfo: hiview(tailcfg.Hostinfo{
|
|
RoutableIPs: []netip.Prefix{
|
|
tsaddr.AllIPv4(),
|
|
tsaddr.AllIPv6(),
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
netip.MustParsePrefix("172.0.0.0/10"),
|
|
},
|
|
}),
|
|
Created: created,
|
|
|
|
Tags: []string{},
|
|
|
|
MachineAuthorized: true,
|
|
|
|
CapMap: tailcfg.NodeCapMap{
|
|
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
|
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "check-dot-suffix-on-node-name",
|
|
node: &types.Node{
|
|
GivenName: "minimal",
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
},
|
|
dnsConfig: &tailcfg.DNSConfig{},
|
|
baseDomain: "example.com",
|
|
want: &tailcfg.Node{
|
|
// a node name should have a dot appended
|
|
Name: "minimal.example.com.",
|
|
StableID: "0",
|
|
HomeDERP: 0,
|
|
LegacyDERPString: "127.3.3.40:0",
|
|
Hostinfo: hiview(tailcfg.Hostinfo{}),
|
|
MachineAuthorized: true,
|
|
|
|
CapMap: tailcfg.NodeCapMap{
|
|
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
|
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
// TODO: Add tests to check other aspects of the node conversion:
|
|
// - With tags and policy
|
|
// - dnsconfig and basedomain
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &types.Config{
|
|
BaseDomain: tt.baseDomain,
|
|
TailcfgDNSConfig: tt.dnsConfig,
|
|
Taildrop: types.TaildropConfig{Enabled: true},
|
|
}
|
|
|
|
// Stub primary-route lookup: tt.node owns its SubnetRoutes,
|
|
// node ID 2 owns 192.168.0.0/24 (a hack carried over from
|
|
// the original routes-package-driven version of this test —
|
|
// avoids spinning up a second node just to validate that
|
|
// other nodes' primaries don't leak into tt.node's TailNode
|
|
// output).
|
|
primaries := map[types.NodeID][]netip.Prefix{
|
|
tt.node.ID: tt.node.SubnetRoutes(),
|
|
2: {netip.MustParsePrefix("192.168.0.0/24")},
|
|
}
|
|
nv := tt.node.View()
|
|
got, err := nv.TailNode(
|
|
0,
|
|
func(id types.NodeID) []netip.Prefix {
|
|
// Route function returns primaries + exit routes
|
|
// (matching the real caller contract).
|
|
return slices.Concat(primaries[id], nv.ExitRoutes())
|
|
},
|
|
cfg,
|
|
nil,
|
|
)
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("TailNode() error = %v, wantErr %v", err, tt.wantErr)
|
|
|
|
return
|
|
}
|
|
|
|
if diff := cmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" {
|
|
t.Errorf("TailNode() unexpected result (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTailNodeBaselineGates focuses on the cfg-driven baseline cap
|
|
// emission: cfg.Taildrop.Enabled gates [tailcfg.CapabilityFileSharing]
|
|
// and cfg.AutoUpdate.Enabled controls the value of
|
|
// [tailcfg.NodeAttrDefaultAutoUpdate]. Admin and SSH are unconditional
|
|
// baseline.
|
|
func TestTailNodeBaselineGates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
autoUpdate := func(b bool) []tailcfg.RawMessage {
|
|
if b {
|
|
return []tailcfg.RawMessage{tailcfg.RawMessage("true")}
|
|
}
|
|
|
|
return []tailcfg.RawMessage{tailcfg.RawMessage("false")}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
cfg *types.Config
|
|
want tailcfg.NodeCapMap
|
|
}{
|
|
{
|
|
name: "taildrop_on_autoupdate_off",
|
|
cfg: &types.Config{
|
|
Taildrop: types.TaildropConfig{Enabled: true},
|
|
AutoUpdate: types.AutoUpdateConfig{Enabled: false},
|
|
},
|
|
want: tailcfg.NodeCapMap{
|
|
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
|
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(false),
|
|
},
|
|
},
|
|
{
|
|
name: "taildrop_off_autoupdate_off",
|
|
cfg: &types.Config{
|
|
Taildrop: types.TaildropConfig{Enabled: false},
|
|
AutoUpdate: types.AutoUpdateConfig{Enabled: false},
|
|
},
|
|
want: tailcfg.NodeCapMap{
|
|
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
|
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(false),
|
|
},
|
|
},
|
|
{
|
|
name: "taildrop_on_autoupdate_on",
|
|
cfg: &types.Config{
|
|
Taildrop: types.TaildropConfig{Enabled: true},
|
|
AutoUpdate: types.AutoUpdateConfig{Enabled: true},
|
|
},
|
|
want: tailcfg.NodeCapMap{
|
|
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
|
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(true),
|
|
},
|
|
},
|
|
{
|
|
name: "taildrop_off_autoupdate_on",
|
|
cfg: &types.Config{
|
|
Taildrop: types.TaildropConfig{Enabled: false},
|
|
AutoUpdate: types.AutoUpdateConfig{Enabled: true},
|
|
},
|
|
want: tailcfg.NodeCapMap{
|
|
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
|
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
|
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(true),
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
node := &types.Node{GivenName: "baseline-node", Hostinfo: &tailcfg.Hostinfo{}}
|
|
|
|
got, err := node.View().TailNode(
|
|
0,
|
|
func(types.NodeID) []netip.Prefix { return nil },
|
|
tt.cfg,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("TailNode: %v", err)
|
|
}
|
|
|
|
if diff := cmp.Diff(tt.want, got.CapMap, cmpopts.EquateEmpty()); diff != "" {
|
|
t.Errorf("CapMap mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNodeExpiry(t *testing.T) {
|
|
tp := func(t time.Time) *time.Time {
|
|
return &t
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
exp *time.Time
|
|
wantTime time.Time
|
|
wantTimeZero bool
|
|
}{
|
|
{
|
|
name: "no-expiry",
|
|
exp: nil,
|
|
wantTimeZero: true,
|
|
},
|
|
{
|
|
name: "zero-expiry",
|
|
exp: &time.Time{},
|
|
wantTimeZero: true,
|
|
},
|
|
{
|
|
name: "localtime",
|
|
exp: tp(time.Time{}.Local()), //nolint:gosmopolitan
|
|
wantTimeZero: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
node := &types.Node{
|
|
ID: 0,
|
|
GivenName: "test",
|
|
Expiry: tt.exp,
|
|
}
|
|
|
|
tn, err := node.View().TailNode(
|
|
0,
|
|
func(id types.NodeID) []netip.Prefix {
|
|
return []netip.Prefix{}
|
|
},
|
|
&types.Config{Taildrop: types.TaildropConfig{Enabled: true}},
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("nodeExpiry() error = %v", err)
|
|
}
|
|
|
|
// Round trip the node through JSON to ensure the time is serialized correctly
|
|
seri, err := json.Marshal(tn)
|
|
if err != nil {
|
|
t.Fatalf("nodeExpiry() error = %v", err)
|
|
}
|
|
|
|
var deseri tailcfg.Node
|
|
|
|
err = json.Unmarshal(seri, &deseri)
|
|
if err != nil {
|
|
t.Fatalf("nodeExpiry() error = %v", err)
|
|
}
|
|
|
|
if tt.wantTimeZero {
|
|
if !deseri.KeyExpiry.IsZero() {
|
|
t.Errorf("nodeExpiry() = %v, want zero", deseri.KeyExpiry)
|
|
}
|
|
} else if deseri.KeyExpiry != tt.wantTime {
|
|
t.Errorf("nodeExpiry() = %v, want %v", deseri.KeyExpiry, tt.wantTime)
|
|
}
|
|
})
|
|
}
|
|
}
|