From 5bad48a24ed5bf7eef0922e11b2ae392570591b5 Mon Sep 17 00:00:00 2001
From: Kristoffer Dalby <kristoffer@tailscale.com>
Date: Wed, 31 May 2023 09:59:37 +0200
Subject: [PATCH] remove DB dependency of tailNode conversion, add test

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
---
 hscontrol/db/machine.go         | 174 +-----------------------------
 hscontrol/mapper/mapper.go      |  89 +++++++++-------
 hscontrol/mapper/mapper_test.go |   2 +-
 hscontrol/mapper/tail.go        | 151 ++++++++++++++++++++++++++
 hscontrol/mapper/tail_test.go   | 183 ++++++++++++++++++++++++++++++++
 hscontrol/policy/acls_test.go   |   4 +-
 hscontrol/protocol_common.go    |   2 +-
 hscontrol/types/machine.go      |  78 +++++++++++++-
 8 files changed, 462 insertions(+), 221 deletions(-)
 create mode 100644 hscontrol/mapper/tail.go
 create mode 100644 hscontrol/mapper/tail_test.go

diff --git a/hscontrol/db/machine.go b/hscontrol/db/machine.go
index 74cf75a..849ce0c 100644
--- a/hscontrol/db/machine.go
+++ b/hscontrol/db/machine.go
@@ -5,25 +5,20 @@ import (
 	"fmt"
 	"net/netip"
 	"sort"
-	"strconv"
 	"strings"
 	"time"
 
-	"github.com/juanfont/headscale/hscontrol/policy"
 	"github.com/juanfont/headscale/hscontrol/types"
 	"github.com/juanfont/headscale/hscontrol/util"
 	"github.com/patrickmn/go-cache"
 	"github.com/rs/zerolog/log"
-	"github.com/samber/lo"
 	"gorm.io/gorm"
-	"tailscale.com/tailcfg"
 	"tailscale.com/types/key"
 )
 
 const (
 	MachineGivenNameHashLength = 8
 	MachineGivenNameTrimSize   = 2
-	MaxHostnameLength          = 255
 )
 
 var (
@@ -33,7 +28,6 @@ var (
 		"machine not found in registration cache",
 	)
 	ErrCouldNotConvertMachineInterface = errors.New("failed to convert machine interface")
-	ErrHostnameTooLong                 = errors.New("hostname too long")
 	ErrDifferentRegisteredUser         = errors.New(
 		"machine was previously registered with a different user",
 	)
@@ -471,7 +465,7 @@ func (hsdb *HSDatabase) RegisterMachine(machine types.Machine,
 	log.Trace().
 		Caller().
 		Str("machine", machine.Hostname).
-		Str("ip", strings.Join(ips.ToStringSlice(), ",")).
+		Str("ip", strings.Join(ips.StringSlice(), ",")).
 		Msg("Machine registered with the database")
 
 	return &machine, nil
@@ -785,169 +779,3 @@ func (hsdb *HSDatabase) ExpireExpiredMachines(lastChange time.Time) {
 		}
 	}
 }
-
-func (hsdb *HSDatabase) TailNodes(
-	machines types.Machines,
-	pol *policy.ACLPolicy,
-	dnsConfig *tailcfg.DNSConfig,
-) ([]*tailcfg.Node, error) {
-	nodes := make([]*tailcfg.Node, len(machines))
-
-	for index, machine := range machines {
-		node, err := hsdb.TailNode(machine, pol, dnsConfig)
-		if err != nil {
-			return nil, err
-		}
-
-		nodes[index] = node
-	}
-
-	return nodes, nil
-}
-
-// TailNode converts a Machine into a Tailscale Node. includeRoutes is false for shared nodes
-// as per the expected behaviour in the official SaaS.
-func (hsdb *HSDatabase) TailNode(
-	machine types.Machine,
-	pol *policy.ACLPolicy,
-	dnsConfig *tailcfg.DNSConfig,
-) (*tailcfg.Node, error) {
-	var nodeKey key.NodePublic
-	err := nodeKey.UnmarshalText([]byte(util.NodePublicKeyEnsurePrefix(machine.NodeKey)))
-	if err != nil {
-		log.Trace().
-			Caller().
-			Str("node_key", machine.NodeKey).
-			Msgf("Failed to parse node public key from hex")
-
-		return nil, fmt.Errorf("failed to parse node public key: %w", err)
-	}
-
-	var machineKey key.MachinePublic
-	// MachineKey is only used in the legacy protocol
-	if machine.MachineKey != "" {
-		err = machineKey.UnmarshalText(
-			[]byte(util.MachinePublicKeyEnsurePrefix(machine.MachineKey)),
-		)
-		if err != nil {
-			return nil, fmt.Errorf("failed to parse machine public key: %w", err)
-		}
-	}
-
-	var discoKey key.DiscoPublic
-	if machine.DiscoKey != "" {
-		err := discoKey.UnmarshalText(
-			[]byte(util.DiscoPublicKeyEnsurePrefix(machine.DiscoKey)),
-		)
-		if err != nil {
-			return nil, fmt.Errorf("failed to parse disco public key: %w", err)
-		}
-	} else {
-		discoKey = key.DiscoPublic{}
-	}
-
-	addrs := []netip.Prefix{}
-	for _, machineAddress := range machine.IPAddresses {
-		ip := netip.PrefixFrom(machineAddress, machineAddress.BitLen())
-		addrs = append(addrs, ip)
-	}
-
-	allowedIPs := append(
-		[]netip.Prefix{},
-		addrs...) // we append the node own IP, as it is required by the clients
-
-	primaryRoutes, err := hsdb.GetMachinePrimaryRoutes(&machine)
-	if err != nil {
-		return nil, err
-	}
-	primaryPrefixes := primaryRoutes.Prefixes()
-
-	machineRoutes, err := hsdb.GetMachineRoutes(&machine)
-	if err != nil {
-		return nil, err
-	}
-	for _, route := range machineRoutes {
-		if route.Enabled && (route.IsPrimary || route.IsExitRoute()) {
-			allowedIPs = append(allowedIPs, netip.Prefix(route.Prefix))
-		}
-	}
-
-	var derp string
-	if machine.HostInfo.NetInfo != nil {
-		derp = fmt.Sprintf("127.3.3.40:%d", machine.HostInfo.NetInfo.PreferredDERP)
-	} else {
-		derp = "127.3.3.40:0" // Zero means disconnected or unknown.
-	}
-
-	var keyExpiry time.Time
-	if machine.Expiry != nil {
-		keyExpiry = *machine.Expiry
-	} else {
-		keyExpiry = time.Time{}
-	}
-
-	var hostname string
-	if dnsConfig != nil && dnsConfig.Proxied { // MagicDNS
-		hostname = fmt.Sprintf(
-			"%s.%s.%s",
-			machine.GivenName,
-			machine.User.Name,
-			hsdb.baseDomain,
-		)
-		if len(hostname) > MaxHostnameLength {
-			return nil, fmt.Errorf(
-				"hostname %q is too long it cannot except 255 ASCII chars: %w",
-				hostname,
-				ErrHostnameTooLong,
-			)
-		}
-	} else {
-		hostname = machine.GivenName
-	}
-
-	hostInfo := machine.GetHostInfo()
-
-	online := machine.IsOnline()
-
-	tags, _ := pol.GetTagsOfMachine(machine, hsdb.stripEmailDomain)
-	tags = lo.Uniq(append(tags, machine.ForcedTags...))
-
-	node := tailcfg.Node{
-		ID: tailcfg.NodeID(machine.ID), // this is the actual ID
-		StableID: tailcfg.StableNodeID(
-			strconv.FormatUint(machine.ID, util.Base10),
-		), // in headscale, unlike tailcontrol server, IDs are permanent
-		Name: hostname,
-
-		User: tailcfg.UserID(machine.UserID),
-
-		Key:       nodeKey,
-		KeyExpiry: keyExpiry,
-
-		Machine:    machineKey,
-		DiscoKey:   discoKey,
-		Addresses:  addrs,
-		AllowedIPs: allowedIPs,
-		Endpoints:  machine.Endpoints,
-		DERP:       derp,
-		Hostinfo:   hostInfo.View(),
-		Created:    machine.CreatedAt,
-
-		Tags: tags,
-
-		PrimaryRoutes: primaryPrefixes,
-
-		LastSeen:          machine.LastSeen,
-		Online:            &online,
-		KeepAlive:         true,
-		MachineAuthorized: !machine.IsExpired(),
-
-		Capabilities: []string{
-			tailcfg.CapabilityFileSharing,
-			tailcfg.CapabilityAdmin,
-			tailcfg.CapabilitySSH,
-		},
-	}
-
-	return &node, nil
-}
diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go
index 5dfa949..68db7c5 100644
--- a/hscontrol/mapper/mapper.go
+++ b/hscontrol/mapper/mapper.go
@@ -69,27 +69,11 @@ func NewMapper(
 	}
 }
 
-func (m Mapper) fullMapResponse(
+func (m *Mapper) tempWrap(
 	mapRequest tailcfg.MapRequest,
 	machine *types.Machine,
 	pol *policy.ACLPolicy,
 ) (*tailcfg.MapResponse, error) {
-	log.Trace().
-		Caller().
-		Str("machine", mapRequest.Hostinfo.Hostname).
-		Msg("Creating Map response")
-
-	// TODO(kradalby): Decouple this from DB?
-	node, err := m.db.TailNode(*machine, pol, m.dnsCfg)
-	if err != nil {
-		log.Error().
-			Caller().
-			Err(err).
-			Msg("Cannot convert to node")
-
-		return nil, err
-	}
-
 	peers, err := m.db.ListPeers(machine)
 	if err != nil {
 		log.Error().
@@ -100,7 +84,39 @@ func (m Mapper) fullMapResponse(
 		return nil, err
 	}
 
-	rules, sshPolicy, err := policy.GenerateFilterRules(pol, peers, m.stripEmailDomain)
+	return fullMapResponse(
+		mapRequest,
+		pol,
+		machine,
+		peers,
+		m.stripEmailDomain,
+		m.baseDomain,
+		m.dnsCfg,
+		m.derpMap,
+		m.logtail,
+		m.randomClientPort,
+	)
+}
+
+func fullMapResponse(
+	mapRequest tailcfg.MapRequest,
+	pol *policy.ACLPolicy,
+	machine *types.Machine,
+	peers types.Machines,
+
+	stripEmailDomain bool,
+	baseDomain string,
+	dnsCfg *tailcfg.DNSConfig,
+	derpMap *tailcfg.DERPMap,
+	logtail bool,
+	randomClientPort bool,
+) (*tailcfg.MapResponse, error) {
+	tailnode, err := tailNode(*machine, pol, dnsCfg, baseDomain, stripEmailDomain)
+	if err != nil {
+		return nil, err
+	}
+
+	rules, sshPolicy, err := policy.GenerateFilterRules(pol, peers, stripEmailDomain)
 	if err != nil {
 		return nil, err
 	}
@@ -109,38 +125,31 @@ func (m Mapper) fullMapResponse(
 		peers = policy.FilterMachinesByACL(machine, peers, rules)
 	}
 
-	profiles := generateUserProfiles(machine, peers, m.baseDomain)
+	profiles := generateUserProfiles(machine, peers, baseDomain)
 
-	// TODO(kradalby): Decouple this from DB?
-	nodePeers, err := m.db.TailNodes(peers, pol, m.dnsCfg)
-	if err != nil {
-		log.Error().
-			Caller().
-			Err(err).
-			Msg("Failed to convert peers to Tailscale nodes")
-
-		return nil, err
-	}
-
-	// TODO(kradalby): Shold this mutation happen before TailNode(s) is called?
 	dnsConfig := generateDNSConfig(
-		m.dnsCfg,
-		m.baseDomain,
+		dnsCfg,
+		baseDomain,
 		*machine,
 		peers,
 	)
 
+	tailPeers, err := tailNodes(peers, pol, dnsCfg, baseDomain, stripEmailDomain)
+	if err != nil {
+		return nil, err
+	}
+
 	now := time.Now()
 
 	resp := tailcfg.MapResponse{
 		KeepAlive: false,
-		Node:      node,
+		Node:      tailnode,
 
 		// TODO: Only send if updated
-		DERPMap: m.derpMap,
+		DERPMap: derpMap,
 
 		// TODO: Only send if updated
-		Peers: nodePeers,
+		Peers: tailPeers,
 
 		// TODO(kradalby): Implement:
 		// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L1351-L1374
@@ -154,7 +163,7 @@ func (m Mapper) fullMapResponse(
 		DNSConfig: dnsConfig,
 
 		// TODO: Only send if updated
-		Domain: m.baseDomain,
+		Domain: baseDomain,
 
 		// Do not instruct clients to collect services, we do not
 		// support or do anything with them
@@ -171,8 +180,8 @@ func (m Mapper) fullMapResponse(
 		ControlTime: &now,
 
 		Debug: &tailcfg.Debug{
-			DisableLogTail:      !m.logtail,
-			RandomizeClientPort: m.randomClientPort,
+			DisableLogTail:      !logtail,
+			RandomizeClientPort: randomClientPort,
 		},
 	}
 
@@ -283,7 +292,7 @@ func (m Mapper) CreateMapResponse(
 	machine *types.Machine,
 	pol *policy.ACLPolicy,
 ) ([]byte, error) {
-	mapResponse, err := m.fullMapResponse(mapRequest, machine, pol)
+	mapResponse, err := m.tempWrap(mapRequest, machine, pol)
 	if err != nil {
 		return nil, err
 	}
diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go
index a5d65c9..e593eaa 100644
--- a/hscontrol/mapper/mapper_test.go
+++ b/hscontrol/mapper/mapper_test.go
@@ -124,7 +124,7 @@ func TestDNSConfigMapResponse(t *testing.T) {
 			)
 
 			if diff := cmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" {
-				t.Errorf("expandAlias() = %v, want %v", got, tt.want)
+				t.Errorf("expandAlias() unexpected result (-want +got):\n%s", diff)
 			}
 		})
 	}
diff --git a/hscontrol/mapper/tail.go b/hscontrol/mapper/tail.go
new file mode 100644
index 0000000..a611864
--- /dev/null
+++ b/hscontrol/mapper/tail.go
@@ -0,0 +1,151 @@
+package mapper
+
+import (
+	"fmt"
+	"net/netip"
+	"strconv"
+	"time"
+
+	"github.com/juanfont/headscale/hscontrol/policy"
+	"github.com/juanfont/headscale/hscontrol/types"
+	"github.com/juanfont/headscale/hscontrol/util"
+	"github.com/samber/lo"
+	"tailscale.com/tailcfg"
+)
+
+func tailNodes(
+	machines types.Machines,
+	pol *policy.ACLPolicy,
+	dnsConfig *tailcfg.DNSConfig,
+	baseDomain string,
+	stripEmailDomain bool,
+) ([]*tailcfg.Node, error) {
+	nodes := make([]*tailcfg.Node, len(machines))
+
+	for index, machine := range machines {
+		node, err := tailNode(
+			machine,
+			pol,
+			dnsConfig,
+			baseDomain,
+			stripEmailDomain,
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		nodes[index] = node
+	}
+
+	return nodes, nil
+}
+
+// tailNode converts a Machine into a Tailscale Node. includeRoutes is false for shared nodes
+// as per the expected behaviour in the official SaaS.
+func tailNode(
+	machine types.Machine,
+	pol *policy.ACLPolicy,
+	dnsConfig *tailcfg.DNSConfig,
+	baseDomain string,
+	stripEmailDomain bool,
+) (*tailcfg.Node, error) {
+	nodeKey, err := machine.NodePublicKey()
+	if err != nil {
+		return nil, err
+	}
+
+	// MachineKey is only used in the legacy protocol
+	machineKey, err := machine.MachinePublicKey()
+	if err != nil {
+		return nil, err
+	}
+
+	discoKey, err := machine.DiscoPublicKey()
+	if err != nil {
+		return nil, err
+	}
+
+	addrs := machine.IPAddresses.Prefixes()
+
+	allowedIPs := append(
+		[]netip.Prefix{},
+		addrs...) // we append the node own IP, as it is required by the clients
+
+	primaryPrefixes := []netip.Prefix{}
+
+	for _, route := range machine.Routes {
+		if route.Enabled {
+			if route.IsPrimary {
+				allowedIPs = append(allowedIPs, netip.Prefix(route.Prefix))
+				primaryPrefixes = append(primaryPrefixes, netip.Prefix(route.Prefix))
+			} else if route.IsExitRoute() {
+				allowedIPs = append(allowedIPs, netip.Prefix(route.Prefix))
+			}
+		}
+	}
+
+	var derp string
+	if machine.HostInfo.NetInfo != nil {
+		derp = fmt.Sprintf("127.3.3.40:%d", machine.HostInfo.NetInfo.PreferredDERP)
+	} else {
+		derp = "127.3.3.40:0" // Zero means disconnected or unknown.
+	}
+
+	var keyExpiry time.Time
+	if machine.Expiry != nil {
+		keyExpiry = *machine.Expiry
+	} else {
+		keyExpiry = time.Time{}
+	}
+
+	hostname, err := machine.GetFQDN(dnsConfig, baseDomain)
+	if err != nil {
+		return nil, err
+	}
+
+	hostInfo := machine.GetHostInfo()
+
+	online := machine.IsOnline()
+
+	tags, _ := pol.GetTagsOfMachine(machine, stripEmailDomain)
+	tags = lo.Uniq(append(tags, machine.ForcedTags...))
+
+	node := tailcfg.Node{
+		ID: tailcfg.NodeID(machine.ID), // this is the actual ID
+		StableID: tailcfg.StableNodeID(
+			strconv.FormatUint(machine.ID, util.Base10),
+		), // in headscale, unlike tailcontrol server, IDs are permanent
+		Name: hostname,
+
+		User: tailcfg.UserID(machine.UserID),
+
+		Key:       nodeKey,
+		KeyExpiry: keyExpiry,
+
+		Machine:    machineKey,
+		DiscoKey:   discoKey,
+		Addresses:  addrs,
+		AllowedIPs: allowedIPs,
+		Endpoints:  machine.Endpoints,
+		DERP:       derp,
+		Hostinfo:   hostInfo.View(),
+		Created:    machine.CreatedAt,
+
+		Tags: tags,
+
+		PrimaryRoutes: primaryPrefixes,
+
+		LastSeen:          machine.LastSeen,
+		Online:            &online,
+		KeepAlive:         true,
+		MachineAuthorized: !machine.IsExpired(),
+
+		Capabilities: []string{
+			tailcfg.CapabilityFileSharing,
+			tailcfg.CapabilityAdmin,
+			tailcfg.CapabilitySSH,
+		},
+	}
+
+	return &node, nil
+}
diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go
new file mode 100644
index 0000000..a8a3f40
--- /dev/null
+++ b/hscontrol/mapper/tail_test.go
@@ -0,0 +1,183 @@
+package mapper
+
+import (
+	"net/netip"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/juanfont/headscale/hscontrol/policy"
+	"github.com/juanfont/headscale/hscontrol/types"
+	"tailscale.com/tailcfg"
+	"tailscale.com/types/key"
+)
+
+func TestTailNode(t *testing.T) {
+	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
+		machine          types.Machine
+		pol              *policy.ACLPolicy
+		dnsConfig        *tailcfg.DNSConfig
+		baseDomain       string
+		stripEmailDomain bool
+		want             *tailcfg.Node
+		wantErr          bool
+	}{
+		{
+			name:             "empty-machine",
+			machine:          types.Machine{},
+			pol:              &policy.ACLPolicy{},
+			dnsConfig:        &tailcfg.DNSConfig{},
+			baseDomain:       "",
+			stripEmailDomain: false,
+			want:             nil,
+			wantErr:          true,
+		},
+		{
+			name: "minimal-machine",
+			machine: types.Machine{
+				ID:         0,
+				MachineKey: "mkey:f08305b4ee4250b95a70f3b7504d048d75d899993c624a26d422c67af0422507",
+				NodeKey:    "nodekey:9b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306fe",
+				DiscoKey:   "discokey:cf7b0fd05da556fdc3bab365787b506fd82d64a70745db70e00e86c1b1c03084",
+				IPAddresses: []netip.Addr{
+					netip.MustParseAddr("100.64.0.1"),
+				},
+				Hostname:  "mini",
+				GivenName: "mini",
+				UserID:    0,
+				User: types.User{
+					Name: "mini",
+				},
+				ForcedTags: []string{},
+				AuthKeyID:  0,
+				AuthKey:    &types.PreAuthKey{},
+				LastSeen:   &lastSeen,
+				Expiry:     &expire,
+				HostInfo:   types.HostInfo{},
+				Endpoints:  []string{},
+				Routes: []types.Route{
+					{
+						Prefix:     types.IPPrefix(netip.MustParsePrefix("0.0.0.0/0")),
+						Advertised: true,
+						Enabled:    true,
+						IsPrimary:  false,
+					},
+					{
+						Prefix:     types.IPPrefix(netip.MustParsePrefix("192.168.0.0/24")),
+						Advertised: true,
+						Enabled:    true,
+						IsPrimary:  true,
+					},
+				},
+				CreatedAt: created,
+			},
+			pol:              &policy.ACLPolicy{},
+			dnsConfig:        &tailcfg.DNSConfig{},
+			baseDomain:       "",
+			stripEmailDomain: false,
+			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{
+					netip.MustParsePrefix("100.64.0.1/32"),
+					netip.MustParsePrefix("0.0.0.0/0"),
+					netip.MustParsePrefix("192.168.0.0/24"),
+				},
+				Endpoints: []string{},
+				DERP:      "127.3.3.40:0",
+				Hostinfo:  hiview(tailcfg.Hostinfo{}),
+				Created:   created,
+
+				Tags: []string{},
+
+				PrimaryRoutes: []netip.Prefix{
+					netip.MustParsePrefix("192.168.0.0/24"),
+				},
+
+				LastSeen:          &lastSeen,
+				Online:            new(bool),
+				KeepAlive:         true,
+				MachineAuthorized: true,
+
+				Capabilities: []string{
+					tailcfg.CapabilityFileSharing,
+					tailcfg.CapabilityAdmin,
+					tailcfg.CapabilitySSH,
+				},
+			},
+			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) {
+			got, err := tailNode(
+				tt.machine,
+				tt.pol,
+				tt.dnsConfig,
+				tt.baseDomain,
+				tt.stripEmailDomain,
+			)
+
+			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)
+			}
+		})
+	}
+}
diff --git a/hscontrol/policy/acls_test.go b/hscontrol/policy/acls_test.go
index f6c5e10..e9b99ce 100644
--- a/hscontrol/policy/acls_test.go
+++ b/hscontrol/policy/acls_test.go
@@ -1332,7 +1332,7 @@ func Test_expandAlias(t *testing.T) {
 				return
 			}
 			if diff := cmp.Diff(test.want, got); diff != "" {
-				t.Errorf("expandAlias() = %v, want %v", got, test.want)
+				t.Errorf("expandAlias() unexpected result (-want +got):\n%s", diff)
 			}
 		})
 	}
@@ -1711,7 +1711,7 @@ func TestACLPolicy_generateFilterRules(t *testing.T) {
 
 			if diff := cmp.Diff(tt.want, got); diff != "" {
 				log.Trace().Interface("got", got).Msg("result")
-				t.Errorf("ACLgenerateFilterRules() = %v, want %v", got, tt.want)
+				t.Errorf("ACLgenerateFilterRules() unexpected result (-want +got):\n%s", diff)
 			}
 		})
 	}
diff --git a/hscontrol/protocol_common.go b/hscontrol/protocol_common.go
index c0ba924..85d1894 100644
--- a/hscontrol/protocol_common.go
+++ b/hscontrol/protocol_common.go
@@ -516,7 +516,7 @@ func (h *Headscale) handleAuthKeyCommon(
 		Str("func", "handleAuthKeyCommon").
 		Bool("noise", isNoise).
 		Str("machine", registerRequest.Hostinfo.Hostname).
-		Str("ips", strings.Join(machine.IPAddresses.ToStringSlice(), ", ")).
+		Str("ips", strings.Join(machine.IPAddresses.StringSlice(), ", ")).
 		Msg("Successfully authenticated via AuthKey")
 }
 
diff --git a/hscontrol/types/machine.go b/hscontrol/types/machine.go
index 562d7d6..15f71b7 100644
--- a/hscontrol/types/machine.go
+++ b/hscontrol/types/machine.go
@@ -10,17 +10,23 @@ import (
 
 	v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
 	"github.com/juanfont/headscale/hscontrol/policy/matcher"
+	"github.com/juanfont/headscale/hscontrol/util"
 	"go4.org/netipx"
 	"google.golang.org/protobuf/types/known/timestamppb"
 	"tailscale.com/tailcfg"
+	"tailscale.com/types/key"
 )
 
 const (
 	// TODO(kradalby): Move out of here when we got circdeps under control.
 	keepAliveInterval = 60 * time.Second
+	MaxHostnameLength = 255
 )
 
-var ErrMachineAddressesInvalid = errors.New("failed to parse machine addresses")
+var (
+	ErrMachineAddressesInvalid = errors.New("failed to parse machine addresses")
+	ErrHostnameTooLong         = errors.New("hostname too long")
+)
 
 // Machine is a Headscale client.
 type Machine struct {
@@ -73,7 +79,7 @@ type (
 
 type MachineAddresses []netip.Addr
 
-func (ma MachineAddresses) ToStringSlice() []string {
+func (ma MachineAddresses) StringSlice() []string {
 	strSlice := make([]string, 0, len(ma))
 	for _, addr := range ma {
 		strSlice = append(strSlice, addr.String())
@@ -125,7 +131,7 @@ func (ma *MachineAddresses) Scan(destination interface{}) error {
 
 // Value return json value, implement driver.Valuer interface.
 func (ma MachineAddresses) Value() (driver.Value, error) {
-	addresses := strings.Join(ma.ToStringSlice(), ",")
+	addresses := strings.Join(ma.StringSlice(), ",")
 
 	return addresses, nil
 }
@@ -201,7 +207,7 @@ func (machine *Machine) Proto() *v1.Machine {
 
 		NodeKey:     machine.NodeKey,
 		DiscoKey:    machine.DiscoKey,
-		IpAddresses: machine.IPAddresses.ToStringSlice(),
+		IpAddresses: machine.IPAddresses.StringSlice(),
 		Name:        machine.Hostname,
 		GivenName:   machine.GivenName,
 		User:        machine.User.Proto(),
@@ -240,6 +246,70 @@ func (machine *Machine) GetHostInfo() tailcfg.Hostinfo {
 	return tailcfg.Hostinfo(machine.HostInfo)
 }
 
+func (machine *Machine) GetFQDN(dnsConfig *tailcfg.DNSConfig, baseDomain string) (string, error) {
+	var hostname string
+	if dnsConfig != nil && dnsConfig.Proxied { // MagicDNS
+		hostname = fmt.Sprintf(
+			"%s.%s.%s",
+			machine.GivenName,
+			machine.User.Name,
+			baseDomain,
+		)
+		if len(hostname) > MaxHostnameLength {
+			return "", fmt.Errorf(
+				"hostname %q is too long it cannot except 255 ASCII chars: %w",
+				hostname,
+				ErrHostnameTooLong,
+			)
+		}
+	} else {
+		hostname = machine.GivenName
+	}
+
+	return hostname, nil
+}
+
+func (machine *Machine) MachinePublicKey() (key.MachinePublic, error) {
+	var machineKey key.MachinePublic
+
+	if machine.MachineKey != "" {
+		err := machineKey.UnmarshalText(
+			[]byte(util.MachinePublicKeyEnsurePrefix(machine.MachineKey)),
+		)
+		if err != nil {
+			return key.MachinePublic{}, fmt.Errorf("failed to parse machine public key: %w", err)
+		}
+	}
+
+	return machineKey, nil
+}
+
+func (machine *Machine) DiscoPublicKey() (key.DiscoPublic, error) {
+	var discoKey key.DiscoPublic
+	if machine.DiscoKey != "" {
+		err := discoKey.UnmarshalText(
+			[]byte(util.DiscoPublicKeyEnsurePrefix(machine.DiscoKey)),
+		)
+		if err != nil {
+			return key.DiscoPublic{}, fmt.Errorf("failed to parse disco public key: %w", err)
+		}
+	} else {
+		discoKey = key.DiscoPublic{}
+	}
+
+	return discoKey, nil
+}
+
+func (machine *Machine) NodePublicKey() (key.NodePublic, error) {
+	var nodeKey key.NodePublic
+	err := nodeKey.UnmarshalText([]byte(util.NodePublicKeyEnsurePrefix(machine.NodeKey)))
+	if err != nil {
+		return key.NodePublic{}, fmt.Errorf("failed to parse node public key: %w", err)
+	}
+
+	return nodeKey, nil
+}
+
 func (machine Machine) String() string {
 	return machine.Hostname
 }