Merge remote-tracking branch 'origin/main'

This commit is contained in:
Ryan Malloy 2026-05-21 17:58:02 -06:00
commit 2c8640f822
1496 changed files with 12110903 additions and 24774 deletions

View file

@ -4,9 +4,18 @@ import (
"time"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
// NewAPIKeyPrefixLength is the length of the prefix for new API keys.
NewAPIKeyPrefixLength = 12
// LegacyAPIKeyPrefixLength is the length of the prefix for legacy API keys.
LegacyAPIKeyPrefixLength = 7
)
// APIKey describes the datamodel for API keys used to remotely authenticate with
// headscale.
type APIKey struct {
@ -21,8 +30,16 @@ type APIKey struct {
func (key *APIKey) Proto() *v1.ApiKey {
protoKey := v1.ApiKey{
Id: key.ID,
Prefix: key.Prefix,
Id: key.ID,
}
// Show prefix format: distinguish between new (12-char) and legacy (7-char) keys
if len(key.Prefix) == NewAPIKeyPrefixLength {
// New format key (12-char prefix)
protoKey.Prefix = "hskey-api-" + key.Prefix + "-***"
} else {
// Legacy format key (7-char prefix) or fallback
protoKey.Prefix = key.Prefix + "***"
}
if key.Expiration != nil {
@ -39,3 +56,33 @@ func (key *APIKey) Proto() *v1.ApiKey {
return &protoKey
}
// maskedPrefix returns the API key prefix in masked format for safe logging.
// SECURITY: Never log the full key or hash, only the masked prefix.
func (k *APIKey) maskedPrefix() string {
if len(k.Prefix) == NewAPIKeyPrefixLength {
return "hskey-api-" + k.Prefix + "-***"
}
return k.Prefix + "***"
}
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
// SECURITY: This method intentionally does NOT log the full key or hash.
// Only the masked prefix is logged for identification purposes.
func (k *APIKey) MarshalZerologObject(e *zerolog.Event) {
if k == nil {
return
}
e.Uint64(zf.APIKeyID, k.ID)
e.Str(zf.APIKeyPrefix, k.maskedPrefix())
if k.Expiration != nil {
e.Time(zf.APIKeyExpiration, *k.Expiration)
}
if k.LastSeen != nil {
e.Time(zf.APIKeyLastSeen, *k.LastSeen)
}
}

View file

@ -1,213 +1,503 @@
//go:generate go tool stringer -type=Change
// Package change declares the [Change] type: a compact description of
// what must land in a [tailcfg.MapResponse]. The mapper reads [Change] values to
// build responses without inspecting state, and [Change.Merge] combines
// multiple pending changes for a single tick.
package change
import (
"errors"
"fmt"
"slices"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/tailcfg"
)
type (
NodeID = types.NodeID
UserID = types.UserID
)
// Change declares what should be included in a [tailcfg.MapResponse].
// The mapper uses this to build the response without guessing.
type Change struct {
// Reason is a human-readable description for logging/debugging.
Reason string
type Change int
// TargetNode, if set, means this response should only be sent to this node.
TargetNode types.NodeID
const (
ChangeUnknown Change = 0
// OriginNode is the node that triggered this change.
// Used for self-update detection and filtering.
OriginNode types.NodeID
// Deprecated: Use specific change instead
// Full is a legacy change to ensure places where we
// have not yet determined the specific update, can send.
Full Change = 9
// Content flags - what to include in the [tailcfg.MapResponse].
IncludeSelf bool
IncludeDERPMap bool
IncludeDNS bool
IncludeDomain bool
IncludePolicy bool // [tailcfg.MapResponse.PacketFilters] and [tailcfg.MapResponse.SSHPolicy] - always sent together
// Server changes.
Policy Change = 11
DERP Change = 12
ExtraRecords Change = 13
// Peer changes.
PeersChanged []types.NodeID
PeersRemoved []types.NodeID
PeerPatches []*tailcfg.PeerChange
SendAllPeers bool
// Node changes.
NodeCameOnline Change = 21
NodeWentOffline Change = 22
NodeRemove Change = 23
NodeKeyExpiry Change = 24
NodeNewOrUpdate Change = 25
// RequiresRuntimePeerComputation indicates that peer visibility
// must be computed at runtime per-node. Used for policy changes
// where each node may have different peer visibility.
RequiresRuntimePeerComputation bool
// User changes.
UserNewOrUpdate Change = 51
UserRemove Change = 52
)
// PingRequest, if non-nil, is a ping request to send to the node.
// Used by the debug ping endpoint to verify node connectivity.
// [Change.PingRequest] is always targeted to a specific node via [Change.TargetNode].
PingRequest *tailcfg.PingRequest
}
// AlsoSelf reports whether this change should also be sent to the node itself.
func (c Change) AlsoSelf() bool {
switch c {
case NodeRemove, NodeKeyExpiry, NodeNewOrUpdate:
return true
// boolFieldNames returns all boolean field names for exhaustive testing.
// When adding a new boolean field to [Change], add it here.
// Tests use reflection to verify this matches the struct.
func (r Change) boolFieldNames() []string {
return []string{
"IncludeSelf",
"IncludeDERPMap",
"IncludeDNS",
"IncludeDomain",
"IncludePolicy",
"SendAllPeers",
"RequiresRuntimePeerComputation",
}
}
func (r Change) Merge(other Change) Change {
merged := r
merged.IncludeSelf = r.IncludeSelf || other.IncludeSelf
merged.IncludeDERPMap = r.IncludeDERPMap || other.IncludeDERPMap
merged.IncludeDNS = r.IncludeDNS || other.IncludeDNS
merged.IncludeDomain = r.IncludeDomain || other.IncludeDomain
merged.IncludePolicy = r.IncludePolicy || other.IncludePolicy
merged.SendAllPeers = r.SendAllPeers || other.SendAllPeers
merged.RequiresRuntimePeerComputation = r.RequiresRuntimePeerComputation || other.RequiresRuntimePeerComputation
merged.PeersChanged = uniqueNodeIDs(slices.Concat(r.PeersChanged, other.PeersChanged))
merged.PeersRemoved = uniqueNodeIDs(slices.Concat(r.PeersRemoved, other.PeersRemoved))
merged.PeerPatches = slices.Concat(r.PeerPatches, other.PeerPatches)
// Preserve [Change.OriginNode] for self-update detection.
// If either change has [Change.OriginNode] set, keep it so the mapper
// can detect self-updates and send the node its own changes.
if merged.OriginNode == 0 {
merged.OriginNode = other.OriginNode
}
return false
}
type ChangeSet struct {
Change Change
// SelfUpdateOnly indicates that this change should only be sent
// to the node itself, and not to other nodes.
// This is used for changes that are not relevant to other nodes.
// NodeID must be set if this is true.
SelfUpdateOnly bool
// NodeID if set, is the ID of the node that is being changed.
// It must be set if this is a node change.
NodeID types.NodeID
// UserID if set, is the ID of the user that is being changed.
// It must be set if this is a user change.
UserID types.UserID
// IsSubnetRouter indicates whether the node is a subnet router.
IsSubnetRouter bool
}
func (c *ChangeSet) Validate() error {
if c.Change >= NodeCameOnline || c.Change <= NodeNewOrUpdate {
if c.NodeID == 0 {
return errors.New("ChangeSet.NodeID must be set for node updates")
}
// Preserve [Change.TargetNode] for targeted responses.
// Merging two changes targeted at different nodes is not supported
// because the merged result can only have one [Change.TargetNode], which
// would cause the other target's content to be misrouted.
if merged.TargetNode != 0 && other.TargetNode != 0 && merged.TargetNode != other.TargetNode {
panic(fmt.Sprintf(
"cannot merge changes with different TargetNode: %d != %d",
merged.TargetNode, other.TargetNode,
))
}
if c.Change >= UserNewOrUpdate || c.Change <= UserRemove {
if c.UserID == 0 {
return errors.New("ChangeSet.UserID must be set for user updates")
}
if merged.TargetNode == 0 {
merged.TargetNode = other.TargetNode
}
return nil
// Preserve [Change.PingRequest] (first wins).
//
// Foot-gun: if two [tailcfg.PingRequest] values to the same target merge in the
// same tick, only the first is emitted. The client-side
// isUniquePingRequest check then suppresses the second when it
// eventually arrives, and the caller waits out the full
// pingTimeout. Call sites must avoid issuing rapid successive
// pings to one node within a single batcher tick.
if merged.PingRequest == nil {
merged.PingRequest = other.PingRequest
}
if r.Reason != "" && other.Reason != "" && r.Reason != other.Reason {
merged.Reason = r.Reason + "; " + other.Reason
} else if other.Reason != "" {
merged.Reason = other.Reason
}
return merged
}
// Empty reports whether the ChangeSet is empty, meaning it does not
// represent any change.
func (c ChangeSet) Empty() bool {
return c.Change == ChangeUnknown && c.NodeID == 0 && c.UserID == 0
func (r Change) IsEmpty() bool {
if r.IncludeSelf || r.IncludeDERPMap || r.IncludeDNS ||
r.IncludeDomain || r.IncludePolicy || r.SendAllPeers {
return false
}
if r.RequiresRuntimePeerComputation {
return false
}
if r.PingRequest != nil {
return false
}
return len(r.PeersChanged) == 0 &&
len(r.PeersRemoved) == 0 &&
len(r.PeerPatches) == 0
}
// IsFull reports whether the ChangeSet represents a full update.
func (c ChangeSet) IsFull() bool {
return c.Change == Full || c.Change == Policy
func (r Change) IsSelfOnly() bool {
if r.TargetNode == 0 || !r.IncludeSelf {
return false
}
if r.SendAllPeers || len(r.PeersChanged) > 0 || len(r.PeersRemoved) > 0 || len(r.PeerPatches) > 0 {
return false
}
return true
}
func HasFull(cs []ChangeSet) bool {
for _, c := range cs {
if c.IsFull() {
// IsTargetedToNode returns true if this response should only be sent to [Change.TargetNode].
func (r Change) IsTargetedToNode() bool {
return r.TargetNode != 0
}
// IsFull reports whether this is a full update response.
func (r Change) IsFull() bool {
return r.SendAllPeers && r.IncludeSelf && r.IncludeDERPMap &&
r.IncludeDNS && r.IncludeDomain && r.IncludePolicy
}
// Type returns a categorized type string for metrics.
// This provides a bounded set of values suitable for Prometheus labels,
// unlike [Change.Reason] which is free-form text for logging.
func (r Change) Type() string {
if r.IsFull() {
return "full"
}
if r.IsSelfOnly() {
return "self"
}
if r.RequiresRuntimePeerComputation {
return "policy"
}
if len(r.PeerPatches) > 0 && len(r.PeersChanged) == 0 && len(r.PeersRemoved) == 0 && !r.SendAllPeers {
return "patch"
}
if len(r.PeersChanged) > 0 || len(r.PeersRemoved) > 0 || r.SendAllPeers {
return "peers"
}
if r.IncludeDERPMap || r.IncludeDNS || r.IncludeDomain || r.IncludePolicy {
return "config"
}
if r.PingRequest != nil {
return "ping"
}
return "unknown"
}
// ShouldSendToNode determines if this response should be sent to nodeID.
// It handles self-only targeting and filtering out self-updates for non-origin nodes.
func (r Change) ShouldSendToNode(nodeID types.NodeID) bool {
// If targeted to a specific node, only send to that node
if r.TargetNode != 0 {
return r.TargetNode == nodeID
}
return true
}
// HasFull returns true if any response in the slice is a full update ([Change.IsFull]).
func HasFull(rs []Change) bool {
for _, r := range rs {
if r.IsFull() {
return true
}
}
return false
}
func SplitAllAndSelf(cs []ChangeSet) (all []ChangeSet, self []ChangeSet) {
for _, c := range cs {
if c.SelfUpdateOnly {
self = append(self, c)
// SplitTargetedAndBroadcast separates responses into targeted (to specific node) and broadcast.
func SplitTargetedAndBroadcast(rs []Change) ([]Change, []Change) {
var broadcast, targeted []Change
for _, r := range rs {
if r.IsTargetedToNode() {
targeted = append(targeted, r)
} else {
all = append(all, c)
broadcast = append(broadcast, r)
}
}
return all, self
return broadcast, targeted
}
func RemoveUpdatesForSelf(id types.NodeID, cs []ChangeSet) (ret []ChangeSet) {
for _, c := range cs {
if c.NodeID != id || c.Change.AlsoSelf() {
ret = append(ret, c)
// FilterForNode returns responses that should be sent to the given node.
func FilterForNode(nodeID types.NodeID, rs []Change) []Change {
var result []Change
for _, r := range rs {
if r.ShouldSendToNode(nodeID) {
result = append(result, r)
}
}
return ret
return result
}
func (c ChangeSet) AlsoSelf() bool {
// If NodeID is 0, it means this ChangeSet is not related to a specific node,
// so we consider it as a change that should be sent to all nodes.
if c.NodeID == 0 {
return true
func uniqueNodeIDs(ids []types.NodeID) []types.NodeID {
if len(ids) == 0 {
return nil
}
return c.Change.AlsoSelf() || c.SelfUpdateOnly
slices.Sort(ids)
return slices.Compact(ids)
}
var (
EmptySet = ChangeSet{Change: ChangeUnknown}
FullSet = ChangeSet{Change: Full}
DERPSet = ChangeSet{Change: DERP}
PolicySet = ChangeSet{Change: Policy}
ExtraRecordsSet = ChangeSet{Change: ExtraRecords}
)
// Constructor functions
func FullSelf(id types.NodeID) ChangeSet {
return ChangeSet{
Change: Full,
SelfUpdateOnly: true,
NodeID: id,
func FullUpdate() Change {
return Change{
Reason: "full update",
IncludeSelf: true,
IncludeDERPMap: true,
IncludeDNS: true,
IncludeDomain: true,
IncludePolicy: true,
SendAllPeers: true,
}
}
func NodeAdded(id types.NodeID) ChangeSet {
return ChangeSet{
Change: NodeNewOrUpdate,
NodeID: id,
// FullSelf returns a full update targeted at a specific node.
func FullSelf(nodeID types.NodeID) Change {
return Change{
Reason: "full self update",
TargetNode: nodeID,
IncludeSelf: true,
IncludeDERPMap: true,
IncludeDNS: true,
IncludeDomain: true,
IncludePolicy: true,
SendAllPeers: true,
}
}
func NodeRemoved(id types.NodeID) ChangeSet {
return ChangeSet{
Change: NodeRemove,
NodeID: id,
func SelfUpdate(nodeID types.NodeID) Change {
return Change{
Reason: "self update",
TargetNode: nodeID,
IncludeSelf: true,
}
}
func NodeOnline(id types.NodeID) ChangeSet {
return ChangeSet{
Change: NodeCameOnline,
NodeID: id,
func PolicyOnly() Change {
return Change{
Reason: "policy update",
IncludePolicy: true,
}
}
func NodeOffline(id types.NodeID) ChangeSet {
return ChangeSet{
Change: NodeWentOffline,
NodeID: id,
func PolicyAndPeers(changedPeers ...types.NodeID) Change {
return Change{
Reason: "policy and peers update",
IncludePolicy: true,
PeersChanged: changedPeers,
}
}
func KeyExpiry(id types.NodeID) ChangeSet {
return ChangeSet{
Change: NodeKeyExpiry,
NodeID: id,
func VisibilityChange(reason string, added, removed []types.NodeID) Change {
return Change{
Reason: reason,
IncludePolicy: true,
PeersChanged: added,
PeersRemoved: removed,
}
}
func UserAdded(id types.UserID) ChangeSet {
return ChangeSet{
Change: UserNewOrUpdate,
UserID: id,
func PeersChanged(reason string, peerIDs ...types.NodeID) Change {
return Change{
Reason: reason,
PeersChanged: peerIDs,
}
}
func UserRemoved(id types.UserID) ChangeSet {
return ChangeSet{
Change: UserRemove,
UserID: id,
func PeersRemoved(peerIDs ...types.NodeID) Change {
return Change{
Reason: "peers removed",
PeersRemoved: peerIDs,
}
}
func PolicyChange() ChangeSet {
return ChangeSet{
Change: Policy,
func PeerPatched(reason string, patches ...*tailcfg.PeerChange) Change {
return Change{
Reason: reason,
PeerPatches: patches,
}
}
func DERPChange() ChangeSet {
return ChangeSet{
Change: DERP,
func DERPMap() Change {
return Change{
Reason: "DERP map update",
IncludeDERPMap: true,
}
}
// PolicyChange creates a response for policy changes.
// Policy changes require runtime peer visibility computation ([Change.RequiresRuntimePeerComputation]).
func PolicyChange() Change {
return Change{
Reason: "policy change",
IncludePolicy: true,
RequiresRuntimePeerComputation: true,
}
}
// DNSConfig creates a response for DNS configuration updates.
func DNSConfig() Change {
return Change{
Reason: "DNS config update",
IncludeDNS: true,
}
}
// NodeOnline creates a patch response for a node coming online.
func NodeOnline(nodeID types.NodeID) Change {
return Change{
Reason: "node online",
PeerPatches: []*tailcfg.PeerChange{
{
NodeID: nodeID.NodeID(),
Online: new(true),
},
},
}
}
// NodeOffline creates a patch response for a node going offline.
func NodeOffline(nodeID types.NodeID) Change {
return Change{
Reason: "node offline",
PeerPatches: []*tailcfg.PeerChange{
{
NodeID: nodeID.NodeID(),
Online: new(false),
},
},
}
}
// KeyExpiry creates a patch response for a node's key expiry change.
func KeyExpiry(nodeID types.NodeID, expiry *time.Time) Change {
return Change{
Reason: "key expiry",
PeerPatches: []*tailcfg.PeerChange{
{
NodeID: nodeID.NodeID(),
KeyExpiry: expiry,
},
},
}
}
// High-level change constructors
// NodeAdded returns a [Change] for when a node is added or updated.
// The [Change.OriginNode] field enables self-update detection by the mapper.
func NodeAdded(id types.NodeID) Change {
c := PeersChanged("node added", id)
c.OriginNode = id
return c
}
// NodeRemoved returns a [Change] for when a node is removed.
func NodeRemoved(id types.NodeID) Change {
return PeersRemoved(id)
}
// NodeOnlineFor returns a [Change] for when a node comes online.
// If the node is a subnet router, a full update is sent instead of a patch.
func NodeOnlineFor(node types.NodeView) Change {
if node.IsSubnetRouter() {
c := FullUpdate()
c.Reason = "subnet router online"
return c
}
return NodeOnline(node.ID())
}
// NodeOfflineFor returns a [Change] for when a node goes offline.
// If the node is a subnet router, a full update is sent instead of a patch.
func NodeOfflineFor(node types.NodeView) Change {
if node.IsSubnetRouter() {
c := FullUpdate()
c.Reason = "subnet router offline"
return c
}
return NodeOffline(node.ID())
}
// KeyExpiryFor returns a [Change] for when a node's key expiry changes.
// The [Change.OriginNode] field enables self-update detection by the mapper.
func KeyExpiryFor(id types.NodeID, expiry time.Time) Change {
c := KeyExpiry(id, &expiry)
c.OriginNode = id
return c
}
// EndpointOrDERPUpdate returns a [Change] for when a node's endpoints or DERP region changes.
// The [Change.OriginNode] field enables self-update detection by the mapper.
func EndpointOrDERPUpdate(id types.NodeID, patch *tailcfg.PeerChange) Change {
c := PeerPatched("endpoint/DERP update", patch)
c.OriginNode = id
return c
}
// UserAdded returns a [Change] for when a user is added or updated.
// A full update is sent to refresh user profiles on all nodes.
func UserAdded() Change {
c := FullUpdate()
c.Reason = "user added"
return c
}
// UserRemoved returns a [Change] for when a user is removed.
// A full update is sent to refresh user profiles on all nodes.
func UserRemoved() Change {
c := FullUpdate()
c.Reason = "user removed"
return c
}
// PingNode creates a [Change] that sends a [tailcfg.PingRequest] to a specific
// node. pr must be non-nil and nodeID must be non-zero; the node
// responds to the [tailcfg.PingRequest] URL to prove connectivity.
func PingNode(nodeID types.NodeID, pr *tailcfg.PingRequest) Change {
return Change{
Reason: "ping node",
TargetNode: nodeID,
PingRequest: pr,
}
}
// ExtraRecords returns a [Change] for when DNS extra records change.
func ExtraRecords() Change {
c := DNSConfig()
c.Reason = "extra records update"
return c
}

View file

@ -1,57 +0,0 @@
// Code generated by "stringer -type=Change"; DO NOT EDIT.
package change
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[ChangeUnknown-0]
_ = x[Full-9]
_ = x[Policy-11]
_ = x[DERP-12]
_ = x[ExtraRecords-13]
_ = x[NodeCameOnline-21]
_ = x[NodeWentOffline-22]
_ = x[NodeRemove-23]
_ = x[NodeKeyExpiry-24]
_ = x[NodeNewOrUpdate-25]
_ = x[UserNewOrUpdate-51]
_ = x[UserRemove-52]
}
const (
_Change_name_0 = "ChangeUnknown"
_Change_name_1 = "Full"
_Change_name_2 = "PolicyDERPExtraRecords"
_Change_name_3 = "NodeCameOnlineNodeWentOfflineNodeRemoveNodeKeyExpiryNodeNewOrUpdate"
_Change_name_4 = "UserNewOrUpdateUserRemove"
)
var (
_Change_index_2 = [...]uint8{0, 6, 10, 22}
_Change_index_3 = [...]uint8{0, 14, 29, 39, 52, 67}
_Change_index_4 = [...]uint8{0, 15, 25}
)
func (i Change) String() string {
switch {
case i == 0:
return _Change_name_0
case i == 9:
return _Change_name_1
case 11 <= i && i <= 13:
i -= 11
return _Change_name_2[_Change_index_2[i]:_Change_index_2[i+1]]
case 21 <= i && i <= 25:
i -= 21
return _Change_name_3[_Change_index_3[i]:_Change_index_3[i+1]]
case 51 <= i && i <= 52:
i -= 51
return _Change_name_4[_Change_index_4[i]:_Change_index_4[i+1]]
default:
return "Change(" + strconv.FormatInt(int64(i), 10) + ")"
}
}

View file

@ -0,0 +1,521 @@
package change
import (
"reflect"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"tailscale.com/tailcfg"
)
func TestChange_FieldSync(t *testing.T) {
r := Change{}
fieldNames := r.boolFieldNames()
typ := reflect.TypeFor[Change]()
boolCount := 0
for field := range typ.Fields() {
if field.Type.Kind() == reflect.Bool {
boolCount++
}
}
if len(fieldNames) != boolCount {
t.Fatalf("boolFieldNames() returns %d fields but struct has %d bool fields; "+
"update boolFieldNames() when adding new bool fields", len(fieldNames), boolCount)
}
}
func TestChange_IsEmpty(t *testing.T) {
tests := []struct {
name string
response Change
want bool
}{
{
name: "zero value is empty",
response: Change{},
want: true,
},
{
name: "only reason is still empty",
response: Change{Reason: "test"},
want: true,
},
{
name: "IncludeSelf not empty",
response: Change{IncludeSelf: true},
want: false,
},
{
name: "IncludeDERPMap not empty",
response: Change{IncludeDERPMap: true},
want: false,
},
{
name: "IncludeDNS not empty",
response: Change{IncludeDNS: true},
want: false,
},
{
name: "IncludeDomain not empty",
response: Change{IncludeDomain: true},
want: false,
},
{
name: "IncludePolicy not empty",
response: Change{IncludePolicy: true},
want: false,
},
{
name: "SendAllPeers not empty",
response: Change{SendAllPeers: true},
want: false,
},
{
name: "PeersChanged not empty",
response: Change{PeersChanged: []types.NodeID{1}},
want: false,
},
{
name: "PeersRemoved not empty",
response: Change{PeersRemoved: []types.NodeID{1}},
want: false,
},
{
name: "PeerPatches not empty",
response: Change{PeerPatches: []*tailcfg.PeerChange{{}}},
want: false,
},
{
name: "PingRequest not empty",
response: Change{PingRequest: &tailcfg.PingRequest{URL: "https://example.com"}},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.response.IsEmpty()
assert.Equal(t, tt.want, got)
})
}
}
func TestChange_IsSelfOnly(t *testing.T) {
tests := []struct {
name string
response Change
want bool
}{
{
name: "empty is not self only",
response: Change{},
want: false,
},
{
name: "IncludeSelf without TargetNode is not self only",
response: Change{IncludeSelf: true},
want: false,
},
{
name: "TargetNode without IncludeSelf is not self only",
response: Change{TargetNode: 1},
want: false,
},
{
name: "TargetNode with IncludeSelf is self only",
response: Change{TargetNode: 1, IncludeSelf: true},
want: true,
},
{
name: "self only with SendAllPeers is not self only",
response: Change{TargetNode: 1, IncludeSelf: true, SendAllPeers: true},
want: false,
},
{
name: "self only with PeersChanged is not self only",
response: Change{TargetNode: 1, IncludeSelf: true, PeersChanged: []types.NodeID{2}},
want: false,
},
{
name: "self only with PeersRemoved is not self only",
response: Change{TargetNode: 1, IncludeSelf: true, PeersRemoved: []types.NodeID{2}},
want: false,
},
{
name: "self only with PeerPatches is not self only",
response: Change{TargetNode: 1, IncludeSelf: true, PeerPatches: []*tailcfg.PeerChange{{}}},
want: false,
},
{
name: "self only with other include flags is still self only",
response: Change{
TargetNode: 1,
IncludeSelf: true,
IncludePolicy: true,
IncludeDNS: true,
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.response.IsSelfOnly()
assert.Equal(t, tt.want, got)
})
}
}
func TestChange_Merge(t *testing.T) {
tests := []struct {
name string
r1 Change
r2 Change
want Change
}{
{
name: "empty merge",
r1: Change{},
r2: Change{},
want: Change{},
},
{
name: "bool fields OR together",
r1: Change{IncludeSelf: true, IncludePolicy: true},
r2: Change{IncludeDERPMap: true, IncludePolicy: true},
want: Change{IncludeSelf: true, IncludeDERPMap: true, IncludePolicy: true},
},
{
name: "all bool fields merge",
r1: Change{IncludeSelf: true, IncludeDNS: true, IncludePolicy: true},
r2: Change{IncludeDERPMap: true, IncludeDomain: true, SendAllPeers: true},
want: Change{
IncludeSelf: true,
IncludeDERPMap: true,
IncludeDNS: true,
IncludeDomain: true,
IncludePolicy: true,
SendAllPeers: true,
},
},
{
name: "peers deduplicated and sorted",
r1: Change{PeersChanged: []types.NodeID{3, 1}},
r2: Change{PeersChanged: []types.NodeID{2, 1}},
want: Change{PeersChanged: []types.NodeID{1, 2, 3}},
},
{
name: "peers removed deduplicated",
r1: Change{PeersRemoved: []types.NodeID{1, 2}},
r2: Change{PeersRemoved: []types.NodeID{2, 3}},
want: Change{PeersRemoved: []types.NodeID{1, 2, 3}},
},
{
name: "peer patches concatenated",
r1: Change{PeerPatches: []*tailcfg.PeerChange{{NodeID: 1}}},
r2: Change{PeerPatches: []*tailcfg.PeerChange{{NodeID: 2}}},
want: Change{PeerPatches: []*tailcfg.PeerChange{{NodeID: 1}, {NodeID: 2}}},
},
{
name: "reasons combined when different",
r1: Change{Reason: "route change"},
r2: Change{Reason: "tag change"},
want: Change{Reason: "route change; tag change"},
},
{
name: "same reason not duplicated",
r1: Change{Reason: "policy"},
r2: Change{Reason: "policy"},
want: Change{Reason: "policy"},
},
{
name: "empty reason takes other",
r1: Change{},
r2: Change{Reason: "update"},
want: Change{Reason: "update"},
},
{
name: "OriginNode preserved from first",
r1: Change{OriginNode: 42},
r2: Change{IncludePolicy: true},
want: Change{OriginNode: 42, IncludePolicy: true},
},
{
name: "OriginNode preserved from second when first is zero",
r1: Change{IncludePolicy: true},
r2: Change{OriginNode: 42},
want: Change{OriginNode: 42, IncludePolicy: true},
},
{
name: "OriginNode first wins when both set",
r1: Change{OriginNode: 1},
r2: Change{OriginNode: 2},
want: Change{OriginNode: 1},
},
{
name: "TargetNode preserved from first",
r1: Change{TargetNode: 42},
r2: Change{IncludeSelf: true},
want: Change{TargetNode: 42, IncludeSelf: true},
},
{
name: "TargetNode preserved from second when first is zero",
r1: Change{IncludeSelf: true},
r2: Change{TargetNode: 42},
want: Change{TargetNode: 42, IncludeSelf: true},
},
{
name: "PingRequest preserved from first",
r1: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}},
r2: Change{IncludeSelf: true},
want: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}, IncludeSelf: true},
},
{
name: "PingRequest preserved from second when first is nil",
r1: Change{IncludeSelf: true},
r2: Change{PingRequest: &tailcfg.PingRequest{URL: "second"}},
want: Change{PingRequest: &tailcfg.PingRequest{URL: "second"}, IncludeSelf: true},
},
{
name: "PingRequest first wins when both set",
r1: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}},
r2: Change{PingRequest: &tailcfg.PingRequest{URL: "second"}},
want: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.r1.Merge(tt.r2)
assert.Equal(t, tt.want, got)
})
}
}
func TestChange_Constructors(t *testing.T) {
tests := []struct {
name string
constructor func() Change
wantReason string
want Change
}{
{
name: "FullUpdateResponse",
constructor: FullUpdate,
wantReason: "full update",
want: Change{
Reason: "full update",
IncludeSelf: true,
IncludeDERPMap: true,
IncludeDNS: true,
IncludeDomain: true,
IncludePolicy: true,
SendAllPeers: true,
},
},
{
name: "PolicyOnlyResponse",
constructor: PolicyOnly,
wantReason: "policy update",
want: Change{
Reason: "policy update",
IncludePolicy: true,
},
},
{
name: "DERPMapResponse",
constructor: DERPMap,
wantReason: "DERP map update",
want: Change{
Reason: "DERP map update",
IncludeDERPMap: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := tt.constructor()
assert.Equal(t, tt.wantReason, r.Reason)
assert.Equal(t, tt.want, r)
})
}
}
func TestSelfUpdate(t *testing.T) {
r := SelfUpdate(42)
assert.Equal(t, "self update", r.Reason)
assert.Equal(t, types.NodeID(42), r.TargetNode)
assert.True(t, r.IncludeSelf)
assert.True(t, r.IsSelfOnly())
}
func TestPolicyAndPeers(t *testing.T) {
r := PolicyAndPeers(1, 2, 3)
assert.Equal(t, "policy and peers update", r.Reason)
assert.True(t, r.IncludePolicy)
assert.Equal(t, []types.NodeID{1, 2, 3}, r.PeersChanged)
}
func TestVisibilityChange(t *testing.T) {
r := VisibilityChange("tag change", []types.NodeID{1}, []types.NodeID{2, 3})
assert.Equal(t, "tag change", r.Reason)
assert.True(t, r.IncludePolicy)
assert.Equal(t, []types.NodeID{1}, r.PeersChanged)
assert.Equal(t, []types.NodeID{2, 3}, r.PeersRemoved)
}
func TestPeersChanged(t *testing.T) {
r := PeersChanged("routes approved", 1, 2)
assert.Equal(t, "routes approved", r.Reason)
assert.Equal(t, []types.NodeID{1, 2}, r.PeersChanged)
assert.False(t, r.IncludePolicy)
}
func TestPeersRemoved(t *testing.T) {
r := PeersRemoved(1, 2, 3)
assert.Equal(t, "peers removed", r.Reason)
assert.Equal(t, []types.NodeID{1, 2, 3}, r.PeersRemoved)
}
func TestPeerPatched(t *testing.T) {
patch := &tailcfg.PeerChange{NodeID: 1}
r := PeerPatched("endpoint change", patch)
assert.Equal(t, "endpoint change", r.Reason)
assert.Equal(t, []*tailcfg.PeerChange{patch}, r.PeerPatches)
}
func TestChange_Type(t *testing.T) {
tests := []struct {
name string
response Change
want string
}{
{
name: "full update",
response: FullUpdate(),
want: "full",
},
{
name: "self only",
response: SelfUpdate(1),
want: "self",
},
{
name: "policy with runtime computation",
response: PolicyChange(),
want: "policy",
},
{
name: "patch only",
response: PeerPatched("test", &tailcfg.PeerChange{NodeID: 1}),
want: "patch",
},
{
name: "peers changed",
response: PeersChanged("test", 1, 2),
want: "peers",
},
{
name: "peers removed",
response: PeersRemoved(1, 2),
want: "peers",
},
{
name: "config - DERP map",
response: DERPMap(),
want: "config",
},
{
name: "config - DNS",
response: DNSConfig(),
want: "config",
},
{
name: "config - policy only (no runtime)",
response: PolicyOnly(),
want: "config",
},
{
name: "ping request",
response: Change{
PingRequest: &tailcfg.PingRequest{URL: "https://example.com"},
TargetNode: 1,
},
want: "ping",
},
{
name: "empty is unknown",
response: Change{},
want: "unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.response.Type()
assert.Equal(t, tt.want, got)
})
}
}
func TestPingNode(t *testing.T) {
pr := &tailcfg.PingRequest{URL: "https://example.com/ping", URLIsNoise: true, Log: true}
r := PingNode(42, pr)
assert.Equal(t, "ping node", r.Reason)
assert.Equal(t, types.NodeID(42), r.TargetNode)
assert.Equal(t, pr, r.PingRequest)
assert.True(t, r.IsTargetedToNode())
assert.False(t, r.IsEmpty())
assert.Equal(t, "ping", r.Type())
}
func TestUniqueNodeIDs(t *testing.T) {
tests := []struct {
name string
input []types.NodeID
want []types.NodeID
}{
{
name: "nil input",
input: nil,
want: nil,
},
{
name: "empty input",
input: []types.NodeID{},
want: nil,
},
{
name: "single element",
input: []types.NodeID{1},
want: []types.NodeID{1},
},
{
name: "no duplicates",
input: []types.NodeID{1, 2, 3},
want: []types.NodeID{1, 2, 3},
},
{
name: "with duplicates",
input: []types.NodeID{3, 1, 2, 1, 3},
want: []types.NodeID{1, 2, 3},
},
{
name: "all same",
input: []types.NodeID{5, 5, 5, 5},
want: []types.NodeID{5},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := uniqueNodeIDs(tt.input)
assert.Equal(t, tt.want, got)
})
}
}

View file

@ -7,6 +7,8 @@ import (
"errors"
"fmt"
"runtime"
"strings"
"sync/atomic"
"time"
"github.com/juanfont/headscale/hscontrol/util"
@ -19,7 +21,12 @@ const (
DatabaseSqlite = "sqlite3"
)
var ErrCannotParsePrefix = errors.New("cannot parse prefix")
// Common errors.
var (
ErrCannotParsePrefix = errors.New("cannot parse prefix")
ErrInvalidAuthIDLength = errors.New("auth ID has invalid length")
ErrInvalidAuthIDPrefix = errors.New("auth ID has invalid prefix")
)
type StateUpdateType int
@ -62,35 +69,35 @@ const (
// StateUpdate is an internal message containing information about
// a state change that has happened to the network.
// If type is StateFullUpdate, all fields are ignored.
// If type is [StateFullUpdate], all fields are ignored.
type StateUpdate struct {
// The type of update
Type StateUpdateType
// ChangeNodes must be set when Type is StatePeerAdded
// and StatePeerChanged and contains the full node
// and [StatePeerChanged] and contains the full node
// object for added nodes.
ChangeNodes []NodeID
// ChangePatches must be set when Type is StatePeerChangedPatch
// and contains a populated PeerChange object.
// ChangePatches must be set when Type is [StatePeerChangedPatch]
// and contains a populated [tailcfg.PeerChange] object.
ChangePatches []*tailcfg.PeerChange
// Removed must be set when Type is StatePeerRemoved and
// Removed must be set when Type is [StatePeerRemoved] and
// contain a list of the nodes that has been removed from
// the network.
Removed []NodeID
// DERPMap must be set when Type is StateDERPUpdated and
// DERPMap must be set when Type is [StateDERPUpdated] and
// contain the new DERP Map.
DERPMap *tailcfg.DERPMap
// Additional message for tracking origin or what being
// updated, useful for ambiguous updates like StatePeerChanged.
// updated, useful for ambiguous updates like [StatePeerChanged].
Message string
}
// Empty reports if there are any updates in the StateUpdate.
// Empty reports if there are any updates in the [StateUpdate].
func (su *StateUpdate) Empty() bool {
switch su.Type {
case StatePeerChanged:
@ -99,6 +106,10 @@ func (su *StateUpdate) Empty() bool {
return len(su.ChangePatches) == 0
case StatePeerRemoved:
return len(su.Removed) == 0
case StateFullUpdate, StateSelfUpdate, StateDERPUpdated:
// These update types don't have associated data to check,
// so they are never considered empty.
return false
}
return false
@ -150,21 +161,26 @@ func UpdateExpire(nodeID NodeID, expiry time.Time) StateUpdate {
}
}
const RegistrationIDLength = 24
const (
authIDPrefix = "hskey-authreq-"
authIDRandomLength = 24
// AuthIDLength is the total length of an AuthID: 14 (prefix) + 24 (random).
AuthIDLength = 38
)
type RegistrationID string
type AuthID string
func NewRegistrationID() (RegistrationID, error) {
rid, err := util.GenerateRandomStringURLSafe(RegistrationIDLength)
func NewAuthID() (AuthID, error) {
rid, err := util.GenerateRandomStringURLSafe(authIDRandomLength)
if err != nil {
return "", err
}
return RegistrationID(rid), nil
return AuthID(authIDPrefix + rid), nil
}
func MustRegistrationID() RegistrationID {
rid, err := NewRegistrationID()
func MustAuthID() AuthID {
rid, err := NewAuthID()
if err != nil {
panic(err)
}
@ -172,20 +188,219 @@ func MustRegistrationID() RegistrationID {
return rid
}
func RegistrationIDFromString(str string) (RegistrationID, error) {
if len(str) != RegistrationIDLength {
return "", fmt.Errorf("registration ID must be %d characters long", RegistrationIDLength)
func AuthIDFromString(str string) (AuthID, error) {
r := AuthID(str)
err := r.Validate()
if err != nil {
return "", err
}
return RegistrationID(str), nil
return r, nil
}
func (r RegistrationID) String() string {
func (r AuthID) String() string {
return string(r)
}
type RegisterNode struct {
Node Node
Registered chan *Node
func (r AuthID) Validate() error {
if !strings.HasPrefix(string(r), authIDPrefix) {
return fmt.Errorf(
"%w: expected prefix %q",
ErrInvalidAuthIDPrefix, authIDPrefix,
)
}
if len(r) != AuthIDLength {
return fmt.Errorf(
"%w: expected %d, got %d",
ErrInvalidAuthIDLength, AuthIDLength, len(r),
)
}
return nil
}
// SSHCheckBinding identifies the (source, destination) node pair an SSH
// check-mode auth request is bound to. It is captured at HoldAndDelegate
// time so the follow-up request and OIDC callback can verify that no
// other (src, dst) pair has been substituted via tampered URL parameters.
type SSHCheckBinding struct {
SrcNodeID NodeID
DstNodeID NodeID
}
// PendingRegistrationConfirmation captures the server-side state needed
// to finalise a node registration after the user has confirmed it on
// the OIDC interstitial. The OIDC callback resolves the user identity
// and node expiry, stores them on the cached [AuthRequest], and renders
// a confirmation page; only when the user POSTs the confirmation form
// does the actual node registration run.
//
// CSRF is a one-shot per-session token that the OIDC callback set
// both as a cookie and as a hidden form field. The confirm POST
// handler refuses to proceed unless the cookie and form values match.
type PendingRegistrationConfirmation struct {
UserID uint
NodeExpiry *time.Time
CSRF string
}
// AuthRequest represents a pending authentication request from a user or a
// node. It carries the minimum data needed to either complete a node
// registration (regData populated) or an SSH check-mode auth (sshBinding
// populated), and signals the verdict via the finished channel. The closed
// flag guards [AuthRequest.FinishAuth] against double-close.
//
// [AuthRequest] is always handled by pointer so the channel and atomic flag
// have a single canonical instance even when stored in caches that
// internally copy values.
type AuthRequest struct {
// regData is populated for node-registration flows (interactive web
// or OIDC). It carries the cached registration payload that the
// auth callback uses to promote this request into a real node.
//
// nil for non-registration flows. Use [AuthRequest.RegistrationData] to read it
// safely.
regData *RegistrationData
// sshBinding is populated for SSH check-mode flows. It captures the
// (src, dst) node pair the request was minted for so the follow-up
// and OIDC callback can refuse to record a verdict for any other
// pair.
//
// nil for non-SSH-check flows. Use [AuthRequest.SSHCheckBinding] to read it
// safely.
sshBinding *SSHCheckBinding
// pendingConfirmation is populated by the OIDC callback for the
// node-registration flow once the user identity has been resolved
// but before the user has explicitly confirmed the registration on
// the interstitial. The /register/confirm POST handler reads it to
// finalise the registration without re-running the OIDC flow.
pendingConfirmation *PendingRegistrationConfirmation
finished chan AuthVerdict
closed *atomic.Bool
}
// NewAuthRequest creates a pending auth request with no payload, suitable
// for non-registration flows that only need a verdict channel.
func NewAuthRequest() *AuthRequest {
return &AuthRequest{
finished: make(chan AuthVerdict, 1),
closed: &atomic.Bool{},
}
}
// NewRegisterAuthRequest creates a pending auth request carrying the
// minimal [RegistrationData] for a node-registration flow. The data is
// stored by pointer; callers must not mutate it after handing it off.
func NewRegisterAuthRequest(data *RegistrationData) *AuthRequest {
return &AuthRequest{
regData: data,
finished: make(chan AuthVerdict, 1),
closed: &atomic.Bool{},
}
}
// NewSSHCheckAuthRequest creates a pending auth request bound to a
// specific (src, dst) SSH check-mode pair. The follow-up handler and
// OIDC callback must verify their incoming request matches this binding
// before recording any verdict.
func NewSSHCheckAuthRequest(src, dst NodeID) *AuthRequest {
return &AuthRequest{
sshBinding: &SSHCheckBinding{
SrcNodeID: src,
DstNodeID: dst,
},
finished: make(chan AuthVerdict, 1),
closed: &atomic.Bool{},
}
}
// RegistrationData returns the cached registration payload. It panics if
// called on an [AuthRequest] that was not created via
// [NewRegisterAuthRequest].
func (rn *AuthRequest) RegistrationData() *RegistrationData {
if rn.regData == nil {
panic("RegistrationData can only be used in registration requests")
}
return rn.regData
}
// SSHCheckBinding returns the (src, dst) node pair an SSH check-mode
// auth request is bound to. It panics if called on an [AuthRequest] that
// was not created via [NewSSHCheckAuthRequest].
func (rn *AuthRequest) SSHCheckBinding() *SSHCheckBinding {
if rn.sshBinding == nil {
panic("SSHCheckBinding can only be used in SSH check-mode requests")
}
return rn.sshBinding
}
// IsRegistration reports whether this auth request carries registration
// data (i.e. it was created via [NewRegisterAuthRequest]).
func (rn *AuthRequest) IsRegistration() bool {
return rn.regData != nil
}
// IsSSHCheck reports whether this auth request is bound to an SSH
// check-mode (src, dst) pair (i.e. it was created via
// [NewSSHCheckAuthRequest]).
func (rn *AuthRequest) IsSSHCheck() bool {
return rn.sshBinding != nil
}
// SetPendingConfirmation marks this [AuthRequest] as having an
// OIDC-resolved user that is waiting to confirm the registration on
// the interstitial. The OIDC callback should call this and then render
// the confirmation page; the /register/confirm POST handler reads the
// stored UserID/NodeExpiry to finish the registration.
func (rn *AuthRequest) SetPendingConfirmation(p *PendingRegistrationConfirmation) {
rn.pendingConfirmation = p
}
// PendingConfirmation returns the pending OIDC-resolved registration
// state captured by [AuthRequest.SetPendingConfirmation], or nil if no OIDC callback
// has yet resolved an identity for this [AuthRequest].
func (rn *AuthRequest) PendingConfirmation() *PendingRegistrationConfirmation {
return rn.pendingConfirmation
}
func (rn *AuthRequest) FinishAuth(verdict AuthVerdict) {
if rn.closed.Swap(true) {
return
}
select {
case rn.finished <- verdict:
default:
}
close(rn.finished)
}
func (rn *AuthRequest) WaitForAuth() <-chan AuthVerdict {
return rn.finished
}
type AuthVerdict struct {
// Err is the error that occurred during the authentication process, if any.
// If Err is nil, the authentication process has succeeded.
// If Err is not nil, the authentication process has failed and the node should not be authenticated.
Err error
// Node is the node that has been authenticated.
// Node is only valid if the auth request was a registration request
// and the authentication process has succeeded.
Node NodeView
}
func (v AuthVerdict) Accept() bool {
return v.Err == nil
}
// DefaultBatcherWorkers returns the default number of batcher workers.
@ -197,10 +412,12 @@ func DefaultBatcherWorkers() int {
// DefaultBatcherWorkersFor returns the default number of batcher workers for a given CPU count.
// Default to 3/4 of CPU cores, minimum 1, no maximum.
func DefaultBatcherWorkersFor(cpuCount int) int {
defaultWorkers := (cpuCount * 3) / 4
if defaultWorkers < 1 {
defaultWorkers = 1
}
const (
workerNumerator = 3
workerDenominator = 4
)
defaultWorkers := max((cpuCount*workerNumerator)/workerDenominator, 1)
return defaultWorkers
}

View file

@ -2,8 +2,82 @@ package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestNewSSHCheckAuthRequestBinding verifies that an SSH-check [AuthRequest]
// captures the (src, dst) node pair at construction time and rejects
// callers that try to read [AuthRequest.RegistrationData] from it.
func TestNewSSHCheckAuthRequestBinding(t *testing.T) {
const src, dst NodeID = 7, 11
req := NewSSHCheckAuthRequest(src, dst)
require.True(t, req.IsSSHCheck(), "SSH-check request must report IsSSHCheck=true")
require.False(t, req.IsRegistration(), "SSH-check request must not report IsRegistration")
binding := req.SSHCheckBinding()
assert.Equal(t, src, binding.SrcNodeID, "SrcNodeID must match")
assert.Equal(t, dst, binding.DstNodeID, "DstNodeID must match")
assert.Panics(t, func() {
_ = req.RegistrationData()
}, "RegistrationData() must panic on an SSH-check AuthRequest")
}
// TestNewRegisterAuthRequestPayload verifies that a registration
// [AuthRequest] carries the supplied [RegistrationData] and rejects callers
// that try to read SSH-check binding from it.
func TestNewRegisterAuthRequestPayload(t *testing.T) {
data := &RegistrationData{Hostname: "node-a"}
req := NewRegisterAuthRequest(data)
require.True(t, req.IsRegistration(), "registration request must report IsRegistration=true")
require.False(t, req.IsSSHCheck(), "registration request must not report IsSSHCheck")
assert.Same(t, data, req.RegistrationData(), "RegistrationData() must return the supplied pointer")
assert.Panics(t, func() {
_ = req.SSHCheckBinding()
}, "SSHCheckBinding() must panic on a registration AuthRequest")
}
// TestNewAuthRequestEmptyPayload verifies that a payload-less
// [AuthRequest] reports both Is* helpers as false and panics on either
// payload accessor.
func TestNewAuthRequestEmptyPayload(t *testing.T) {
req := NewAuthRequest()
assert.False(t, req.IsRegistration())
assert.False(t, req.IsSSHCheck())
assert.Panics(t, func() { _ = req.RegistrationData() })
assert.Panics(t, func() { _ = req.SSHCheckBinding() })
}
// TestPendingRegistrationConfirmation verifies that the OIDC callback
// can stash a pending confirmation onto an [AuthRequest] and that the
// /register/confirm POST handler can read it back unchanged.
func TestPendingRegistrationConfirmation(t *testing.T) {
req := NewRegisterAuthRequest(&RegistrationData{Hostname: "phish-test"})
require.Nil(t, req.PendingConfirmation(),
"new AuthRequest must have no pending confirmation")
pending := &PendingRegistrationConfirmation{
UserID: 42,
CSRF: "csrf-marker",
}
req.SetPendingConfirmation(pending)
got := req.PendingConfirmation()
require.NotNil(t, got, "PendingConfirmation must return the stored value")
assert.Equal(t, uint(42), got.UserID)
assert.Equal(t, "csrf-marker", got.CSRF)
}
func TestDefaultBatcherWorkersFor(t *testing.T) {
tests := []struct {
cpuCount int

View file

@ -24,17 +24,20 @@ import (
)
const (
defaultOIDCExpiryTime = 180 * 24 * time.Hour // 180 Days
maxDuration time.Duration = 1<<63 - 1
PKCEMethodPlain string = "plain"
PKCEMethodS256 string = "S256"
PKCEMethodPlain string = "plain"
PKCEMethodS256 string = "S256"
defaultNodeStoreBatchSize = 100
)
var (
errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable")
errServerURLSame = errors.New("server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable")
errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'")
errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable")
errServerURLSame = errors.New("server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable")
errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'")
errTrustedProxyZeroRange = errors.New("0.0.0.0/0 and ::/0 are not allowed")
ErrNoPrefixConfigured = errors.New("no IPv4 or IPv6 prefix configured, minimum one prefix is required")
ErrInvalidAllocationStrategy = errors.New("invalid prefix allocation strategy")
)
type IPAllocationStrategy string
@ -51,21 +54,60 @@ const (
PolicyModeFile = "file"
)
// EphemeralConfig contains configuration for ephemeral node lifecycle.
type EphemeralConfig struct {
// InactivityTimeout is how long an ephemeral node can be offline
// before it is automatically deleted.
InactivityTimeout time.Duration
}
// HARouteConfig contains configuration for HA subnet router health probing.
type HARouteConfig struct {
// ProbeInterval is how often HA subnet routers are probed.
// A zero or negative duration disables probing.
ProbeInterval time.Duration
// ProbeTimeout is the maximum time to wait for a probe response
// before declaring a node unhealthy. Must be less than [HARouteConfig.ProbeInterval].
ProbeTimeout time.Duration
}
// RouteConfig contains configuration for route behaviour.
type RouteConfig struct {
HA HARouteConfig
}
// NodeConfig contains configuration for node lifecycle and expiry.
type NodeConfig struct {
// Expiry is the default key expiry duration for non-tagged nodes.
// Applies to all registration methods (auth key, CLI, web, OIDC).
// Tagged nodes are exempt and never expire.
// A zero/negative duration means no default expiry (nodes never expire).
Expiry time.Duration
// Ephemeral contains configuration for ephemeral node lifecycle.
Ephemeral EphemeralConfig
// Routes contains configuration for route behaviour.
Routes RouteConfig
}
// Config contains the initial Headscale configuration.
type Config struct {
ServerURL string
Addr string
MetricsAddr string
GRPCAddr string
GRPCAllowInsecure bool
EphemeralNodeInactivityTimeout time.Duration
PrefixV4 *netip.Prefix
PrefixV6 *netip.Prefix
IPAllocation IPAllocationStrategy
NoisePrivateKeyPath string
BaseDomain string
Log LogConfig
DisableUpdateCheck bool
ServerURL string
Addr string
MetricsAddr string
GRPCAddr string
GRPCAllowInsecure bool
TrustedProxies []netip.Prefix
Node NodeConfig
PrefixV4 *netip.Prefix
PrefixV6 *netip.Prefix
IPAllocation IPAllocationStrategy
NoisePrivateKeyPath string
BaseDomain string
Log LogConfig
DisableUpdateCheck bool
Database DatabaseConfig
@ -78,7 +120,7 @@ type Config struct {
// DNSConfig is the headscale representation of the DNS configuration.
// It is kept in the config update for some settings that are
// not directly converted into a tailcfg.DNSConfig.
// not directly converted into a [tailcfg.DNSConfig].
DNSConfig DNSConfig
// TailcfgDNSConfig is the tailcfg representation of the DNS configuration,
@ -90,8 +132,9 @@ type Config struct {
OIDC OIDCConfig
LogTail LogTailConfig
RandomizeClientPort bool
LogTail LogTailConfig
Taildrop TaildropConfig
AutoUpdate AutoUpdateConfig
CLI CLIConfig
@ -126,7 +169,7 @@ type PostgresConfig struct {
Port int
Name string
User string
Pass string
Pass string `json:"-"` // never serialise the database password
Ssl string
MaxOpenConnections int
MaxIdleConnections int
@ -176,13 +219,13 @@ type OIDCConfig struct {
OnlyStartIfOIDCIsAvailable bool
Issuer string
ClientID string
ClientSecret string
ClientSecret string `json:"-"` // never serialise the OIDC client secret
Scope []string
ExtraParams map[string]string
AllowedDomains []string
AllowedUsers []string
AllowedGroups []string
Expiry time.Duration
EmailVerifiedRequired bool
UseExpiryFromToken bool
PKCE PKCEConfig
}
@ -209,9 +252,22 @@ type LogTailConfig struct {
Enabled bool
}
type TaildropConfig struct {
Enabled bool
}
// AutoUpdateConfig controls the tailnet-wide default for client
// auto-update. When Enabled is true, headscale emits the
// [tailcfg.NodeAttrDefaultAutoUpdate] cap with value [true] on every
// node's CapMap; clients fall back to that default unless they have
// opted in or out locally.
type AutoUpdateConfig struct {
Enabled bool
}
type CLIConfig struct {
Address string
APIKey string
APIKey string `json:"-"` // never serialise the headscale admin API key
Timeout time.Duration
Insecure bool
}
@ -230,22 +286,77 @@ type LogConfig struct {
Level zerolog.Level
}
// Tuning contains advanced performance tuning parameters for Headscale.
// These settings control internal batching, timeouts, and resource allocation.
// The defaults are carefully chosen for typical deployments and should rarely
// need adjustment. Changes to these values can significantly impact performance
// and resource usage.
type Tuning struct {
NotifierSendTimeout time.Duration
BatchChangeDelay time.Duration
// NotifierSendTimeout is the maximum time to wait when sending notifications
// to connected clients about network changes.
NotifierSendTimeout time.Duration
// BatchChangeDelay controls how long to wait before sending batched updates
// to clients when multiple changes occur in rapid succession.
BatchChangeDelay time.Duration
// NodeMapSessionBufferedChanSize sets the buffer size for the channel that
// queues map updates to be sent to connected clients.
NodeMapSessionBufferedChanSize int
BatcherWorkers int
// BatcherWorkers controls the number of parallel workers processing map
// updates for connected clients.
BatcherWorkers int
// RegisterCacheExpiration is how long registration cache entries remain
// valid before being eligible for eviction.
RegisterCacheExpiration time.Duration
// RegisterCacheMaxEntries bounds the number of pending registration
// entries the auth cache will hold. Older entries are evicted (LRU)
// when the cap is reached, preventing unauthenticated cache-fill DoS.
// A value of 0 falls back to defaultRegisterCacheMaxEntries (1024).
RegisterCacheMaxEntries int
// NodeStoreBatchSize controls how many write operations are accumulated
// before rebuilding the in-memory node snapshot.
//
// The NodeStore batches write operations (add/update/delete nodes) before
// rebuilding its in-memory data structures. Rebuilding involves recalculating
// peer relationships between all nodes based on the current ACL policy, which
// is computationally expensive and scales with the square of the number of nodes.
//
// By batching writes, Headscale can process N operations but only rebuild once,
// rather than rebuilding N times. This significantly reduces CPU usage during
// bulk operations like initial sync or policy updates.
//
// Trade-off: Higher values reduce CPU usage from rebuilds but increase latency
// for individual operations waiting for their batch to complete.
NodeStoreBatchSize int
// NodeStoreBatchTimeout is the maximum time to wait before processing a
// partial batch of node operations.
//
// When [Tuning.NodeStoreBatchSize] operations haven't accumulated, this timeout ensures
// writes don't wait indefinitely. The batch processes when either the size
// threshold is reached OR this timeout expires, whichever comes first.
//
// Trade-off: Lower values provide faster response for individual operations
// but trigger more frequent (expensive) peer map rebuilds. Higher values
// optimize for bulk throughput at the cost of individual operation latency.
NodeStoreBatchTimeout time.Duration
}
func validatePKCEMethod(method string) error {
if method != PKCEMethodPlain && method != PKCEMethodS256 {
return errInvalidPKCEMethod
}
return nil
}
// Domain returns the hostname/domain part of the ServerURL.
// If the ServerURL is not a valid URL, it returns the BaseDomain.
// Domain returns the hostname/domain part of the [Config.ServerURL].
// If the [Config.ServerURL] is not a valid URL, it returns the [Config.BaseDomain].
func (c *Config) Domain() string {
u, err := url.Parse(c.ServerURL)
if err != nil {
@ -258,7 +369,7 @@ func (c *Config) Domain() string {
// LoadConfig prepares and loads the Headscale configuration into Viper.
// This means it sets the default values, reads the configuration file and
// environment variables, and handles deprecated configuration options.
// It has to be called before LoadServerConfig and LoadCLIConfig.
// It has to be called before [LoadServerConfig] and [LoadCLIConfig].
// The configuration is not validated and the caller should check for errors
// using a validation function.
func LoadConfig(path string, isFile bool) error {
@ -266,6 +377,7 @@ func LoadConfig(path string, isFile bool) error {
viper.SetConfigFile(path)
} else {
viper.SetConfigName("config")
if path == "" {
viper.AddConfigPath("/etc/headscale/")
viper.AddConfigPath("$HOME/.headscale")
@ -321,25 +433,32 @@ func LoadConfig(path string, isFile bool) error {
viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"})
viper.SetDefault("oidc.only_start_if_oidc_is_available", true)
viper.SetDefault("oidc.expiry", "180d")
viper.SetDefault("oidc.use_expiry_from_token", false)
viper.SetDefault("oidc.pkce.enabled", false)
viper.SetDefault("oidc.pkce.method", "S256")
viper.SetDefault("oidc.email_verified_required", true)
viper.SetDefault("logtail.enabled", false)
viper.SetDefault("randomize_client_port", false)
viper.SetDefault("taildrop.enabled", true)
viper.SetDefault("auto_update.enabled", false)
viper.SetDefault("ephemeral_node_inactivity_timeout", "120s")
viper.SetDefault("node.expiry", "0")
viper.SetDefault("node.ephemeral.inactivity_timeout", "120s")
viper.SetDefault("node.routes.ha.probe_interval", "10s")
viper.SetDefault("node.routes.ha.probe_timeout", "5s")
viper.SetDefault("tuning.notifier_send_timeout", "800ms")
viper.SetDefault("tuning.batch_change_delay", "800ms")
viper.SetDefault("tuning.node_mapsession_buffered_chan_size", 30)
viper.SetDefault("tuning.node_store_batch_size", defaultNodeStoreBatchSize)
viper.SetDefault("tuning.node_store_batch_timeout", "500ms")
viper.SetDefault("prefixes.allocation", string(IPAllocationStrategySequential))
if err := viper.ReadInConfig(); err != nil {
if errors.Is(err, fs.ErrNotExist) {
log.Warn().Msg("No config file found, using defaults")
err := viper.ReadInConfig()
if err != nil {
if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); ok {
log.Warn().Msg("no config file found, using defaults")
return nil
}
@ -349,6 +468,51 @@ func LoadConfig(path string, isFile bool) error {
return nil
}
// resolveEphemeralInactivityTimeout resolves the ephemeral inactivity timeout
// from config, supporting both the new key (node.ephemeral.inactivity_timeout)
// and the old key (ephemeral_node_inactivity_timeout) for backwards compatibility.
//
// We cannot use viper.RegisterAlias here because aliases silently ignore
// config values set under the alias name. If a user writes the new key in
// their config file, RegisterAlias redirects reads to the old key (which
// has no config value), returning only the default and discarding the
// user's setting.
func resolveEphemeralInactivityTimeout() time.Duration {
// New key takes precedence if explicitly set in config.
if viper.IsSet("node.ephemeral.inactivity_timeout") &&
viper.GetString("node.ephemeral.inactivity_timeout") != "" {
return viper.GetDuration("node.ephemeral.inactivity_timeout")
}
// Fall back to old key for backwards compatibility.
if viper.IsSet("ephemeral_node_inactivity_timeout") {
return viper.GetDuration("ephemeral_node_inactivity_timeout")
}
// Default
return viper.GetDuration("node.ephemeral.inactivity_timeout")
}
// resolveNodeExpiry parses the node.expiry config value.
// Returns 0 if set to "0" (no default expiry) or on parse failure.
func resolveNodeExpiry() time.Duration {
value := viper.GetString("node.expiry")
if value == "" || value == "0" {
return 0
}
expiry, err := model.ParseDuration(value)
if err != nil {
log.Warn().
Str("value", value).
Msg("failed to parse node.expiry, defaulting to no expiry")
return 0
}
return time.Duration(expiry)
}
func validateServerConfig() error {
depr := deprecator{
warns: make(set.Set[string]),
@ -377,8 +541,25 @@ func validateServerConfig() error {
depr.fatal("oidc.strip_email_domain")
depr.fatal("oidc.map_legacy_users")
// Removed since v0.29.0: `randomize_client_port` moved to the ACL
// policy as a top-level `randomizeClientPort` field, matching the
// Tailscale-hosted control plane schema. Per-node `nodeAttrs`
// entries granting `https://tailscale.com/cap/randomize-client-port`
// also work.
depr.fatalWithHint("randomize_client_port",
`Set "randomizeClientPort": true at the top level of your policy file `+
`(see policy.path / policy.mode), or grant the cap per-node via a `+
`"nodeAttrs" entry. See CHANGELOG.md (BREAKING / Configuration).`)
// Deprecated: ephemeral_node_inactivity_timeout -> node.ephemeral.inactivity_timeout
depr.warnNoAlias("node.ephemeral.inactivity_timeout", "ephemeral_node_inactivity_timeout")
// Removed: oidc.expiry -> node.expiry
depr.fatalIfSet("oidc.expiry", "node.expiry")
if viper.GetBool("oidc.enabled") {
if err := validatePKCEMethod(viper.GetString("oidc.pkce.method")); err != nil {
err := validatePKCEMethod(viper.GetString("oidc.pkce.method"))
if err != nil {
return err
}
}
@ -386,7 +567,7 @@ func validateServerConfig() error {
depr.Log()
if viper.IsSet("dns.extra_records") && viper.IsSet("dns.extra_records_path") {
log.Fatal().Msg("Fatal config error: dns.extra_records and dns.extra_records_path are mutually exclusive. Please remove one of them from your config file")
log.Fatal().Msg("fatal config error: dns.extra_records and dns.extra_records_path are mutually exclusive. Please remove one of them from your config file")
}
// Collect any validation errors and return them all at once
@ -421,10 +602,12 @@ func validateServerConfig() error {
// Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
// to avoid races
minInactivityTimeout, _ := time.ParseDuration("65s")
if viper.GetDuration("ephemeral_node_inactivity_timeout") <= minInactivityTimeout {
ephemeralTimeout := resolveEphemeralInactivityTimeout()
if ephemeralTimeout <= minInactivityTimeout {
errorText += fmt.Sprintf(
"Fatal config error: ephemeral_node_inactivity_timeout (%s) is set too low, must be more than %s",
viper.GetString("ephemeral_node_inactivity_timeout"),
"Fatal config error: node.ephemeral.inactivity_timeout (%s) is set too low, must be more than %s",
ephemeralTimeout,
minInactivityTimeout,
)
}
@ -435,6 +618,49 @@ func validateServerConfig() error {
}
}
// Validate HA health probing parameters
if haInterval := viper.GetDuration(
"node.routes.ha.probe_interval",
); haInterval > 0 {
if haInterval < 2*time.Second {
errorText += fmt.Sprintf(
"Fatal config error: node.routes.ha.probe_interval (%s) must be >= 2s\n",
haInterval,
)
}
haTimeout := viper.GetDuration("node.routes.ha.probe_timeout")
if haTimeout < 1*time.Second {
errorText += fmt.Sprintf(
"Fatal config error: node.routes.ha.probe_timeout (%s) must be >= 1s\n",
haTimeout,
)
}
if haTimeout >= haInterval {
errorText += fmt.Sprintf(
"Fatal config error: node.routes.ha.probe_timeout (%s) must be less than node.routes.ha.probe_interval (%s)\n",
haTimeout,
haInterval,
)
}
}
// Validate tuning parameters
if size := viper.GetInt("tuning.node_store_batch_size"); size <= 0 {
errorText += fmt.Sprintf(
"Fatal config error: tuning.node_store_batch_size must be positive, got %d\n",
size,
)
}
if timeout := viper.GetDuration("tuning.node_store_batch_timeout"); timeout <= 0 {
errorText += fmt.Sprintf(
"Fatal config error: tuning.node_store_batch_timeout must be positive, got %s\n",
timeout,
)
}
if errorText != "" {
// nolint
return errors.New(strings.TrimSuffix(errorText, "\n"))
@ -477,6 +703,7 @@ func derpConfig() DERPConfig {
automaticallyAddEmbeddedDerpRegion := viper.GetBool(
"derp.server.automatically_add_embedded_derp_region",
)
if serverEnabled && stunAddr == "" {
log.Fatal().
Msg("derp.server.stun_listen_addr must be set if derp.server.enabled is true")
@ -546,13 +773,16 @@ func policyConfig() PolicyConfig {
func logConfig() LogConfig {
logLevelStr := viper.GetString("log.level")
logLevel, err := zerolog.ParseLevel(logLevelStr)
if err != nil {
logLevel = zerolog.DebugLevel
}
logFormatOpt := viper.GetString("log.format")
var logFormat string
switch logFormatOpt {
case JSONLogFormat:
logFormat = JSONLogFormat
@ -579,7 +809,7 @@ func databaseConfig() DatabaseConfig {
type_ := viper.GetString("database.type")
skipErrRecordNotFound := viper.GetBool("database.gorm.skip_err_record_not_found")
slowThreshold := viper.GetDuration("database.gorm.slow_threshold") * time.Millisecond
slowThreshold := time.Duration(viper.GetInt64("database.gorm.slow_threshold")) * time.Millisecond
parameterizedQueries := viper.GetBool("database.gorm.parameterized_queries")
prepareStmt := viper.GetBool("database.gorm.prepare_stmt")
@ -651,6 +881,7 @@ func dns() (DNSConfig, error) {
if err != nil {
return DNSConfig{}, fmt.Errorf("unmarshalling dns extra records: %w", err)
}
dns.ExtraRecords = extraRecords
}
@ -666,30 +897,23 @@ func (d *DNSConfig) globalResolvers() []*dnstype.Resolver {
var resolvers []*dnstype.Resolver
for _, nsStr := range d.Nameservers.Global {
warn := ""
if _, err := netip.ParseAddr(nsStr); err == nil {
if _, err := netip.ParseAddr(nsStr); err == nil { //nolint:noinlineerr
resolvers = append(resolvers, &dnstype.Resolver{
Addr: nsStr,
})
continue
} else {
warn = fmt.Sprintf("Invalid global nameserver %q. Parsing error: %s ignoring", nsStr, err)
}
if _, err := url.Parse(nsStr); err == nil {
if _, err := url.Parse(nsStr); err == nil { //nolint:noinlineerr
resolvers = append(resolvers, &dnstype.Resolver{
Addr: nsStr,
})
continue
} else {
warn = fmt.Sprintf("Invalid global nameserver %q. Parsing error: %s ignoring", nsStr, err)
}
if warn != "" {
log.Warn().Msg(warn)
}
log.Warn().Str("nameserver", nsStr).Msg("invalid global nameserver, ignoring")
}
return resolvers
@ -701,34 +925,30 @@ func (d *DNSConfig) globalResolvers() []*dnstype.Resolver {
// If a nameserver is neither a valid URL nor a valid IP, it will be ignored.
func (d *DNSConfig) splitResolvers() map[string][]*dnstype.Resolver {
routes := make(map[string][]*dnstype.Resolver)
for domain, nameservers := range d.Nameservers.Split {
var resolvers []*dnstype.Resolver
for _, nsStr := range nameservers {
warn := ""
if _, err := netip.ParseAddr(nsStr); err == nil {
if _, err := netip.ParseAddr(nsStr); err == nil { //nolint:noinlineerr
resolvers = append(resolvers, &dnstype.Resolver{
Addr: nsStr,
})
continue
} else {
warn = fmt.Sprintf("Invalid split dns nameserver %q. Parsing error: %s ignoring", nsStr, err)
}
if _, err := url.Parse(nsStr); err == nil {
if _, err := url.Parse(nsStr); err == nil { //nolint:noinlineerr
resolvers = append(resolvers, &dnstype.Resolver{
Addr: nsStr,
})
continue
} else {
warn = fmt.Sprintf("Invalid split dns nameserver %q. Parsing error: %s ignoring", nsStr, err)
}
if warn != "" {
log.Warn().Msg(warn)
}
log.Warn().Str("nameserver", nsStr).Str("domain", domain).Msg("invalid split dns nameserver, ignoring")
}
routes[domain] = resolvers
}
@ -743,6 +963,7 @@ func dnsToTailcfgDNS(dns DNSConfig) *tailcfg.DNSConfig {
}
cfg.Proxied = dns.MagicDNS
cfg.ExtraRecords = dns.ExtraRecords
if dns.OverrideLocalDNS {
cfg.Resolvers = dns.globalResolvers()
@ -751,62 +972,108 @@ func dnsToTailcfgDNS(dns DNSConfig) *tailcfg.DNSConfig {
}
routes := dns.splitResolvers()
cfg.Routes = routes
if dns.BaseDomain != "" {
cfg.Domains = []string{dns.BaseDomain}
}
cfg.Domains = append(cfg.Domains, dns.SearchDomains...)
return &cfg
}
func prefixV4() (*netip.Prefix, error) {
// warnBanner prints a highly visible warning banner to the log output.
// It wraps the provided lines in an ASCII-art box with a "Warning!" header.
// This is intended for critical configuration issues that users must not ignore.
func warnBanner(lines []string) {
var b strings.Builder
b.WriteString("\n")
b.WriteString("################################################################\n")
b.WriteString("### __ __ _ _ ###\n")
b.WriteString("### \\ \\ / / (_) | | ###\n")
b.WriteString("### \\ \\ /\\ / /_ _ _ __ _ __ _ _ __ __ _| | ###\n")
b.WriteString("### \\ \\/ \\/ / _` | '__| '_ \\| | '_ \\ / _` | | ###\n")
b.WriteString("### \\ /\\ / (_| | | | | | | | | | | (_| |_| ###\n")
b.WriteString("### \\/ \\/ \\__,_|_| |_| |_|_|_| |_|\\__, (_) ###\n")
b.WriteString("### __/ | ###\n")
b.WriteString("### |___/ ###\n")
b.WriteString("################################################################\n")
b.WriteString("### ###\n")
for _, line := range lines {
fmt.Fprintf(&b, "### %-54s ###\n", line)
}
b.WriteString("### ###\n")
b.WriteString("################################################################")
log.Warn().Msg(b.String())
}
func prefixV4() (*netip.Prefix, bool, error) {
prefixV4Str := viper.GetString("prefixes.v4")
if prefixV4Str == "" {
return nil, nil
return nil, false, nil
}
prefixV4, err := netip.ParsePrefix(prefixV4Str)
if err != nil {
return nil, fmt.Errorf("parsing IPv4 prefix from config: %w", err)
return nil, false, fmt.Errorf("parsing IPv4 prefix from config: %w", err)
}
builder := netipx.IPSetBuilder{}
builder.AddPrefix(tsaddr.CGNATRange())
ipSet, _ := builder.IPSet()
if !ipSet.ContainsPrefix(prefixV4) {
log.Warn().
Msgf("Prefix %s is not in the %s range. This is an unsupported configuration.",
prefixV4Str, tsaddr.CGNATRange())
}
return &prefixV4, nil
ipSet, _ := builder.IPSet()
return &prefixV4, !ipSet.ContainsPrefix(prefixV4), nil
}
func prefixV6() (*netip.Prefix, error) {
func prefixV6() (*netip.Prefix, bool, error) {
prefixV6Str := viper.GetString("prefixes.v6")
if prefixV6Str == "" {
return nil, nil
return nil, false, nil
}
prefixV6, err := netip.ParsePrefix(prefixV6Str)
if err != nil {
return nil, fmt.Errorf("parsing IPv6 prefix from config: %w", err)
return nil, false, fmt.Errorf("parsing IPv6 prefix from config: %w", err)
}
builder := netipx.IPSetBuilder{}
builder.AddPrefix(tsaddr.TailscaleULARange())
ipSet, _ := builder.IPSet()
if !ipSet.ContainsPrefix(prefixV6) {
log.Warn().
Msgf("Prefix %s is not in the %s range. This is an unsupported configuration.",
prefixV6Str, tsaddr.TailscaleULARange())
return &prefixV6, !ipSet.ContainsPrefix(prefixV6), nil
}
// trustedProxies rejects 0.0.0.0/0 and ::/0 because they defeat the
// peer-trust gate and almost always indicate misconfiguration.
func trustedProxies() ([]netip.Prefix, error) {
raw := viper.GetStringSlice("trusted_proxies")
if len(raw) == 0 {
return nil, nil
}
return &prefixV6, nil
out := make([]netip.Prefix, 0, len(raw))
for i, s := range raw {
p, err := netip.ParsePrefix(s)
if err != nil {
return nil, fmt.Errorf("trusted_proxies[%d] %q: %w", i, s, err)
}
if p.Bits() == 0 {
return nil, fmt.Errorf("trusted_proxies[%d] %q: %w", i, s, errTrustedProxyZeroRange)
}
out = append(out, p.Masked())
}
return out, nil
}
// LoadCLIConfig returns the needed configuration for the CLI client
@ -831,29 +1098,57 @@ func LoadCLIConfig() (*Config, error) {
// LoadServerConfig returns the full Headscale configuration to
// host a Headscale server. This is called as part of `headscale serve`.
func LoadServerConfig() (*Config, error) {
if err := validateServerConfig(); err != nil {
if err := validateServerConfig(); err != nil { //nolint:noinlineerr
return nil, err
}
logConfig := logConfig()
zerolog.SetGlobalLevel(logConfig.Level)
prefix4, err := prefixV4()
prefix4, v4NonStandard, err := prefixV4()
if err != nil {
return nil, err
}
prefix6, err := prefixV6()
prefix6, v6NonStandard, err := prefixV6()
if err != nil {
return nil, err
}
trusted, err := trustedProxies()
if err != nil {
return nil, err
}
if prefix4 == nil && prefix6 == nil {
return nil, errors.New("no IPv4 or IPv6 prefix configured, minimum one prefix is required")
return nil, ErrNoPrefixConfigured
}
if v4NonStandard || v6NonStandard {
warnBanner([]string{
"You have overridden the default Headscale IP prefixes",
"with a range outside of the standard CGNAT and/or ULA",
"ranges. This is NOT a supported configuration.",
"",
"Using subsets of the default ranges (100.64.0.0/10 for",
"IPv4, fd7a:115c:a1e0::/48 for IPv6) is fine. Using",
"ranges outside of these will cause undefined behaviour",
"as the Tailscale client is NOT designed to operate on",
"any other ranges.",
"",
"Please revert your prefixes to subsets of the standard",
"ranges as described in the example configuration.",
"",
"Any issue raised using a range outside of the",
"supported range will be labelled as wontfix",
"and closed.",
})
}
allocStr := viper.GetString("prefixes.allocation")
var alloc IPAllocationStrategy
switch allocStr {
case string(IPAllocationStrategySequential):
alloc = IPAllocationStrategySequential
@ -861,7 +1156,8 @@ func LoadServerConfig() (*Config, error) {
alloc = IPAllocationStrategyRandom
default:
return nil, fmt.Errorf(
"config error, prefixes.allocation is set to %s, which is not a valid strategy, allowed options: %s, %s",
"%w: %q, allowed options: %s, %s",
ErrInvalidAllocationStrategy,
allocStr,
IPAllocationStrategySequential,
IPAllocationStrategyRandom,
@ -875,18 +1171,20 @@ func LoadServerConfig() (*Config, error) {
derpConfig := derpConfig()
logTailConfig := logtailConfig()
randomizeClientPort := viper.GetBool("randomize_client_port")
oidcClientSecret := viper.GetString("oidc.client_secret")
oidcClientSecretPath := viper.GetString("oidc.client_secret_path")
if oidcClientSecretPath != "" && oidcClientSecret != "" {
return nil, errOidcMutuallyExclusive
}
if oidcClientSecretPath != "" {
secretBytes, err := os.ReadFile(os.ExpandEnv(oidcClientSecretPath))
if err != nil {
return nil, err
}
oidcClientSecret = strings.TrimSpace(string(secretBytes))
}
@ -900,7 +1198,8 @@ func LoadServerConfig() (*Config, error) {
// - Control plane runs on login.tailscale.com/controlplane.tailscale.com
// - MagicDNS (BaseDomain) for users is on a *.ts.net domain per tailnet (e.g. tail-scale.ts.net)
if dnsConfig.BaseDomain != "" {
if err := isSafeServerURL(serverURL, dnsConfig.BaseDomain); err != nil {
err := isSafeServerURL(serverURL, dnsConfig.BaseDomain)
if err != nil {
return nil, err
}
}
@ -911,11 +1210,12 @@ func LoadServerConfig() (*Config, error) {
MetricsAddr: viper.GetString("metrics_listen_addr"),
GRPCAddr: viper.GetString("grpc_listen_addr"),
GRPCAllowInsecure: viper.GetBool("grpc_allow_insecure"),
TrustedProxies: trusted,
DisableUpdateCheck: false,
PrefixV4: prefix4,
PrefixV6: prefix6,
IPAllocation: IPAllocationStrategy(alloc),
IPAllocation: alloc,
NoisePrivateKeyPath: util.AbsolutePathFromConfigPath(
viper.GetString("noise.private_key_path"),
@ -924,9 +1224,18 @@ func LoadServerConfig() (*Config, error) {
DERP: derpConfig,
EphemeralNodeInactivityTimeout: viper.GetDuration(
"ephemeral_node_inactivity_timeout",
),
Node: NodeConfig{
Expiry: resolveNodeExpiry(),
Ephemeral: EphemeralConfig{
InactivityTimeout: resolveEphemeralInactivityTimeout(),
},
Routes: RouteConfig{
HA: HARouteConfig{
ProbeInterval: viper.GetDuration("node.routes.ha.probe_interval"),
ProbeTimeout: viper.GetDuration("node.routes.ha.probe_timeout"),
},
},
},
Database: databaseConfig(),
@ -945,38 +1254,29 @@ func LoadServerConfig() (*Config, error) {
OnlyStartIfOIDCIsAvailable: viper.GetBool(
"oidc.only_start_if_oidc_is_available",
),
Issuer: viper.GetString("oidc.issuer"),
ClientID: viper.GetString("oidc.client_id"),
ClientSecret: oidcClientSecret,
Scope: viper.GetStringSlice("oidc.scope"),
ExtraParams: viper.GetStringMapString("oidc.extra_params"),
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
Expiry: func() time.Duration {
// if set to 0, we assume no expiry
if value := viper.GetString("oidc.expiry"); value == "0" {
return maxDuration
} else {
expiry, err := model.ParseDuration(value)
if err != nil {
log.Warn().Msg("failed to parse oidc.expiry, defaulting back to 180 days")
return defaultOIDCExpiryTime
}
return time.Duration(expiry)
}
}(),
UseExpiryFromToken: viper.GetBool("oidc.use_expiry_from_token"),
Issuer: viper.GetString("oidc.issuer"),
ClientID: viper.GetString("oidc.client_id"),
ClientSecret: oidcClientSecret,
Scope: viper.GetStringSlice("oidc.scope"),
ExtraParams: viper.GetStringMapString("oidc.extra_params"),
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
EmailVerifiedRequired: viper.GetBool("oidc.email_verified_required"),
UseExpiryFromToken: viper.GetBool("oidc.use_expiry_from_token"),
PKCE: PKCEConfig{
Enabled: viper.GetBool("oidc.pkce.enabled"),
Method: viper.GetString("oidc.pkce.method"),
},
},
LogTail: logTailConfig,
RandomizeClientPort: randomizeClientPort,
LogTail: logTailConfig,
Taildrop: TaildropConfig{
Enabled: viper.GetBool("taildrop.enabled"),
},
AutoUpdate: AutoUpdateConfig{
Enabled: viper.GetBool("auto_update.enabled"),
},
Policy: policyConfig(),
@ -989,7 +1289,6 @@ func LoadServerConfig() (*Config, error) {
Log: logConfig,
// TODO(kradalby): Document these settings when more stable
Tuning: Tuning{
NotifierSendTimeout: viper.GetDuration("tuning.notifier_send_timeout"),
BatchChangeDelay: viper.GetDuration("tuning.batch_change_delay"),
@ -1000,8 +1299,13 @@ func LoadServerConfig() (*Config, error) {
if workers := viper.GetInt("tuning.batcher_workers"); workers > 0 {
return workers
}
return DefaultBatcherWorkers()
}(),
RegisterCacheExpiration: viper.GetDuration("tuning.register_cache_expiration"),
RegisterCacheMaxEntries: viper.GetInt("tuning.register_cache_max_entries"),
NodeStoreBatchSize: viper.GetInt("tuning.node_store_batch_size"),
NodeStoreBatchTimeout: viper.GetDuration("tuning.node_store_batch_timeout"),
},
}, nil
}
@ -1031,6 +1335,7 @@ func isSafeServerURL(serverURL, baseDomain string) error {
}
s := len(serverDomainParts)
b := len(baseDomainParts)
for i := range baseDomainParts {
if serverDomainParts[s-i-1] != baseDomainParts[b-i-1] {
@ -1048,9 +1353,12 @@ type deprecator struct {
// warnWithAlias will register an alias between the newKey and the oldKey,
// and log a deprecation warning if the oldKey is set.
//
//nolint:unused
func (d *deprecator) warnWithAlias(newKey, oldKey string) {
// NOTE: RegisterAlias is called with NEW KEY -> OLD KEY
viper.RegisterAlias(newKey, oldKey)
if viper.IsSet(oldKey) {
d.warns.Add(
fmt.Sprintf(
@ -1075,6 +1383,22 @@ func (d *deprecator) fatal(oldKey string) {
}
}
// fatalWithHint behaves like fatal but appends a remediation pointer to
// the message so operators see exactly what to do without leaving the
// terminal. Use it when the removed key has a clean replacement on the
// policy side.
func (d *deprecator) fatalWithHint(oldKey, hint string) {
if viper.IsSet(oldKey) {
d.fatals.Add(
fmt.Sprintf(
"The %q configuration key has been removed. %s",
oldKey,
hint,
),
)
}
}
// fatalIfNewKeyIsNotUsed deprecates and adds an entry to the fatal list of options if the oldKey is set and the new key is _not_ set.
// If the new key is set, a warning is emitted instead.
func (d *deprecator) fatalIfNewKeyIsNotUsed(newKey, oldKey string) {
@ -1092,7 +1416,24 @@ func (d *deprecator) fatalIfNewKeyIsNotUsed(newKey, oldKey string) {
}
}
// fatalIfSet fatals if the oldKey is set at all, regardless of whether
// the newKey is set. Use this when the old key has been fully removed
// and any use of it should be a hard error.
func (d *deprecator) fatalIfSet(oldKey, newKey string) {
if viper.IsSet(oldKey) {
d.fatals.Add(
fmt.Sprintf(
"The %q configuration key has been removed. Please use %q instead.",
oldKey,
newKey,
),
)
}
}
// warn deprecates and adds an option to log a warning if the oldKey is set.
//
//nolint:unused
func (d *deprecator) warnNoAlias(newKey, oldKey string) {
if viper.IsSet(oldKey) {
d.warns.Add(
@ -1107,6 +1448,8 @@ func (d *deprecator) warnNoAlias(newKey, oldKey string) {
}
// warn deprecates and adds an entry to the warn list of options if the oldKey is set.
//
//nolint:unused
func (d *deprecator) warn(oldKey string) {
if viper.IsSet(oldKey) {
d.warns.Add(

View file

@ -1,7 +1,9 @@
package types
import (
"encoding/json"
"fmt"
"net/netip"
"os"
"path/filepath"
"testing"
@ -26,7 +28,7 @@ func TestReadConfig(t *testing.T) {
{
name: "unmarshal-dns-full-config",
configPath: "testdata/dns_full.yaml",
setup: func(t *testing.T) (any, error) {
setup: func(t *testing.T) (any, error) { //nolint:thelper
dns, err := dns()
if err != nil {
return nil, err
@ -61,7 +63,7 @@ func TestReadConfig(t *testing.T) {
{
name: "dns-to-tailcfg.DNSConfig",
configPath: "testdata/dns_full.yaml",
setup: func(t *testing.T) (any, error) {
setup: func(t *testing.T) (any, error) { //nolint:thelper
dns, err := dns()
if err != nil {
return nil, err
@ -92,7 +94,7 @@ func TestReadConfig(t *testing.T) {
{
name: "unmarshal-dns-full-no-magic",
configPath: "testdata/dns_full_no_magic.yaml",
setup: func(t *testing.T) (any, error) {
setup: func(t *testing.T) (any, error) { //nolint:thelper
dns, err := dns()
if err != nil {
return nil, err
@ -127,7 +129,7 @@ func TestReadConfig(t *testing.T) {
{
name: "dns-to-tailcfg.DNSConfig",
configPath: "testdata/dns_full_no_magic.yaml",
setup: func(t *testing.T) (any, error) {
setup: func(t *testing.T) (any, error) { //nolint:thelper
dns, err := dns()
if err != nil {
return nil, err
@ -158,7 +160,7 @@ func TestReadConfig(t *testing.T) {
{
name: "base-domain-in-server-url-err",
configPath: "testdata/base-domain-in-server-url.yaml",
setup: func(t *testing.T) (any, error) {
setup: func(t *testing.T) (any, error) { //nolint:thelper
return LoadServerConfig()
},
want: nil,
@ -167,7 +169,7 @@ func TestReadConfig(t *testing.T) {
{
name: "base-domain-not-in-server-url",
configPath: "testdata/base-domain-not-in-server-url.yaml",
setup: func(t *testing.T) (any, error) {
setup: func(t *testing.T) (any, error) { //nolint:thelper
cfg, err := LoadServerConfig()
if err != nil {
return nil, err
@ -187,7 +189,7 @@ func TestReadConfig(t *testing.T) {
{
name: "dns-override-true-errors",
configPath: "testdata/dns-override-true-error.yaml",
setup: func(t *testing.T) (any, error) {
setup: func(t *testing.T) (any, error) { //nolint:thelper
return LoadServerConfig()
},
wantErr: "Fatal config error: dns.nameservers.global must be set when dns.override_local_dns is true",
@ -195,7 +197,7 @@ func TestReadConfig(t *testing.T) {
{
name: "dns-override-true",
configPath: "testdata/dns-override-true.yaml",
setup: func(t *testing.T) (any, error) {
setup: func(t *testing.T) (any, error) { //nolint:thelper
_, err := LoadServerConfig()
if err != nil {
return nil, err
@ -221,7 +223,7 @@ func TestReadConfig(t *testing.T) {
{
name: "policy-path-is-loaded",
configPath: "testdata/policy-path-is-loaded.yaml",
setup: func(t *testing.T) (any, error) {
setup: func(t *testing.T) (any, error) { //nolint:thelper // inline test closure
cfg, err := LoadServerConfig()
if err != nil {
return nil, err
@ -242,6 +244,7 @@ func TestReadConfig(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
viper.Reset()
err := LoadConfig(tt.configPath, true)
require.NoError(t, err)
@ -276,14 +279,14 @@ func TestReadConfigFromEnv(t *testing.T) {
"HEADSCALE_DATABASE_SQLITE_WRITE_AHEAD_LOG": "false",
"HEADSCALE_PREFIXES_V4": "100.64.0.0/10",
},
setup: func(t *testing.T) (any, error) {
setup: func(t *testing.T) (any, error) { //nolint:thelper // inline test closure
t.Logf("all settings: %#v", viper.AllSettings())
assert.Equal(t, "trace", viper.GetString("log.level"))
assert.Equal(t, "100.64.0.0/10", viper.GetString("prefixes.v4"))
assert.False(t, viper.GetBool("database.sqlite.write_ahead_log"))
return nil, nil
return nil, nil //nolint:nilnil // test setup returns nil to indicate no expected value
},
want: nil,
},
@ -300,7 +303,7 @@ func TestReadConfigFromEnv(t *testing.T) {
// "HEADSCALE_DNS_NAMESERVERS_SPLIT": `{foo.bar.com: ["1.1.1.1"]}`,
// "HEADSCALE_DNS_EXTRA_RECORDS": `[{ name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.4" }]`,
},
setup: func(t *testing.T) (any, error) {
setup: func(t *testing.T) (any, error) { //nolint:thelper // inline test closure
t.Logf("all settings: %#v", viper.AllSettings())
dns, err := dns()
@ -335,6 +338,7 @@ func TestReadConfigFromEnv(t *testing.T) {
}
viper.Reset()
err := LoadConfig("testdata/minimal.yaml", true)
require.NoError(t, err)
@ -349,11 +353,10 @@ func TestReadConfigFromEnv(t *testing.T) {
}
func TestTLSConfigValidation(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "headscale")
if err != nil {
t.Fatal(err)
}
// defer os.RemoveAll(tmpDir)
tmpDir := t.TempDir()
var err error
configYaml := []byte(`---
tls_letsencrypt_hostname: example.com
tls_letsencrypt_challenge_type: ""
@ -363,6 +366,7 @@ noise:
// Populate a custom config file
configFilePath := filepath.Join(tmpDir, "config.yaml")
err = os.WriteFile(configFilePath, configYaml, 0o600)
if err != nil {
t.Fatalf("Couldn't write file %s", configFilePath)
@ -398,10 +402,12 @@ server_url: http://127.0.0.1:8080
tls_letsencrypt_hostname: example.com
tls_letsencrypt_challenge_type: TLS-ALPN-01
`)
err = os.WriteFile(configFilePath, configYaml, 0o600)
if err != nil {
t.Fatalf("Couldn't write file %s", configFilePath)
}
err = LoadConfig(tmpDir, false)
require.NoError(t, err)
}
@ -463,7 +469,135 @@ func TestSafeServerURL(t *testing.T) {
return
}
assert.NoError(t, err)
})
}
}
// TestConfigJSONOmitsSecrets verifies that marshalling a [Config] to JSON
// (as /debug/config does via [state.State.DebugConfig]) does not leak the
// Postgres password, the OIDC client secret, or the headscale admin
// API key. Operators who widen metrics_listen_addr to 0.0.0.0 should
// not be able to read these back via debug endpoints reachable over
// CGNAT/loopback.
func TestConfigJSONOmitsSecrets(t *testing.T) {
const (
secretPostgresPass = "p0stgres-secret-marker"
secretClientSecret = "oidc-client-secret-marker" //nolint:gosec // test marker, not a real credential
secretAPIKey = "headscale-cli-api-key-marker" //nolint:gosec // test marker, not a real credential
)
cfg := &Config{
Database: DatabaseConfig{
Postgres: PostgresConfig{
Pass: secretPostgresPass,
},
},
OIDC: OIDCConfig{
ClientSecret: secretClientSecret,
},
CLI: CLIConfig{
APIKey: secretAPIKey,
},
}
out, err := json.Marshal(cfg)
require.NoError(t, err)
body := string(out)
for _, secret := range []string{secretPostgresPass, secretClientSecret, secretAPIKey} {
assert.NotContains(t, body, secret,
"marshalled Config must not contain secret %q", secret)
}
}
//nolint:goconst // repeated CIDR strings are test fixtures, not refactor candidates
func TestTrustedProxies(t *testing.T) {
tests := []struct {
name string
input any
want []netip.Prefix
wantErr string
}{
{
name: "unset",
input: nil,
want: nil,
},
{
name: "empty",
input: []string{},
want: nil,
},
{
name: "single-v4",
input: []string{"10.0.0.0/16"},
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/16")},
},
{
name: "single-v6",
input: []string{"fd00::/8"},
want: []netip.Prefix{netip.MustParsePrefix("fd00::/8")},
},
{
name: "mixed-v4-v6",
input: []string{"127.0.0.1/32", "::1/128", "10.0.0.0/16"},
want: []netip.Prefix{
netip.MustParsePrefix("127.0.0.1/32"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("10.0.0.0/16"),
},
},
{
name: "non-canonical-masked",
input: []string{"10.0.0.5/16"},
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/16")},
},
{
name: "bare-ip-rejected",
input: []string{"10.0.0.1"},
wantErr: `trusted_proxies[0] "10.0.0.1"`,
},
{
name: "garbage-reports-index",
input: []string{"10.0.0.0/16", "not-an-ip"},
wantErr: `trusted_proxies[1] "not-an-ip"`,
},
{
name: "ipv4-zero-rejected",
input: []string{"0.0.0.0/0"},
wantErr: "0.0.0.0/0 and ::/0 are not allowed",
},
{
name: "ipv6-zero-rejected",
input: []string{"::/0"},
wantErr: "0.0.0.0/0 and ::/0 are not allowed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
viper.Reset()
if tt.input != nil {
viper.Set("trusted_proxies", tt.input)
}
got, err := trustedProxies()
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
if diff := cmp.Diff(tt.want, got, cmpopts.EquateComparable(netip.Prefix{})); diff != "" {
t.Errorf("trustedProxies() mismatch (-want +got):\n%s", diff)
}
})
}
}

View file

@ -0,0 +1,22 @@
package types
import "net/netip"
// DebugRoutes is the JSON-shaped snapshot of the headscale primary
// route ledger exposed by the /debug/routes endpoint and consumed by
// the integration test harness. It used to live in hscontrol/routes,
// but the algorithm now runs inside hscontrol/state and that package
// must not be imported from integration code.
type DebugRoutes struct {
// AvailableRoutes maps node IDs to their advertised routes
// (intersection of announced and approved). Only nodes currently
// connected to headscale are listed.
AvailableRoutes map[NodeID][]netip.Prefix `json:"available_routes"`
// PrimaryRoutes maps route prefixes to the node currently elected
// primary for that prefix.
PrimaryRoutes map[string]NodeID `json:"primary_routes"`
// UnhealthyNodes lists nodes that have failed health probes.
UnhealthyNodes []NodeID `json:"unhealthy_nodes,omitempty"`
}

View file

@ -0,0 +1,25 @@
package types
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// TestMain ensures the working directory is set to the package source directory
// so that relative testdata/ paths resolve correctly when the test binary is
// executed from an arbitrary location (e.g., via "go tool stress").
func TestMain(m *testing.M) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
panic("could not determine test source directory")
}
err := os.Chdir(filepath.Dir(filename))
if err != nil {
panic("could not chdir to test source directory: " + err.Error())
}
os.Exit(m.Run())
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,73 @@
package types
import (
"fmt"
"net/netip"
"testing"
"github.com/juanfont/headscale/hscontrol/policy/matcher"
"tailscale.com/tailcfg"
)
func BenchmarkNodeViewCanAccess(b *testing.B) {
addr := func(ip string) *netip.Addr {
parsed := netip.MustParseAddr(ip)
return &parsed
}
rules := []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.1/32"},
DstPorts: []tailcfg.NetPortRange{
{
IP: "100.64.0.2/32",
Ports: tailcfg.PortRangeAny,
},
},
},
}
matchers := matcher.MatchesFromFilterRules(rules)
derpLatency := make(map[string]float64, 256)
for i := range 128 {
derpLatency[fmt.Sprintf("%d-v4", i)] = float64(i) / 10
derpLatency[fmt.Sprintf("%d-v6", i)] = float64(i) / 10
}
src := Node{
IPv4: addr("100.64.0.1"),
}
dst := Node{
IPv4: addr("100.64.0.2"),
Hostinfo: &tailcfg.Hostinfo{
NetInfo: &tailcfg.NetInfo{
DERPLatency: derpLatency,
},
},
}
srcView := src.View()
dstView := dst.View()
if !srcView.CanAccess(matchers, dstView) {
b.Fatal("benchmark setup error: expected source to access destination")
}
b.Run("pointer", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
srcView.CanAccess(matchers, dstView)
}
})
b.Run("struct clone", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
src.CanAccess(matchers, dstView.AsStruct())
}
})
}

View file

@ -0,0 +1,294 @@
package types
import (
"testing"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
)
// TestNodeIsTagged tests the [Node.IsTagged] method for determining if a node is tagged.
func TestNodeIsTagged(t *testing.T) {
tests := []struct {
name string
node Node
want bool
}{
{
name: "node with tags - is tagged",
node: Node{
Tags: []string{"tag:server", "tag:prod"},
},
want: true,
},
{
name: "node with single tag - is tagged",
node: Node{
Tags: []string{"tag:web"},
},
want: true,
},
{
name: "node with no tags - not tagged",
node: Node{
Tags: []string{},
},
want: false,
},
{
name: "node with nil tags - not tagged",
node: Node{
Tags: nil,
},
want: false,
},
{
// Tags should be copied from [Node.AuthKey] during registration, so a node
// with only [PreAuthKey.Tags] and no [Node.Tags] would be invalid in practice.
// [Node.IsTagged] only checks [Node.Tags], not [PreAuthKey.Tags].
name: "node registered with tagged authkey only - not tagged (tags should be copied)",
node: Node{
AuthKey: &PreAuthKey{
Tags: []string{"tag:database"},
},
},
want: false,
},
{
name: "node with both tags and authkey tags - is tagged",
node: Node{
Tags: []string{"tag:server"},
AuthKey: &PreAuthKey{
Tags: []string{"tag:database"},
},
},
want: true,
},
{
name: "node with user and no tags - not tagged",
node: Node{
UserID: new(uint(42)),
Tags: []string{},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.node.IsTagged()
assert.Equal(t, tt.want, got, "IsTagged() returned unexpected value")
})
}
}
// TestNodeViewIsTagged tests the [NodeView.IsTagged] method on [NodeView].
func TestNodeViewIsTagged(t *testing.T) {
tests := []struct {
name string
node Node
want bool
}{
{
name: "tagged node via Tags field",
node: Node{
Tags: []string{"tag:server"},
},
want: true,
},
{
// Tags should be copied from [Node.AuthKey] during registration, so a node
// with only [PreAuthKey.Tags] and no [Node.Tags] would be invalid in practice.
name: "node with only AuthKey tags - not tagged (tags should be copied)",
node: Node{
AuthKey: &PreAuthKey{
Tags: []string{"tag:web"},
},
},
want: false, // [Node.IsTagged] only checks [Node.Tags]
},
{
name: "user-owned node",
node: Node{
UserID: new(uint(1)),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
view := tt.node.View()
got := view.IsTagged()
assert.Equal(t, tt.want, got, "NodeView.IsTagged() returned unexpected value")
})
}
}
// TestNodeHasTag tests the [Node.HasTag] method for checking specific tag membership.
func TestNodeHasTag(t *testing.T) {
tests := []struct {
name string
node Node
tag string
want bool
}{
{
name: "node has the tag",
node: Node{
Tags: []string{"tag:server", "tag:prod"},
},
tag: "tag:server",
want: true,
},
{
name: "node does not have the tag",
node: Node{
Tags: []string{"tag:server", "tag:prod"},
},
tag: "tag:web",
want: false,
},
{
// Tags should be copied from [Node.AuthKey] during registration
// [Node.HasTag] only checks [Node.Tags], not [PreAuthKey.Tags]
name: "node has tag only in authkey - returns false",
node: Node{
AuthKey: &PreAuthKey{
Tags: []string{"tag:database"},
},
},
tag: "tag:database",
want: false,
},
{
// [Node.Tags] is what matters, not [PreAuthKey.Tags]
name: "node has tag in Tags but not in AuthKey",
node: Node{
Tags: []string{"tag:server"},
AuthKey: &PreAuthKey{
Tags: []string{"tag:database"},
},
},
tag: "tag:server",
want: true,
},
{
name: "invalid tag format still returns false",
node: Node{
Tags: []string{"tag:server"},
},
tag: "invalid-tag",
want: false,
},
{
name: "empty tag returns false",
node: Node{
Tags: []string{"tag:server"},
},
tag: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.node.HasTag(tt.tag)
assert.Equal(t, tt.want, got, "HasTag() returned unexpected value")
})
}
}
// TestNodeTagsImmutableAfterRegistration tests that tags can only be set during registration.
func TestNodeTagsImmutableAfterRegistration(t *testing.T) {
// Test that a node registered with tags keeps them
taggedNode := Node{
ID: 1,
Tags: []string{"tag:server"},
AuthKey: &PreAuthKey{
Tags: []string{"tag:server"},
},
RegisterMethod: util.RegisterMethodAuthKey,
}
// Node should be tagged
assert.True(t, taggedNode.IsTagged(), "Node registered with tags should be tagged")
// Node should have the tag
has := taggedNode.HasTag("tag:server")
assert.True(t, has, "Node should have the tag it was registered with")
// Test that a user-owned node is not tagged
userNode := Node{
ID: 2,
UserID: new(uint(42)),
Tags: []string{},
RegisterMethod: util.RegisterMethodOIDC,
}
assert.False(t, userNode.IsTagged(), "User-owned node should not be tagged")
}
// TestNodeOwnershipModel tests the tags-as-identity model.
func TestNodeOwnershipModel(t *testing.T) {
tests := []struct {
name string
node Node
wantIsTagged bool
description string
}{
{
name: "tagged node has tags, UserID is informational",
node: Node{
ID: 1,
UserID: new(uint(5)), // "created by" user 5
Tags: []string{"tag:server"},
},
wantIsTagged: true,
description: "Tagged nodes may have UserID set for tracking, but ownership is defined by tags",
},
{
name: "user-owned node has no tags",
node: Node{
ID: 2,
UserID: new(uint(5)),
Tags: []string{},
},
wantIsTagged: false,
description: "User-owned nodes are owned by the user, not by tags",
},
{
// Tags should be copied from [Node.AuthKey] to [Node] during registration
// [Node.IsTagged] only checks [Node.Tags], not [PreAuthKey.Tags]
name: "node with only authkey tags - not tagged (tags should be copied)",
node: Node{
ID: 3,
UserID: new(uint(5)), // "created by" user 5
AuthKey: &PreAuthKey{
Tags: []string{"tag:database"},
},
},
wantIsTagged: false,
description: "IsTagged() only checks node.Tags; AuthKey.Tags should be copied during registration",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.node.IsTagged()
assert.Equal(t, tt.wantIsTagged, got, tt.description)
})
}
}
// TestUserTypedID tests the TypedID() helper method.
func TestUserTypedID(t *testing.T) {
user := User{
Model: gorm.Model{ID: 42},
}
typedID := user.TypedID()
assert.NotNil(t, typedID, "TypedID() should return non-nil pointer")
assert.Equal(t, UserID(42), *typedID, "TypedID() should return correct UserID value")
}

View file

@ -113,6 +113,183 @@ func Test_NodeCanAccess(t *testing.T) {
},
want: true,
},
// Subnet-to-subnet tests.
// When ACL src and dst are both subnet CIDRs, subnet
// routers advertising those subnets must see each other.
{
name: "subnet-to-subnet-src-router-sees-dst-router-3157",
node1: Node{
IPv4: iap("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.88.8.0/24"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.88.8.0/24"),
},
},
node2: Node{
IPv4: iap("100.64.0.2"),
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.99.9.0/24"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.99.9.0/24"),
},
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"10.88.8.0/24"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
},
},
},
want: true,
},
{
// With a unidirectional ACL (src=A→dst=B), the dst
// router cannot access the src router. Bidirectional
// peer visibility comes from [policy.ReduceNodes] checking
// both A.CanAccess(B) || B.CanAccess(A).
name: "subnet-to-subnet-unidirectional-dst-cannot-access-src-3157",
node1: Node{
IPv4: iap("100.64.0.2"),
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.99.9.0/24"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.99.9.0/24"),
},
},
node2: Node{
IPv4: iap("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.88.8.0/24"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.88.8.0/24"),
},
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"10.88.8.0/24"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
},
},
},
want: false,
},
{
// With a bidirectional ACL, both routers can access
// each other.
name: "subnet-to-subnet-bidirectional-3157",
node1: Node{
IPv4: iap("100.64.0.2"),
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.99.9.0/24"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.99.9.0/24"),
},
},
node2: Node{
IPv4: iap("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.88.8.0/24"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.88.8.0/24"),
},
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"10.88.8.0/24"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
},
},
{
SrcIPs: []string{"10.99.9.0/24"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.88.8.0/24", Ports: tailcfg.PortRangeAny},
},
},
},
want: true,
},
{
name: "subnet-to-subnet-regular-node-excluded-3157",
node1: Node{
IPv4: iap("100.64.0.3"),
},
node2: Node{
IPv4: iap("100.64.0.2"),
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.99.9.0/24"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.99.9.0/24"),
},
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"10.88.8.0/24"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
},
},
},
want: false,
},
{
name: "subnet-to-subnet-unrelated-router-excluded-3157",
node1: Node{
IPv4: iap("100.64.0.3"),
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("172.16.0.0/24"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("172.16.0.0/24"),
},
},
node2: Node{
IPv4: iap("100.64.0.2"),
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.99.9.0/24"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.99.9.0/24"),
},
},
rules: []tailcfg.FilterRule{
{
SrcIPs: []string{"10.88.8.0/24"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
},
},
},
want: false,
},
}
for _, tt := range tests {
@ -127,6 +304,40 @@ func Test_NodeCanAccess(t *testing.T) {
}
}
// Test_NodeCanAccess_Unidirectional asserts that a one-way rule grants
// access in one direction only. A unidirectional ACL is a valid and
// intentional pattern; the "OR" aggregation in the v1 compat harness
// loses this asymmetry, which motivated the directional split in
// TestRoutesCompatPeerVisibility.
func Test_NodeCanAccess_Unidirectional(t *testing.T) {
iap := func(ipStr string) *netip.Addr {
ip := netip.MustParseAddr(ipStr)
return &ip
}
nodeA := Node{IPv4: iap("100.64.0.1")}
nodeB := Node{IPv4: iap("100.64.0.2")}
rules := []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.1/32"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.64.0.2/32", Ports: tailcfg.PortRangeAny},
},
},
}
matchers := matcher.MatchesFromFilterRules(rules)
if !nodeA.CanAccess(matchers, &nodeB) {
t.Errorf("A→B: want true, got false")
}
if nodeB.CanAccess(matchers, &nodeA) {
t.Errorf("B→A: want false, got true (unidirectional rule leaked reverse access)")
}
}
func TestNodeFQDN(t *testing.T) {
tests := []struct {
name string
@ -139,7 +350,7 @@ func TestNodeFQDN(t *testing.T) {
name: "no-dnsconfig-with-username",
node: Node{
GivenName: "test",
User: User{
User: &User{
Name: "user",
},
},
@ -150,7 +361,7 @@ func TestNodeFQDN(t *testing.T) {
name: "all-set",
node: Node{
GivenName: "test",
User: User{
User: &User{
Name: "user",
},
},
@ -160,12 +371,12 @@ func TestNodeFQDN(t *testing.T) {
{
name: "no-given-name",
node: Node{
User: User{
User: &User{
Name: "user",
},
},
domain: "example.com",
wantErr: "failed to create valid FQDN: node has no given name",
wantErr: "creating valid FQDN: node has no given name",
},
{
name: "too-long-username",
@ -173,13 +384,13 @@ func TestNodeFQDN(t *testing.T) {
GivenName: strings.Repeat("a", 256),
},
domain: "example.com",
wantErr: fmt.Sprintf("failed to create valid FQDN (%s.example.com.): hostname too long, cannot except 255 ASCII chars", strings.Repeat("a", 256)),
wantErr: fmt.Sprintf("creating valid FQDN (%s.example.com.): hostname too long, cannot accept more than 255 ASCII chars", strings.Repeat("a", 256)),
},
{
name: "no-dnsconfig",
node: Node{
GivenName: "test",
User: User{
User: &User{
Name: "user",
},
},
@ -339,66 +550,6 @@ func TestPeerChangeFromMapRequest(t *testing.T) {
}
}
func TestApplyHostnameFromHostInfo(t *testing.T) {
tests := []struct {
name string
nodeBefore Node
change *tailcfg.Hostinfo
want Node
}{
{
name: "hostinfo-not-exists",
nodeBefore: Node{
GivenName: "manual-test.local",
Hostname: "TestHost.Local",
},
change: nil,
want: Node{
GivenName: "manual-test.local",
Hostname: "TestHost.Local",
},
},
{
name: "hostinfo-exists-no-automatic-givenName",
nodeBefore: Node{
GivenName: "manual-test.local",
Hostname: "TestHost.Local",
},
change: &tailcfg.Hostinfo{
Hostname: "NewHostName.Local",
},
want: Node{
GivenName: "manual-test.local",
Hostname: "NewHostName.Local",
},
},
{
name: "hostinfo-exists-automatic-givenName",
nodeBefore: Node{
GivenName: "automaticname.test",
Hostname: "AutomaticName.Test",
},
change: &tailcfg.Hostinfo{
Hostname: "NewHostName.Local",
},
want: Node{
GivenName: "newhostname.local",
Hostname: "NewHostName.Local",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.nodeBefore.ApplyHostnameFromHostInfo(tt.change)
if diff := cmp.Diff(tt.want, tt.nodeBefore, util.Comparers...); diff != "" {
t.Errorf("Patch unexpected result (-want +got):\n%s", diff)
}
})
}
}
func TestApplyPeerChange(t *testing.T) {
tests := []struct {
name string
@ -555,3 +706,210 @@ func TestNodeRegisterMethodToV1Enum(t *testing.T) {
})
}
}
// TestHasNetworkChanges tests the [NodeView] method for detecting
// when a node's network properties have changed.
func TestHasNetworkChanges(t *testing.T) {
mustIPPtr := func(s string) *netip.Addr {
ip := netip.MustParseAddr(s)
return &ip
}
tests := []struct {
name string
old *Node
new *Node
changed bool
}{
{
name: "no changes",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
IPv6: mustIPPtr("fd7a:115c:a1e0::1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/24")},
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
IPv6: mustIPPtr("fd7a:115c:a1e0::1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/24")},
},
changed: false,
},
{
name: "IPv4 changed",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
IPv6: mustIPPtr("fd7a:115c:a1e0::1"),
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.2"),
IPv6: mustIPPtr("fd7a:115c:a1e0::1"),
},
changed: true,
},
{
name: "IPv6 changed",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
IPv6: mustIPPtr("fd7a:115c:a1e0::1"),
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
IPv6: mustIPPtr("fd7a:115c:a1e0::2"),
},
changed: true,
},
{
name: "RoutableIPs added",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{},
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}},
},
changed: true,
},
{
name: "RoutableIPs removed",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}},
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{},
},
changed: true,
},
{
name: "RoutableIPs changed",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}},
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/24")}},
},
changed: true,
},
{
name: "SubnetRoutes added",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/24")}},
ApprovedRoutes: []netip.Prefix{},
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/24")}},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/24")},
},
changed: true,
},
{
name: "SubnetRoutes removed",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/24")}},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/24")},
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/24")}},
ApprovedRoutes: []netip.Prefix{},
},
changed: true,
},
{
name: "SubnetRoutes changed",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24"), netip.MustParsePrefix("192.168.0.0/24")}},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24"), netip.MustParsePrefix("192.168.0.0/24")}},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/24")},
},
changed: true,
},
{
name: "irrelevant property changed (Hostname)",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostname: "old-name",
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostname: "new-name",
},
changed: false,
},
{
name: "ExitRoutes approved",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}},
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")},
},
changed: true,
},
{
name: "ExitRoutes unchanged when SubnetRoutes change",
old: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), netip.MustParsePrefix("10.0.0.0/24")}},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")},
},
new: &Node{
ID: 1,
IPv4: mustIPPtr("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), netip.MustParsePrefix("10.0.0.0/24")}},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), netip.MustParsePrefix("10.0.0.0/24")},
},
changed: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.new.View().HasNetworkChanges(tt.old.View())
if got != tt.changed {
t.Errorf("HasNetworkChanges() = %v, want %v", got, tt.changed)
}
})
}
}

View file

@ -4,6 +4,8 @@ import (
"time"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/timestamppb"
)
@ -14,35 +16,60 @@ func (e PAKError) Error() string { return string(e) }
// PreAuthKey describes a pre-authorization key usable in a particular user.
type PreAuthKey struct {
ID uint64 `gorm:"primary_key"`
Key string
UserID uint
User User `gorm:"constraint:OnDelete:SET NULL;"`
ID uint64 `gorm:"primary_key"`
// Legacy plaintext key (for backwards compatibility)
Key string
// New bcrypt-based authentication
Prefix string
Hash []byte // bcrypt
// For tagged keys: [PreAuthKey.UserID] tracks who created the key (informational)
// For user-owned keys: [PreAuthKey.UserID] tracks the node owner
// Can be nil for system-created tagged keys
UserID *uint
User *User `gorm:"constraint:OnDelete:SET NULL;"`
Reusable bool
Ephemeral bool `gorm:"default:false"`
Used bool `gorm:"default:false"`
// Tags are always applied to the node and is one of
// the sources of tags a node might have. They are copied
// from the PreAuthKey when the node logs in the first time,
// and ignored after.
// Tags to assign to nodes registered with this key.
// Tags are copied to the node during registration.
// If non-empty, this creates tagged nodes (not user-owned).
Tags []string `gorm:"serializer:json"`
CreatedAt *time.Time
Expiration *time.Time
}
func (key *PreAuthKey) Proto() *v1.PreAuthKey {
// PreAuthKeyNew is returned once when the key is created.
type PreAuthKeyNew struct {
ID uint64 `gorm:"primary_key"`
Key string
Reusable bool
Ephemeral bool
Tags []string
Expiration *time.Time
CreatedAt *time.Time
User *User // Can be nil for system-created tagged keys
}
func (key *PreAuthKeyNew) Proto() *v1.PreAuthKey {
protoKey := v1.PreAuthKey{
User: key.User.Proto(),
Id: key.ID,
Key: key.Key,
Ephemeral: key.Ephemeral,
User: nil, // Will be set below if not nil
Reusable: key.Reusable,
Used: key.Used,
Ephemeral: key.Ephemeral,
AclTags: key.Tags,
}
if key.User != nil {
protoKey.User = key.User.Proto()
}
if key.Expiration != nil {
protoKey.Expiration = timestamppb.New(*key.Expiration)
}
@ -54,25 +81,51 @@ func (key *PreAuthKey) Proto() *v1.PreAuthKey {
return &protoKey
}
// canUsePreAuthKey checks if a pre auth key can be used.
func (key *PreAuthKey) Proto() *v1.PreAuthKey {
protoKey := v1.PreAuthKey{
User: nil, // Will be set below if not nil
Id: key.ID,
Ephemeral: key.Ephemeral,
Reusable: key.Reusable,
Used: key.Used,
AclTags: key.Tags,
}
if key.User != nil {
protoKey.User = key.User.Proto()
}
// For new keys (with prefix/hash), show the prefix so users can identify the key
// For legacy keys (with plaintext key), show the full key for backwards compatibility
if key.Prefix != "" {
protoKey.Key = "hskey-auth-" + key.Prefix + "-***"
} else if key.Key != "" {
// Legacy key - show full key for backwards compatibility
// TODO: Consider hiding this in a future major version
protoKey.Key = key.Key
}
if key.Expiration != nil {
protoKey.Expiration = timestamppb.New(*key.Expiration)
}
if key.CreatedAt != nil {
protoKey.CreatedAt = timestamppb.New(*key.CreatedAt)
}
return &protoKey
}
// Validate checks if a pre auth key can be used.
func (pak *PreAuthKey) Validate() error {
if pak == nil {
return PAKError("invalid authkey")
}
// Use [zerolog.Event.EmbedObject] for safe logging - never log full key
log.Debug().
Caller().
Str("key", pak.Key).
Bool("hasExpiration", pak.Expiration != nil).
Time("expiration", func() time.Time {
if pak.Expiration != nil {
return *pak.Expiration
}
return time.Time{}
}()).
Time("now", time.Now()).
Bool("reusable", pak.Reusable).
Bool("used", pak.Used).
EmbedObject(pak).
Msg("PreAuthKey.Validate: checking key")
if pak.Expiration != nil && pak.Expiration.Before(time.Now()) {
@ -90,3 +143,51 @@ func (pak *PreAuthKey) Validate() error {
return nil
}
// IsTagged returns true if this [PreAuthKey] creates tagged nodes.
// When a [PreAuthKey] has tags, nodes registered with it will be tagged nodes.
func (pak *PreAuthKey) IsTagged() bool {
return len(pak.Tags) > 0
}
// maskedPrefix returns the key prefix in masked format for safe logging.
// SECURITY: Never log the full key or hash, only the masked prefix.
func (pak *PreAuthKey) maskedPrefix() string {
if pak.Prefix != "" {
return "hskey-auth-" + pak.Prefix + "-***"
}
return ""
}
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
// SECURITY: This method intentionally does NOT log the full key or hash.
// Only the masked prefix is logged for identification purposes.
func (pak *PreAuthKey) MarshalZerologObject(e *zerolog.Event) {
if pak == nil {
return
}
e.Uint64(zf.PAKID, pak.ID)
e.Bool(zf.PAKReusable, pak.Reusable)
e.Bool(zf.PAKEphemeral, pak.Ephemeral)
e.Bool(zf.PAKUsed, pak.Used)
e.Bool(zf.PAKIsTagged, pak.IsTagged())
// SECURITY: Only log masked prefix, never full key or hash
if masked := pak.maskedPrefix(); masked != "" {
e.Str(zf.PAKPrefix, masked)
}
if len(pak.Tags) > 0 {
e.Strs(zf.PAKTags, pak.Tags)
}
if pak.User != nil {
e.Str(zf.UserName, pak.User.Username())
}
if pak.Expiration != nil {
e.Time(zf.PAKExpiration, *pak.Expiration)
}
}

View file

@ -110,8 +110,7 @@ func TestCanUsePreAuthKey(t *testing.T) {
if err == nil {
t.Errorf("expected error but got none")
} else {
var httpErr PAKError
ok := errors.As(err, &httpErr)
httpErr, ok := errors.AsType[PAKError](err)
if !ok {
t.Errorf("expected HTTPError but got %T", err)
} else {

View file

@ -0,0 +1,55 @@
package types
import (
"net/netip"
"time"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
// RegistrationData is the payload cached for a pending node registration.
// It replaces the previous practice of caching a full *[Node] and carries
// only the fields the registration callback path actually consumes when
// promoting a pending registration to a real node.
//
// Combined with the bounded-LRU cache that holds these entries, this caps
// the worst-case memory footprint of unauthenticated cache-fill attempts
// at (max_entries × per_entry_size). The cache is sized so that the
// product is bounded to a few MiB even with attacker-supplied 1 MiB
// Hostinfos (the Noise body limit).
type RegistrationData struct {
// MachineKey is the cryptographic identity of the machine being
// registered. Required.
MachineKey key.MachinePublic
// NodeKey is the cryptographic identity of the node session.
// Required.
NodeKey key.NodePublic
// DiscoKey is the disco public key for peer-to-peer connections.
DiscoKey key.DiscoPublic
// Hostname is the resolved hostname for the registering node.
// Already validated/normalised by EnsureHostname at producer time.
Hostname string
// Hostinfo is the original [tailcfg.Hostinfo] from the [tailcfg.RegisterRequest],
// stored so that the auth callback can populate the new node's
// initial [tailcfg.Hostinfo] (and so that observability/CLI consumers see
// fields like OS, OSVersion, and IPNVersion before the first
// [tailcfg.MapRequest] restores the live set).
//
// May be nil if the client did not send [tailcfg.Hostinfo] in the original
// [tailcfg.RegisterRequest].
Hostinfo *tailcfg.Hostinfo
// Endpoints is the initial set of WireGuard endpoints the node
// reported. The first [tailcfg.MapRequest] after registration overwrites
// this with the live set.
Endpoints []netip.AddrPort
// Expiry is the optional client-requested expiry for this node.
// May be nil if the client did not request a specific expiry.
Expiry *time.Time
}

View file

@ -19,7 +19,7 @@ type Route struct {
// Advertised is now only stored as part of [Node.Hostinfo].
Advertised bool
// Enabled is stored directly on the node as ApprovedRoutes.
// Enabled is stored directly on the node as [Node.ApprovedRoutes].
Enabled bool
// IsPrimary is only determined in memory as it is only relevant

46
hscontrol/types/slices.go Normal file
View file

@ -0,0 +1,46 @@
package types
import "net/netip"
// The named slice types below are used for GORM-persisted [Node] columns
// that serialise as JSON. GORM v2's struct-based Updates skips fields
// it considers zero — for unnamed slice types that is nil — and the
// default [reflect.Value.IsZero] treats a nil slice as zero. By giving
// each slice an IsZero() that always returns false, the column is
// always included in UPDATE statements regardless of whether the
// caller is clearing the field. JSON marshalling is unchanged: a nil
// value serialises to null and an empty value serialises to [].
//
// The .List() helpers return the underlying unnamed slice for the
// places (mainly testify assertions over [reflect.DeepEqual]) where the
// distinction between the named and unnamed type matters.
// Strings is a []string with a GORM-friendly [Strings.IsZero].
type Strings []string
// IsZero implements GORM's zeroer interface to keep the column in the
// UPDATE set even when the slice is nil or empty.
func (Strings) IsZero() bool { return false }
// List returns the underlying []string.
func (s Strings) List() []string { return []string(s) }
// Prefixes is a []netip.Prefix with a GORM-friendly [Prefixes.IsZero].
type Prefixes []netip.Prefix
// IsZero implements GORM's zeroer interface to keep the column in the
// UPDATE set even when the slice is nil or empty.
func (Prefixes) IsZero() bool { return false }
// List returns the underlying []netip.Prefix.
func (s Prefixes) List() []netip.Prefix { return []netip.Prefix(s) }
// AddrPorts is a []netip.AddrPort with a GORM-friendly [AddrPorts.IsZero].
type AddrPorts []netip.AddrPort
// IsZero implements GORM's zeroer interface to keep the column in the
// UPDATE set even when the slice is nil or empty.
func (AddrPorts) IsZero() bool { return false }
// List returns the underlying []netip.AddrPort.
func (s AddrPorts) List() []netip.AddrPort { return []netip.AddrPort(s) }

View file

@ -0,0 +1,114 @@
package testcapture
import (
"fmt"
"strings"
)
// CommentHeader returns the // comment header that gets prepended to
// a [Capture] file when it is written. The header is purely
// informational; consumers ignore it. Format:
//
// <TestID>
//
// <Description, possibly multi-line>
//
// Nodes with filter rules: <X> of <Y> ← for non-SSH captures
// Nodes with SSH rules: <X> of <Y> ← for SSH captures
// Captured at: <RFC3339 UTC>
// tool version: <ToolVersion>
// schema version: <SchemaVersion>
//
// Both `tool_version` and `schema_version` are also stored as
// first-class JSON fields on the [Capture] struct; the comment lines
// exist purely so the values are visible at a glance without
// parsing the file.
//
// The leading "// " on every line is added by the hujson writer.
func CommentHeader(c *Capture) string {
if c == nil {
return ""
}
var b strings.Builder
b.WriteString(c.TestID)
b.WriteByte('\n')
if c.Description != "" {
b.WriteByte('\n')
b.WriteString(c.Description)
b.WriteByte('\n')
}
stats := captureStats(c)
if stats != "" {
b.WriteByte('\n')
b.WriteString(stats)
b.WriteByte('\n')
}
if !c.CapturedAt.IsZero() {
fmt.Fprintf(&b, "Captured at: %s\n", c.CapturedAt.UTC().Format("2006-01-02T15:04:05Z"))
}
if c.ToolVersion != "" {
fmt.Fprintf(&b, "tool version: %s\n", c.ToolVersion)
}
if c.SchemaVersion != 0 {
fmt.Fprintf(&b, "schema version: %d\n", c.SchemaVersion)
}
return strings.TrimRight(b.String(), "\n")
}
// captureStats returns a one-line summary of how many nodes had
// non-empty captured data, or the empty string if there are no
// captures at all.
//
// The phrasing depends on which fields the capture uses:
// - SSH captures populate [NodeCapture.SSHRules]
// - other captures populate [NodeCapture.PacketFilterRules]
//
// If both fields appear (mixed/unusual), filter rules wins.
func captureStats(c *Capture) string {
if len(c.Captures) == 0 {
return ""
}
var (
total = len(c.Captures)
filterRules int
sshRules int
filterRulesSet bool
sshRulesSet bool
)
for _, n := range c.Captures {
if n.PacketFilterRules != nil {
filterRulesSet = true
if len(n.PacketFilterRules) > 0 {
filterRules++
}
}
if n.SSHRules != nil {
sshRulesSet = true
if len(n.SSHRules) > 0 {
sshRules++
}
}
}
switch {
case filterRulesSet:
return fmt.Sprintf("Nodes with filter rules: %d of %d", filterRules, total)
case sshRulesSet:
return fmt.Sprintf("Nodes with SSH rules: %d of %d", sshRules, total)
default:
return ""
}
}

View file

@ -0,0 +1,62 @@
package testcapture
import (
"encoding/json"
"errors"
"fmt"
"os"
"github.com/tailscale/hujson"
)
// ErrUnsupportedSchemaVersion is returned by [Read] when a capture
// advertises a [Capture.SchemaVersion] newer than the current binary supports.
var ErrUnsupportedSchemaVersion = errors.New("testcapture: unsupported schema version")
// Read parses a HuJSON capture file from disk into a [Capture].
//
// Comments and trailing commas in the file are stripped before
// unmarshaling. Files advertising a [Capture.SchemaVersion] newer than the
// current binary's are rejected with [ErrUnsupportedSchemaVersion];
// [Capture.SchemaVersion] == 0 (pre-versioning) is accepted for backwards compat.
// The returned [Capture]'s [Capture.CapturedAt] is the value recorded in the file
// (not "now").
func Read(path string) (*Capture, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("testcapture: read %s: %w", path, err)
}
var c Capture
err = unmarshalHuJSON(data, &c)
if err != nil {
return nil, fmt.Errorf("testcapture: %s: %w", path, err)
}
if c.SchemaVersion > SchemaVersion {
return nil, fmt.Errorf("%w: %s has version %d, binary supports %d",
ErrUnsupportedSchemaVersion, path, c.SchemaVersion, SchemaVersion)
}
return &c, nil
}
// unmarshalHuJSON parses HuJSON bytes (JSON with comments / trailing
// commas) into v. Comments are stripped via [hujson.Value.Standardize] before
// [json.Unmarshal] is called.
func unmarshalHuJSON(data []byte, v any) error {
ast, err := hujson.Parse(data)
if err != nil {
return fmt.Errorf("hujson parse: %w", err)
}
ast.Standardize()
err = json.Unmarshal(ast.Pack(), v)
if err != nil {
return fmt.Errorf("json unmarshal: %w", err)
}
return nil
}

View file

@ -0,0 +1,332 @@
// Package testcapture defines the on-disk format used by Headscale's
// policy v2 compatibility tests for golden data captured from a
// Tailscale-hosted control plane by an external capture tool.
//
// Files are HuJSON. Wire-format Tailscale data (filter rules, netmap,
// whois, SSH rules) is stored as proper tailcfg/netmap/filtertype/
// apitype values rather than [json.RawMessage] so that schema drift
// between the capture tool and headscale becomes a compile error
// rather than a silent test failure, and so that consumers don't
// have to repeat [json.Unmarshal] at every read site. Storing data as
// [json.RawMessage] previously hid a serious capture-pipeline bug (the
// IPN bus initial notification returns a stale Peers slice — see the
// comment on [Node.Netmap] below) for months.
//
// All four capture types (acl, routes, grant, ssh) use the same [Capture]
// shape. SSH scenarios populate Captures[name].SSHRules; the others
// populate Captures[name].PacketFilterRules + Captures[name].Netmap.
package testcapture
import (
"bytes"
"encoding/json"
"time"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/wgengine/filter/filtertype"
)
// SchemaVersion identifies the on-disk format. Bumped on breaking changes.
//
// Files written before SchemaVersion existed do not have this field; new
// captures always set it to the current value.
const SchemaVersion = 1
// Capture is one captured run of one scenario.
//
// All four capture types (acl, routes, grant, ssh) use this same shape.
// SSH scenarios populate [Capture.Captures][name].SSHRules; the others populate
// [Capture.Captures][name].PacketFilterRules + [Capture.Captures][name].Netmap.
type Capture struct {
// SchemaVersion identifies the on-disk format version. Always set
// to [SchemaVersion] when written.
SchemaVersion int `json:"schema_version"`
// TestID is the stable identifier of the scenario, derived from
// its filename. Used as the test name in Go tests.
TestID string `json:"test_id"`
// Description is free-form text copied from the scenario file.
// Rendered in the comment header at the top of the file.
Description string `json:"description,omitempty"`
// Category is an optional grouping label (e.g. "routes",
// "grant", "ssh").
Category string `json:"category,omitempty"`
// CapturedAt is the UTC timestamp of when the capture was taken.
CapturedAt time.Time `json:"captured_at"`
// ToolVersion identifies the binary that produced the file.
ToolVersion string `json:"tool_version"`
// Tailnet is the name of the SaaS tailnet the capture was taken
// against (e.g. "kratail2tid@passkey").
Tailnet string `json:"tailnet"`
// Error is true when the SaaS API rejected the policy or when
// capture itself failed. In the rejection case, [Capture.Captures] reflects
// the pre-push baseline (deny-all default) and [Input.APIResponseBody]
// is populated.
Error bool `json:"error,omitempty"`
// CaptureError is set when the capture itself failed (timeout,
// missing data, etc.). The partially-captured [Capture.Captures] map is
// still included for post-mortem. Distinct from
// [Input.APIResponseBody] which describes a SaaS API rejection.
CaptureError string `json:"capture_error,omitempty"`
// Input is everything that was sent to the tailnet to produce
// the captured state.
Input Input `json:"input"`
// Topology is the users and nodes present in the tailnet at
// capture time. Always populated by the capture tool.
Topology Topology `json:"topology"`
// Captures holds the per-node captured data, keyed by node
// GivenName.
Captures map[string]Node `json:"captures"`
}
// Input describes everything that was sent to the tailnet to produce
// the captured state.
//
// [Input] has a custom [Input.UnmarshalJSON] to accept both the new on-disk
// shape (where full_policy is a JSON-encoded string) and the legacy
// shape (where full_policy is a JSON object). The legacy shape is
// re-marshaled to a string at load time so consumers see the typed
// field uniformly.
type Input struct {
// FullPolicy is the unchanged policy that was POSTed to the SaaS
// API. Stored as a string because it is opaque JSON that round-
// trips losslessly without parsing — headscale's policy parser
// reads it on demand.
FullPolicy string `json:"full_policy"`
// APIResponseCode is the HTTP status code of the policy POST.
APIResponseCode int `json:"api_response_code"`
// APIResponseBody is only populated when [Input.APIResponseCode] != 200.
APIResponseBody *APIResponseBody `json:"api_response_body,omitempty"`
// Tailnet describes the tailnet-wide settings the capture tool applied
// before pushing the policy.
Tailnet TailnetInput `json:"tailnet"`
// ScenarioHuJSON is the unchanged contents of the scenario file
// (HuJSON). Reading this back is enough to re-run the exact
// same scenario.
ScenarioHuJSON string `json:"scenario_hujson"`
// ScenarioPath is the path the scenario was loaded from,
// relative to the captures directory. Informational only.
ScenarioPath string `json:"scenario_path,omitempty"`
}
// MarshalJSON writes [Input.FullPolicy] as a raw JSON object rather than a
// double-quoted string. Consumers (including via_compat_test.go which
// uses its own local types) expect to parse full_policy as a JSON
// object, not a JSON string. The [Input.UnmarshalJSON] below accepts both
// forms on read so old and new captures are interchangeable.
func (i Input) MarshalJSON() ([]byte, error) {
type alias Input
raw := struct {
alias
FullPolicy json.RawMessage `json:"full_policy"`
}{
alias: alias(i),
}
if i.FullPolicy != "" {
raw.FullPolicy = json.RawMessage(i.FullPolicy)
}
return json.Marshal(raw)
}
// UnmarshalJSON handles both the current on-disk shape (full_policy
// as a JSON-encoded string) and the legacy shape (full_policy as a
// JSON object). Legacy objects are re-marshaled into a string at
// load time so consumers see the typed field uniformly. New captures
// always write the object form via the custom [Input.MarshalJSON] above.
func (i *Input) UnmarshalJSON(data []byte) error {
type alias Input
var raw struct {
alias
FullPolicy json.RawMessage `json:"full_policy"`
}
err := json.Unmarshal(data, &raw)
if err != nil {
return err
}
*i = Input(raw.alias)
// raw.FullPolicy might be a JSON-encoded string ("...") or a JSON
// object/array/null. Try string first; on failure use the raw bytes
// as is, normalised to compact form.
if len(raw.FullPolicy) == 0 || string(raw.FullPolicy) == "null" {
i.FullPolicy = ""
return nil
}
if raw.FullPolicy[0] == '"' {
var s string
err := json.Unmarshal(raw.FullPolicy, &s)
if err != nil {
return err
}
i.FullPolicy = s
return nil
}
// Legacy (and new MarshalJSON output): full_policy is a raw JSON
// object. Compact whitespace but preserve key ordering so the
// round-trip is stable.
var buf bytes.Buffer
err = json.Compact(&buf, raw.FullPolicy)
if err != nil {
return err
}
i.FullPolicy = buf.String()
return nil
}
// APIResponseBody is the (subset of) the SaaS API error response we keep.
type APIResponseBody struct {
Message string `json:"message,omitempty"`
}
// TailnetInput captures tailnet-wide settings the capture tool applied before
// pushing the policy.
type TailnetInput struct {
DNS DNSInput `json:"dns"`
Settings SettingsInput `json:"settings"`
}
// DNSInput describes the DNS configuration applied to the tailnet.
type DNSInput struct {
MagicDNS bool `json:"magic_dns"`
Nameservers []string `json:"nameservers"`
SearchPaths []string `json:"search_paths"`
SplitDNS map[string][]string `json:"split_dns"`
}
// SettingsInput describes tailnet settings applied via the API.
//
// Pointer fields are nil when the scenario does not override the
// reset default for that setting. The fields mirror the
// PATCH /tailnet/{tailnet}/settings request shape exposed by
// tailscale.com/client/tailscale/v2 — in practice the headscale
// compatibility tests use the subset that observably affects the
// captured netmap CapMap or DNSConfig.
type SettingsInput struct {
DevicesApprovalOn *bool `json:"devices_approval_on,omitempty"`
DevicesAutoUpdatesOn *bool `json:"devices_auto_updates_on,omitempty"`
DevicesKeyDurationDays *int `json:"devices_key_duration_days,omitempty"`
NetworkFlowLoggingOn *bool `json:"network_flow_logging_on,omitempty"`
RegionalRoutingOn *bool `json:"regional_routing_on,omitempty"`
PostureIdentityCollectionOn *bool `json:"posture_identity_collection_on,omitempty"`
}
// Topology describes the users and nodes present in the tailnet at
// capture time. Headscale's compat tests use this to construct
// equivalent [types.User] and [types.Node] objects.
type Topology struct {
// Users in the tailnet. Always populated by the capture tool.
Users []TopologyUser `json:"users"`
// Nodes in the tailnet, keyed by GivenName.
Nodes map[string]TopologyNode `json:"nodes"`
}
// TopologyUser identifies one user account in the tailnet.
type TopologyUser struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// TopologyNode is one node in the tailnet topology.
type TopologyNode struct {
Hostname string `json:"hostname"`
Tags []string `json:"tags"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
// User is the [TopologyUser.Name] for user-owned nodes. Empty for
// tagged nodes.
User string `json:"user,omitempty"`
// RoutableIPs is what the node advertised
// ([tailcfg.Hostinfo.RoutableIPs] in its own [netmap.NetworkMap.SelfNode]).
// May include 0.0.0.0/0 + ::/0 for exit nodes.
RoutableIPs []string `json:"routable_ips"`
// ApprovedRoutes is the subset of [TopologyNode.RoutableIPs] the tailnet has
// approved. Used by Headscale's NodeCanApproveRoute test.
ApprovedRoutes []string `json:"approved_routes"`
}
// Node is the captured state for one node, keyed by GivenName in
// [Capture.Captures].
//
// All four capture types populate the same struct. Different fields are
// used by different test types:
//
// - acl, routes, grant: PacketFilterRules + PacketFilterMatches + Netmap
// - grant (with capture_whois): + Whois
// - ssh: SSHRules
//
// Whichever fields are set in the file is what the consumer reads.
type Node struct {
// PacketFilterRules is the wire-format filter rules as returned
// by tailscaled localapi /debug-packet-filter-rules. The single
// most important field for ACL/routes/grant tests.
PacketFilterRules []tailcfg.FilterRule `json:"packet_filter_rules,omitempty"`
// PacketFilterMatches is the compiled filter matches (with
// CapMatch) returned by tailscaled localapi
// /debug-packet-filter-matches. Captured alongside
// [Node.PacketFilterRules]; useful for grant tests that want the
// compiled form.
PacketFilterMatches []filtertype.Match `json:"packet_filter_matches,omitempty"`
// Netmap is the full netmap as observed by the local tailscaled.
// NEVER trimmed. Consumers extract whatever fields they need.
//
// IMPORTANT: the capture tool captures this by waiting for the IPN bus to
// settle on a fresh delta-triggered notification, NOT by reading
// the WatchIPNBus(NotifyInitialNetMap) initial notification.
// The initial notification carries cn.NetMap() which returns
// nb.netMap as-is — the [netmap.NetworkMap] whose Peers slice was
// set at full-sync time and never re-synchronized from the
// authoritative nb.peers map. The capture tool previously used the initial
// notification and silently captured netmaps with mostly-empty
// Peers, which corrupted every via-grant compat test against the
// stale data. See the capture tool source for the for the
// stability-wait pattern, and tailscale.com/ipn/ipnlocal/c2n.go
// :handleC2NDebugNetMap which uses netMapWithPeers() for the
// same reason.
Netmap *netmap.NetworkMap `json:"netmap,omitempty"`
// Whois is per-peer whois lookups, keyed by peer IP. Captured
// only when scenario.options.capture_whois is true.
Whois map[string]*apitype.WhoIsResponse `json:"whois,omitempty"`
// SSHRules is the SSH rules slice extracted from
// [netmap.NetworkMap.SSHPolicy].Rules. Populated only for SSH scenarios.
SSHRules []*tailcfg.SSHRule `json:"ssh_rules,omitempty"`
}

View file

@ -0,0 +1,484 @@
package testcapture_test
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
func sampleACLCapture() *testcapture.Capture {
rules := []tailcfg.FilterRule{
{
SrcIPs: []string{"*"},
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
},
}
nm := &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{Name: "user1.tail.example.com."}).View(),
}
return &testcapture.Capture{
SchemaVersion: testcapture.SchemaVersion,
TestID: "ACL-A01",
Description: "wildcard ACL: every node sees every other node",
Category: "acl",
CapturedAt: time.Date(2026, 4, 7, 12, 34, 56, 0, time.UTC),
ToolVersion: "capture-test-0.0.0",
Tailnet: "kratail2tid@passkey",
Input: testcapture.Input{
FullPolicy: `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`,
APIResponseCode: 200,
Tailnet: testcapture.TailnetInput{
DNS: testcapture.DNSInput{
MagicDNS: false,
Nameservers: []string{},
SearchPaths: []string{},
SplitDNS: map[string][]string{},
},
},
ScenarioHuJSON: `{"id":"acl-a01","policy":{}}`,
ScenarioPath: "scenarios/acl/acl-a01.hujson",
},
Topology: testcapture.Topology{
Users: []testcapture.TopologyUser{
{ID: 1, Name: "kratail2tid", Email: "kratail2tid@passkey"},
{ID: 2, Name: "kristoffer", Email: "kristoffer@dalby.cc"},
},
Nodes: map[string]testcapture.TopologyNode{
"user1": {
Hostname: "user1",
IPv4: "100.90.199.68",
IPv6: "fd7a:115c:a1e0::2d01:c747",
User: "kratail2tid",
RoutableIPs: []string{},
ApprovedRoutes: []string{},
},
"tagged-server": {
Hostname: "tagged-server",
Tags: []string{"tag:server"},
IPv4: "100.108.74.26",
IPv6: "fd7a:115c:a1e0::b901:4a87",
RoutableIPs: []string{},
ApprovedRoutes: []string{},
},
},
},
Captures: map[string]testcapture.Node{
"user1": {
PacketFilterRules: rules,
Netmap: nm,
},
"tagged-server": {
PacketFilterRules: rules,
Netmap: nm,
},
},
}
}
func sampleSSHCapture() *testcapture.Capture {
sshRules := []*tailcfg.SSHRule{
{
Action: &tailcfg.SSHAction{Accept: true},
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.90.199.68"}},
SSHUsers: map[string]string{"root": "root"},
},
}
return &testcapture.Capture{
SchemaVersion: testcapture.SchemaVersion,
TestID: "SSH-A01",
Description: "ssh accept autogroup:member to autogroup:self",
Category: "ssh",
CapturedAt: time.Date(2026, 4, 7, 13, 0, 0, 0, time.UTC),
ToolVersion: "capture-test-0.0.0",
Tailnet: "kratail2tid@passkey",
Input: testcapture.Input{
FullPolicy: `{"ssh":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self"],"users":["root"]}]}`,
APIResponseCode: 200,
Tailnet: testcapture.TailnetInput{
DNS: testcapture.DNSInput{
Nameservers: []string{},
SearchPaths: []string{},
SplitDNS: map[string][]string{},
},
},
ScenarioHuJSON: `{"id":"ssh-a01"}`,
},
Topology: testcapture.Topology{
Users: []testcapture.TopologyUser{
{ID: 1, Name: "kratail2tid", Email: "kratail2tid@passkey"},
},
Nodes: map[string]testcapture.TopologyNode{
"user1": {
Hostname: "user1",
IPv4: "100.90.199.68",
IPv6: "fd7a:115c:a1e0::2d01:c747",
User: "kratail2tid",
RoutableIPs: []string{},
ApprovedRoutes: []string{},
},
},
},
Captures: map[string]testcapture.Node{
"user1": {SSHRules: sshRules},
},
}
}
// equalViaJSON compares two captures by JSON-marshaling them and
// comparing the bytes. The [testcapture.Capture] struct embeds tailcfg view types
// with unexported pointer fields that go-cmp can't traverse, so a
// JSON round-trip is the simplest way to verify [testcapture.Write]+[testcapture.Read] produced
// equivalent values.
func equalViaJSON(t *testing.T, want, got *testcapture.Capture) {
t.Helper()
wantJSON, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal want: %v", err)
}
gotJSON, err := json.MarshalIndent(got, "", " ")
if err != nil {
t.Fatalf("marshal got: %v", err)
}
if string(wantJSON) != string(gotJSON) {
t.Errorf("roundtrip mismatch\n--- want ---\n%s\n--- got ---\n%s",
string(wantJSON), string(gotJSON))
}
}
func TestWriteReadRoundtrip_ACL(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ACL-A01.hujson")
in := sampleACLCapture()
err := testcapture.Write(path, in)
if err != nil {
t.Fatalf("Write: %v", err)
}
out, err := testcapture.Read(path)
if err != nil {
t.Fatalf("Read: %v", err)
}
equalViaJSON(t, in, out)
}
func TestWriteReadRoundtrip_SSH(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "SSH-A01.hujson")
in := sampleSSHCapture()
err := testcapture.Write(path, in)
if err != nil {
t.Fatalf("Write: %v", err)
}
out, err := testcapture.Read(path)
if err != nil {
t.Fatalf("Read: %v", err)
}
equalViaJSON(t, in, out)
}
func TestWrite_ProducesCommentHeader(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ACL-A01.hujson")
c := sampleACLCapture()
err := testcapture.Write(path, c)
if err != nil {
t.Fatalf("Write: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
lines := strings.Split(string(raw), "\n")
if len(lines) == 0 {
t.Fatal("file is empty")
}
// First line should be the test ID prefixed with "// ".
if want := "// ACL-A01"; lines[0] != want {
t.Errorf("first line: want %q, got %q", want, lines[0])
}
header := strings.Join(extractHeaderLines(lines), "\n")
if !strings.Contains(header, "wildcard ACL") {
t.Errorf("header missing description; got:\n%s", header)
}
if !strings.Contains(header, "Nodes with filter rules: 2 of 2") {
t.Errorf("header missing stats line; got:\n%s", header)
}
if !strings.Contains(header, "Captured at:") || !strings.Contains(header, "2026-04-07T12:34:56Z") {
t.Errorf("header missing capture timestamp; got:\n%s", header)
}
if !strings.Contains(header, "tool version:") || !strings.Contains(header, "capture-test-0.0.0") {
t.Errorf("header missing tool version; got:\n%s", header)
}
if !strings.Contains(header, "schema version: 1") {
t.Errorf("header missing schema version; got:\n%s", header)
}
}
func TestWrite_SSH_StatsUseSSHRules(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "SSH-A01.hujson")
c := sampleSSHCapture()
err := testcapture.Write(path, c)
if err != nil {
t.Fatalf("Write: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
header := strings.Join(extractHeaderLines(strings.Split(string(raw), "\n")), "\n")
if !strings.Contains(header, "Nodes with SSH rules: 1 of 1") {
t.Errorf("ssh stats line missing; got:\n%s", header)
}
}
func TestRead_HuJSONWithComments(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "manual.hujson")
const content = `// hand-written
// comments + trailing commas
{
"schema_version": 1,
"test_id": "MANUAL",
"captured_at": "2026-04-07T12:00:00Z",
"tool_version": "test",
"tailnet": "example.com",
"input": {
"full_policy": "{}",
"api_response_code": 200,
"tailnet": {
"dns": {
"magic_dns": false,
"nameservers": [],
"search_paths": [],
"split_dns": {},
},
"settings": {},
},
"scenario_hujson": "",
},
"topology": {
"users": [],
"nodes": {},
},
"captures": {},
}
`
err := os.WriteFile(path, []byte(content), 0o600)
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
c, err := testcapture.Read(path)
if err != nil {
t.Fatalf("Read: %v", err)
}
if c.TestID != "MANUAL" {
t.Errorf("TestID = %q, want MANUAL", c.TestID)
}
if c.SchemaVersion != testcapture.SchemaVersion {
t.Errorf("SchemaVersion = %d, want %d", c.SchemaVersion, testcapture.SchemaVersion)
}
}
func TestRead_RejectsNewerSchemaVersion(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "future.hujson")
content := fmt.Sprintf(`{
"schema_version": %d,
"test_id": "FUTURE",
"captured_at": "2099-01-01T00:00:00Z",
"tool_version": "future",
"tailnet": "example.com",
"input": {
"full_policy": "{}",
"api_response_code": 200,
"tailnet": {
"dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}},
"settings": {}
},
"scenario_hujson": ""
},
"topology": {"users": [], "nodes": {}},
"captures": {}
}`, testcapture.SchemaVersion+1)
err := os.WriteFile(path, []byte(content), 0o600)
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, err = testcapture.Read(path)
if !errors.Is(err, testcapture.ErrUnsupportedSchemaVersion) {
t.Fatalf("Read(future-schema) = %v, want ErrUnsupportedSchemaVersion", err)
}
}
func TestWrite_NilCapture(t *testing.T) {
err := testcapture.Write(filepath.Join(t.TempDir(), "x.hujson"), nil)
if err == nil {
t.Fatal("Write(nil) returned nil error, want error")
}
}
func TestCommentHeader_NilSafe(t *testing.T) {
if got := testcapture.CommentHeader(nil); got != "" {
t.Errorf("CommentHeader(nil) = %q, want empty", got)
}
}
func TestCommentHeader_ZeroTime(t *testing.T) {
c := &testcapture.Capture{TestID: "ZERO"}
got := testcapture.CommentHeader(c)
if strings.Contains(got, "Captured at") {
t.Errorf("zero time should not produce 'Captured at': %q", got)
}
if !strings.HasPrefix(got, "ZERO") {
t.Errorf("header should start with TestID: %q", got)
}
}
func TestCommentHeader_NoStatsForEmptyCaptures(t *testing.T) {
c := &testcapture.Capture{TestID: "EMPTY"}
header := testcapture.CommentHeader(c)
if strings.Contains(header, "filter rules") || strings.Contains(header, "SSH rules") {
t.Errorf("empty captures should not produce stats line: %q", header)
}
}
func TestCommentHeader_EmptyFilterRulesCountAsEmpty(t *testing.T) {
// Mixed: nil, empty slice, and one populated rule. Only the
// populated entry should be counted in the "filter rules" stat.
c := &testcapture.Capture{
TestID: "NULLS",
Captures: map[string]testcapture.Node{
"a": {PacketFilterRules: nil},
"b": {PacketFilterRules: []tailcfg.FilterRule{}},
"c": {PacketFilterRules: []tailcfg.FilterRule{{SrcIPs: []string{"*"}}}},
},
}
header := testcapture.CommentHeader(c)
// Only "b" and "c" are non-nil, so the capture is detected as
// "filter rules" — and only "c" actually has rules. With the new
// typed semantics, b's empty slice still counts as "set" (not
// nil), so the denominator is 2 of 3 capture entries that have
// any filter-rules slice at all, and 1 of those is populated.
if !strings.Contains(header, "Nodes with filter rules: 1 of 3") {
t.Errorf("expected '1 of 3' in header; got:\n%s", header)
}
}
// TestInputUnmarshal_LegacyObjectForm asserts that a legacy capture
// file written with full_policy as a raw JSON object (not a
// JSON-encoded string) still deserialises into a valid [testcapture.Input], with
// the policy re-marshaled to a compact string so downstream consumers
// see a uniform typed field.
func TestInputUnmarshal_LegacyObjectForm(t *testing.T) {
legacy := []byte(`{
"full_policy": {"tagOwners": {"tag:ops": ["user@example.com"]}},
"api_response_code": 200,
"tailnet": {"name": "corp", "dnsConfig": {}},
"scenario_hujson": "",
"scenario_path": ""
}`)
var got testcapture.Input
err := json.Unmarshal(legacy, &got)
if err != nil {
t.Fatalf("legacy unmarshal: %v", err)
}
if got.APIResponseCode != 200 {
t.Errorf("APIResponseCode: got %d, want 200", got.APIResponseCode)
}
want := `{"tagOwners":{"tag:ops":["user@example.com"]}}`
if got.FullPolicy != want {
t.Errorf("FullPolicy:\n got %q\nwant %q", got.FullPolicy, want)
}
// Round-trip: the new [testcapture.Input.MarshalJSON] must emit the object form so
// [testcapture.Input.UnmarshalJSON] re-reads it identically.
out, err := json.Marshal(got)
if err != nil {
t.Fatalf("re-marshal: %v", err)
}
var back testcapture.Input
err = json.Unmarshal(out, &back)
if err != nil {
t.Fatalf("re-unmarshal: %v", err)
}
if back.FullPolicy != want {
t.Errorf("round-trip FullPolicy drift:\n got %q\nwant %q", back.FullPolicy, want)
}
}
// extractHeaderLines returns the leading "// ..." comment lines from a
// slice of raw file lines, stripped of the "// " prefix. Stops at the
// first non-comment line.
func extractHeaderLines(lines []string) []string {
var out []string
for _, l := range lines {
switch {
case strings.HasPrefix(l, "// "):
out = append(out, strings.TrimPrefix(l, "// "))
case l == "//":
out = append(out, "")
default:
return out
}
}
return out
}

View file

@ -0,0 +1,137 @@
package testcapture
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/tailscale/hujson"
)
// ErrNilCapture is returned by [Write] when called with a nil [Capture].
var ErrNilCapture = errors.New("testcapture: nil capture")
// Write serializes c as a HuJSON file with a comment header. The
// write is atomic: body lands in a temp file in the target directory
// and is then renamed into place, so concurrent regeneration cannot
// leave a half-written file behind.
//
// The comment header is built by [CommentHeader] from c's [Capture.TestID],
// [Capture.Description], and [Capture.Captures]. The file's parent directory must
// already exist; callers should [os.MkdirAll] first.
func Write(path string, c *Capture) error {
if c == nil {
return fmt.Errorf("testcapture: Write %s: %w", path, ErrNilCapture)
}
header := CommentHeader(c)
body, err := marshalHuJSON(c)
if err != nil {
return fmt.Errorf("testcapture: marshal %s: %w", path, err)
}
data := prependCommentHeader(body, header)
dir := filepath.Dir(path)
base := filepath.Base(path)
tmp, err := os.CreateTemp(dir, base+".*.tmp")
if err != nil {
return fmt.Errorf("testcapture: tempfile %s: %w", path, err)
}
tmpName := tmp.Name()
cleanup := func() {
_ = os.Remove(tmpName)
}
_, err = tmp.Write(data)
if err != nil {
_ = tmp.Close()
cleanup()
return fmt.Errorf("testcapture: write %s: %w", path, err)
}
err = tmp.Chmod(0o600)
if err != nil {
_ = tmp.Close()
cleanup()
return fmt.Errorf("testcapture: chmod %s: %w", path, err)
}
err = tmp.Close()
if err != nil {
cleanup()
return fmt.Errorf("testcapture: close %s: %w", path, err)
}
err = os.Rename(tmpName, path)
if err != nil {
cleanup()
return fmt.Errorf("testcapture: rename %s: %w", path, err)
}
return nil
}
// marshalHuJSON serializes v as HuJSON-formatted bytes. It is
// standard JSON encoding followed by [hujson.Format] which produces
// consistent indentation/whitespace.
func marshalHuJSON(v any) ([]byte, error) {
raw, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("json marshal: %w", err)
}
formatted, err := hujson.Format(raw)
if err != nil {
return nil, fmt.Errorf("hujson format: %w", err)
}
return formatted, nil
}
// prependCommentHeader emits header as // comment lines at the top of
// body. Empty lines in header become "//" alone (no trailing space).
// The returned bytes always end with a single trailing newline.
func prependCommentHeader(body []byte, header string) []byte {
if header == "" {
if len(body) == 0 || body[len(body)-1] != '\n' {
body = append(body, '\n')
}
return body
}
var buf strings.Builder
for line := range strings.SplitSeq(header, "\n") {
if line == "" {
buf.WriteString("//\n")
continue
}
buf.WriteString("// ")
buf.WriteString(line)
buf.WriteByte('\n')
}
buf.Write(body)
if !strings.HasSuffix(buf.String(), "\n") {
buf.WriteByte('\n')
}
return []byte(buf.String())
}

View file

@ -0,0 +1,48 @@
package types
import (
"os"
"testing"
"github.com/rs/zerolog"
)
// EnvTestLogLevel overrides the default test log level. Accepts any zerolog
// level string: trace, debug, info, warn, error, fatal, panic, disabled.
const EnvTestLogLevel = "HEADSCALE_TEST_LOG_LEVEL"
// init quiets zerolog when this package is loaded inside a test binary.
//
// hscontrol/types is transitively imported by every test in the repo that
// emits zerolog output, so this init() runs once per test binary and is
// the only place that needs to know about test logging configuration.
//
// Default: [zerolog.ErrorLevel] (silent in green-path runs, real errors still surface).
// Override: HEADSCALE_TEST_LOG_LEVEL=debug (or trace, info, warn, disabled).
//
// Production binaries are unaffected because [testing.Testing] returns false
// outside of test execution. The same [testing.Testing] pattern is already
// used in hscontrol/db/users.go and hscontrol/db/node.go, so importing the
// testing package here is consistent with existing project conventions.
//
// Pitfalls:
// - log.Fatal still calls [os.Exit] and log.Panic still panics regardless of
// level — only the rendered message is suppressed.
// - Local buffer loggers ([zerolog.New] with a buffer) are also gated by the global
// level. Tests that assert on log output (currently only
// hscontrol/util/zlog) re-enable trace level via their own init_test.go.
func init() {
if !testing.Testing() {
return
}
if raw := os.Getenv(EnvTestLogLevel); raw != "" {
lvl, err := zerolog.ParseLevel(raw)
if err == nil {
zerolog.SetGlobalLevel(lvl)
return
}
}
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
}

View file

@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
@ -13,7 +13,6 @@ import (
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/ptr"
)
// Clone makes a deep copy of User.
@ -49,28 +48,34 @@ func (src *Node) Clone() *Node {
dst.Endpoints = append(src.Endpoints[:0:0], src.Endpoints...)
dst.Hostinfo = src.Hostinfo.Clone()
if dst.IPv4 != nil {
dst.IPv4 = ptr.To(*src.IPv4)
dst.IPv4 = new(*src.IPv4)
}
if dst.IPv6 != nil {
dst.IPv6 = ptr.To(*src.IPv6)
dst.IPv6 = new(*src.IPv6)
}
dst.ForcedTags = append(src.ForcedTags[:0:0], src.ForcedTags...)
if dst.UserID != nil {
dst.UserID = new(*src.UserID)
}
if dst.User != nil {
dst.User = new(*src.User)
}
dst.Tags = append(src.Tags[:0:0], src.Tags...)
if dst.AuthKeyID != nil {
dst.AuthKeyID = ptr.To(*src.AuthKeyID)
dst.AuthKeyID = new(*src.AuthKeyID)
}
dst.AuthKey = src.AuthKey.Clone()
if dst.Expiry != nil {
dst.Expiry = ptr.To(*src.Expiry)
dst.Expiry = new(*src.Expiry)
}
if dst.LastSeen != nil {
dst.LastSeen = ptr.To(*src.LastSeen)
dst.LastSeen = new(*src.LastSeen)
}
dst.ApprovedRoutes = append(src.ApprovedRoutes[:0:0], src.ApprovedRoutes...)
if dst.DeletedAt != nil {
dst.DeletedAt = ptr.To(*src.DeletedAt)
dst.DeletedAt = new(*src.DeletedAt)
}
if dst.IsOnline != nil {
dst.IsOnline = ptr.To(*src.IsOnline)
dst.IsOnline = new(*src.IsOnline)
}
return dst
}
@ -81,25 +86,27 @@ var _NodeCloneNeedsRegeneration = Node(struct {
MachineKey key.MachinePublic
NodeKey key.NodePublic
DiscoKey key.DiscoPublic
Endpoints []netip.AddrPort
Endpoints AddrPorts
Hostinfo *tailcfg.Hostinfo
IPv4 *netip.Addr
IPv6 *netip.Addr
Hostname string
GivenName string
UserID uint
User User
UserID *uint
User *User
RegisterMethod string
ForcedTags []string
Tags Strings
AuthKeyID *uint64
AuthKey *PreAuthKey
Expiry *time.Time
LastSeen *time.Time
ApprovedRoutes []netip.Prefix
ApprovedRoutes Prefixes
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
IsOnline *bool
Unhealthy bool
SessionEpoch uint64
}{})
// Clone makes a deep copy of PreAuthKey.
@ -110,12 +117,19 @@ func (src *PreAuthKey) Clone() *PreAuthKey {
}
dst := new(PreAuthKey)
*dst = *src
dst.Hash = append(src.Hash[:0:0], src.Hash...)
if dst.UserID != nil {
dst.UserID = new(*src.UserID)
}
if dst.User != nil {
dst.User = new(*src.User)
}
dst.Tags = append(src.Tags[:0:0], src.Tags...)
if dst.CreatedAt != nil {
dst.CreatedAt = ptr.To(*src.CreatedAt)
dst.CreatedAt = new(*src.CreatedAt)
}
if dst.Expiration != nil {
dst.Expiration = ptr.To(*src.Expiration)
dst.Expiration = new(*src.Expiration)
}
return dst
}
@ -124,8 +138,10 @@ func (src *PreAuthKey) Clone() *PreAuthKey {
var _PreAuthKeyCloneNeedsRegeneration = PreAuthKey(struct {
ID uint64
Key string
UserID uint
User User
Prefix string
Hash []byte
UserID *uint
User *User
Reusable bool
Ephemeral bool
Used bool

View file

@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale/cmd/viewer; DO NOT EDIT.
@ -7,11 +7,13 @@ package types
import (
"database/sql"
"encoding/json"
jsonv1 "encoding/json"
"errors"
"net/netip"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
@ -48,8 +50,17 @@ func (v UserView) AsStruct() *User {
return v.ж.Clone()
}
func (v UserView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
// MarshalJSON implements [jsonv1.Marshaler].
func (v UserView) MarshalJSON() ([]byte, error) {
return jsonv1.Marshal(v.ж)
}
// MarshalJSONTo implements [jsonv2.MarshalerTo].
func (v UserView) MarshalJSONTo(enc *jsontext.Encoder) error {
return jsonv2.MarshalEncode(enc, v.ж)
}
// UnmarshalJSON implements [jsonv1.Unmarshaler].
func (v *UserView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
@ -58,20 +69,51 @@ func (v *UserView) UnmarshalJSON(b []byte) error {
return nil
}
var x User
if err := json.Unmarshal(b, &x); err != nil {
if err := jsonv1.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 }
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
func (v *UserView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
if v.ж != nil {
return errors.New("already initialized")
}
var x User
if err := jsonv2.UnmarshalDecode(dec, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v UserView) Model() gorm.Model { return v.ж.Model }
// Name (username) for the user, is used if email is empty
// Should not be used, please use [User.Username].
// It is unique if [User.ProviderIdentifier] is not set.
func (v UserView) Name() string { return v.ж.Name }
// Typically the full name of the user
func (v UserView) DisplayName() string { return v.ж.DisplayName }
// Email of the user
// Should not be used, please use [User.Username].
func (v UserView) Email() string { return v.ж.Email }
// ProviderIdentifier is a unique or not set identifier of the
// user from OIDC. It is the combination of `iss`
// and `sub` claim in the OIDC token.
// It is unique if set.
// It is unique together with [User.Name].
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 }
// Provider is the origin of the user account,
// same as RegistrationMethod, without authkey.
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 {
@ -112,8 +154,17 @@ func (v NodeView) AsStruct() *Node {
return v.ж.Clone()
}
func (v NodeView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
// MarshalJSON implements [jsonv1.Marshaler].
func (v NodeView) MarshalJSON() ([]byte, error) {
return jsonv1.Marshal(v.ж)
}
// MarshalJSONTo implements [jsonv2.MarshalerTo].
func (v NodeView) MarshalJSONTo(enc *jsontext.Encoder) error {
return jsonv2.MarshalEncode(enc, v.ж)
}
// UnmarshalJSON implements [jsonv1.Unmarshaler].
func (v *NodeView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
@ -122,7 +173,20 @@ func (v *NodeView) UnmarshalJSON(b []byte) error {
return nil
}
var x Node
if err := json.Unmarshal(b, &x); err != nil {
if err := jsonv1.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
func (v *NodeView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
if v.ж != nil {
return errors.New("already initialized")
}
var x Node
if err := jsonv2.UnmarshalDecode(dec, &x); err != nil {
return err
}
v.ж = &x
@ -139,21 +203,50 @@ func (v NodeView) IPv4() views.ValuePointer[netip.Addr] { return views.ValuePo
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) }
// Hostname represents the name given by the Tailscale
// client during registration
func (v NodeView) Hostname() string { return v.ж.Hostname }
// Givenname represents either:
// a DNS normalized version of [Node.Hostname]
// a valid name set by the [User]
//
// GivenName is the name used in all DNS related
// parts of headscale.
func (v NodeView) GivenName() string { return v.ж.GivenName }
// UserID identifies the owning user for user-owned nodes.
// Nil for tagged nodes, which are owned by their tags.
func (v NodeView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) }
func (v NodeView) User() UserView { return v.ж.User.View() }
func (v NodeView) RegisterMethod() string { return v.ж.RegisterMethod }
// 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).
func (v NodeView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) }
// 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.
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) }
// LastSeen is when the node was last in contact with
// headscale. It is best effort and not persisted.
func (v NodeView) LastSeen() views.ValuePointer[time.Time] {
return views.ValuePointerOf(v.ж.LastSeen)
}
// 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]
func (v NodeView) ApprovedRoutes() views.Slice[netip.Prefix] {
return views.SliceOf(v.ж.ApprovedRoutes)
}
@ -165,7 +258,16 @@ func (v NodeView) DeletedAt() views.ValuePointer[time.Time] {
func (v NodeView) IsOnline() views.ValuePointer[bool] { return views.ValuePointerOf(v.ж.IsOnline) }
func (v NodeView) String() string { return v.ж.String() }
// Unhealthy excludes the node from primary route election while
// online. Written by the HA prober. Runtime-only.
func (v NodeView) Unhealthy() bool { return v.ж.Unhealthy }
// SessionEpoch identifies a poll session. Connect bumps it; a
// Disconnect carrying a stale value is dropped, so a deferred
// disconnect from a previous session cannot overwrite a newer
// Connect. Runtime-only.
func (v NodeView) SessionEpoch() uint64 { return v.ж.SessionEpoch }
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 {
@ -173,25 +275,27 @@ var _NodeViewNeedsRegeneration = Node(struct {
MachineKey key.MachinePublic
NodeKey key.NodePublic
DiscoKey key.DiscoPublic
Endpoints []netip.AddrPort
Endpoints AddrPorts
Hostinfo *tailcfg.Hostinfo
IPv4 *netip.Addr
IPv6 *netip.Addr
Hostname string
GivenName string
UserID uint
User User
UserID *uint
User *User
RegisterMethod string
ForcedTags []string
Tags Strings
AuthKeyID *uint64
AuthKey *PreAuthKey
Expiry *time.Time
LastSeen *time.Time
ApprovedRoutes []netip.Prefix
ApprovedRoutes Prefixes
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
IsOnline *bool
Unhealthy bool
SessionEpoch uint64
}{})
// View returns a read-only view of PreAuthKey.
@ -222,8 +326,17 @@ func (v PreAuthKeyView) AsStruct() *PreAuthKey {
return v.ж.Clone()
}
func (v PreAuthKeyView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
// MarshalJSON implements [jsonv1.Marshaler].
func (v PreAuthKeyView) MarshalJSON() ([]byte, error) {
return jsonv1.Marshal(v.ж)
}
// MarshalJSONTo implements [jsonv2.MarshalerTo].
func (v PreAuthKeyView) MarshalJSONTo(enc *jsontext.Encoder) error {
return jsonv2.MarshalEncode(enc, v.ж)
}
// UnmarshalJSON implements [jsonv1.Unmarshaler].
func (v *PreAuthKeyView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
@ -232,20 +345,50 @@ func (v *PreAuthKeyView) UnmarshalJSON(b []byte) error {
return nil
}
var x PreAuthKey
if err := json.Unmarshal(b, &x); err != nil {
if err := jsonv1.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 }
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
func (v *PreAuthKeyView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
if v.ж != nil {
return errors.New("already initialized")
}
var x PreAuthKey
if err := jsonv2.UnmarshalDecode(dec, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v PreAuthKeyView) ID() uint64 { return v.ж.ID }
// Legacy plaintext key (for backwards compatibility)
func (v PreAuthKeyView) Key() string { return v.ж.Key }
// New bcrypt-based authentication
func (v PreAuthKeyView) Prefix() string { return v.ж.Prefix }
// bcrypt
func (v PreAuthKeyView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Hash) }
// For tagged keys: [PreAuthKey.UserID] tracks who created the key (informational)
// For user-owned keys: [PreAuthKey.UserID] tracks the node owner
// Can be nil for system-created tagged keys
func (v PreAuthKeyView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) }
func (v PreAuthKeyView) User() UserView { return v.ж.User.View() }
func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable }
func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral }
func (v PreAuthKeyView) Used() bool { return v.ж.Used }
// Tags to assign to nodes registered with this key.
// Tags are copied to the node during registration.
// If non-empty, this creates tagged nodes (not user-owned).
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)
@ -259,8 +402,10 @@ func (v PreAuthKeyView) Expiration() views.ValuePointer[time.Time] {
var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct {
ID uint64
Key string
UserID uint
User User
Prefix string
Hash []byte
UserID *uint
User *User
Reusable bool
Ephemeral bool
Used bool

View file

@ -4,6 +4,7 @@ import (
"cmp"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/mail"
"net/url"
@ -12,22 +13,47 @@ import (
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/timestamppb"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
// ErrCannotParseBoolean is returned when a value cannot be parsed as boolean.
var ErrCannotParseBoolean = errors.New("cannot parse value as boolean")
// ErrCannotParseStringSlice is returned when a value cannot be parsed as string or []string.
var ErrCannotParseStringSlice = errors.New("cannot parse value as string or []string")
type UserID uint64
type Users []User
const (
// TaggedDevicesUserID is the special user ID for tagged devices.
// This ID is used when rendering tagged nodes in the Tailscale protocol.
TaggedDevicesUserID = 2147455555
)
// TaggedDevices is a special user used in [tailcfg.MapResponse] for tagged nodes.
// Tagged nodes don't belong to a real user - the tag is their identity.
// This special user ID is used when rendering tagged nodes in the Tailscale protocol.
var TaggedDevices = User{
Model: gorm.Model{ID: TaggedDevicesUserID},
Name: "tagged-devices",
DisplayName: "Tagged Devices",
}
func (u Users) String() string {
var sb strings.Builder
sb.WriteString("[ ")
for _, user := range u {
fmt.Fprintf(&sb, "%d: %s, ", user.ID, user.Name)
}
sb.WriteString(" ]")
return sb.String()
@ -38,29 +64,30 @@ func (u Users) String() string {
// At the end of the day, users in Tailscale are some kind of 'bubbles' or users
// that contain our machines.
type User struct {
gorm.Model
gorm.Model //nolint:embeddedstructfieldcheck
// The index `idx_name_provider_identifier` is to enforce uniqueness
// between Name and ProviderIdentifier. This ensures that
// you can have multiple users with the same name in OIDC,
// but not if you only run with CLI users.
// Name (username) for the user, is used if email is empty
// Should not be used, please use Username().
// It is unique if ProviderIdentifier is not set.
// Should not be used, please use [User.Username].
// It is unique if [User.ProviderIdentifier] is not set.
Name string
// Typically the full name of the user
DisplayName string
// Email of the user
// Should not be used, please use Username().
// Should not be used, please use [User.Username].
Email string
// ProviderIdentifier is a unique or not set identifier of the
// user from OIDC. It is the combination of `iss`
// and `sub` claim in the OIDC token.
// It is unique if set.
// It is unique together with Name.
// It is unique together with [User.Name].
ProviderIdentifier sql.NullString
// Provider is the origin of the user account,
@ -79,9 +106,17 @@ func (u *User) StringID() string {
if u == nil {
return ""
}
return strconv.FormatUint(uint64(u.ID), 10)
}
// TypedID returns a pointer to the user's ID as a [UserID] type.
// This is a convenience method to avoid ugly casting like ptr.To(types.UserID(user.ID)).
func (u *User) TypedID() *UserID {
uid := UserID(u.ID)
return &uid
}
// Username is the main way to get the username of a user,
// it will return the email if it exists, the name if it exists,
// the OIDCIdentifier if it exists, and the ID if nothing else exists.
@ -98,8 +133,8 @@ func (u *User) Username() string {
)
}
// Display returns the DisplayName if it exists, otherwise
// it will return the Username.
// Display returns the [User.DisplayName] if it exists, otherwise
// it will return the [User.Username].
func (u *User) Display() string {
return cmp.Or(u.DisplayName, u.Username())
}
@ -115,13 +150,13 @@ func (u *User) GetGroups() []string {
if u.Groups == "" {
return []string{}
}
var groups []string
if err := json.Unmarshal([]byte(u.Groups), &groups); err != nil {
log.Error().Err(err).Msg("Failed to unmarshal user groups")
return []string{}
}
return groups
}
@ -131,53 +166,77 @@ func (u *User) SetGroups(groups []string) {
u.Groups = ""
return
}
data, err := json.Marshal(groups)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal user groups")
u.Groups = ""
return
}
u.Groups = string(data)
}
func (u *User) TailscaleUser() *tailcfg.User {
user := tailcfg.User{
ID: tailcfg.UserID(u.ID),
func (u *User) TailscaleUser() tailcfg.User {
return tailcfg.User{
ID: tailcfg.UserID(u.ID), //nolint:gosec // UserID is bounded
DisplayName: u.Display(),
ProfilePicURL: u.profilePicURL(),
Created: u.CreatedAt,
}
return &user
}
func (u *User) TailscaleLogin() *tailcfg.Login {
login := tailcfg.Login{
ID: tailcfg.LoginID(u.ID),
func (u UserView) TailscaleUser() tailcfg.User {
return u.ж.TailscaleUser()
}
// ID returns the user's ID.
// This is a custom accessor because [gorm.Model].ID is embedded
// and the viewer generator doesn't always produce it.
func (u UserView) ID() uint {
return u.ж.ID
}
func (u *User) TailscaleLogin() tailcfg.Login {
return tailcfg.Login{
ID: tailcfg.LoginID(u.ID), //nolint:gosec // safe conversion for user ID
Provider: u.Provider,
LoginName: u.Username(),
DisplayName: u.Display(),
ProfilePicURL: u.profilePicURL(),
}
}
return &login
func (u UserView) TailscaleLogin() tailcfg.Login {
return u.ж.TailscaleLogin()
}
func (u *User) TailscaleUserProfile() tailcfg.UserProfile {
return tailcfg.UserProfile{
ID: tailcfg.UserID(u.ID),
ID: tailcfg.UserID(u.ID), //nolint:gosec // UserID is bounded
LoginName: u.Username(),
DisplayName: u.Display(),
ProfilePicURL: u.profilePicURL(),
}
}
func (u UserView) TailscaleUserProfile() tailcfg.UserProfile {
return u.ж.TailscaleUserProfile()
}
func (u *User) Proto() *v1.User {
// Use Name if set, otherwise fall back to Username() which provides
// a display-friendly identifier (Email > ProviderIdentifier > ID).
// This ensures OIDC users (who typically have empty Name) display
// their email, while CLI users retain their original Name.
name := u.Name
if name == "" {
name = u.Username()
}
return &v1.User{
Id: uint64(u.ID),
Name: u.Name,
Name: name,
CreatedAt: timestamppb.New(u.CreatedAt),
DisplayName: u.DisplayName,
Email: u.Email,
@ -187,18 +246,67 @@ func (u *User) Proto() *v1.User {
}
}
// JumpCloud returns a JSON where email_verified is returned as a
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
func (u *User) MarshalZerologObject(e *zerolog.Event) {
if u == nil {
return
}
e.Uint(zf.UserID, u.ID)
e.Str(zf.UserName, u.Username())
e.Str(zf.UserDisplay, u.Display())
if u.Provider != "" {
e.Str(zf.UserProvider, u.Provider)
}
}
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for [UserView].
func (u UserView) MarshalZerologObject(e *zerolog.Event) {
if !u.Valid() {
return
}
u.ж.MarshalZerologObject(e)
}
// FlexibleStringSlice handles OIDC providers (e.g. JumpCloud) that return the
// groups claim as a plain string when the user belongs to a single group,
// instead of a single-element array.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
var arr []string
err := json.Unmarshal(data, &arr)
if err == nil {
*f = arr
return nil
}
var single string
err = json.Unmarshal(data, &single)
if err == nil {
*f = []string{single}
return nil
}
return fmt.Errorf("%w: %s", ErrCannotParseStringSlice, string(data))
}
// FlexibleBoolean handles JumpCloud's JSON where email_verified is returned as a
// string "true" or "false" instead of a boolean.
// This maps bool to a specific type with a custom unmarshaler to
// ensure we can decode it from a string.
// https://github.com/juanfont/headscale/issues/2293
type FlexibleBoolean bool
func (bit *FlexibleBoolean) UnmarshalJSON(data []byte) error {
var val any
err := json.Unmarshal(data, &val)
if err != nil {
return fmt.Errorf("could not unmarshal data: %w", err)
return fmt.Errorf("unmarshalling data: %w", err)
}
switch v := val.(type) {
@ -207,12 +315,13 @@ func (bit *FlexibleBoolean) UnmarshalJSON(data []byte) error {
case string:
pv, err := strconv.ParseBool(v)
if err != nil {
return fmt.Errorf("could not parse %s as boolean: %w", v, err)
return fmt.Errorf("parsing %s as boolean: %w", v, err)
}
*bit = FlexibleBoolean(pv)
default:
return fmt.Errorf("could not parse %v as boolean", v)
return fmt.Errorf("%w: %v", ErrCannotParseBoolean, v)
}
return nil
@ -224,31 +333,33 @@ type OIDCClaims struct {
Iss string `json:"iss"`
// Name is the user's full name.
Name string `json:"name,omitempty"`
Groups []string `json:"groups,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
ProfilePictureURL string `json:"picture,omitempty"`
Username string `json:"preferred_username,omitempty"`
Name string `json:"name,omitempty"`
Groups FlexibleStringSlice `json:"groups,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
ProfilePictureURL string `json:"picture,omitempty"`
Username string `json:"preferred_username,omitempty"`
}
// Identifier returns a unique identifier string combining the Iss and Sub claims.
// The format depends on whether Iss is a URL or not:
// Identifier returns a unique identifier string combining the [OIDCClaims.Iss] and [OIDCClaims.Sub] claims.
// The format depends on whether [OIDCClaims.Iss] is a URL or not:
// - For URLs: Joins the URL and sub path (e.g., "https://example.com/sub")
// - For non-URLs: Joins with a slash (e.g., "oidc/sub")
// - For empty Iss: Returns just "sub"
// - For empty Sub: Returns just the Issuer
// - For empty [OIDCClaims.Iss]: Returns just "sub"
// - For empty [OIDCClaims.Sub]: Returns just the Issuer
// - For both empty: Returns empty string
//
// The result is cleaned using CleanIdentifier() to ensure consistent formatting.
// The result is cleaned using [CleanIdentifier] to ensure consistent formatting.
func (c *OIDCClaims) Identifier() string {
// Handle empty components special cases
if c.Iss == "" && c.Sub == "" {
return ""
}
if c.Iss == "" {
return CleanIdentifier(c.Sub)
}
if c.Sub == "" {
return CleanIdentifier(c.Iss)
}
@ -258,21 +369,14 @@ func (c *OIDCClaims) Identifier() string {
subject := c.Sub
var result string
// Try to parse as URL to handle URL joining correctly
if u, err := url.Parse(issuer); err == nil && u.Scheme != "" {
// For URLs, use proper URL path joining
if joined, err := url.JoinPath(issuer, subject); err == nil {
result = joined
}
}
// If URL joining failed or issuer wasn't a URL, do simple string join
if result == "" {
// Default case: simple string joining with slash
issuer = strings.TrimSuffix(issuer, "/")
subject = strings.TrimPrefix(subject, "/")
result = issuer + "/" + subject
}
// Always use simple string concatenation with a slash separator.
// url.JoinPath resolves path-traversal segments like ".." and ".",
// which can silently drop the subject and cause identifier collisions
// between distinct OIDC users (e.g., Sub=".." produces the same
// identifier as an empty Sub).
issuer = strings.TrimSuffix(issuer, "/")
subject = strings.TrimPrefix(subject, "/")
result = issuer + "/" + subject
// Clean the result and return it
return CleanIdentifier(result)
@ -333,6 +437,7 @@ func CleanIdentifier(identifier string) string {
cleanParts = append(cleanParts, trimmed)
}
}
if len(cleanParts) == 0 {
return ""
}
@ -341,28 +446,28 @@ func CleanIdentifier(identifier string) string {
}
type OIDCUserInfo struct {
Sub string `json:"sub"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
Groups []string `json:"groups"`
Picture string `json:"picture"`
Sub string `json:"sub"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
Groups FlexibleStringSlice `json:"groups"`
Picture string `json:"picture"`
}
// FromClaim overrides a User from OIDC claims.
// FromClaim overrides a [User] from OIDC claims.
// All fields will be updated, except for the ID.
func (u *User) FromClaim(claims *OIDCClaims) {
func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) {
err := util.ValidateUsername(claims.Username)
if err == nil {
u.Name = claims.Username
} else {
log.Debug().Caller().Err(err).Msgf("Username %s is not valid", claims.Username)
log.Debug().Caller().Err(err).Msgf("username %s is not valid", claims.Username)
}
if claims.EmailVerified {
if claims.EmailVerified || !FlexibleBoolean(emailVerifiedRequired) {
_, err = mail.ParseAddress(claims.Email)
if err == nil {
u.Email = claims.Email
@ -375,6 +480,7 @@ func (u *User) FromClaim(claims *OIDCClaims) {
if claims.Iss == "" && !strings.HasPrefix(identifier, "/") {
identifier = "/" + identifier
}
u.ProviderIdentifier = sql.NullString{String: identifier, Valid: true}
u.DisplayName = claims.Name
u.ProfilePicURL = claims.ProfilePictureURL

View file

@ -61,15 +61,69 @@ func TestUnmarshallOIDCClaims(t *testing.T) {
EmailVerified: false,
},
},
{
name: "groups-array",
jsonstr: `
{
"sub": "test4",
"email": "test4@test.no",
"email_verified": true,
"groups": ["Group1", "Group2"]
}
`,
want: OIDCClaims{
Sub: "test4",
Email: "test4@test.no",
EmailVerified: true,
Groups: FlexibleStringSlice{"Group1", "Group2"},
},
},
{
name: "groups-single-string",
jsonstr: `
{
"sub": "test5",
"email": "test5@test.no",
"email_verified": true,
"groups": "SingleGroup"
}
`,
want: OIDCClaims{
Sub: "test5",
Email: "test5@test.no",
EmailVerified: true,
Groups: FlexibleStringSlice{"SingleGroup"},
},
},
{
name: "groups-empty-array",
jsonstr: `
{
"sub": "test6",
"email": "test6@test.no",
"email_verified": true,
"groups": []
}
`,
want: OIDCClaims{
Sub: "test6",
Email: "test6@test.no",
EmailVerified: true,
Groups: FlexibleStringSlice{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got OIDCClaims
if err := json.Unmarshal([]byte(tt.jsonstr), &got); err != nil {
err := json.Unmarshal([]byte(tt.jsonstr), &got)
if err != nil {
t.Errorf("UnmarshallOIDCClaims() error = %v", err)
return
}
if diff := cmp.Diff(got, tt.want); diff != "" {
t.Errorf("UnmarshallOIDCClaims() mismatch (-want +got):\n%s", diff)
}
@ -190,6 +244,7 @@ func TestOIDCClaimsIdentifier(t *testing.T) {
}
result := claims.Identifier()
assert.Equal(t, tt.expected, result)
if diff := cmp.Diff(tt.expected, result); diff != "" {
t.Errorf("Identifier() mismatch (-want +got):\n%s", diff)
}
@ -282,6 +337,7 @@ func TestCleanIdentifier(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
result := CleanIdentifier(tt.identifier)
assert.Equal(t, tt.expected, result)
if diff := cmp.Diff(tt.expected, result); diff != "" {
t.Errorf("CleanIdentifier() mismatch (-want +got):\n%s", diff)
}
@ -291,12 +347,14 @@ func TestCleanIdentifier(t *testing.T) {
func TestOIDCClaimsJSONToUser(t *testing.T) {
tests := []struct {
name string
jsonstr string
want User
name string
jsonstr string
emailVerifiedRequired bool
want User
}{
{
name: "normal-bool",
name: "normal-bool",
emailVerifiedRequired: true,
jsonstr: `
{
"sub": "test",
@ -314,7 +372,8 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
},
},
{
name: "string-bool-true",
name: "string-bool-true",
emailVerifiedRequired: true,
jsonstr: `
{
"sub": "test2",
@ -332,7 +391,8 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
},
},
{
name: "string-bool-false",
name: "string-bool-false",
emailVerifiedRequired: true,
jsonstr: `
{
"sub": "test3",
@ -348,9 +408,29 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
},
},
},
{
name: "allow-unverified-email",
emailVerifiedRequired: false,
jsonstr: `
{
"sub": "test4",
"email": "test4@test.no",
"email_verified": "false"
}
`,
want: User{
Provider: util.RegisterMethodOIDC,
Email: "test4@test.no",
ProviderIdentifier: sql.NullString{
String: "/test4",
Valid: true,
},
},
},
{
// From https://github.com/juanfont/headscale/issues/2333
name: "okta-oidc-claim-20250121",
name: "okta-oidc-claim-20250121",
emailVerifiedRequired: true,
jsonstr: `
{
"sub": "00u7dr4qp7XXXXXXXXXX",
@ -375,6 +455,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
want: User{
Provider: util.RegisterMethodOIDC,
DisplayName: "Tim Horton",
Email: "",
Name: "tim.horton@company.com",
ProviderIdentifier: sql.NullString{
String: "https://sso.company.com/oauth2/default/00u7dr4qp7XXXXXXXXXX",
@ -384,7 +465,8 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
},
{
// From https://github.com/juanfont/headscale/issues/2333
name: "okta-oidc-claim-20250121",
name: "okta-oidc-claim-20250121",
emailVerifiedRequired: true,
jsonstr: `
{
"aud": "79xxxxxx-xxxx-xxxx-xxxx-892146xxxxxx",
@ -409,6 +491,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
Provider: util.RegisterMethodOIDC,
DisplayName: "XXXXXX XXXX",
Name: "user@domain.com",
Email: "",
ProviderIdentifier: sql.NullString{
String: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
Valid: true,
@ -417,7 +500,8 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
},
{
// From https://github.com/juanfont/headscale/issues/2333
name: "casby-oidc-claim-20250513",
name: "casby-oidc-claim-20250513",
emailVerifiedRequired: true,
jsonstr: `
{
"sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
@ -451,14 +535,17 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got OIDCClaims
if err := json.Unmarshal([]byte(tt.jsonstr), &got); err != nil {
err := json.Unmarshal([]byte(tt.jsonstr), &got)
if err != nil {
t.Errorf("TestOIDCClaimsJSONToUser() error = %v", err)
return
}
var user User
user.FromClaim(&got)
user.FromClaim(&got, tt.emailVerifiedRequired)
if diff := cmp.Diff(user, tt.want); diff != "" {
t.Errorf("TestOIDCClaimsJSONToUser() mismatch (-want +got):\n%s", diff)
}

View file

@ -30,17 +30,15 @@ func (v *VersionInfo) String() string {
version += "-dirty"
}
sb.WriteString(fmt.Sprintf("headscale version %s\n", version))
sb.WriteString(fmt.Sprintf("commit: %s\n", v.Commit))
sb.WriteString(fmt.Sprintf("build time: %s\n", v.BuildTime))
sb.WriteString(fmt.Sprintf("built with: %s %s/%s\n", v.Go.Version, v.Go.OS, v.Go.Arch))
fmt.Fprintf(&sb, "headscale version %s\n", version)
fmt.Fprintf(&sb, "commit: %s\n", v.Commit)
fmt.Fprintf(&sb, "build time: %s\n", v.BuildTime)
fmt.Fprintf(&sb, "built with: %s %s/%s\n", v.Go.Version, v.Go.OS, v.Go.Arch)
return sb.String()
}
var buildInfo = sync.OnceValues(func() (*debug.BuildInfo, bool) {
return debug.ReadBuildInfo()
})
var buildInfo = sync.OnceValues(debug.ReadBuildInfo)
var GetVersionInfo = sync.OnceValue(func() *VersionInfo {
info := &VersionInfo{