Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
2c8640f822
1496 changed files with 12110903 additions and 24774 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) + ")"
|
||||
}
|
||||
}
|
||||
521
hscontrol/types/change/change_test.go
Normal file
521
hscontrol/types/change/change_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
hscontrol/types/debug_routes.go
Normal file
22
hscontrol/types/debug_routes.go
Normal 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"`
|
||||
}
|
||||
25
hscontrol/types/main_test.go
Normal file
25
hscontrol/types/main_test.go
Normal 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
73
hscontrol/types/node_benchmark_test.go
Normal file
73
hscontrol/types/node_benchmark_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
294
hscontrol/types/node_tags_test.go
Normal file
294
hscontrol/types/node_tags_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
55
hscontrol/types/registration.go
Normal file
55
hscontrol/types/registration.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
46
hscontrol/types/slices.go
Normal 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) }
|
||||
114
hscontrol/types/testcapture/header.go
Normal file
114
hscontrol/types/testcapture/header.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
62
hscontrol/types/testcapture/read.go
Normal file
62
hscontrol/types/testcapture/read.go
Normal 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
|
||||
}
|
||||
332
hscontrol/types/testcapture/testcapture.go
Normal file
332
hscontrol/types/testcapture/testcapture.go
Normal 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"`
|
||||
}
|
||||
484
hscontrol/types/testcapture/testcapture_test.go
Normal file
484
hscontrol/types/testcapture/testcapture_test.go
Normal 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
|
||||
}
|
||||
137
hscontrol/types/testcapture/write.go
Normal file
137
hscontrol/types/testcapture/write.go
Normal 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())
|
||||
}
|
||||
48
hscontrol/types/testlog.go
Normal file
48
hscontrol/types/testlog.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue