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:
Kristoffer Dalby 2026-05-18 18:35:53 +00:00
parent 17236fd284
commit 4cca63155d
124 changed files with 1037 additions and 1011 deletions

View file

@ -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) {

View file

@ -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"

View file

@ -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
}

View file

@ -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"})

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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 {

View file

@ -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) {

View file

@ -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

View file

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

View file

@ -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

View file

@ -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 {

View file

@ -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 {

View file

@ -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"`
}

View file

@ -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)

View file

@ -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)

View file

@ -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() {

View file

@ -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) }

View file

@ -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)