mapper: move tail node conversion to node type (#2950)

This commit is contained in:
Kristoffer Dalby
2025-12-10 09:16:22 +01:00
committed by GitHub
parent 5d0a6ab0e9
commit c8376e44a2
5 changed files with 234 additions and 234 deletions

View File

@@ -76,8 +76,9 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
}
_, matchers := b.mapper.state.Filter()
tailnode, err := tailNode(
nv, b.capVer,
tailnode, err := nv.TailNode(
b.capVer,
func(id types.NodeID) []netip.Prefix {
return policy.ReduceRoutes(nv, b.mapper.state.GetNodePrimaryRoutes(id), matchers)
},
@@ -251,7 +252,7 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
changedViews = peers
}
tailPeers, err := tailNodes(
tailPeers, err := types.TailNodes(
changedViews, b.capVer,
func(id types.NodeID) []netip.Prefix {
return policy.ReduceRoutes(node, b.mapper.state.GetNodePrimaryRoutes(id), matchers)

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io/fs"
"net/netip"
"net/url"
"os"
"path"
@@ -259,11 +258,6 @@ func writeDebugMapResponse(
}
}
// routeFilterFunc is a function that takes a node ID and returns a list of
// netip.Prefixes that are allowed for that node. It is used to filter routes
// from the primary route manager to the node.
type routeFilterFunc func(id types.NodeID) []netip.Prefix
func (m *mapper) debugMapResponses() (map[types.NodeID][]tailcfg.MapResponse, error) {
if debugDumpMapResponsePath == "" {
return nil, nil

View File

@@ -1,125 +0,0 @@
package mapper
import (
"fmt"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
)
func tailNodes(
nodes views.Slice[types.NodeView],
capVer tailcfg.CapabilityVersion,
primaryRouteFunc routeFilterFunc,
cfg *types.Config,
) ([]*tailcfg.Node, error) {
tNodes := make([]*tailcfg.Node, 0, nodes.Len())
for _, node := range nodes.All() {
tNode, err := tailNode(
node,
capVer,
primaryRouteFunc,
cfg,
)
if err != nil {
return nil, err
}
tNodes = append(tNodes, tNode)
}
return tNodes, nil
}
// tailNode converts a Node into a Tailscale Node.
func tailNode(
node types.NodeView,
capVer tailcfg.CapabilityVersion,
primaryRouteFunc routeFilterFunc,
cfg *types.Config,
) (*tailcfg.Node, error) {
addrs := node.Prefixes()
var derp int
// TODO(kradalby): legacyDERP was removed in tailscale/tailscale@2fc4455e6dd9ab7f879d4e2f7cffc2be81f14077
// and should be removed after 111 is the minimum capver.
var legacyDERP string
if node.Hostinfo().Valid() && node.Hostinfo().NetInfo().Valid() {
legacyDERP = fmt.Sprintf("127.3.3.40:%d", node.Hostinfo().NetInfo().PreferredDERP())
derp = node.Hostinfo().NetInfo().PreferredDERP()
} else {
legacyDERP = "127.3.3.40:0" // Zero means disconnected or unknown.
}
var keyExpiry time.Time
if node.Expiry().Valid() {
keyExpiry = node.Expiry().Get()
} else {
keyExpiry = time.Time{}
}
hostname, err := node.GetFQDN(cfg.BaseDomain)
if err != nil {
return nil, err
}
routes := primaryRouteFunc(node.ID())
allowed := append(addrs, routes...)
allowed = append(allowed, node.ExitRoutes()...)
tsaddr.SortPrefixes(allowed)
tNode := tailcfg.Node{
ID: tailcfg.NodeID(node.ID()), // this is the actual ID
StableID: node.ID().StableID(),
Name: hostname,
Cap: capVer,
User: node.TailscaleUserID(),
Key: node.NodeKey(),
KeyExpiry: keyExpiry.UTC(),
Machine: node.MachineKey(),
DiscoKey: node.DiscoKey(),
Addresses: addrs,
PrimaryRoutes: routes,
AllowedIPs: allowed,
Endpoints: node.Endpoints().AsSlice(),
HomeDERP: derp,
LegacyDERPString: legacyDERP,
Hostinfo: node.Hostinfo(),
Created: node.CreatedAt().UTC(),
Online: node.IsOnline().Clone(),
Tags: node.Tags().AsSlice(),
MachineAuthorized: !node.IsExpired(),
Expired: node.IsExpired(),
}
tNode.CapMap = tailcfg.NodeCapMap{
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
}
if cfg.RandomizeClientPort {
tNode.CapMap[tailcfg.NodeAttrRandomizeClientPort] = []tailcfg.RawMessage{}
}
// Set LastSeen only for offline nodes to avoid confusing Tailscale clients
// during rapid reconnection cycles. Online nodes should not have LastSeen set
// as this can make clients interpret them as "not online" despite Online=true.
if node.LastSeen().Valid() && node.IsOnline().Valid() && !node.IsOnline().Get() {
lastSeen := node.LastSeen().Get()
tNode.LastSeen = &lastSeen
}
return &tNode, nil
}

View File

@@ -211,8 +211,7 @@ func TestTailNode(t *testing.T) {
// This is a hack to avoid having a second node to test the primary route.
// This should be baked into the test case proper if it is extended in the future.
_ = primary.SetRoutes(2, netip.MustParsePrefix("192.168.0.0/24"))
got, err := tailNode(
tt.node.View(),
got, err := tt.node.View().TailNode(
0,
func(id types.NodeID) []netip.Prefix {
return primary.PrimaryRoutes(id)
@@ -221,13 +220,13 @@ func TestTailNode(t *testing.T) {
)
if (err != nil) != tt.wantErr {
t.Errorf("tailNode() error = %v, wantErr %v", err, 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)
t.Errorf("TailNode() unexpected result (-want +got):\n%s", diff)
}
})
}
@@ -268,8 +267,7 @@ func TestNodeExpiry(t *testing.T) {
Expiry: tt.exp,
}
tn, err := tailNode(
node.View(),
tn, err := node.View().TailNode(
0,
func(id types.NodeID) []netip.Prefix {
return []netip.Prefix{}

View File

@@ -28,10 +28,15 @@ var (
ErrNodeHasNoGivenName = errors.New("node has no given name")
ErrNodeUserHasNoName = errors.New("node user has no name")
ErrCannotRemoveAllTags = errors.New("cannot remove all tags from node")
ErrInvalidNodeView = errors.New("cannot convert invalid NodeView to tailcfg.Node")
invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+")
)
// RouteFunc is a function that takes a node ID and returns a list of
// netip.Prefixes representing the primary routes for that node.
type RouteFunc func(id NodeID) []netip.Prefix
type (
NodeID uint64
NodeIDs []NodeID
@@ -714,80 +719,88 @@ func (node Node) DebugString() string {
return sb.String()
}
func (v NodeView) UserView() UserView {
return v.User()
func (nv NodeView) UserView() UserView {
return nv.User()
}
func (v NodeView) IPs() []netip.Addr {
if !v.Valid() {
func (nv NodeView) IPs() []netip.Addr {
if !nv.Valid() {
return nil
}
return v.ж.IPs()
return nv.ж.IPs()
}
func (v NodeView) InIPSet(set *netipx.IPSet) bool {
if !v.Valid() {
return false
}
return v.ж.InIPSet(set)
}
func (v NodeView) CanAccess(matchers []matcher.Match, node2 NodeView) bool {
if !v.Valid() {
func (nv NodeView) InIPSet(set *netipx.IPSet) bool {
if !nv.Valid() {
return false
}
return v.ж.CanAccess(matchers, node2.AsStruct())
return nv.ж.InIPSet(set)
}
func (v NodeView) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
if !v.Valid() {
func (nv NodeView) CanAccess(matchers []matcher.Match, node2 NodeView) bool {
if !nv.Valid() {
return false
}
return v.ж.CanAccessRoute(matchers, route)
return nv.ж.CanAccess(matchers, node2.AsStruct())
}
func (v NodeView) AnnouncedRoutes() []netip.Prefix {
if !v.Valid() {
return nil
}
return v.ж.AnnouncedRoutes()
}
func (v NodeView) SubnetRoutes() []netip.Prefix {
if !v.Valid() {
return nil
}
return v.ж.SubnetRoutes()
}
func (v NodeView) IsSubnetRouter() bool {
if !v.Valid() {
func (nv NodeView) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
if !nv.Valid() {
return false
}
return v.ж.IsSubnetRouter()
return nv.ж.CanAccessRoute(matchers, route)
}
func (v NodeView) AllApprovedRoutes() []netip.Prefix {
if !v.Valid() {
func (nv NodeView) AnnouncedRoutes() []netip.Prefix {
if !nv.Valid() {
return nil
}
return v.ж.AllApprovedRoutes()
return nv.ж.AnnouncedRoutes()
}
func (v NodeView) AppendToIPSet(build *netipx.IPSetBuilder) {
if !v.Valid() {
func (nv NodeView) SubnetRoutes() []netip.Prefix {
if !nv.Valid() {
return nil
}
return nv.ж.SubnetRoutes()
}
func (nv NodeView) IsSubnetRouter() bool {
if !nv.Valid() {
return false
}
return nv.ж.IsSubnetRouter()
}
func (nv NodeView) AllApprovedRoutes() []netip.Prefix {
if !nv.Valid() {
return nil
}
return nv.ж.AllApprovedRoutes()
}
func (nv NodeView) AppendToIPSet(build *netipx.IPSetBuilder) {
if !nv.Valid() {
return
}
v.ж.AppendToIPSet(build)
nv.ж.AppendToIPSet(build)
}
func (v NodeView) RequestTagsSlice() views.Slice[string] {
if !v.Valid() || !v.Hostinfo().Valid() {
func (nv NodeView) RequestTagsSlice() views.Slice[string] {
if !nv.Valid() || !nv.Hostinfo().Valid() {
return views.Slice[string]{}
}
return v.Hostinfo().RequestTags()
return nv.Hostinfo().RequestTags()
}
// IsTagged reports if a device is tagged
@@ -795,154 +808,273 @@ func (v NodeView) RequestTagsSlice() views.Slice[string] {
// user owned device.
// Currently, this function only handles tags set
// via CLI ("forced tags" and preauthkeys).
func (v NodeView) IsTagged() bool {
if !v.Valid() {
func (nv NodeView) IsTagged() bool {
if !nv.Valid() {
return false
}
return v.ж.IsTagged()
return nv.ж.IsTagged()
}
// IsExpired returns whether the node registration has expired.
func (v NodeView) IsExpired() bool {
if !v.Valid() {
func (nv NodeView) IsExpired() bool {
if !nv.Valid() {
return true
}
return v.ж.IsExpired()
return nv.ж.IsExpired()
}
// IsEphemeral returns if the node is registered as an Ephemeral node.
// https://tailscale.com/kb/1111/ephemeral-nodes/
func (v NodeView) IsEphemeral() bool {
if !v.Valid() {
func (nv NodeView) IsEphemeral() bool {
if !nv.Valid() {
return false
}
return v.ж.IsEphemeral()
return nv.ж.IsEphemeral()
}
// PeerChangeFromMapRequest takes a MapRequest and compares it to the node
// to produce a PeerChange struct that can be used to updated the node and
// inform peers about smaller changes to the node.
func (v NodeView) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
if !v.Valid() {
func (nv NodeView) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
if !nv.Valid() {
return tailcfg.PeerChange{}
}
return v.ж.PeerChangeFromMapRequest(req)
return nv.ж.PeerChangeFromMapRequest(req)
}
// GetFQDN returns the fully qualified domain name for the node.
func (v NodeView) GetFQDN(baseDomain string) (string, error) {
if !v.Valid() {
func (nv NodeView) GetFQDN(baseDomain string) (string, error) {
if !nv.Valid() {
return "", errors.New("failed to create valid FQDN: node view is invalid")
}
return v.ж.GetFQDN(baseDomain)
return nv.ж.GetFQDN(baseDomain)
}
// ExitRoutes returns a list of both exit routes if the
// node has any exit routes enabled.
// If none are enabled, it will return nil.
func (v NodeView) ExitRoutes() []netip.Prefix {
if !v.Valid() {
func (nv NodeView) ExitRoutes() []netip.Prefix {
if !nv.Valid() {
return nil
}
return v.ж.ExitRoutes()
return nv.ж.ExitRoutes()
}
func (v NodeView) IsExitNode() bool {
if !v.Valid() {
func (nv NodeView) IsExitNode() bool {
if !nv.Valid() {
return false
}
return v.ж.IsExitNode()
return nv.ж.IsExitNode()
}
// RequestTags returns the ACL tags that the node is requesting.
func (v NodeView) RequestTags() []string {
if !v.Valid() || !v.Hostinfo().Valid() {
func (nv NodeView) RequestTags() []string {
if !nv.Valid() || !nv.Hostinfo().Valid() {
return []string{}
}
return v.Hostinfo().RequestTags().AsSlice()
return nv.Hostinfo().RequestTags().AsSlice()
}
// Proto converts the NodeView to a protobuf representation.
func (v NodeView) Proto() *v1.Node {
if !v.Valid() {
func (nv NodeView) Proto() *v1.Node {
if !nv.Valid() {
return nil
}
return v.ж.Proto()
return nv.ж.Proto()
}
// HasIP reports if a node has a given IP address.
func (v NodeView) HasIP(i netip.Addr) bool {
if !v.Valid() {
func (nv NodeView) HasIP(i netip.Addr) bool {
if !nv.Valid() {
return false
}
return v.ж.HasIP(i)
return nv.ж.HasIP(i)
}
// HasTag reports if a node has a given tag.
func (v NodeView) HasTag(tag string) bool {
if !v.Valid() {
func (nv NodeView) HasTag(tag string) bool {
if !nv.Valid() {
return false
}
return v.ж.HasTag(tag)
return nv.ж.HasTag(tag)
}
// TypedUserID returns the UserID as a typed UserID type.
// Returns 0 if UserID is nil or node is invalid.
func (v NodeView) TypedUserID() UserID {
if !v.Valid() {
func (nv NodeView) TypedUserID() UserID {
if !nv.Valid() {
return 0
}
return v.ж.TypedUserID()
return nv.ж.TypedUserID()
}
// TailscaleUserID returns the user ID to use in Tailscale protocol.
// Tagged nodes always return TaggedDevices.ID, user-owned nodes return their actual UserID.
func (v NodeView) TailscaleUserID() tailcfg.UserID {
if !v.Valid() {
func (nv NodeView) TailscaleUserID() tailcfg.UserID {
if !nv.Valid() {
return 0
}
if v.IsTagged() {
if nv.IsTagged() {
//nolint:gosec // G115: TaggedDevices.ID is a constant that fits in int64
return tailcfg.UserID(int64(TaggedDevices.ID))
}
//nolint:gosec // G115: UserID values are within int64 range
return tailcfg.UserID(int64(v.UserID().Get()))
return tailcfg.UserID(int64(nv.UserID().Get()))
}
// Prefixes returns the node IPs as netip.Prefix.
func (v NodeView) Prefixes() []netip.Prefix {
if !v.Valid() {
func (nv NodeView) Prefixes() []netip.Prefix {
if !nv.Valid() {
return nil
}
return v.ж.Prefixes()
return nv.ж.Prefixes()
}
// IPsAsString returns the node IPs as strings.
func (v NodeView) IPsAsString() []string {
if !v.Valid() {
func (nv NodeView) IPsAsString() []string {
if !nv.Valid() {
return nil
}
return v.ж.IPsAsString()
return nv.ж.IPsAsString()
}
// HasNetworkChanges checks if the node has network-related changes.
// Returns true if IPs, announced routes, or approved routes changed.
// This is primarily used for policy cache invalidation.
func (v NodeView) HasNetworkChanges(other NodeView) bool {
if !slices.Equal(v.IPs(), other.IPs()) {
func (nv NodeView) HasNetworkChanges(other NodeView) bool {
if !slices.Equal(nv.IPs(), other.IPs()) {
return true
}
if !slices.Equal(v.AnnouncedRoutes(), other.AnnouncedRoutes()) {
if !slices.Equal(nv.AnnouncedRoutes(), other.AnnouncedRoutes()) {
return true
}
if !slices.Equal(v.SubnetRoutes(), other.SubnetRoutes()) {
if !slices.Equal(nv.SubnetRoutes(), other.SubnetRoutes()) {
return true
}
return false
}
// TailNodes converts a slice of NodeViews into Tailscale tailcfg.Nodes.
func TailNodes(
nodes views.Slice[NodeView],
capVer tailcfg.CapabilityVersion,
primaryRouteFunc RouteFunc,
cfg *Config,
) ([]*tailcfg.Node, error) {
tNodes := make([]*tailcfg.Node, 0, nodes.Len())
for _, node := range nodes.All() {
tNode, err := node.TailNode(capVer, primaryRouteFunc, cfg)
if err != nil {
return nil, err
}
tNodes = append(tNodes, tNode)
}
return tNodes, nil
}
// TailNode converts a NodeView into a Tailscale tailcfg.Node.
func (nv NodeView) TailNode(
capVer tailcfg.CapabilityVersion,
primaryRouteFunc RouteFunc,
cfg *Config,
) (*tailcfg.Node, error) {
if !nv.Valid() {
return nil, ErrInvalidNodeView
}
hostname, err := nv.GetFQDN(cfg.BaseDomain)
if err != nil {
return nil, err
}
var derp int
// TODO(kradalby): legacyDERP was removed in tailscale/tailscale@2fc4455e6dd9ab7f879d4e2f7cffc2be81f14077
// and should be removed after 111 is the minimum capver.
legacyDERP := "127.3.3.40:0" // Zero means disconnected or unknown.
if nv.Hostinfo().Valid() && nv.Hostinfo().NetInfo().Valid() {
legacyDERP = fmt.Sprintf("127.3.3.40:%d", nv.Hostinfo().NetInfo().PreferredDERP())
derp = nv.Hostinfo().NetInfo().PreferredDERP()
}
var keyExpiry time.Time
if nv.Expiry().Valid() {
keyExpiry = nv.Expiry().Get()
}
primaryRoutes := primaryRouteFunc(nv.ID())
allowedIPs := slices.Concat(nv.Prefixes(), primaryRoutes, nv.ExitRoutes())
tsaddr.SortPrefixes(allowedIPs)
capMap := tailcfg.NodeCapMap{
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
}
if cfg.RandomizeClientPort {
capMap[tailcfg.NodeAttrRandomizeClientPort] = []tailcfg.RawMessage{}
}
tNode := tailcfg.Node{
//nolint:gosec // G115: NodeID values are within int64 range
ID: tailcfg.NodeID(nv.ID()),
StableID: nv.ID().StableID(),
Name: hostname,
Cap: capVer,
CapMap: capMap,
User: nv.TailscaleUserID(),
Key: nv.NodeKey(),
KeyExpiry: keyExpiry.UTC(),
Machine: nv.MachineKey(),
DiscoKey: nv.DiscoKey(),
Addresses: nv.Prefixes(),
PrimaryRoutes: primaryRoutes,
AllowedIPs: allowedIPs,
Endpoints: nv.Endpoints().AsSlice(),
HomeDERP: derp,
LegacyDERPString: legacyDERP,
Hostinfo: nv.Hostinfo(),
Created: nv.CreatedAt().UTC(),
Online: nv.IsOnline().Clone(),
Tags: nv.Tags().AsSlice(),
MachineAuthorized: !nv.IsExpired(),
Expired: nv.IsExpired(),
}
// Set LastSeen only for offline nodes to avoid confusing Tailscale clients
// during rapid reconnection cycles. Online nodes should not have LastSeen set
// as this can make clients interpret them as "not online" despite Online=true.
if nv.LastSeen().Valid() && nv.IsOnline().Valid() && !nv.IsOnline().Get() {
lastSeen := nv.LastSeen().Get()
tNode.LastSeen = &lastSeen
}
return &tNode, nil
}