Files
headscale/hscontrol/types/node.go
2025-12-12 11:35:16 +01:00

1084 lines
27 KiB
Go

package types
import (
"errors"
"fmt"
"net/netip"
"regexp"
"slices"
"strconv"
"strings"
"time"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/policy/matcher"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/rs/zerolog/log"
"go4.org/netipx"
"google.golang.org/protobuf/types/known/timestamppb"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
var (
ErrNodeAddressesInvalid = errors.New("failed to parse node addresses")
ErrHostnameTooLong = errors.New("hostname too long, cannot except 255 ASCII chars")
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
)
func (n NodeIDs) Len() int { return len(n) }
func (n NodeIDs) Less(i, j int) bool { return n[i] < n[j] }
func (n NodeIDs) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
func (id NodeID) StableID() tailcfg.StableNodeID {
return tailcfg.StableNodeID(strconv.FormatUint(uint64(id), util.Base10))
}
func (id NodeID) NodeID() tailcfg.NodeID {
return tailcfg.NodeID(id)
}
func (id NodeID) Uint64() uint64 {
return uint64(id)
}
func (id NodeID) String() string {
return strconv.FormatUint(id.Uint64(), util.Base10)
}
func ParseNodeID(s string) (NodeID, error) {
id, err := strconv.ParseUint(s, util.Base10, 64)
return NodeID(id), err
}
func MustParseNodeID(s string) NodeID {
id, err := ParseNodeID(s)
if err != nil {
panic(err)
}
return id
}
// Node is a Headscale client.
type Node struct {
ID NodeID `gorm:"primary_key"`
MachineKey key.MachinePublic `gorm:"serializer:text"`
NodeKey key.NodePublic `gorm:"serializer:text"`
DiscoKey key.DiscoPublic `gorm:"serializer:text"`
Endpoints []netip.AddrPort `gorm:"serializer:json"`
Hostinfo *tailcfg.Hostinfo `gorm:"column:host_info;serializer:json"`
IPv4 *netip.Addr `gorm:"column:ipv4;serializer:text"`
IPv6 *netip.Addr `gorm:"column:ipv6;serializer:text"`
// Hostname represents the name given by the Tailscale
// client during registration
Hostname string
// Givenname represents either:
// a DNS normalized version of Hostname
// a valid name set by the User
//
// GivenName is the name used in all DNS related
// parts of headscale.
GivenName string `gorm:"type:varchar(63);unique_index"`
// UserID is set for ALL nodes (tagged and user-owned) to track "created by".
// For tagged nodes, this is informational only - the tag is the owner.
// For user-owned nodes, this identifies the owner.
// Only nil for orphaned nodes (should not happen in normal operation).
UserID *uint
User *User `gorm:"constraint:OnDelete:CASCADE;"`
RegisterMethod string
// Tags is the definitive owner for tagged nodes.
// When non-empty, the node is "tagged" and tags define its identity.
// Empty for user-owned nodes.
// Tags cannot be removed once set (one-way transition).
Tags []string `gorm:"column:tags;serializer:json"`
// When a node has been created with a PreAuthKey, we need to
// prevent the preauthkey from being deleted before the node.
// The preauthkey can define "tags" of the node so we need it
// around.
AuthKeyID *uint64 `sql:"DEFAULT:NULL"`
AuthKey *PreAuthKey
Expiry *time.Time
// LastSeen is when the node was last in contact with
// headscale. It is best effort and not persisted.
LastSeen *time.Time `gorm:"column:last_seen"`
// ApprovedRoutes is a list of routes that the node is allowed to announce
// as a subnet router. They are not necessarily the routes that the node
// announces at the moment.
// See [Node.Hostinfo]
ApprovedRoutes []netip.Prefix `gorm:"column:approved_routes;serializer:json"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
IsOnline *bool `gorm:"-"`
}
type Nodes []*Node
func (ns Nodes) ViewSlice() views.Slice[NodeView] {
vs := make([]NodeView, len(ns))
for i, n := range ns {
vs[i] = n.View()
}
return views.SliceOf(vs)
}
// GivenNameHasBeenChanged returns whether the `givenName` can be automatically changed based on the `Hostname` of the node.
func (node *Node) GivenNameHasBeenChanged() bool {
// Strip invalid DNS characters for givenName comparison
normalised := strings.ToLower(node.Hostname)
normalised = invalidDNSRegex.ReplaceAllString(normalised, "")
return node.GivenName == normalised
}
// IsExpired returns whether the node registration has expired.
func (node Node) IsExpired() bool {
// If Expiry is not set, the client has not indicated that
// it wants an expiry time, it is therefore considered
// to mean "not expired"
if node.Expiry == nil || node.Expiry.IsZero() {
return false
}
return time.Since(*node.Expiry) > 0
}
// IsEphemeral returns if the node is registered as an Ephemeral node.
// https://tailscale.com/kb/1111/ephemeral-nodes/
func (node *Node) IsEphemeral() bool {
return node.AuthKey != nil && node.AuthKey.Ephemeral
}
func (node *Node) IPs() []netip.Addr {
var ret []netip.Addr
if node.IPv4 != nil {
ret = append(ret, *node.IPv4)
}
if node.IPv6 != nil {
ret = append(ret, *node.IPv6)
}
return ret
}
// HasIP reports if a node has a given IP address.
func (node *Node) HasIP(i netip.Addr) bool {
for _, ip := range node.IPs() {
if ip.Compare(i) == 0 {
return true
}
}
return false
}
// IsTagged reports if a device is tagged and therefore should not be treated
// as a user-owned device.
// When a node has tags, the tags define its identity (not the user).
func (node *Node) IsTagged() bool {
return len(node.Tags) > 0
}
// IsUserOwned returns true if node is owned by a user (not tagged).
// Tagged nodes may have a UserID for "created by" tracking, but the tag is the owner.
func (node *Node) IsUserOwned() bool {
return !node.IsTagged()
}
// HasTag reports if a node has a given tag.
func (node *Node) HasTag(tag string) bool {
return slices.Contains(node.Tags, tag)
}
// TypedUserID returns the UserID as a typed UserID type.
// Returns 0 if UserID is nil.
func (node *Node) TypedUserID() UserID {
if node.UserID == nil {
return 0
}
return UserID(*node.UserID)
}
func (node *Node) RequestTags() []string {
if node.Hostinfo == nil {
return []string{}
}
return node.Hostinfo.RequestTags
}
func (node *Node) Prefixes() []netip.Prefix {
var addrs []netip.Prefix
for _, nodeAddress := range node.IPs() {
ip := netip.PrefixFrom(nodeAddress, nodeAddress.BitLen())
addrs = append(addrs, ip)
}
return addrs
}
// 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 (node *Node) ExitRoutes() []netip.Prefix {
var routes []netip.Prefix
for _, route := range node.AnnouncedRoutes() {
if tsaddr.IsExitRoute(route) && slices.Contains(node.ApprovedRoutes, route) {
routes = append(routes, route)
}
}
return routes
}
func (node *Node) IsExitNode() bool {
return len(node.ExitRoutes()) > 0
}
func (node *Node) IPsAsString() []string {
var ret []string
for _, ip := range node.IPs() {
ret = append(ret, ip.String())
}
return ret
}
func (node *Node) InIPSet(set *netipx.IPSet) bool {
return slices.ContainsFunc(node.IPs(), set.Contains)
}
// AppendToIPSet adds the individual ips in NodeAddresses to a
// given netipx.IPSetBuilder.
func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
for _, ip := range node.IPs() {
build.Add(ip)
}
}
func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
src := node.IPs()
allowedIPs := node2.IPs()
for _, matcher := range matchers {
if !matcher.SrcsContainsIPs(src...) {
continue
}
if matcher.DestsContainsIP(allowedIPs...) {
return true
}
// Check if the node has access to routes that might be part of a
// smaller subnet that is served from node2 as a subnet router.
if matcher.DestsOverlapsPrefixes(node2.SubnetRoutes()...) {
return true
}
// If the dst is "the internet" and node2 is an exit node, allow access.
if matcher.DestsIsTheInternet() && node2.IsExitNode() {
return true
}
}
return false
}
func (node *Node) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
src := node.IPs()
for _, matcher := range matchers {
if matcher.SrcsContainsIPs(src...) && matcher.DestsOverlapsPrefixes(route) {
return true
}
if matcher.SrcsOverlapsPrefixes(route) && matcher.DestsContainsIP(src...) {
return true
}
}
return false
}
func (nodes Nodes) FilterByIP(ip netip.Addr) Nodes {
var found Nodes
for _, node := range nodes {
if node.IPv4 != nil && ip == *node.IPv4 {
found = append(found, node)
continue
}
if node.IPv6 != nil && ip == *node.IPv6 {
found = append(found, node)
}
}
return found
}
func (nodes Nodes) ContainsNodeKey(nodeKey key.NodePublic) bool {
for _, node := range nodes {
if node.NodeKey == nodeKey {
return true
}
}
return false
}
func (node *Node) Proto() *v1.Node {
nodeProto := &v1.Node{
Id: uint64(node.ID),
MachineKey: node.MachineKey.String(),
NodeKey: node.NodeKey.String(),
DiscoKey: node.DiscoKey.String(),
// TODO(kradalby): replace list with v4, v6 field?
IpAddresses: node.IPsAsString(),
Name: node.Hostname,
GivenName: node.GivenName,
User: nil, // Will be set below based on node type
ForcedTags: node.Tags,
Online: node.IsOnline != nil && *node.IsOnline,
// Only ApprovedRoutes and AvailableRoutes is set here. SubnetRoutes has
// to be populated manually with PrimaryRoute, to ensure it includes the
// routes that are actively served from the node.
ApprovedRoutes: util.PrefixesToString(node.ApprovedRoutes),
AvailableRoutes: util.PrefixesToString(node.AnnouncedRoutes()),
RegisterMethod: node.RegisterMethodToV1Enum(),
CreatedAt: timestamppb.New(node.CreatedAt),
}
// Set User field based on node ownership
// Note: User will be set to TaggedDevices in the gRPC layer (grpcv1.go)
// for proper MapResponse formatting
if node.User != nil {
nodeProto.User = node.User.Proto()
}
if node.AuthKey != nil {
nodeProto.PreAuthKey = node.AuthKey.Proto()
}
if node.LastSeen != nil {
nodeProto.LastSeen = timestamppb.New(*node.LastSeen)
}
if node.Expiry != nil {
nodeProto.Expiry = timestamppb.New(*node.Expiry)
}
return nodeProto
}
func (node *Node) GetFQDN(baseDomain string) (string, error) {
if node.GivenName == "" {
return "", fmt.Errorf("failed to create valid FQDN: %w", ErrNodeHasNoGivenName)
}
hostname := node.GivenName
if baseDomain != "" {
hostname = fmt.Sprintf(
"%s.%s.",
node.GivenName,
baseDomain,
)
}
if len(hostname) > MaxHostnameLength {
return "", fmt.Errorf(
"failed to create valid FQDN (%s): %w",
hostname,
ErrHostnameTooLong,
)
}
return hostname, nil
}
// AnnouncedRoutes returns the list of routes that the node announces.
// It should be used instead of checking Hostinfo.RoutableIPs directly.
func (node *Node) AnnouncedRoutes() []netip.Prefix {
if node.Hostinfo == nil {
return nil
}
return node.Hostinfo.RoutableIPs
}
// SubnetRoutes returns the list of routes (excluding exit routes) that the node
// announces and are approved.
//
// IMPORTANT: This method is used for internal data structures and should NOT be
// used for the gRPC Proto conversion. For Proto, SubnetRoutes must be populated
// manually with PrimaryRoutes to ensure it includes only routes actively served
// by the node. See the comment in Proto() method and the implementation in
// grpcv1.go/nodesToProto.
func (node *Node) SubnetRoutes() []netip.Prefix {
var routes []netip.Prefix
for _, route := range node.AnnouncedRoutes() {
if tsaddr.IsExitRoute(route) {
continue
}
if slices.Contains(node.ApprovedRoutes, route) {
routes = append(routes, route)
}
}
return routes
}
// IsSubnetRouter reports if the node has any subnet routes.
func (node *Node) IsSubnetRouter() bool {
return len(node.SubnetRoutes()) > 0
}
// AllApprovedRoutes returns the combination of SubnetRoutes and ExitRoutes
func (node *Node) AllApprovedRoutes() []netip.Prefix {
return append(node.SubnetRoutes(), node.ExitRoutes()...)
}
func (node *Node) String() string {
return node.Hostname
}
// 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.
// When a field is added to this function, remember to also add it to:
// - node.ApplyPeerChange
// - logTracePeerChange in poll.go.
func (node *Node) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
ret := tailcfg.PeerChange{
NodeID: tailcfg.NodeID(node.ID),
}
if node.NodeKey.String() != req.NodeKey.String() {
ret.Key = &req.NodeKey
}
if node.DiscoKey.String() != req.DiscoKey.String() {
ret.DiscoKey = &req.DiscoKey
}
if node.Hostinfo != nil &&
node.Hostinfo.NetInfo != nil &&
req.Hostinfo != nil &&
req.Hostinfo.NetInfo != nil &&
node.Hostinfo.NetInfo.PreferredDERP != req.Hostinfo.NetInfo.PreferredDERP {
ret.DERPRegion = req.Hostinfo.NetInfo.PreferredDERP
}
if req.Hostinfo != nil && req.Hostinfo.NetInfo != nil {
// If there is no stored Hostinfo or NetInfo, use
// the new PreferredDERP.
if node.Hostinfo == nil {
ret.DERPRegion = req.Hostinfo.NetInfo.PreferredDERP
} else if node.Hostinfo.NetInfo == nil {
ret.DERPRegion = req.Hostinfo.NetInfo.PreferredDERP
} else {
// If there is a PreferredDERP check if it has changed.
if node.Hostinfo.NetInfo.PreferredDERP != req.Hostinfo.NetInfo.PreferredDERP {
ret.DERPRegion = req.Hostinfo.NetInfo.PreferredDERP
}
}
}
// Compare endpoints using order-independent comparison
if EndpointsChanged(node.Endpoints, req.Endpoints) {
ret.Endpoints = req.Endpoints
}
now := time.Now()
ret.LastSeen = &now
return ret
}
// EndpointsChanged compares two endpoint slices and returns true if they differ.
// The comparison is order-independent - endpoints are sorted before comparison.
func EndpointsChanged(oldEndpoints, newEndpoints []netip.AddrPort) bool {
if len(oldEndpoints) != len(newEndpoints) {
return true
}
if len(oldEndpoints) == 0 {
return false
}
// Make copies to avoid modifying the original slices
oldCopy := slices.Clone(oldEndpoints)
newCopy := slices.Clone(newEndpoints)
// Sort both slices to enable order-independent comparison
slices.SortFunc(oldCopy, func(a, b netip.AddrPort) int {
return a.Compare(b)
})
slices.SortFunc(newCopy, func(a, b netip.AddrPort) int {
return a.Compare(b)
})
return !slices.Equal(oldCopy, newCopy)
}
func (node *Node) RegisterMethodToV1Enum() v1.RegisterMethod {
switch node.RegisterMethod {
case "authkey":
return v1.RegisterMethod_REGISTER_METHOD_AUTH_KEY
case "oidc":
return v1.RegisterMethod_REGISTER_METHOD_OIDC
case "cli":
return v1.RegisterMethod_REGISTER_METHOD_CLI
default:
return v1.RegisterMethod_REGISTER_METHOD_UNSPECIFIED
}
}
// ApplyHostnameFromHostInfo takes a Hostinfo struct and updates the node.
func (node *Node) ApplyHostnameFromHostInfo(hostInfo *tailcfg.Hostinfo) {
if hostInfo == nil {
return
}
newHostname := strings.ToLower(hostInfo.Hostname)
if err := util.ValidateHostname(newHostname); err != nil {
log.Warn().
Str("node.id", node.ID.String()).
Str("current_hostname", node.Hostname).
Str("rejected_hostname", hostInfo.Hostname).
Err(err).
Msg("Rejecting invalid hostname update from hostinfo")
return
}
if node.Hostname != newHostname {
log.Trace().
Str("node.id", node.ID.String()).
Str("old_hostname", node.Hostname).
Str("new_hostname", newHostname).
Str("old_given_name", node.GivenName).
Bool("given_name_changed", node.GivenNameHasBeenChanged()).
Msg("Updating hostname from hostinfo")
if node.GivenNameHasBeenChanged() {
// Strip invalid DNS characters for givenName display
givenName := strings.ToLower(newHostname)
givenName = invalidDNSRegex.ReplaceAllString(givenName, "")
node.GivenName = givenName
}
node.Hostname = newHostname
log.Trace().
Str("node.id", node.ID.String()).
Str("new_hostname", node.Hostname).
Str("new_given_name", node.GivenName).
Msg("Hostname updated")
}
}
// ApplyPeerChange takes a PeerChange struct and updates the node.
func (node *Node) ApplyPeerChange(change *tailcfg.PeerChange) {
if change.Key != nil {
node.NodeKey = *change.Key
}
if change.DiscoKey != nil {
node.DiscoKey = *change.DiscoKey
}
if change.Online != nil {
node.IsOnline = change.Online
}
if change.Endpoints != nil {
node.Endpoints = change.Endpoints
}
// This might technically not be useful as we replace
// the whole hostinfo blob when it has changed.
if change.DERPRegion != 0 {
if node.Hostinfo == nil {
node.Hostinfo = &tailcfg.Hostinfo{
NetInfo: &tailcfg.NetInfo{
PreferredDERP: change.DERPRegion,
},
}
} else if node.Hostinfo.NetInfo == nil {
node.Hostinfo.NetInfo = &tailcfg.NetInfo{
PreferredDERP: change.DERPRegion,
}
} else {
node.Hostinfo.NetInfo.PreferredDERP = change.DERPRegion
}
}
node.LastSeen = change.LastSeen
}
func (nodes Nodes) String() string {
temp := make([]string, len(nodes))
for index, node := range nodes {
temp[index] = node.Hostname
}
return fmt.Sprintf("[ %s ](%d)", strings.Join(temp, ", "), len(temp))
}
func (nodes Nodes) IDMap() map[NodeID]*Node {
ret := map[NodeID]*Node{}
for _, node := range nodes {
ret[node.ID] = node
}
return ret
}
func (nodes Nodes) DebugString() string {
var sb strings.Builder
sb.WriteString("Nodes:\n")
for _, node := range nodes {
sb.WriteString(node.DebugString())
sb.WriteString("\n")
}
return sb.String()
}
func (node Node) DebugString() string {
var sb strings.Builder
fmt.Fprintf(&sb, "%s(%s):\n", node.Hostname, node.ID)
// Show ownership status
if node.IsTagged() {
fmt.Fprintf(&sb, "\tTagged: %v\n", node.Tags)
if node.User != nil {
fmt.Fprintf(&sb, "\tCreated by: %s (%d, %q)\n", node.User.Display(), node.User.ID, node.User.Username())
}
} else if node.User != nil {
fmt.Fprintf(&sb, "\tUser-owned: %s (%d, %q)\n", node.User.Display(), node.User.ID, node.User.Username())
} else {
fmt.Fprintf(&sb, "\tOrphaned: no user or tags\n")
}
fmt.Fprintf(&sb, "\tIPs: %v\n", node.IPs())
fmt.Fprintf(&sb, "\tApprovedRoutes: %v\n", node.ApprovedRoutes)
fmt.Fprintf(&sb, "\tAnnouncedRoutes: %v\n", node.AnnouncedRoutes())
fmt.Fprintf(&sb, "\tSubnetRoutes: %v\n", node.SubnetRoutes())
fmt.Fprintf(&sb, "\tExitRoutes: %v\n", node.ExitRoutes())
sb.WriteString("\n")
return sb.String()
}
func (nv NodeView) UserView() UserView {
return nv.User()
}
func (nv NodeView) IPs() []netip.Addr {
if !nv.Valid() {
return nil
}
return nv.ж.IPs()
}
func (nv NodeView) InIPSet(set *netipx.IPSet) bool {
if !nv.Valid() {
return false
}
return nv.ж.InIPSet(set)
}
func (nv NodeView) CanAccess(matchers []matcher.Match, node2 NodeView) bool {
if !nv.Valid() {
return false
}
return nv.ж.CanAccess(matchers, node2.AsStruct())
}
func (nv NodeView) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
if !nv.Valid() {
return false
}
return nv.ж.CanAccessRoute(matchers, route)
}
func (nv NodeView) AnnouncedRoutes() []netip.Prefix {
if !nv.Valid() {
return nil
}
return nv.ж.AnnouncedRoutes()
}
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
}
nv.ж.AppendToIPSet(build)
}
func (nv NodeView) RequestTagsSlice() views.Slice[string] {
if !nv.Valid() || !nv.Hostinfo().Valid() {
return views.Slice[string]{}
}
return nv.Hostinfo().RequestTags()
}
// IsTagged reports if a device is tagged
// and therefore should not be treated as a
// user owned device.
// Currently, this function only handles tags set
// via CLI ("forced tags" and preauthkeys).
func (nv NodeView) IsTagged() bool {
if !nv.Valid() {
return false
}
return nv.ж.IsTagged()
}
// IsExpired returns whether the node registration has expired.
func (nv NodeView) IsExpired() bool {
if !nv.Valid() {
return true
}
return nv.ж.IsExpired()
}
// IsEphemeral returns if the node is registered as an Ephemeral node.
// https://tailscale.com/kb/1111/ephemeral-nodes/
func (nv NodeView) IsEphemeral() bool {
if !nv.Valid() {
return false
}
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 (nv NodeView) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
if !nv.Valid() {
return tailcfg.PeerChange{}
}
return nv.ж.PeerChangeFromMapRequest(req)
}
// GetFQDN returns the fully qualified domain name for the node.
func (nv NodeView) GetFQDN(baseDomain string) (string, error) {
if !nv.Valid() {
return "", errors.New("failed to create valid FQDN: node view is invalid")
}
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 (nv NodeView) ExitRoutes() []netip.Prefix {
if !nv.Valid() {
return nil
}
return nv.ж.ExitRoutes()
}
func (nv NodeView) IsExitNode() bool {
if !nv.Valid() {
return false
}
return nv.ж.IsExitNode()
}
// RequestTags returns the ACL tags that the node is requesting.
func (nv NodeView) RequestTags() []string {
if !nv.Valid() || !nv.Hostinfo().Valid() {
return []string{}
}
return nv.Hostinfo().RequestTags().AsSlice()
}
// Proto converts the NodeView to a protobuf representation.
func (nv NodeView) Proto() *v1.Node {
if !nv.Valid() {
return nil
}
return nv.ж.Proto()
}
// HasIP reports if a node has a given IP address.
func (nv NodeView) HasIP(i netip.Addr) bool {
if !nv.Valid() {
return false
}
return nv.ж.HasIP(i)
}
// HasTag reports if a node has a given tag.
func (nv NodeView) HasTag(tag string) bool {
if !nv.Valid() {
return false
}
return nv.ж.HasTag(tag)
}
// TypedUserID returns the UserID as a typed UserID type.
// Returns 0 if UserID is nil or node is invalid.
func (nv NodeView) TypedUserID() UserID {
if !nv.Valid() {
return 0
}
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 (nv NodeView) TailscaleUserID() tailcfg.UserID {
if !nv.Valid() {
return 0
}
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(nv.UserID().Get()))
}
// Prefixes returns the node IPs as netip.Prefix.
func (nv NodeView) Prefixes() []netip.Prefix {
if !nv.Valid() {
return nil
}
return nv.ж.Prefixes()
}
// IPsAsString returns the node IPs as strings.
func (nv NodeView) IPsAsString() []string {
if !nv.Valid() {
return nil
}
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 (nv NodeView) HasNetworkChanges(other NodeView) bool {
if !slices.Equal(nv.IPs(), other.IPs()) {
return true
}
if !slices.Equal(nv.AnnouncedRoutes(), other.AnnouncedRoutes()) {
return true
}
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.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
}
if cfg.RandomizeClientPort {
capMap[tailcfg.NodeAttrRandomizeClientPort] = []tailcfg.RawMessage{}
}
if cfg.Taildrop.Enabled {
capMap[tailcfg.CapabilityFileSharing] = []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
}