all: use immutable node view in read path

This commit changes most of our (*)types.Node to
types.NodeView, which is a readonly version of the
underlying node ensuring that there is no mutations
happening in the read path.

Based on the migration, there didnt seem to be any, but the
idea here is to prevent it in the future and simplify other
new implementations.

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
Kristoffer Dalby
2025-07-05 23:31:13 +02:00
committed by Kristoffer Dalby
parent 5ba7120418
commit 73023c2ec3
24 changed files with 866 additions and 196 deletions

View File

@@ -1,3 +1,5 @@
//go:generate go run tailscale.com/cmd/viewer --type=User,Node,PreAuthKey
package types
import (

View File

@@ -18,6 +18,7 @@ import (
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
var (
@@ -115,6 +116,15 @@ type Node struct {
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 {
return node.GivenName == util.ConvertWithFQDNRules(node.Hostname)
@@ -582,3 +592,185 @@ func (node Node) DebugString() string {
sb.WriteString("\n")
return sb.String()
}
func (v NodeView) IPs() []netip.Addr {
if !v.Valid() {
return nil
}
return v.ж.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() || !node2.Valid() {
return false
}
src := v.IPs()
allowedIPs := node2.IPs()
for _, matcher := range matchers {
if !matcher.SrcsContainsIPs(src...) {
continue
}
if matcher.DestsContainsIP(allowedIPs...) {
return true
}
if matcher.DestsOverlapsPrefixes(node2.SubnetRoutes()...) {
return true
}
}
return false
}
func (v NodeView) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
if !v.Valid() {
return false
}
src := v.IPs()
for _, matcher := range matchers {
if !matcher.SrcsContainsIPs(src...) {
continue
}
if matcher.DestsOverlapsPrefixes(route) {
return true
}
}
return false
}
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) AppendToIPSet(build *netipx.IPSetBuilder) {
if !v.Valid() {
return
}
v.ж.AppendToIPSet(build)
}
func (v NodeView) RequestTagsSlice() views.Slice[string] {
if !v.Valid() || !v.Hostinfo().Valid() {
return views.Slice[string]{}
}
return v.Hostinfo().RequestTags()
}
func (v NodeView) Tags() []string {
if !v.Valid() {
return nil
}
return v.ж.Tags()
}
// 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 (v NodeView) IsTagged() bool {
if !v.Valid() {
return false
}
return v.ж.IsTagged()
}
// IsExpired returns whether the node registration has expired.
func (v NodeView) IsExpired() bool {
if !v.Valid() {
return true
}
return v.ж.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() {
return false
}
return v.ж.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() {
return tailcfg.PeerChange{}
}
return v.ж.PeerChangeFromMapRequest(req)
}
// GetFQDN returns the fully qualified domain name for the node.
func (v NodeView) GetFQDN(baseDomain string) (string, error) {
if !v.Valid() {
return "", fmt.Errorf("failed to create valid FQDN: node view is invalid")
}
return v.ж.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() {
return nil
}
return v.ж.ExitRoutes()
}
// HasIP reports if a node has a given IP address.
func (v NodeView) HasIP(i netip.Addr) bool {
if !v.Valid() {
return false
}
return v.ж.HasIP(i)
}
// HasTag reports if a node has a given tag.
func (v NodeView) HasTag(tag string) bool {
if !v.Valid() {
return false
}
return v.ж.HasTag(tag)
}
// Prefixes returns the node IPs as netip.Prefix.
func (v NodeView) Prefixes() []netip.Prefix {
if !v.Valid() {
return nil
}
return v.ж.Prefixes()
}
// IPsAsString returns the node IPs as strings.
func (v NodeView) IPsAsString() []string {
if !v.Valid() {
return nil
}
return v.ж.IPsAsString()
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
package types
import (
"database/sql"
"net/netip"
"time"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/ptr"
)
// Clone makes a deep copy of User.
// The result aliases no memory with the original.
func (src *User) Clone() *User {
if src == nil {
return nil
}
dst := new(User)
*dst = *src
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _UserCloneNeedsRegeneration = User(struct {
gorm.Model
Name string
DisplayName string
Email string
ProviderIdentifier sql.NullString
Provider string
ProfilePicURL string
}{})
// Clone makes a deep copy of Node.
// The result aliases no memory with the original.
func (src *Node) Clone() *Node {
if src == nil {
return nil
}
dst := new(Node)
*dst = *src
dst.Endpoints = append(src.Endpoints[:0:0], src.Endpoints...)
dst.Hostinfo = src.Hostinfo.Clone()
if dst.IPv4 != nil {
dst.IPv4 = ptr.To(*src.IPv4)
}
if dst.IPv6 != nil {
dst.IPv6 = ptr.To(*src.IPv6)
}
dst.ForcedTags = append(src.ForcedTags[:0:0], src.ForcedTags...)
if dst.AuthKeyID != nil {
dst.AuthKeyID = ptr.To(*src.AuthKeyID)
}
dst.AuthKey = src.AuthKey.Clone()
if dst.Expiry != nil {
dst.Expiry = ptr.To(*src.Expiry)
}
if dst.LastSeen != nil {
dst.LastSeen = ptr.To(*src.LastSeen)
}
dst.ApprovedRoutes = append(src.ApprovedRoutes[:0:0], src.ApprovedRoutes...)
if dst.DeletedAt != nil {
dst.DeletedAt = ptr.To(*src.DeletedAt)
}
if dst.IsOnline != nil {
dst.IsOnline = ptr.To(*src.IsOnline)
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _NodeCloneNeedsRegeneration = Node(struct {
ID NodeID
MachineKey key.MachinePublic
NodeKey key.NodePublic
DiscoKey key.DiscoPublic
Endpoints []netip.AddrPort
Hostinfo *tailcfg.Hostinfo
IPv4 *netip.Addr
IPv6 *netip.Addr
Hostname string
GivenName string
UserID uint
User User
RegisterMethod string
ForcedTags []string
AuthKeyID *uint64
AuthKey *PreAuthKey
Expiry *time.Time
LastSeen *time.Time
ApprovedRoutes []netip.Prefix
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
IsOnline *bool
}{})
// Clone makes a deep copy of PreAuthKey.
// The result aliases no memory with the original.
func (src *PreAuthKey) Clone() *PreAuthKey {
if src == nil {
return nil
}
dst := new(PreAuthKey)
*dst = *src
dst.Tags = append(src.Tags[:0:0], src.Tags...)
if dst.CreatedAt != nil {
dst.CreatedAt = ptr.To(*src.CreatedAt)
}
if dst.Expiration != nil {
dst.Expiration = ptr.To(*src.Expiration)
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _PreAuthKeyCloneNeedsRegeneration = PreAuthKey(struct {
ID uint64
Key string
UserID uint
User User
Reusable bool
Ephemeral bool
Used bool
Tags []string
CreatedAt *time.Time
Expiration *time.Time
}{})

View File

@@ -0,0 +1,270 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale/cmd/viewer; DO NOT EDIT.
package types
import (
"database/sql"
"encoding/json"
"errors"
"net/netip"
"time"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=User,Node,PreAuthKey
// View returns a read-only view of User.
func (p *User) View() UserView {
return UserView{ж: p}
}
// UserView provides a read-only view over User.
//
// Its methods should only be called if `Valid()` returns true.
type UserView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *User
}
// Valid reports whether v's underlying value is non-nil.
func (v UserView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v UserView) AsStruct() *User {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v UserView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *UserView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x User
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v UserView) Model() gorm.Model { return v.ж.Model }
func (v UserView) Name() string { return v.ж.Name }
func (v UserView) DisplayName() string { return v.ж.DisplayName }
func (v UserView) Email() string { return v.ж.Email }
func (v UserView) ProviderIdentifier() sql.NullString { return v.ж.ProviderIdentifier }
func (v UserView) Provider() string { return v.ж.Provider }
func (v UserView) ProfilePicURL() string { return v.ж.ProfilePicURL }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _UserViewNeedsRegeneration = User(struct {
gorm.Model
Name string
DisplayName string
Email string
ProviderIdentifier sql.NullString
Provider string
ProfilePicURL string
}{})
// View returns a read-only view of Node.
func (p *Node) View() NodeView {
return NodeView{ж: p}
}
// NodeView provides a read-only view over Node.
//
// Its methods should only be called if `Valid()` returns true.
type NodeView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *Node
}
// Valid reports whether v's underlying value is non-nil.
func (v NodeView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v NodeView) AsStruct() *Node {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v NodeView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *NodeView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x Node
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v NodeView) ID() NodeID { return v.ж.ID }
func (v NodeView) MachineKey() key.MachinePublic { return v.ж.MachineKey }
func (v NodeView) NodeKey() key.NodePublic { return v.ж.NodeKey }
func (v NodeView) DiscoKey() key.DiscoPublic { return v.ж.DiscoKey }
func (v NodeView) Endpoints() views.Slice[netip.AddrPort] { return views.SliceOf(v.ж.Endpoints) }
func (v NodeView) Hostinfo() tailcfg.HostinfoView { return v.ж.Hostinfo.View() }
func (v NodeView) IPv4() views.ValuePointer[netip.Addr] { return views.ValuePointerOf(v.ж.IPv4) }
func (v NodeView) IPv6() views.ValuePointer[netip.Addr] { return views.ValuePointerOf(v.ж.IPv6) }
func (v NodeView) Hostname() string { return v.ж.Hostname }
func (v NodeView) GivenName() string { return v.ж.GivenName }
func (v NodeView) UserID() uint { return v.ж.UserID }
func (v NodeView) User() User { return v.ж.User }
func (v NodeView) RegisterMethod() string { return v.ж.RegisterMethod }
func (v NodeView) ForcedTags() views.Slice[string] { return views.SliceOf(v.ж.ForcedTags) }
func (v NodeView) AuthKeyID() views.ValuePointer[uint64] { return views.ValuePointerOf(v.ж.AuthKeyID) }
func (v NodeView) AuthKey() PreAuthKeyView { return v.ж.AuthKey.View() }
func (v NodeView) Expiry() views.ValuePointer[time.Time] { return views.ValuePointerOf(v.ж.Expiry) }
func (v NodeView) LastSeen() views.ValuePointer[time.Time] {
return views.ValuePointerOf(v.ж.LastSeen)
}
func (v NodeView) ApprovedRoutes() views.Slice[netip.Prefix] {
return views.SliceOf(v.ж.ApprovedRoutes)
}
func (v NodeView) CreatedAt() time.Time { return v.ж.CreatedAt }
func (v NodeView) UpdatedAt() time.Time { return v.ж.UpdatedAt }
func (v NodeView) DeletedAt() views.ValuePointer[time.Time] {
return views.ValuePointerOf(v.ж.DeletedAt)
}
func (v NodeView) IsOnline() views.ValuePointer[bool] { return views.ValuePointerOf(v.ж.IsOnline) }
func (v NodeView) String() string { return v.ж.String() }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _NodeViewNeedsRegeneration = Node(struct {
ID NodeID
MachineKey key.MachinePublic
NodeKey key.NodePublic
DiscoKey key.DiscoPublic
Endpoints []netip.AddrPort
Hostinfo *tailcfg.Hostinfo
IPv4 *netip.Addr
IPv6 *netip.Addr
Hostname string
GivenName string
UserID uint
User User
RegisterMethod string
ForcedTags []string
AuthKeyID *uint64
AuthKey *PreAuthKey
Expiry *time.Time
LastSeen *time.Time
ApprovedRoutes []netip.Prefix
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
IsOnline *bool
}{})
// View returns a read-only view of PreAuthKey.
func (p *PreAuthKey) View() PreAuthKeyView {
return PreAuthKeyView{ж: p}
}
// PreAuthKeyView provides a read-only view over PreAuthKey.
//
// Its methods should only be called if `Valid()` returns true.
type PreAuthKeyView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *PreAuthKey
}
// Valid reports whether v's underlying value is non-nil.
func (v PreAuthKeyView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v PreAuthKeyView) AsStruct() *PreAuthKey {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v PreAuthKeyView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *PreAuthKeyView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x PreAuthKey
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v PreAuthKeyView) ID() uint64 { return v.ж.ID }
func (v PreAuthKeyView) Key() string { return v.ж.Key }
func (v PreAuthKeyView) UserID() uint { return v.ж.UserID }
func (v PreAuthKeyView) User() User { return v.ж.User }
func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable }
func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral }
func (v PreAuthKeyView) Used() bool { return v.ж.Used }
func (v PreAuthKeyView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) }
func (v PreAuthKeyView) CreatedAt() views.ValuePointer[time.Time] {
return views.ValuePointerOf(v.ж.CreatedAt)
}
func (v PreAuthKeyView) Expiration() views.ValuePointer[time.Time] {
return views.ValuePointerOf(v.ж.Expiration)
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct {
ID uint64
Key string
UserID uint
User User
Reusable bool
Ephemeral bool
Used bool
Tags []string
CreatedAt *time.Time
Expiration *time.Time
}{})