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) } }) } } // TestTailNodeDisableIPv4 asserts that a node with the disable-ipv4 // nodeAttr has its own IPv4 (the CGNAT /32) stripped from Addresses // and AllowedIPs, while subnet routes the node advertises -- even // IPv4 ones -- remain in AllowedIPs and PrimaryRoutes. Matches the // SaaS behaviour captured in // hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c1{5,6}-disable-ipv4*.hujson. func TestTailNodeDisableIPv4(t *testing.T) { t.Parallel() const NodeAttrDisableIPv4 tailcfg.NodeCapability = "disable-ipv4" v4 := iap("100.64.0.1") v6Addr := netip.MustParseAddr("fd7a:115c:a1e0::1") v6 := &v6Addr subnet := netip.MustParsePrefix("10.33.0.0/16") tests := []struct { name string hasCap bool approved []netip.Prefix wantAllowed []netip.Prefix wantPrimary []netip.Prefix wantAddrs []netip.Prefix }{ { name: "no-cap_emits_both_families", hasCap: false, wantAllowed: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), netip.MustParsePrefix("fd7a:115c:a1e0::1/128")}, wantAddrs: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), netip.MustParsePrefix("fd7a:115c:a1e0::1/128")}, }, { name: "cap_strips_own_ipv4", hasCap: true, wantAllowed: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")}, wantAddrs: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")}, }, { name: "cap_keeps_advertised_subnet_route", hasCap: true, approved: []netip.Prefix{subnet}, // AllowedIPs is sorted by netip.Prefix.Compare so IPv4 // sorts before IPv6. wantAllowed: []netip.Prefix{ subnet, netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), }, wantPrimary: []netip.Prefix{subnet}, wantAddrs: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() node := &types.Node{ GivenName: "ipv4-disabled-node", IPv4: v4, IPv6: v6, Hostinfo: &tailcfg.Hostinfo{ RoutableIPs: tt.approved, }, ApprovedRoutes: tt.approved, } var selfCaps tailcfg.NodeCapMap if tt.hasCap { selfCaps = tailcfg.NodeCapMap{NodeAttrDisableIPv4: nil} } got, err := node.View().TailNode( 0, func(types.NodeID) []netip.Prefix { return tt.approved }, &types.Config{Taildrop: types.TaildropConfig{Enabled: true}}, selfCaps, ) if err != nil { t.Fatalf("TailNode: %v", err) } prefStrings := func(ps []netip.Prefix) []string { out := make([]string, len(ps)) for i, p := range ps { out[i] = p.String() } return out } if diff := cmp.Diff(prefStrings(tt.wantAddrs), prefStrings(got.Addresses), cmpopts.EquateEmpty()); diff != "" { t.Errorf("Addresses (-want +got):\n%s", diff) } if diff := cmp.Diff(prefStrings(tt.wantAllowed), prefStrings(got.AllowedIPs), cmpopts.EquateEmpty()); diff != "" { t.Errorf("AllowedIPs (-want +got):\n%s", diff) } if diff := cmp.Diff(prefStrings(tt.wantPrimary), prefStrings(got.PrimaryRoutes), cmpopts.EquateEmpty()); diff != "" { t.Errorf("PrimaryRoutes (-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) } }) } }