all: apply godoc [Name] link conventions across comments
Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.
Pattern applied across the tree:
In-package [Foo], [Foo.Bar]
Cross-package [pkg.Foo], [pkg.Foo.Bar]
Stdlib [netip.Prefix], [errors.Is], [context.Context]
Tailscale [tailcfg.MapResponse], [tailcfg.Node.CapMap],
[tailcfg.NodeAttrSuggestExitNode]
Skip rules:
- File:line refs left as plain text
- HuJSON wire keys inside backtick raw strings untouched
- ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
symbols, left as plain text
- JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
link tags, decorative dividers — all left as-is
This commit is contained in:
parent
17236fd284
commit
4cca63155d
124 changed files with 1037 additions and 1011 deletions
|
|
@ -67,7 +67,7 @@ func (k *APIKey) maskedPrefix() string {
|
|||
return k.Prefix + "***"
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging.
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Package change declares the Change type: a compact description of
|
||||
// what must land in a MapResponse. The mapper reads Change values to
|
||||
// build responses without inspecting state, and Merge combines
|
||||
// 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
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ import (
|
|||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// Change declares what should be included in a MapResponse.
|
||||
// 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.
|
||||
|
|
@ -26,12 +26,12 @@ type Change struct {
|
|||
// Used for self-update detection and filtering.
|
||||
OriginNode types.NodeID
|
||||
|
||||
// Content flags - what to include in the MapResponse.
|
||||
// Content flags - what to include in the [tailcfg.MapResponse].
|
||||
IncludeSelf bool
|
||||
IncludeDERPMap bool
|
||||
IncludeDNS bool
|
||||
IncludeDomain bool
|
||||
IncludePolicy bool // PacketFilters and SSHPolicy - always sent together
|
||||
IncludePolicy bool // [tailcfg.MapResponse.PacketFilters] and [tailcfg.MapResponse.SSHPolicy] - always sent together
|
||||
|
||||
// Peer changes.
|
||||
PeersChanged []types.NodeID
|
||||
|
|
@ -46,12 +46,12 @@ type Change struct {
|
|||
|
||||
// PingRequest, if non-nil, is a ping request to send to the node.
|
||||
// Used by the debug ping endpoint to verify node connectivity.
|
||||
// PingRequest is always targeted to a specific node via TargetNode.
|
||||
// [Change.PingRequest] is always targeted to a specific node via [Change.TargetNode].
|
||||
PingRequest *tailcfg.PingRequest
|
||||
}
|
||||
|
||||
// boolFieldNames returns all boolean field names for exhaustive testing.
|
||||
// When adding a new boolean field to Change, add it here.
|
||||
// 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{
|
||||
|
|
@ -80,16 +80,16 @@ func (r Change) Merge(other Change) Change {
|
|||
merged.PeersRemoved = uniqueNodeIDs(slices.Concat(r.PeersRemoved, other.PeersRemoved))
|
||||
merged.PeerPatches = slices.Concat(r.PeerPatches, other.PeerPatches)
|
||||
|
||||
// Preserve OriginNode for self-update detection.
|
||||
// If either change has OriginNode set, keep it so the mapper
|
||||
// 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
|
||||
}
|
||||
|
||||
// Preserve TargetNode for targeted responses.
|
||||
// Preserve [Change.TargetNode] for targeted responses.
|
||||
// Merging two changes targeted at different nodes is not supported
|
||||
// because the merged result can only have one TargetNode, which
|
||||
// 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(
|
||||
|
|
@ -102,9 +102,9 @@ func (r Change) Merge(other Change) Change {
|
|||
merged.TargetNode = other.TargetNode
|
||||
}
|
||||
|
||||
// Preserve PingRequest (first wins).
|
||||
// Preserve [Change.PingRequest] (first wins).
|
||||
//
|
||||
// Foot-gun: if two PingRequests to the same target merge in the
|
||||
// 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
|
||||
|
|
@ -154,7 +154,7 @@ func (r Change) IsSelfOnly() bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// IsTargetedToNode returns true if this response should only be sent to TargetNode.
|
||||
// IsTargetedToNode returns true if this response should only be sent to [Change.TargetNode].
|
||||
func (r Change) IsTargetedToNode() bool {
|
||||
return r.TargetNode != 0
|
||||
}
|
||||
|
|
@ -167,7 +167,7 @@ func (r Change) IsFull() bool {
|
|||
|
||||
// Type returns a categorized type string for metrics.
|
||||
// This provides a bounded set of values suitable for Prometheus labels,
|
||||
// unlike Reason which is free-form text for logging.
|
||||
// unlike [Change.Reason] which is free-form text for logging.
|
||||
func (r Change) Type() string {
|
||||
if r.IsFull() {
|
||||
return "full"
|
||||
|
|
@ -211,7 +211,7 @@ func (r Change) ShouldSendToNode(nodeID types.NodeID) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// HasFull returns true if any response in the slice is a full update.
|
||||
// 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() {
|
||||
|
|
@ -349,7 +349,7 @@ func DERPMap() Change {
|
|||
}
|
||||
|
||||
// PolicyChange creates a response for policy changes.
|
||||
// Policy changes require runtime peer visibility computation.
|
||||
// Policy changes require runtime peer visibility computation ([Change.RequiresRuntimePeerComputation]).
|
||||
func PolicyChange() Change {
|
||||
return Change{
|
||||
Reason: "policy change",
|
||||
|
|
@ -407,8 +407,8 @@ func KeyExpiry(nodeID types.NodeID, expiry *time.Time) Change {
|
|||
|
||||
// High-level change constructors
|
||||
|
||||
// NodeAdded returns a Change for when a node is added or updated.
|
||||
// The OriginNode field enables self-update detection by the mapper.
|
||||
// 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
|
||||
|
|
@ -416,12 +416,12 @@ func NodeAdded(id types.NodeID) Change {
|
|||
return c
|
||||
}
|
||||
|
||||
// NodeRemoved returns a Change for when a node is removed.
|
||||
// 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.
|
||||
// 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() {
|
||||
|
|
@ -434,7 +434,7 @@ func NodeOnlineFor(node types.NodeView) Change {
|
|||
return NodeOnline(node.ID())
|
||||
}
|
||||
|
||||
// NodeOfflineFor returns a Change for when a node goes offline.
|
||||
// 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() {
|
||||
|
|
@ -447,8 +447,8 @@ func NodeOfflineFor(node types.NodeView) Change {
|
|||
return NodeOffline(node.ID())
|
||||
}
|
||||
|
||||
// KeyExpiryFor returns a Change for when a node's key expiry changes.
|
||||
// The OriginNode field enables self-update detection by the mapper.
|
||||
// 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
|
||||
|
|
@ -456,8 +456,8 @@ func KeyExpiryFor(id types.NodeID, expiry time.Time) Change {
|
|||
return c
|
||||
}
|
||||
|
||||
// EndpointOrDERPUpdate returns a Change for when a node's endpoints or DERP region changes.
|
||||
// The OriginNode field enables self-update detection by the mapper.
|
||||
// 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
|
||||
|
|
@ -465,7 +465,7 @@ func EndpointOrDERPUpdate(id types.NodeID, patch *tailcfg.PeerChange) Change {
|
|||
return c
|
||||
}
|
||||
|
||||
// UserAdded returns a Change for when a user is added or updated.
|
||||
// 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()
|
||||
|
|
@ -474,7 +474,7 @@ func UserAdded() Change {
|
|||
return c
|
||||
}
|
||||
|
||||
// UserRemoved returns a Change for when a user is removed.
|
||||
// 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()
|
||||
|
|
@ -483,9 +483,9 @@ func UserRemoved() Change {
|
|||
return c
|
||||
}
|
||||
|
||||
// PingNode creates a Change that sends a PingRequest to a specific
|
||||
// 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 PingRequest URL to prove connectivity.
|
||||
// responds to the [tailcfg.PingRequest] URL to prove connectivity.
|
||||
func PingNode(nodeID types.NodeID, pr *tailcfg.PingRequest) Change {
|
||||
return Change{
|
||||
Reason: "ping node",
|
||||
|
|
@ -494,7 +494,7 @@ func PingNode(nodeID types.NodeID, pr *tailcfg.PingRequest) Change {
|
|||
}
|
||||
}
|
||||
|
||||
// ExtraRecords returns a Change for when DNS extra records change.
|
||||
// ExtraRecords returns a [Change] for when DNS extra records change.
|
||||
func ExtraRecords() Change {
|
||||
c := DNSConfig()
|
||||
c.Reason = "extra records update"
|
||||
|
|
|
|||
|
|
@ -69,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:
|
||||
|
|
@ -233,7 +233,7 @@ type SSHCheckBinding struct {
|
|||
// 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
|
||||
// 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.
|
||||
//
|
||||
|
|
@ -250,9 +250,9 @@ type PendingRegistrationConfirmation struct {
|
|||
// 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 FinishAuth against double-close.
|
||||
// flag guards [AuthRequest.FinishAuth] against double-close.
|
||||
//
|
||||
// AuthRequest is always handled by pointer so the channel and atomic flag
|
||||
// [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 {
|
||||
|
|
@ -260,7 +260,7 @@ type AuthRequest struct {
|
|||
// 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 RegistrationData() to read it
|
||||
// nil for non-registration flows. Use [AuthRequest.RegistrationData] to read it
|
||||
// safely.
|
||||
regData *RegistrationData
|
||||
|
||||
|
|
@ -269,7 +269,7 @@ type AuthRequest struct {
|
|||
// and OIDC callback can refuse to record a verdict for any other
|
||||
// pair.
|
||||
//
|
||||
// nil for non-SSH-check flows. Use SSHCheckBinding() to read it
|
||||
// nil for non-SSH-check flows. Use [AuthRequest.SSHCheckBinding] to read it
|
||||
// safely.
|
||||
sshBinding *SSHCheckBinding
|
||||
|
||||
|
|
@ -294,7 +294,7 @@ func NewAuthRequest() *AuthRequest {
|
|||
}
|
||||
|
||||
// NewRegisterAuthRequest creates a pending auth request carrying the
|
||||
// minimal RegistrationData for a node-registration flow. The data is
|
||||
// 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{
|
||||
|
|
@ -320,8 +320,8 @@ func NewSSHCheckAuthRequest(src, dst NodeID) *AuthRequest {
|
|||
}
|
||||
|
||||
// RegistrationData returns the cached registration payload. It panics if
|
||||
// called on an AuthRequest that was not created via
|
||||
// NewRegisterAuthRequest.
|
||||
// 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")
|
||||
|
|
@ -331,8 +331,8 @@ func (rn *AuthRequest) RegistrationData() *RegistrationData {
|
|||
}
|
||||
|
||||
// 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.
|
||||
// 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")
|
||||
|
|
@ -342,19 +342,19 @@ func (rn *AuthRequest) SSHCheckBinding() *SSHCheckBinding {
|
|||
}
|
||||
|
||||
// IsRegistration reports whether this auth request carries registration
|
||||
// data (i.e. it was created via NewRegisterAuthRequest).
|
||||
// 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).
|
||||
// [NewSSHCheckAuthRequest]).
|
||||
func (rn *AuthRequest) IsSSHCheck() bool {
|
||||
return rn.sshBinding != nil
|
||||
}
|
||||
|
||||
// SetPendingConfirmation marks this AuthRequest as having an
|
||||
// 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
|
||||
|
|
@ -364,8 +364,8 @@ func (rn *AuthRequest) SetPendingConfirmation(p *PendingRegistrationConfirmation
|
|||
}
|
||||
|
||||
// PendingConfirmation returns the pending OIDC-resolved registration
|
||||
// state captured by SetPendingConfirmation, or nil if no OIDC callback
|
||||
// has yet resolved an identity for this AuthRequest.
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestNewSSHCheckAuthRequestBinding verifies that an SSH-check AuthRequest
|
||||
// TestNewSSHCheckAuthRequestBinding verifies that an SSH-check [AuthRequest]
|
||||
// captures the (src, dst) node pair at construction time and rejects
|
||||
// callers that try to read RegistrationData from it.
|
||||
// callers that try to read [AuthRequest.RegistrationData] from it.
|
||||
func TestNewSSHCheckAuthRequestBinding(t *testing.T) {
|
||||
const src, dst NodeID = 7, 11
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ func TestNewSSHCheckAuthRequestBinding(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestNewRegisterAuthRequestPayload verifies that a registration
|
||||
// AuthRequest carries the supplied RegistrationData and rejects callers
|
||||
// [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"}
|
||||
|
|
@ -45,7 +45,7 @@ func TestNewRegisterAuthRequestPayload(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestNewAuthRequestEmptyPayload verifies that a payload-less
|
||||
// AuthRequest reports both Is* helpers as false and panics on either
|
||||
// [AuthRequest] reports both Is* helpers as false and panics on either
|
||||
// payload accessor.
|
||||
func TestNewAuthRequestEmptyPayload(t *testing.T) {
|
||||
req := NewAuthRequest()
|
||||
|
|
@ -58,7 +58,7 @@ func TestNewAuthRequestEmptyPayload(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestPendingRegistrationConfirmation verifies that the OIDC callback
|
||||
// can stash a pending confirmation onto an AuthRequest and that the
|
||||
// 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"})
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ type HARouteConfig struct {
|
|||
ProbeInterval time.Duration
|
||||
|
||||
// ProbeTimeout is the maximum time to wait for a probe response
|
||||
// before declaring a node unhealthy. Must be less than ProbeInterval.
|
||||
// before declaring a node unhealthy. Must be less than [HARouteConfig.ProbeInterval].
|
||||
ProbeTimeout time.Duration
|
||||
}
|
||||
|
||||
|
|
@ -120,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,
|
||||
|
|
@ -337,7 +337,7 @@ type Tuning struct {
|
|||
// NodeStoreBatchTimeout is the maximum time to wait before processing a
|
||||
// partial batch of node operations.
|
||||
//
|
||||
// When NodeStoreBatchSize operations haven't accumulated, this timeout ensures
|
||||
// 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.
|
||||
//
|
||||
|
|
@ -355,8 +355,8 @@ func validatePKCEMethod(method string) error {
|
|||
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 {
|
||||
|
|
@ -369,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 {
|
||||
|
|
|
|||
|
|
@ -475,8 +475,8 @@ func TestSafeServerURL(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestConfigJSONOmitsSecrets verifies that marshalling a Config to JSON
|
||||
// (as /debug/config does via state.DebugConfig) does not leak the
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ var (
|
|||
)
|
||||
|
||||
// RouteFunc is a function that takes a node ID and returns a list of
|
||||
// netip.Prefixes representing the routes for that node.
|
||||
// [netip.Prefix] values representing the routes for that node.
|
||||
type RouteFunc func(id NodeID) []netip.Prefix
|
||||
|
||||
// nodeAttrDisableIPv4 is the policy nodeAttr key that suppresses the
|
||||
|
|
@ -58,17 +58,17 @@ func filterIPv4(ps []netip.Prefix) []netip.Prefix {
|
|||
}
|
||||
|
||||
// ViaRouteResult describes via grant effects for a viewer-peer pair.
|
||||
// UsePrimary is always a subset of Include: it marks which included
|
||||
// [ViaRouteResult.UsePrimary] is always a subset of [ViaRouteResult.Include]: it marks which included
|
||||
// prefixes must additionally defer to HA primary election.
|
||||
type ViaRouteResult struct {
|
||||
// Include contains prefixes this peer should serve to this viewer (via-designated).
|
||||
Include []netip.Prefix
|
||||
// Exclude contains prefixes steered to OTHER peers (suppress from global primary).
|
||||
Exclude []netip.Prefix
|
||||
// UsePrimary contains prefixes from Include where a regular
|
||||
// UsePrimary contains prefixes from [ViaRouteResult.Include] where a regular
|
||||
// (non-via) grant also covers the prefix. In these cases HA
|
||||
// primary election wins — only the primary router should get
|
||||
// the route in AllowedIPs. When a prefix is NOT in UsePrimary,
|
||||
// the route in [tailcfg.Node.AllowedIPs]. When a prefix is NOT in [ViaRouteResult.UsePrimary],
|
||||
// per-viewer via steering applies.
|
||||
UsePrimary []netip.Prefix
|
||||
}
|
||||
|
|
@ -132,8 +132,8 @@ type Node struct {
|
|||
Hostname string
|
||||
|
||||
// Givenname represents either:
|
||||
// a DNS normalized version of Hostname
|
||||
// a valid name set by the User
|
||||
// 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.
|
||||
|
|
@ -152,7 +152,7 @@ type Node struct {
|
|||
// Tags cannot be removed once set (one-way transition).
|
||||
Tags Strings `gorm:"column:tags;serializer:json"`
|
||||
|
||||
// When a node has been created with a PreAuthKey, we need to
|
||||
// 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.
|
||||
|
|
@ -263,8 +263,8 @@ func (node *Node) HasTag(tag string) bool {
|
|||
return slices.Contains(node.Tags, tag)
|
||||
}
|
||||
|
||||
// TypedUserID returns the UserID as a typed UserID type.
|
||||
// Returns 0 if UserID is nil.
|
||||
// TypedUserID returns the [Node.UserID] as a typed [UserID] type.
|
||||
// Returns 0 if [Node.UserID] is nil.
|
||||
func (node *Node) TypedUserID() UserID {
|
||||
if node.UserID == nil {
|
||||
return 0
|
||||
|
|
@ -299,7 +299,7 @@ func (node *Node) Prefixes() []netip.Prefix {
|
|||
|
||||
// ExitRoutes returns the node's approved exit routes (0.0.0.0/0
|
||||
// and/or ::/0). Consumed unconditionally by RoutesForPeer when the
|
||||
// viewer uses an exit node; excluded from CanAccessRoute which only
|
||||
// viewer uses an exit node; excluded from [Node.CanAccessRoute] which only
|
||||
// handles non-exit routing.
|
||||
func (node *Node) ExitRoutes() []netip.Prefix {
|
||||
var routes []netip.Prefix
|
||||
|
|
@ -315,7 +315,7 @@ func (node *Node) ExitRoutes() []netip.Prefix {
|
|||
|
||||
// IsExitNode reports whether the node has any approved exit routes.
|
||||
// Approval is required: an advertised-but-unapproved exit route does
|
||||
// not make the node an exit node (fix for #3169).
|
||||
// not make the node an exit node.
|
||||
func (node *Node) IsExitNode() bool {
|
||||
return len(node.ExitRoutes()) > 0
|
||||
}
|
||||
|
|
@ -340,9 +340,9 @@ func (node *Node) InIPSet(set *netipx.IPSet) bool {
|
|||
}
|
||||
|
||||
// AppendToIPSet adds all IP addresses of the node to the given
|
||||
// netipx.IPSetBuilder. For identity-based aliases (tags, users,
|
||||
// [netipx.IPSetBuilder]. For identity-based aliases (tags, users,
|
||||
// groups, autogroups), both IPv4 and IPv6 must be included to
|
||||
// match Tailscale's behavior in the FilterRule wire format.
|
||||
// match Tailscale's behavior in the [tailcfg.FilterRule] wire format.
|
||||
func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
|
||||
if node.IPv4 != nil {
|
||||
build.Add(*node.IPv4)
|
||||
|
|
@ -357,7 +357,7 @@ func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
|
|||
// matchers. A node owns two source identities for ACL purposes:
|
||||
// - its own IPs (regular peer membership)
|
||||
// - any approved subnet routes it advertises (subnet-router-as-src,
|
||||
// used for subnet-to-subnet ACLs — issue #3157)
|
||||
// used for subnet-to-subnet ACLs)
|
||||
//
|
||||
// Either identity matching a rule's src — combined with the dst
|
||||
// matching node2's IPs, node2's approved subnet routes, or "the
|
||||
|
|
@ -396,18 +396,18 @@ func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
|
|||
// CanAccessRoute determines whether a specific route prefix should be
|
||||
// visible to this node based on the given matchers.
|
||||
//
|
||||
// Unlike CanAccess, this function intentionally does NOT check
|
||||
// DestsIsTheInternet(). Exit routes (0.0.0.0/0, ::/0) are handled by
|
||||
// Unlike [Node.CanAccess], this function intentionally does NOT check
|
||||
// [matcher.Match.DestsIsTheInternet]. Exit routes (0.0.0.0/0, ::/0) are handled by
|
||||
// RoutesForPeer (state.go) which adds them unconditionally from
|
||||
// ExitRoutes(), not through ACL-based route filtering. The
|
||||
// DestsIsTheInternet check in CanAccess exists solely for peer
|
||||
// [Node.ExitRoutes], not through ACL-based route filtering. The
|
||||
// [matcher.Match.DestsIsTheInternet] check in [Node.CanAccess] exists solely for peer
|
||||
// visibility determination (should two nodes see each other), which
|
||||
// is a separate concern from route prefix authorization.
|
||||
//
|
||||
// Additionally, autogroup:internet is explicitly skipped during filter
|
||||
// rule compilation (filter.go), so no matchers ever contain "the
|
||||
// internet" from internet-targeted ACLs. Wildcard "*" dests produce
|
||||
// matchers where DestsOverlapsPrefixes(0.0.0.0/0) already returns
|
||||
// matchers where [matcher.Match.DestsOverlapsPrefixes](0.0.0.0/0) already returns
|
||||
// true, so the check would be redundant for that case.
|
||||
func (node *Node) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
|
||||
src := node.IPs()
|
||||
|
|
@ -500,8 +500,8 @@ func (node *Node) Proto() *v1.Node {
|
|||
}
|
||||
|
||||
// Set User field based on node ownership
|
||||
// Note: User will be set to TaggedDevices in the gRPC layer (grpcv1.go)
|
||||
// for proper MapResponse formatting
|
||||
// Note: User will be set to [TaggedDevices] in the gRPC layer (grpcv1.go)
|
||||
// for proper [tailcfg.MapResponse] formatting
|
||||
if node.User != nil {
|
||||
nodeProto.User = node.User.Proto()
|
||||
}
|
||||
|
|
@ -548,8 +548,8 @@ func (node *Node) GetFQDN(baseDomain string) (string, error) {
|
|||
}
|
||||
|
||||
// AnnouncedRoutes returns the list of routes the node announces, as
|
||||
// reported by the client in Hostinfo.RoutableIPs. Announcement alone
|
||||
// does not grant visibility — see SubnetRoutes for approval-gated
|
||||
// reported by the client in [tailcfg.Hostinfo.RoutableIPs]. Announcement alone
|
||||
// does not grant visibility — see [Node.SubnetRoutes] for approval-gated
|
||||
// access.
|
||||
func (node *Node) AnnouncedRoutes() []netip.Prefix {
|
||||
if node.Hostinfo == nil {
|
||||
|
|
@ -560,13 +560,13 @@ func (node *Node) AnnouncedRoutes() []netip.Prefix {
|
|||
}
|
||||
|
||||
// SubnetRoutes returns the list of routes (excluding exit routes) that the node
|
||||
// announces and are approved. Also used by CanAccess and CanAccessRoute as part
|
||||
// of the subnet-router-as-source identity (issue #3157).
|
||||
// announces and are approved. Also used by [Node.CanAccess] and [Node.CanAccessRoute] as part
|
||||
// of the subnet-router-as-source identity.
|
||||
//
|
||||
// IMPORTANT: This method is used for internal data structures and should NOT be
|
||||
// used for the gRPC Proto conversion. For Proto, SubnetRoutes must be populated
|
||||
// manually with PrimaryRoutes to ensure it includes only routes actively served
|
||||
// by the node. See the comment in Proto() method and the implementation in
|
||||
// by the node. See the comment in [Node.Proto] method and the implementation in
|
||||
// grpcv1.go/nodesToProto.
|
||||
func (node *Node) SubnetRoutes() []netip.Prefix {
|
||||
var routes []netip.Prefix
|
||||
|
|
@ -589,7 +589,7 @@ func (node *Node) IsSubnetRouter() bool {
|
|||
return len(node.SubnetRoutes()) > 0
|
||||
}
|
||||
|
||||
// AllApprovedRoutes returns the combination of SubnetRoutes and ExitRoutes.
|
||||
// AllApprovedRoutes returns the combination of [Node.SubnetRoutes] and [Node.ExitRoutes].
|
||||
func (node *Node) AllApprovedRoutes() []netip.Prefix {
|
||||
return append(node.SubnetRoutes(), node.ExitRoutes()...)
|
||||
}
|
||||
|
|
@ -598,9 +598,9 @@ func (node *Node) String() string {
|
|||
return node.Hostname
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging.
|
||||
// This method is used with zerolog's EmbedObject() for flat field embedding
|
||||
// or Object() for nested logging when multiple nodes are logged.
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
|
||||
// This method is used with [zerolog.Event.EmbedObject] for flat field embedding
|
||||
// or [zerolog.Event.Object] for nested logging when multiple nodes are logged.
|
||||
func (node *Node) MarshalZerologObject(e *zerolog.Event) {
|
||||
if node == nil {
|
||||
return
|
||||
|
|
@ -628,11 +628,11 @@ func (node *Node) MarshalZerologObject(e *zerolog.Event) {
|
|||
}
|
||||
}
|
||||
|
||||
// PeerChangeFromMapRequest takes a MapRequest and compares it to the node
|
||||
// to produce a PeerChange struct that can be used to updated the node and
|
||||
// PeerChangeFromMapRequest takes a [tailcfg.MapRequest] and compares it to the node
|
||||
// to produce a [tailcfg.PeerChange] struct that can be used to updated the node and
|
||||
// inform peers about smaller changes to the node.
|
||||
// When a field is added to this function, remember to also add it to:
|
||||
// - node.ApplyPeerChange
|
||||
// - [Node.ApplyPeerChange]
|
||||
// - logTracePeerChange in poll.go.
|
||||
func (node *Node) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
|
||||
ret := tailcfg.PeerChange{
|
||||
|
|
@ -714,7 +714,7 @@ func (node *Node) RegisterMethodToV1Enum() v1.RegisterMethod {
|
|||
}
|
||||
}
|
||||
|
||||
// ApplyPeerChange takes a PeerChange struct and updates the node.
|
||||
// ApplyPeerChange takes a [tailcfg.PeerChange] struct and updates the node.
|
||||
func (node *Node) ApplyPeerChange(change *tailcfg.PeerChange) {
|
||||
if change.Key != nil {
|
||||
node.NodeKey = *change.Key
|
||||
|
|
@ -812,8 +812,8 @@ func (node *Node) DebugString() string {
|
|||
return sb.String()
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for NodeView.
|
||||
// This delegates to the underlying Node's implementation.
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for [NodeView].
|
||||
// This delegates to the underlying [Node]'s implementation.
|
||||
func (nv NodeView) MarshalZerologObject(e *zerolog.Event) {
|
||||
if !nv.Valid() {
|
||||
return
|
||||
|
|
@ -823,8 +823,8 @@ func (nv NodeView) MarshalZerologObject(e *zerolog.Event) {
|
|||
}
|
||||
|
||||
// Owner returns the owner for display purposes.
|
||||
// For tagged nodes, returns TaggedDevices. For user-owned nodes, returns the user.
|
||||
// Returns an invalid UserView if the node is in an orphaned state (no tags, no user).
|
||||
// For tagged nodes, returns [TaggedDevices]. For user-owned nodes, returns the user.
|
||||
// Returns an invalid [UserView] if the node is in an orphaned state (no tags, no user).
|
||||
// Callers should check .Valid() on the result before accessing fields.
|
||||
func (nv NodeView) Owner() UserView {
|
||||
if nv.IsTagged() {
|
||||
|
|
@ -950,8 +950,8 @@ func (nv NodeView) IsEphemeral() bool {
|
|||
return nv.ж.IsEphemeral()
|
||||
}
|
||||
|
||||
// PeerChangeFromMapRequest takes a MapRequest and compares it to the node
|
||||
// to produce a PeerChange struct that can be used to updated the node and
|
||||
// PeerChangeFromMapRequest takes a [tailcfg.MapRequest] and compares it to the node
|
||||
// to produce a [tailcfg.PeerChange] struct that can be used to updated the node and
|
||||
// inform peers about smaller changes to the node.
|
||||
func (nv NodeView) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
|
||||
if !nv.Valid() {
|
||||
|
|
@ -998,7 +998,7 @@ func (nv NodeView) RequestTags() []string {
|
|||
return nv.Hostinfo().RequestTags().AsSlice()
|
||||
}
|
||||
|
||||
// Proto converts the NodeView to a protobuf representation.
|
||||
// Proto converts the [NodeView] to a protobuf representation.
|
||||
func (nv NodeView) Proto() *v1.Node {
|
||||
if !nv.Valid() {
|
||||
return nil
|
||||
|
|
@ -1025,8 +1025,8 @@ func (nv NodeView) HasTag(tag string) bool {
|
|||
return nv.ж.HasTag(tag)
|
||||
}
|
||||
|
||||
// TypedUserID returns the UserID as a typed UserID type.
|
||||
// Returns 0 if UserID is nil or node is invalid.
|
||||
// TypedUserID returns the [Node.UserID] as a typed [UserID] type.
|
||||
// Returns 0 if [Node.UserID] is nil or node is invalid.
|
||||
func (nv NodeView) TypedUserID() UserID {
|
||||
if !nv.Valid() {
|
||||
return 0
|
||||
|
|
@ -1036,8 +1036,8 @@ func (nv NodeView) TypedUserID() UserID {
|
|||
}
|
||||
|
||||
// TailscaleUserID returns the user ID to use in Tailscale protocol.
|
||||
// Tagged nodes always return TaggedDevices.ID, user-owned nodes return their actual UserID.
|
||||
// Returns 0 for nodes in an orphaned state (no tags, no UserID).
|
||||
// Tagged nodes always return [TaggedDevices].ID, user-owned nodes return their actual [Node.UserID].
|
||||
// Returns 0 for nodes in an orphaned state (no tags, no [Node.UserID]).
|
||||
func (nv NodeView) TailscaleUserID() tailcfg.UserID {
|
||||
if !nv.Valid() {
|
||||
return 0
|
||||
|
|
@ -1056,7 +1056,7 @@ func (nv NodeView) TailscaleUserID() tailcfg.UserID {
|
|||
return tailcfg.UserID(int64(nv.UserID().Get()))
|
||||
}
|
||||
|
||||
// Prefixes returns the node IPs as netip.Prefix.
|
||||
// Prefixes returns the node IPs as [netip.Prefix].
|
||||
func (nv NodeView) Prefixes() []netip.Prefix {
|
||||
if !nv.Valid() {
|
||||
return nil
|
||||
|
|
@ -1118,7 +1118,7 @@ func equalPrefixesUnordered(a, b []netip.Prefix) bool {
|
|||
|
||||
// HasPolicyChange reports whether the node has changes that affect
|
||||
// policy evaluation. Includes approved subnet routes because they act
|
||||
// as source identity in CanAccess for subnet-to-subnet ACLs (#3157).
|
||||
// as source identity in [Node.CanAccess] for subnet-to-subnet ACLs.
|
||||
func (nv NodeView) HasPolicyChange(other NodeView) bool {
|
||||
if nv.UserID() != other.UserID() {
|
||||
return true
|
||||
|
|
@ -1139,7 +1139,7 @@ func (nv NodeView) HasPolicyChange(other NodeView) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// TailNodes converts a slice of NodeViews into Tailscale tailcfg.Nodes.
|
||||
// TailNodes converts a slice of [NodeView] values into Tailscale [tailcfg.Node] values.
|
||||
func TailNodes(
|
||||
nodes views.Slice[NodeView],
|
||||
capVer tailcfg.CapabilityVersion,
|
||||
|
|
@ -1162,7 +1162,7 @@ func TailNodes(
|
|||
return tNodes, nil
|
||||
}
|
||||
|
||||
// TailNode converts a NodeView into a Tailscale tailcfg.Node.
|
||||
// TailNode converts a [NodeView] into a Tailscale [tailcfg.Node].
|
||||
//
|
||||
// selfPolicyCaps is the per-node CapMap from [policy.PolicyManager.NodeCapMap]
|
||||
// and is merged into the baseline. Pass it when building the self view of the
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestNodeIsTagged tests the IsTagged() method for determining if a node is tagged.
|
||||
// TestNodeIsTagged tests the [Node.IsTagged] method for determining if a node is tagged.
|
||||
func TestNodeIsTagged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -44,9 +44,9 @@ func TestNodeIsTagged(t *testing.T) {
|
|||
want: false,
|
||||
},
|
||||
{
|
||||
// Tags should be copied from AuthKey during registration, so a node
|
||||
// with only AuthKey.Tags and no Tags would be invalid in practice.
|
||||
// IsTagged() only checks node.Tags, not AuthKey.Tags.
|
||||
// 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{
|
||||
|
|
@ -83,7 +83,7 @@ func TestNodeIsTagged(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestNodeViewIsTagged tests the IsTagged() method on NodeView.
|
||||
// TestNodeViewIsTagged tests the [NodeView.IsTagged] method on [NodeView].
|
||||
func TestNodeViewIsTagged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -98,15 +98,15 @@ func TestNodeViewIsTagged(t *testing.T) {
|
|||
want: true,
|
||||
},
|
||||
{
|
||||
// Tags should be copied from AuthKey during registration, so a node
|
||||
// with only AuthKey.Tags and no Tags would be invalid in practice.
|
||||
// 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, // IsTagged() only checks node.Tags
|
||||
want: false, // [Node.IsTagged] only checks [Node.Tags]
|
||||
},
|
||||
{
|
||||
name: "user-owned node",
|
||||
|
|
@ -126,7 +126,7 @@ func TestNodeViewIsTagged(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestNodeHasTag tests the HasTag() method for checking specific tag membership.
|
||||
// TestNodeHasTag tests the [Node.HasTag] method for checking specific tag membership.
|
||||
func TestNodeHasTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -151,8 +151,8 @@ func TestNodeHasTag(t *testing.T) {
|
|||
want: false,
|
||||
},
|
||||
{
|
||||
// Tags should be copied from AuthKey during registration
|
||||
// HasTag() only checks node.Tags, not AuthKey.Tags
|
||||
// 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{
|
||||
|
|
@ -163,7 +163,7 @@ func TestNodeHasTag(t *testing.T) {
|
|||
want: false,
|
||||
},
|
||||
{
|
||||
// node.Tags is what matters, not AuthKey.Tags
|
||||
// [Node.Tags] is what matters, not [PreAuthKey.Tags]
|
||||
name: "node has tag in Tags but not in AuthKey",
|
||||
node: Node{
|
||||
Tags: []string{"tag:server"},
|
||||
|
|
@ -259,8 +259,8 @@ func TestNodeOwnershipModel(t *testing.T) {
|
|||
description: "User-owned nodes are owned by the user, not by tags",
|
||||
},
|
||||
{
|
||||
// Tags should be copied from AuthKey to Node during registration
|
||||
// IsTagged() only checks node.Tags, not AuthKey.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,
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ func Test_NodeCanAccess(t *testing.T) {
|
|||
},
|
||||
want: true,
|
||||
},
|
||||
// Subnet-to-subnet tests for issue #3157.
|
||||
// Subnet-to-subnet tests.
|
||||
// When ACL src and dst are both subnet CIDRs, subnet
|
||||
// routers advertising those subnets must see each other.
|
||||
{
|
||||
|
|
@ -153,7 +153,7 @@ func Test_NodeCanAccess(t *testing.T) {
|
|||
{
|
||||
// With a unidirectional ACL (src=A→dst=B), the dst
|
||||
// router cannot access the src router. Bidirectional
|
||||
// peer visibility comes from ReduceNodes checking
|
||||
// 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{
|
||||
|
|
@ -550,7 +550,6 @@ func TestPeerChangeFromMapRequest(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
func TestApplyPeerChange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -708,7 +707,7 @@ func TestNodeRegisterMethodToV1Enum(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestHasNetworkChanges tests the NodeView method for detecting
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ type PreAuthKey struct {
|
|||
Prefix string
|
||||
Hash []byte // bcrypt
|
||||
|
||||
// For tagged keys: UserID tracks who created the key (informational)
|
||||
// For user-owned keys: UserID tracks the node owner
|
||||
// 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;"`
|
||||
|
|
@ -122,7 +122,7 @@ func (pak *PreAuthKey) Validate() error {
|
|||
return PAKError("invalid authkey")
|
||||
}
|
||||
|
||||
// Use EmbedObject for safe logging - never log full key
|
||||
// Use [zerolog.Event.EmbedObject] for safe logging - never log full key
|
||||
log.Debug().
|
||||
Caller().
|
||||
EmbedObject(pak).
|
||||
|
|
@ -144,8 +144,8 @@ 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.
|
||||
// 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
|
||||
}
|
||||
|
|
@ -160,7 +160,7 @@ func (pak *PreAuthKey) maskedPrefix() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging.
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
)
|
||||
|
||||
// RegistrationData is the payload cached for a pending node registration.
|
||||
// It replaces the previous practice of caching a full *Node and carries
|
||||
// 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.
|
||||
//
|
||||
|
|
@ -34,18 +34,18 @@ type RegistrationData struct {
|
|||
// Already validated/normalised by EnsureHostname at producer time.
|
||||
Hostname string
|
||||
|
||||
// Hostinfo is the original Hostinfo from the RegisterRequest,
|
||||
// Hostinfo is the original [tailcfg.Hostinfo] from the [tailcfg.RegisterRequest],
|
||||
// stored so that the auth callback can populate the new node's
|
||||
// initial Hostinfo (and so that observability/CLI consumers see
|
||||
// initial [tailcfg.Hostinfo] (and so that observability/CLI consumers see
|
||||
// fields like OS, OSVersion, and IPNVersion before the first
|
||||
// MapRequest restores the live set).
|
||||
// [tailcfg.MapRequest] restores the live set).
|
||||
//
|
||||
// May be nil if the client did not send Hostinfo in the original
|
||||
// RegisterRequest.
|
||||
// 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 MapRequest after registration overwrites
|
||||
// reported. The first [tailcfg.MapRequest] after registration overwrites
|
||||
// this with the live set.
|
||||
Endpoints []netip.AddrPort
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@ package types
|
|||
|
||||
import "net/netip"
|
||||
|
||||
// The named slice types below are used for GORM-persisted Node columns
|
||||
// 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
|
||||
// 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
|
||||
// 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 IsZero.
|
||||
// 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
|
||||
|
|
@ -25,7 +25,7 @@ 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 IsZero.
|
||||
// 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
|
||||
|
|
@ -35,7 +35,7 @@ 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 IsZero.
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
)
|
||||
|
||||
// CommentHeader returns the // comment header that gets prepended to
|
||||
// a Capture file when it is written. The header is purely
|
||||
// a [Capture] file when it is written. The header is purely
|
||||
// informational; consumers ignore it. Format:
|
||||
//
|
||||
// <TestID>
|
||||
|
|
@ -20,7 +20,7 @@ import (
|
|||
// schema version: <SchemaVersion>
|
||||
//
|
||||
// Both `tool_version` and `schema_version` are also stored as
|
||||
// first-class JSON fields on the Capture struct; the comment lines
|
||||
// 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.
|
||||
//
|
||||
|
|
@ -68,8 +68,8 @@ func CommentHeader(c *Capture) string {
|
|||
// captures at all.
|
||||
//
|
||||
// The phrasing depends on which fields the capture uses:
|
||||
// - SSH captures populate SSHRules
|
||||
// - other captures populate PacketFilterRules
|
||||
// - SSH captures populate [NodeCapture.SSHRules]
|
||||
// - other captures populate [NodeCapture.PacketFilterRules]
|
||||
//
|
||||
// If both fields appear (mixed/unusual), filter rules wins.
|
||||
func captureStats(c *Capture) string {
|
||||
|
|
|
|||
|
|
@ -9,17 +9,17 @@ import (
|
|||
"github.com/tailscale/hujson"
|
||||
)
|
||||
|
||||
// ErrUnsupportedSchemaVersion is returned by Read when a capture
|
||||
// advertises a SchemaVersion newer than the current binary supports.
|
||||
// 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.
|
||||
// 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 SchemaVersion newer than the
|
||||
// current binary's are rejected with ErrUnsupportedSchemaVersion;
|
||||
// SchemaVersion == 0 (pre-versioning) is accepted for backwards compat.
|
||||
// The returned Capture's CapturedAt is the value recorded in the file
|
||||
// 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)
|
||||
|
|
@ -43,8 +43,8 @@ func Read(path string) (*Capture, error) {
|
|||
}
|
||||
|
||||
// unmarshalHuJSON parses HuJSON bytes (JSON with comments / trailing
|
||||
// commas) into v. Comments are stripped via hujson.Standardize before
|
||||
// json.Unmarshal is called.
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@
|
|||
//
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
// comment on [Node.Netmap] below) for months.
|
||||
//
|
||||
// All four capture types (acl, routes, grant, ssh) use the same Capture
|
||||
// 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
|
||||
|
|
@ -37,11 +37,11 @@ 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 Captures[name].SSHRules; the others populate
|
||||
// Captures[name].PacketFilterRules + Captures[name].Netmap.
|
||||
// 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 testcapture.SchemaVersion when written.
|
||||
// to [SchemaVersion] when written.
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
|
||||
// TestID is the stable identifier of the scenario, derived from
|
||||
|
|
@ -67,15 +67,15 @@ type Capture struct {
|
|||
Tailnet string `json:"tailnet"`
|
||||
|
||||
// Error is true when the SaaS API rejected the policy or when
|
||||
// capture itself failed. In the rejection case, Captures reflects
|
||||
// the pre-push baseline (deny-all default) and Input.APIResponseBody
|
||||
// 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 Captures map is
|
||||
// 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.
|
||||
// [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
|
||||
|
|
@ -94,7 +94,7 @@ type Capture struct {
|
|||
// Input describes everything that was sent to the tailnet to produce
|
||||
// the captured state.
|
||||
//
|
||||
// Input has a custom UnmarshalJSON to accept both the new on-disk
|
||||
// [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
|
||||
|
|
@ -109,7 +109,7 @@ type Input struct {
|
|||
// APIResponseCode is the HTTP status code of the policy POST.
|
||||
APIResponseCode int `json:"api_response_code"`
|
||||
|
||||
// APIResponseBody is only populated when APIResponseCode != 200.
|
||||
// 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
|
||||
|
|
@ -126,10 +126,10 @@ type Input struct {
|
|||
ScenarioPath string `json:"scenario_path,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON writes FullPolicy as a raw JSON object rather than a
|
||||
// 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 UnmarshalJSON below accepts both
|
||||
// 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
|
||||
|
|
@ -153,7 +153,7 @@ func (i Input) MarshalJSON() ([]byte, error) {
|
|||
// 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 MarshalJSON above.
|
||||
// always write the object form via the custom [Input.MarshalJSON] above.
|
||||
func (i *Input) UnmarshalJSON(data []byte) error {
|
||||
type alias Input
|
||||
|
||||
|
|
@ -243,7 +243,7 @@ type SettingsInput struct {
|
|||
|
||||
// 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.
|
||||
// equivalent [types.User] and [types.Node] objects.
|
||||
type Topology struct {
|
||||
// Users in the tailnet. Always populated by the capture tool.
|
||||
Users []TopologyUser `json:"users"`
|
||||
|
|
@ -266,22 +266,22 @@ type TopologyNode struct {
|
|||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
|
||||
// User is the TopologyUser.Name for user-owned nodes. Empty for
|
||||
// User is the [TopologyUser.Name] for user-owned nodes. Empty for
|
||||
// tagged nodes.
|
||||
User string `json:"user,omitempty"`
|
||||
|
||||
// RoutableIPs is what the node advertised
|
||||
// (Hostinfo.RoutableIPs in its own netmap.SelfNode).
|
||||
// ([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 RoutableIPs the tailnet has
|
||||
// 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.
|
||||
// [Capture.Captures].
|
||||
//
|
||||
// All four capture types populate the same struct. Different fields are
|
||||
// used by different test types:
|
||||
|
|
@ -300,7 +300,7 @@ type Node struct {
|
|||
// PacketFilterMatches is the compiled filter matches (with
|
||||
// CapMatch) returned by tailscaled localapi
|
||||
// /debug-packet-filter-matches. Captured alongside
|
||||
// PacketFilterRules; useful for grant tests that want the
|
||||
// [Node.PacketFilterRules]; useful for grant tests that want the
|
||||
// compiled form.
|
||||
PacketFilterMatches []filtertype.Match `json:"packet_filter_matches,omitempty"`
|
||||
|
||||
|
|
@ -311,7 +311,7 @@ type Node struct {
|
|||
// 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
|
||||
// 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
|
||||
|
|
@ -327,6 +327,6 @@ type Node struct {
|
|||
Whois map[string]*apitype.WhoIsResponse `json:"whois,omitempty"`
|
||||
|
||||
// SSHRules is the SSH rules slice extracted from
|
||||
// netmap.SSHPolicy.Rules. Populated only for SSH scenarios.
|
||||
// [netmap.NetworkMap.SSHPolicy].Rules. Populated only for SSH scenarios.
|
||||
SSHRules []*tailcfg.SSHRule `json:"ssh_rules,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,9 +138,9 @@ func sampleSSHCapture() *testcapture.Capture {
|
|||
}
|
||||
|
||||
// equalViaJSON compares two captures by JSON-marshaling them and
|
||||
// comparing the bytes. The Capture struct embeds tailcfg view types
|
||||
// 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 Write+Read produced
|
||||
// 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()
|
||||
|
|
@ -416,7 +416,7 @@ func TestCommentHeader_EmptyFilterRulesCountAsEmpty(t *testing.T) {
|
|||
|
||||
// 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 Input, with
|
||||
// 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) {
|
||||
|
|
@ -444,8 +444,8 @@ func TestInputUnmarshal_LegacyObjectForm(t *testing.T) {
|
|||
t.Errorf("FullPolicy:\n got %q\nwant %q", got.FullPolicy, want)
|
||||
}
|
||||
|
||||
// Round-trip: the new MarshalJSON must emit the object form so
|
||||
// UnmarshalJSON re-reads it identically.
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"github.com/tailscale/hujson"
|
||||
)
|
||||
|
||||
// ErrNilCapture is returned by Write when called with a nil Capture.
|
||||
// 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
|
||||
|
|
@ -19,9 +19,9 @@ var ErrNilCapture = errors.New("testcapture: nil capture")
|
|||
// 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 TestID,
|
||||
// Description, and Captures. The file's parent directory must
|
||||
// already exist; callers should MkdirAll first.
|
||||
// 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)
|
||||
|
|
@ -86,7 +86,7 @@ func Write(path string, c *Capture) error {
|
|||
}
|
||||
|
||||
// marshalHuJSON serializes v as HuJSON-formatted bytes. It is
|
||||
// standard JSON encoding followed by hujson.Format which produces
|
||||
// standard JSON encoding followed by [hujson.Format] which produces
|
||||
// consistent indentation/whitespace.
|
||||
func marshalHuJSON(v any) ([]byte, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
|
|
|
|||
|
|
@ -17,18 +17,18 @@ const EnvTestLogLevel = "HEADSCALE_TEST_LOG_LEVEL"
|
|||
// 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: ErrorLevel (silent in green-path runs, real errors still surface).
|
||||
// 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
|
||||
// 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
|
||||
// - 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(&buf)) are also gated by the global
|
||||
// - 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() {
|
||||
|
|
|
|||
|
|
@ -92,22 +92,22 @@ func (v *UserView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
|
|||
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 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.
|
||||
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 Username().
|
||||
// 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 Name.
|
||||
// It is unique together with [User.Name].
|
||||
func (v UserView) ProviderIdentifier() sql.NullString { return v.ж.ProviderIdentifier }
|
||||
|
||||
// Provider is the origin of the user account,
|
||||
|
|
@ -208,8 +208,8 @@ func (v NodeView) IPv6() views.ValuePointer[netip.Addr] { return views.ValuePoin
|
|||
func (v NodeView) Hostname() string { return v.ж.Hostname }
|
||||
|
||||
// Givenname represents either:
|
||||
// a DNS normalized version of Hostname
|
||||
// a valid name set by the User
|
||||
// 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.
|
||||
|
|
@ -228,7 +228,7 @@ func (v NodeView) RegisterMethod() string { return v.ж.RegisterMethod }
|
|||
// 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
|
||||
// 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.
|
||||
|
|
@ -376,8 +376,8 @@ func (v PreAuthKeyView) Prefix() string { return v.ж.Prefix }
|
|||
// bcrypt
|
||||
func (v PreAuthKeyView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Hash) }
|
||||
|
||||
// For tagged keys: UserID tracks who created the key (informational)
|
||||
// For user-owned keys: UserID tracks the node owner
|
||||
// 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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const (
|
|||
TaggedDevicesUserID = 2147455555
|
||||
)
|
||||
|
||||
// TaggedDevices is a special user used in MapResponse for tagged nodes.
|
||||
// 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{
|
||||
|
|
@ -72,22 +72,22 @@ type User struct {
|
|||
// 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,
|
||||
|
|
@ -105,7 +105,7 @@ func (u *User) StringID() string {
|
|||
return strconv.FormatUint(uint64(u.ID), 10)
|
||||
}
|
||||
|
||||
// TypedID returns a pointer to the user's ID as a UserID type.
|
||||
// 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)
|
||||
|
|
@ -128,8 +128,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())
|
||||
}
|
||||
|
|
@ -153,7 +153,7 @@ func (u UserView) TailscaleUser() tailcfg.User {
|
|||
}
|
||||
|
||||
// ID returns the user's ID.
|
||||
// This is a custom accessor because gorm.Model.ID is embedded
|
||||
// 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
|
||||
|
|
@ -208,7 +208,7 @@ func (u *User) Proto() *v1.User {
|
|||
}
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging.
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
|
||||
func (u *User) MarshalZerologObject(e *zerolog.Event) {
|
||||
if u == nil {
|
||||
return
|
||||
|
|
@ -223,7 +223,7 @@ func (u *User) MarshalZerologObject(e *zerolog.Event) {
|
|||
}
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for UserView.
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for [UserView].
|
||||
func (u UserView) MarshalZerologObject(e *zerolog.Event) {
|
||||
if !u.Valid() {
|
||||
return
|
||||
|
|
@ -239,6 +239,7 @@ type FlexibleStringSlice []string
|
|||
|
||||
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||
var arr []string
|
||||
|
||||
err := json.Unmarshal(data, &arr)
|
||||
if err == nil {
|
||||
*f = arr
|
||||
|
|
@ -246,6 +247,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
|||
}
|
||||
|
||||
var single string
|
||||
|
||||
err = json.Unmarshal(data, &single)
|
||||
if err == nil {
|
||||
*f = []string{single}
|
||||
|
|
@ -259,7 +261,6 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
|||
// 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 {
|
||||
|
|
@ -294,23 +295,23 @@ type OIDCClaims struct {
|
|||
Iss string `json:"iss"`
|
||||
|
||||
// Name is the user's full name.
|
||||
Name string `json:"name,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"`
|
||||
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 == "" {
|
||||
|
|
@ -407,18 +408,18 @@ 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"`
|
||||
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, emailVerifiedRequired bool) {
|
||||
err := util.ValidateUsername(claims.Username)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue