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

@@ -14,7 +14,7 @@ import (
// Key is the test function name, value is a list of subtest prefixes.
// Each prefix becomes a separate CI job as "TestName/prefix".
//
// Example: TestAutoApproveMultiNetwork has subtests like:
// Example: [TestAutoApproveMultiNetwork] has subtests like:
// - TestAutoApproveMultiNetwork/authkey-tag-advertiseduringup-false-pol-database
// - TestAutoApproveMultiNetwork/webauth-user-advertiseduringup-true-pol-file
//

View File

@@ -830,7 +830,7 @@ func extractContainerLogs(ctx context.Context, cli *client.Client, containerID,
// extractContainerFiles extracts database file and directories from headscale containers.
// Note: The actual file extraction is now handled by the integration tests themselves
// via SaveProfile, SaveMapResponses, and SaveDatabase functions in hsic.go.
// via [SaveProfile], [SaveMapResponses], and [SaveDatabase] functions in hsic.go.
func extractContainerFiles(ctx context.Context, cli *client.Client, containerID, containerName, logsDir string, verbose bool) error {
// Files are now extracted directly by the integration tests
// This function is kept for potential future use or other file types

View File

@@ -11,6 +11,18 @@ import (
"github.com/juanfont/headscale/integration/dockertestutil"
)
const (
statusPass = "PASS"
statusFail = "FAIL"
statusWarn = "WARN"
nameDockerDaemon = "Docker Daemon"
nameDockerContext = "Docker Context"
nameDockerSocket = "Docker Socket"
nameGolangImage = "Golang Image"
nameGoInstall = "Go Installation"
)
var ErrSystemChecksFailed = errors.New("system checks failed")
// DoctorResult represents the result of a single health check.
@@ -33,7 +45,7 @@ func runDoctorCheck(ctx context.Context) error {
results = append(results, dockerResult)
// If Docker is available, run additional checks
if dockerResult.Status == "PASS" {
if dockerResult.Status == statusPass {
results = append(results, checkDockerContext(ctx))
results = append(results, checkDockerSocket(ctx))
results = append(results, checkDockerHubCredentials())
@@ -54,7 +66,7 @@ func runDoctorCheck(ctx context.Context) error {
// Return error if any critical checks failed
for _, result := range results {
if result.Status == "FAIL" {
if result.Status == statusFail {
return fmt.Errorf("%w - see details above", ErrSystemChecksFailed)
}
}
@@ -70,7 +82,7 @@ func checkDockerBinary() DoctorResult {
if err != nil {
return DoctorResult{
Name: "Docker Binary",
Status: "FAIL",
Status: statusFail,
Message: "Docker binary not found in PATH",
Suggestions: []string{
"Install Docker: https://docs.docker.com/get-docker/",
@@ -82,7 +94,7 @@ func checkDockerBinary() DoctorResult {
return DoctorResult{
Name: "Docker Binary",
Status: "PASS",
Status: statusPass,
Message: "Docker binary found",
}
}
@@ -92,8 +104,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: "Docker Daemon",
Status: "FAIL",
Name: nameDockerDaemon,
Status: statusFail,
Message: fmt.Sprintf("Cannot create Docker client: %v", err),
Suggestions: []string{
"Start Docker daemon/service",
@@ -108,8 +120,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
_, err = cli.Ping(ctx)
if err != nil {
return DoctorResult{
Name: "Docker Daemon",
Status: "FAIL",
Name: nameDockerDaemon,
Status: statusFail,
Message: fmt.Sprintf("Cannot ping Docker daemon: %v", err),
Suggestions: []string{
"Ensure Docker daemon is running",
@@ -120,8 +132,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
}
return DoctorResult{
Name: "Docker Daemon",
Status: "PASS",
Name: nameDockerDaemon,
Status: statusPass,
Message: "Docker daemon is running and accessible",
}
}
@@ -131,8 +143,8 @@ func checkDockerContext(ctx context.Context) DoctorResult {
contextInfo, err := getCurrentDockerContext(ctx)
if err != nil {
return DoctorResult{
Name: "Docker Context",
Status: "WARN",
Name: nameDockerContext,
Status: statusWarn,
Message: "Could not detect Docker context, using default settings",
Suggestions: []string{
"Check: docker context ls",
@@ -143,15 +155,15 @@ func checkDockerContext(ctx context.Context) DoctorResult {
if contextInfo == nil {
return DoctorResult{
Name: "Docker Context",
Status: "PASS",
Name: nameDockerContext,
Status: statusPass,
Message: "Using default Docker context",
}
}
return DoctorResult{
Name: "Docker Context",
Status: "PASS",
Name: nameDockerContext,
Status: statusPass,
Message: "Using Docker context: " + contextInfo.Name,
}
}
@@ -161,8 +173,8 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: "Docker Socket",
Status: "FAIL",
Name: nameDockerSocket,
Status: statusFail,
Message: fmt.Sprintf("Cannot access Docker socket: %v", err),
Suggestions: []string{
"Check Docker socket permissions",
@@ -176,8 +188,8 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
info, err := cli.Info(ctx)
if err != nil {
return DoctorResult{
Name: "Docker Socket",
Status: "FAIL",
Name: nameDockerSocket,
Status: statusFail,
Message: fmt.Sprintf("Cannot get Docker info: %v", err),
Suggestions: []string{
"Check Docker daemon status",
@@ -187,8 +199,8 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
}
return DoctorResult{
Name: "Docker Socket",
Status: "PASS",
Name: nameDockerSocket,
Status: statusPass,
Message: fmt.Sprintf("Docker socket accessible (Server: %s)", info.ServerVersion),
}
}
@@ -222,8 +234,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: "Golang Image",
Status: "FAIL",
Name: nameGolangImage,
Status: statusFail,
Message: "Cannot create Docker client for image check",
}
}
@@ -236,8 +248,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
available, err := checkImageAvailableLocally(ctx, cli, imageName)
if err != nil {
return DoctorResult{
Name: "Golang Image",
Status: "FAIL",
Name: nameGolangImage,
Status: statusFail,
Message: fmt.Sprintf("Cannot check golang image %s: %v", imageName, err),
Suggestions: []string{
"Check Docker daemon status",
@@ -248,8 +260,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
if available {
return DoctorResult{
Name: "Golang Image",
Status: "PASS",
Name: nameGolangImage,
Status: statusPass,
Message: fmt.Sprintf("Golang image %s is available locally", imageName),
}
}
@@ -258,8 +270,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
err = ensureImageAvailable(ctx, cli, imageName, false)
if err != nil {
return DoctorResult{
Name: "Golang Image",
Status: "FAIL",
Name: nameGolangImage,
Status: statusFail,
Message: fmt.Sprintf("Golang image %s not available locally and cannot pull: %v", imageName, err),
Suggestions: []string{
"Check internet connectivity",
@@ -271,8 +283,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
}
return DoctorResult{
Name: "Golang Image",
Status: "PASS",
Name: nameGolangImage,
Status: statusPass,
Message: fmt.Sprintf("Golang image %s is now available", imageName),
}
}
@@ -282,8 +294,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
_, err := exec.LookPath("go")
if err != nil {
return DoctorResult{
Name: "Go Installation",
Status: "FAIL",
Name: nameGoInstall,
Status: statusFail,
Message: "Go binary not found in PATH",
Suggestions: []string{
"Install Go: https://golang.org/dl/",
@@ -297,8 +309,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
output, err := cmd.Output()
if err != nil {
return DoctorResult{
Name: "Go Installation",
Status: "FAIL",
Name: nameGoInstall,
Status: statusFail,
Message: fmt.Sprintf("Cannot get Go version: %v", err),
}
}
@@ -306,8 +318,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
version := strings.TrimSpace(string(output))
return DoctorResult{
Name: "Go Installation",
Status: "PASS",
Name: nameGoInstall,
Status: statusPass,
Message: version,
}
}
@@ -320,7 +332,7 @@ func checkGitRepository(ctx context.Context) DoctorResult {
if err != nil {
return DoctorResult{
Name: "Git Repository",
Status: "FAIL",
Status: statusFail,
Message: "Not in a Git repository",
Suggestions: []string{
"Run from within the headscale git repository",
@@ -331,7 +343,7 @@ func checkGitRepository(ctx context.Context) DoctorResult {
return DoctorResult{
Name: "Git Repository",
Status: "PASS",
Status: statusPass,
Message: "Running in Git repository",
}
}
@@ -358,7 +370,7 @@ func checkRequiredFiles(ctx context.Context) DoctorResult {
if len(missingFiles) > 0 {
return DoctorResult{
Name: "Required Files",
Status: "FAIL",
Status: statusFail,
Message: "Missing required files: " + strings.Join(missingFiles, ", "),
Suggestions: []string{
"Ensure you're in the headscale project root directory",
@@ -370,7 +382,7 @@ func checkRequiredFiles(ctx context.Context) DoctorResult {
return DoctorResult{
Name: "Required Files",
Status: "PASS",
Status: statusPass,
Message: "All required files found",
}
}
@@ -384,11 +396,11 @@ func displayDoctorResults(results []DoctorResult) {
var icon string
switch result.Status {
case "PASS":
case statusPass:
icon = "✅"
case "WARN":
case statusWarn:
icon = "⚠️"
case "FAIL":
case statusFail:
icon = "❌"
default:
icon = "❓"

View File

@@ -94,7 +94,7 @@ func detectGoVersion() string {
return "1.26.1"
}
// splitLines splits a string into lines without using strings.Split.
// splitLines splits a string into lines without using [strings.Split].
func splitLines(s string) []string {
var (
lines []string

View File

@@ -160,7 +160,7 @@ func (sc *StatsCollector) monitorDockerEvents(ctx context.Context, runID string,
continue
}
// Convert to types.Container format for consistency
// Convert to [types.Container] format for consistency
cont := types.Container{ //nolint:staticcheck // SA1019: use container.Summary
ID: containerInfo.ID,
Names: []string{containerInfo.Name},
@@ -256,7 +256,7 @@ func (sc *StatsCollector) collectStatsForContainer(ctx context.Context, containe
err := decoder.Decode(&stats)
if err != nil {
// EOF is expected when container stops or stream ends
// [io.EOF] is expected when container stops or stream ends
if err.Error() != "EOF" && verbose {
log.Printf("Failed to decode stats for container %s: %v", containerID[:12], err)
}
@@ -312,7 +312,7 @@ func calculateCPUPercent(prevStats, stats *container.Stats) float64 { //nolint:s
// Calculate CPU percentage: (container CPU delta / system CPU delta) * number of CPUs * 100
numCPUs := float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
if numCPUs == 0 {
// Fallback: if PercpuUsage is not available, assume 1 CPU
// Fallback: if [PercpuUsage] is not available, assume 1 CPU
numCPUs = 1.0
}

View File

@@ -12,7 +12,7 @@
// vendorhash check exit non-zero if flakehashes.json is stale
// vendorhash update recompute and rewrite flakehashes.json
//
// The JSON schema and goModFingerprint algorithm mirror upstream
// The JSON schema and [goModFingerprint] algorithm mirror upstream
// tailscale's tool/updateflakes so a future shared library extraction
// is straightforward.
package main
@@ -82,8 +82,8 @@ func usage() {
fmt.Fprintln(os.Stderr, "usage: vendorhash <check|update>")
}
// errStale signals to main that the check found a mismatch; it has
// already printed a remediation message, so main should exit 1
// errStale signals to [main] that the check found a mismatch; it has
// already printed a remediation message, so [main] should exit 1
// silently.
var errStale = errors.New("vendor hash stale")

View File

@@ -73,8 +73,8 @@ func (h *Headscale) handleRegister(
// the Noise session's machine key matches the cached node.
// Without this check anyone holding a target's NodeKey could
// open a Noise session with a throwaway machine key and read
// the owner's User/Login back through nodeToRegisterResponse.
// handleLogout enforces the same check on its own path.
// the owner's User/Login back through [nodeToRegisterResponse].
// [Headscale.handleLogout] enforces the same check on its own path.
if node.MachineKey() != machineKey {
return nil, NewHTTPError(
http.StatusUnauthorized,
@@ -83,9 +83,8 @@ func (h *Headscale) handleRegister(
)
}
// When tailscaled restarts, it sends RegisterRequest with Auth=nil and Expiry=zero.
// When tailscaled restarts, it sends [tailcfg.RegisterRequest] with Auth=nil and Expiry=zero.
// Return the current node state without modification.
// See: https://github.com/juanfont/headscale/issues/2862
if req.Expiry.IsZero() && !node.IsExpired() {
return nodeToRegisterResponse(node), nil
}
@@ -192,7 +191,7 @@ func (h *Headscale) handleLogout(
}
// If the request expiry is in the past, we consider it a logout.
// Zero expiry is handled in handleRegister() before calling this function.
// Zero expiry is handled in [Headscale.handleRegister] before calling this function.
if req.Expiry.Before(time.Now()) {
log.Debug().
EmbedObject(node).
@@ -254,7 +253,7 @@ func nodeToRegisterResponse(node types.NodeView) *tailcfg.RegisterResponse {
MachineAuthorized: true,
}
// For tagged nodes, use the TaggedDevices special user
// For tagged nodes, use the [types.TaggedDevices] special user
// For user-owned nodes, include User and Login information from the actual user
if node.IsTagged() {
resp.User = types.TaggedDevices.View().TailscaleUser()
@@ -303,8 +302,8 @@ func (h *Headscale) waitForFollowup(
}
// reqToNewRegisterResponse refreshes the registration flow by creating a new
// registration ID and returning the corresponding AuthURL so the client can
// restart the authentication process.
// registration ID and returning the corresponding [tailcfg.RegisterResponse.AuthURL]
// so the client can restart the authentication process.
func (h *Headscale) reqToNewRegisterResponse(
req tailcfg.RegisterRequest,
machineKey key.MachinePublic,
@@ -326,8 +325,8 @@ func (h *Headscale) reqToNewRegisterResponse(
}, nil
}
// registrationDataFromRequest builds the RegistrationData payload stored
// in the auth cache for a pending registration. The original Hostinfo is
// registrationDataFromRequest builds the [types.RegistrationData] payload stored
// in the auth cache for a pending registration. The original [tailcfg.Hostinfo] is
// retained so that consumers (auth callback, observability) see the
// fields the client originally announced; the bounded-LRU cap on the
// cache is what bounds the unauthenticated cache-fill DoS surface.

View File

@@ -60,7 +60,7 @@ func createTestAppWithNodeExpiry(t *testing.T, nodeExpiry time.Duration) *Headsc
// a tagged node with:
// - Tags from the PreAuthKey
// - Nil UserID (tagged nodes are owned by tags, not a user)
// - IsTagged() returns true.
// - [types.Node.IsTagged] returns true.
func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) {
app := createTestApp(t)
@@ -113,7 +113,7 @@ func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) {
// authentication. This is critical for the container restart scenario (#2830).
//
// NOTE: This test verifies that re-authentication preserves the node's current tags
// without testing tag modification via SetNodeTags (which requires ACL policy setup).
// without testing tag modification via [state.State.SetNodeTags] (which requires ACL policy setup).
func TestReAuthDoesNotReapplyTags(t *testing.T) {
app := createTestApp(t)
@@ -180,7 +180,7 @@ func TestReAuthDoesNotReapplyTags(t *testing.T) {
}
// NOTE: TestSetTagsOnUserOwnedNode functionality is covered by gRPC tests in grpcv1_test.go
// which properly handle ACL policy setup. The test verifies that SetTags can convert
// which properly handle ACL policy setup. The test verifies that [headscaleV1APIServer.SetTags] can convert
// user-owned nodes to tagged nodes while preserving UserID.
// TestCannotRemoveAllTags tests that attempting to remove all tags from a
@@ -813,8 +813,8 @@ func TestUntaggedNodeRestartPreservesNilExpiry(t *testing.T) {
// TestExpiryDuringPersonalToTaggedConversion tests that when a personal node
// is converted to tagged via reauth with RequestTags, the expiry is cleared to nil.
// BUG #3048: Previously expiry was NOT cleared because expiry handling ran
// BEFORE processReauthTags.
// Previously expiry was NOT cleared because expiry handling ran
// BEFORE [state.State.processReauthTags].
func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("expiry-test-user")
@@ -886,8 +886,8 @@ func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
// TestExpiryDuringTaggedToPersonalConversion tests that when a tagged node
// is converted to personal via reauth with empty RequestTags, expiry is set
// from the client request.
// BUG #3048: Previously expiry was NOT set because expiry handling ran
// BEFORE processReauthTags (node was still tagged at check time).
// Previously expiry was NOT set because expiry handling ran
// BEFORE [state.State.processReauthTags] (node was still tagged at check time).
func TestExpiryDuringTaggedToPersonalConversion(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("expiry-test-user2")
@@ -1145,7 +1145,7 @@ func TestNodeExpiryZeroDisablesDefault(t *testing.T) {
assert.False(t, node.IsExpired(), "node should not be expired")
// With node.expiry=0 and zero client expiry, the node gets a zero expiry
// which IsExpired() treats as "never expires" — backwards compatible.
// which [types.Node.IsExpired] treats as "never expires" — backwards compatible.
if node.Expiry().Valid() {
assert.True(t, node.Expiry().Get().IsZero(),
"with node.expiry=0 and zero client expiry, expiry should be zero time")
@@ -1266,11 +1266,11 @@ func TestReregistrationAppliesDefaultExpiry(t *testing.T) {
// re-registers with zero client expiry and node.expiry is disabled (0),
// the node's expiry stays nil rather than being set to a pointer to zero
// time. Regression test for the else branch introduced in commit 6337a3db
// which assigned `&regReq.Expiry` (pointer to time.Time{}) instead of nil,
// which assigned `&regReq.Expiry` (pointer to [time.Time]{}) instead of nil,
// causing the database row to hold `0001-01-01 00:00:00` instead of NULL.
//
// The same !regReq.Expiry.IsZero() gate at state.go:2221-2228 is shared by
// the tags-only PreAuthKey path (createAndSaveNewNode also receives nil
// the tags-only PreAuthKey path ([state.State.createAndSaveNewNode] also receives nil
// when the client sends zero expiry), so this regression is covered for
// tagged nodes by inspection.
func TestReregistrationZeroExpiryStaysNil(t *testing.T) {

View File

@@ -31,7 +31,7 @@ type interactiveStep struct {
stepType string // stepTypeInitialRequest, stepTypeAuthCompletion, or stepTypeFollowupRequest
expectAuthURL bool
expectCacheEntry bool
callAuthPath bool // Real call to HandleNodeFromAuthPath, not mocked
callAuthPath bool // Real call to [state.State.HandleNodeFromAuthPath], not mocked
}
//nolint:gocyclo // comprehensive test function with many scenarios
@@ -140,7 +140,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -209,7 +209,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -409,7 +409,7 @@ func TestAuthenticationFlows(t *testing.T) {
t.Logf("Setup registered node: %+v", resp)
// Wait for node to be available in NodeStore with debug info
// Wait for node to be available in [state.NodeStore] with debug info
var attemptCount int
require.EventuallyWithT(t, func(c *assert.CollectT) {
@@ -470,7 +470,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -520,7 +520,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -637,7 +637,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -687,7 +687,7 @@ func TestAuthenticationFlows(t *testing.T) {
app.state.SetAuthCacheEntry(regID, nodeToRegister)
// Simulate successful registration
// handleRegister will receive the value when it starts waiting
// [Headscale.handleRegister] will receive the value when it starts waiting
go func() {
user := app.state.CreateUserForTest("followup-user")
@@ -826,7 +826,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Nil hostinfo is handled with defensive code
// WHAT: Tests that nil hostinfo in register request is handled gracefully
// INPUT: Register request with Hostinfo field set to nil
// INPUT: Register request with [tailcfg.Hostinfo] field set to nil
// EXPECTED: Node registers successfully with generated hostname starting with "node-"
// WHY: Defensive code prevents nil pointer panics; creates valid default hostinfo
{
@@ -856,7 +856,7 @@ func TestAuthenticationFlows(t *testing.T) {
validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper //nolint:thelper
assert.True(t, resp.MachineAuthorized)
// With nil Hostinfo the raw hostname stays empty and GivenName
// With nil [tailcfg.Hostinfo] the raw hostname stays empty and GivenName
// falls back to the literal "node" per the SaaS spec.
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(t, found)
@@ -954,7 +954,7 @@ func TestAuthenticationFlows(t *testing.T) {
// TEST: PreAuthKey registration rejects client-provided RequestTags
// WHAT: Tests that PreAuthKey registrations cannot use client-provided tags
// INPUT: PreAuthKey registration with RequestTags in Hostinfo
// INPUT: PreAuthKey registration with [tailcfg.Hostinfo.RequestTags] set
// EXPECTED: Registration fails with "requested tags [...] are invalid or not permitted" error
// WHY: PreAuthKey nodes get their tags from the key itself, not from client requests
{
@@ -1240,7 +1240,7 @@ func TestAuthenticationFlows(t *testing.T) {
// TEST: Zero-time expiry is handled correctly
// WHAT: Tests registration with expiry set to zero time value
// INPUT: Register request with Expiry set to time.Time{} (zero value)
// INPUT: Register request with Expiry set to [time.Time]{} (zero value)
// EXPECTED: Node registers successfully; zero time treated as no expiry
// WHY: Zero time is valid Go default; should be handled gracefully
{
@@ -1280,7 +1280,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Malformed hostinfo with very long hostname is truncated
// WHAT: Tests that excessively long hostname is truncated to DNS label limit
// INPUT: Hostinfo with 110-character hostname (exceeds 63-char DNS limit)
// INPUT: [tailcfg.Hostinfo] with 110-character hostname (exceeds 63-char DNS limit)
// EXPECTED: Node registers successfully; hostname truncated to 63 characters
// WHY: Defensive code enforces DNS label limit (RFC 1123); prevents errors
{
@@ -1845,7 +1845,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Logout with expiry exactly at current time
// WHAT: Tests logout when expiry is set to exact current time (boundary case)
// INPUT: Existing node sends request with expiry=time.Now() (not past, not future)
// INPUT: Existing node sends request with expiry=[time.Now]() (not past, not future)
// EXPECTED: Node is logged out (treated as expired)
// WHY: Edge case: current time should be treated as expired
{
@@ -2225,7 +2225,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Interactive workflow with nil hostinfo
// WHAT: Tests interactive registration when request has nil hostinfo
// INPUT: Interactive registration request with Hostinfo=nil
// INPUT: Interactive registration request with [tailcfg.Hostinfo]=nil
// EXPECTED: Node registers successfully with generated default hostname
// WHY: Defensive code handles nil hostinfo in interactive flow
{
@@ -2761,7 +2761,7 @@ func TestNodeStoreLookup(t *testing.T) {
t.Logf("Registered node successfully: %+v", resp)
// Wait for node to be available in NodeStore
// Wait for node to be available in [state.NodeStore]
var node types.NodeView
require.EventuallyWithT(t, func(c *assert.CollectT) {
@@ -3072,7 +3072,7 @@ func TestWebFlowReauthDifferentUser(t *testing.T) {
})
t.Run("returned_node_is_user2_new_node", func(t *testing.T) {
// The node returned from HandleNodeFromAuthPath should be user2's NEW node
// The node returned from [state.State.HandleNodeFromAuthPath] should be user2's NEW node
assert.Equal(t, user2.ID, node.UserID().Get(), "Returned node should belong to user2")
assert.NotEqual(t, user1NodeID, node.ID(), "Returned node should be NEW, not transferred from user1")
t.Logf("✓ HandleNodeFromAuthPath returned user2's new node (ID: %d)", node.ID())
@@ -3166,7 +3166,7 @@ func createTestApp(t *testing.T) *Headscale {
// 1. Node registers successfully with a single-use pre-auth key
// 2. Node is running fine
// 3. Node restarts (e.g., after headscale upgrade or tailscale container restart)
// 4. Node sends RegisterRequest with the same pre-auth key
// 4. Node sends [tailcfg.RegisterRequest] with the same pre-auth key
// 5. BUG: Headscale rejects the request with "authkey expired" or "authkey already used"
//
// Expected behavior:
@@ -3223,7 +3223,7 @@ func TestGitHubIssue2830_NodeRestartWithUsedPreAuthKey(t *testing.T) {
require.NoError(t, err)
assert.True(t, usedPak.Used, "pre-auth key should be marked as used after initial registration")
// STEP 2: Simulate node restart - node sends RegisterRequest again with same pre-auth key
// STEP 2: Simulate node restart - node sends [tailcfg.RegisterRequest] again with same pre-auth key
// This happens when:
// - Tailscale container restarts
// - Tailscaled service restarts
@@ -3508,7 +3508,7 @@ func TestGitHubIssue2830_ExistingNodeCanReregisterWithUsedPreAuthKey(t *testing.
// WITHOUT THE FIX: This would fail with "authkey already used" error
// WITH THE FIX: This succeeds because it's the same node re-registering with its own key
// Simulate sending the same RegisterRequest again (same MachineKey, same AuthKey)
// Simulate sending the same [tailcfg.RegisterRequest] again (same MachineKey, same AuthKey)
// This is exactly what happens when a container restarts
reregisterReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
@@ -3787,15 +3787,15 @@ func TestAuthKeyTaggedToUserOwnedViaReauth(t *testing.T) {
nodeAfterReauth.IsTagged(), nodeAfterReauth.UserID().Get())
}
// TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate tests that when a PreAuthKey is deleted,
// subsequent node updates (like those triggered by MapRequests) do not recreate the key.
// TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate tests that when a [types.PreAuthKey] is deleted,
// subsequent node updates (like those triggered by [tailcfg.MapRequest]s) do not recreate the key.
//
// This reproduces the bug where:
// 1. Create a tagged preauthkey and register a node
// 2. Delete the preauthkey (confirmed gone from pre_auth_keys DB table)
// 3. Node sends MapRequest (e.g., after tailscaled restart)
// 3. Node sends [tailcfg.MapRequest] (e.g., after tailscaled restart)
// 4. BUG: The preauthkey reappears because GORM's Updates() upserts the stale AuthKey
// data that still exists in the NodeStore's in-memory cache.
// data that still exists in the [state.NodeStore]'s in-memory cache.
//
// The fix is to use Omit("AuthKey") on all node Updates() calls to prevent GORM
// from touching the AuthKey association.
@@ -3864,11 +3864,11 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
require.Nil(t, dbNode.AuthKeyID, "node's AuthKeyID should be NULL after PreAuthKey deletion")
t.Log("Node's AuthKeyID is NULL in database")
// The NodeStore may still have stale AuthKey data in memory.
// Now simulate what happens when the node sends a MapRequest after a tailscaled restart.
// This triggers persistNodeToDB which calls GORM's Updates().
// The [state.NodeStore] may still have stale AuthKey data in memory.
// Now simulate what happens when the node sends a [tailcfg.MapRequest] after a tailscaled restart.
// This triggers [state.State.persistNodeToDB] which calls GORM's Updates().
// Simulate a MapRequest by updating the node through the state layer
// Simulate a [tailcfg.MapRequest] by updating the node through the state layer
// This mimics what poll.go does when processing MapRequests
mapReq := tailcfg.MapRequest{
NodeKey: nodeKey.Public(),
@@ -3879,8 +3879,8 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
},
}
// Process the MapRequest-like update
// This calls UpdateNodeFromMapRequest which eventually calls persistNodeToDB
// Process the [tailcfg.MapRequest]-like update
// This calls [state.State.UpdateNodeFromMapRequest] which eventually calls [state.State.persistNodeToDB]
_, err = app.state.UpdateNodeFromMapRequest(node.ID(), mapReq)
require.NoError(t, err, "UpdateNodeFromMapRequest should succeed")
t.Log("Simulated MapRequest update completed")
@@ -3943,7 +3943,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
alice := app.state.CreateUserForTest("alice")
require.NotNil(t, alice, "Alice user should be created")
// Step 4: Re-register the node to alice via HandleNodeFromAuthPath
// Step 4: Re-register the node to alice via [state.State.HandleNodeFromAuthPath]
// This is what happens when running: headscale auth register --auth-id <id> --user alice
nodeKey2 := key.NewNode()
registrationID := types.MustAuthID()
@@ -3960,7 +3960,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
// This should NOT panic - before the fix, this would panic with:
// panic: runtime error: invalid memory address or nil pointer dereference
// at UserView.Name() because the existing node has no User
// at [types.UserView.Name] because the existing node has no User
nodeAfterReauth, _, err := app.state.HandleNodeFromAuthPath(
registrationID,
types.UserID(alice.ID),
@@ -3977,8 +3977,8 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
require.False(t, nodeAfterReauth.IsTagged(), "Node should no longer be tagged")
require.Empty(t, nodeAfterReauth.Tags().AsSlice(), "Node should have no tags")
// Verify Owner() works without panicking - this is what the mapper's
// generateUserProfiles calls, and it would panic with a nil pointer
// Verify [types.NodeView.Owner] works without panicking - this is what the mapper's
// [generateUserProfiles] calls, and it would panic with a nil pointer
// dereference if node.User was not set during the tag→user conversion.
owner := nodeAfterReauth.Owner()
require.True(t, owner.Valid(), "Owner should be valid after conversion (mapper would panic if nil)")

View File

@@ -22,7 +22,7 @@ const (
// CanOldCodeBeCleanedUp is intended to be called on startup to see if
// there are old code that can ble cleaned up, entries should contain
// a CapVer where something can be cleaned up and a panic if it can.
// a [tailcfg.CapabilityVersion] where something can be cleaned up and a panic if it can.
// This is only intended to catch things in tests.
//
// All uses of Capability version checks should be listed here.
@@ -46,12 +46,12 @@ func capVersSorted() []tailcfg.CapabilityVersion {
return capVers
}
// TailscaleVersion returns the Tailscale version for the given CapabilityVersion.
// TailscaleVersion returns the Tailscale version for the given [tailcfg.CapabilityVersion].
func TailscaleVersion(ver tailcfg.CapabilityVersion) string {
return capVerToTailscaleVer[ver]
}
// CapabilityVersion returns the CapabilityVersion for the given Tailscale version.
// CapabilityVersion returns the [tailcfg.CapabilityVersion] for the given Tailscale version.
// It accepts both full versions (v1.90.1) and minor versions (v1.90).
func CapabilityVersion(ver string) tailcfg.CapabilityVersion {
if !strings.HasPrefix(ver, "v") {
@@ -115,7 +115,7 @@ func TailscaleLatestMajorMinor(n int, stripV bool) []string {
return majorSl[len(majorSl)-n:]
}
// CapVerLatest returns the n latest CapabilityVersions.
// CapVerLatest returns the n latest [tailcfg.CapabilityVersion] values.
func CapVerLatest(n int) []tailcfg.CapabilityVersion {
if n <= 0 {
return nil

View File

@@ -28,7 +28,7 @@ var (
ErrAPIKeyInvalidGeneration = errors.New("generated API key failed validation")
)
// CreateAPIKey creates a new ApiKey in a user, and returns it.
// CreateAPIKey creates a new [types.APIKey] in a user, and returns it.
func (hsdb *HSDatabase) CreateAPIKey(
expiration *time.Time,
) (string, *types.APIKey, error) {
@@ -84,7 +84,7 @@ func (hsdb *HSDatabase) CreateAPIKey(
return keyStr, &key, nil
}
// ListAPIKeys returns the list of ApiKeys for a user.
// ListAPIKeys returns the list of [types.APIKey] values for a user.
func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
keys := []types.APIKey{}
@@ -96,7 +96,7 @@ func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
return keys, nil
}
// GetAPIKey returns a ApiKey for a given key.
// GetAPIKey returns a [types.APIKey] for a given key.
func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) {
key := types.APIKey{}
if result := hsdb.DB.First(&key, "prefix = ?", prefix); result.Error != nil {
@@ -106,7 +106,7 @@ func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) {
return &key, nil
}
// GetAPIKeyByID returns a ApiKey for a given id.
// GetAPIKeyByID returns a [types.APIKey] for a given id.
func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
key := types.APIKey{}
if result := hsdb.DB.Find(&types.APIKey{ID: id}).First(&key); result.Error != nil {
@@ -116,7 +116,7 @@ func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
return &key, nil
}
// DestroyAPIKey destroys a ApiKey. Returns error if the ApiKey
// DestroyAPIKey destroys a [types.APIKey]. Returns error if the [types.APIKey]
// does not exist.
func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
if result := hsdb.DB.Unscoped().Delete(key); result.Error != nil {
@@ -126,7 +126,7 @@ func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
return nil
}
// ExpireAPIKey marks a ApiKey as expired.
// ExpireAPIKey marks a [types.APIKey] as expired.
func (hsdb *HSDatabase) ExpireAPIKey(key *types.APIKey) error {
err := hsdb.DB.Model(&key).Update("Expiration", time.Now()).Error
if err != nil {

View File

@@ -17,8 +17,8 @@ const (
fifty = 50 * time.Millisecond
)
// TestEphemeralGarbageCollectorGoRoutineLeak is a test for a goroutine leak in EphemeralGarbageCollector().
// It creates a new EphemeralGarbageCollector, schedules several nodes for deletion with a short expiry,
// TestEphemeralGarbageCollectorGoRoutineLeak is a test for a goroutine leak in [EphemeralGarbageCollector].
// It creates a new [EphemeralGarbageCollector], schedules several nodes for deletion with a short expiry,
// and verifies that the nodes are deleted when the expiry time passes, and then
// for any leaked goroutines after the garbage collector is closed.
func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) {
@@ -89,8 +89,8 @@ func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) {
t.Logf("Final number of goroutines: %d", runtime.NumGoroutine())
}
// TestEphemeralGarbageCollectorReschedule is a test for the rescheduling of nodes in EphemeralGarbageCollector().
// It creates a new EphemeralGarbageCollector, schedules a node for deletion with a longer expiry,
// TestEphemeralGarbageCollectorReschedule is a test for the rescheduling of nodes in [EphemeralGarbageCollector].
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion with a longer expiry,
// and then reschedules it with a shorter expiry, and verifies that the node is deleted only once.
func TestEphemeralGarbageCollectorReschedule(t *testing.T) {
// Deletion tracking mechanism
@@ -145,8 +145,8 @@ func TestEphemeralGarbageCollectorReschedule(t *testing.T) {
deleteMutex.Unlock()
}
// TestEphemeralGarbageCollectorCancelAndReschedule is a test for the cancellation and rescheduling of nodes in EphemeralGarbageCollector().
// It creates a new EphemeralGarbageCollector, schedules a node for deletion, cancels it, and then reschedules it,
// TestEphemeralGarbageCollectorCancelAndReschedule is a test for the cancellation and rescheduling of nodes in [EphemeralGarbageCollector].
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion, cancels it, and then reschedules it,
// and verifies that the node is deleted only once.
func TestEphemeralGarbageCollectorCancelAndReschedule(t *testing.T) {
// Deletion tracking mechanism
@@ -214,8 +214,8 @@ func TestEphemeralGarbageCollectorCancelAndReschedule(t *testing.T) {
deleteMutex.Unlock()
}
// TestEphemeralGarbageCollectorCloseBeforeTimerFires is a test for the closing of the EphemeralGarbageCollector before the timer fires.
// It creates a new EphemeralGarbageCollector, schedules a node for deletion, closes the GC, and verifies that the node is not deleted.
// TestEphemeralGarbageCollectorCloseBeforeTimerFires is a test for the closing of the [EphemeralGarbageCollector] before the timer fires.
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion, closes the GC, and verifies that the node is not deleted.
func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) {
// Deletion tracking
var (
@@ -264,7 +264,7 @@ func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) {
deleteMutex.Unlock()
}
// TestEphemeralGarbageCollectorScheduleAfterClose verifies that calling Schedule after Close
// TestEphemeralGarbageCollectorScheduleAfterClose verifies that calling [EphemeralGarbageCollector.Schedule] after [EphemeralGarbageCollector.Close]
// is a no-op and doesn't cause any panics, goroutine leaks, or other issues.
func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) {
// Count initial goroutines to check for leaks
@@ -339,7 +339,7 @@ func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) {
}
// TestEphemeralGarbageCollectorConcurrentScheduleAndClose tests the behavior of the garbage collector
// when Schedule and Close are called concurrently from multiple goroutines.
// when [EphemeralGarbageCollector.Schedule] and [EphemeralGarbageCollector.Close] are called concurrently from multiple goroutines.
func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
// Count initial goroutines
initialGoroutines := runtime.NumGoroutine()

View File

@@ -49,7 +49,7 @@ type IPAllocator struct {
usedIPs netipx.IPSetBuilder
}
// NewIPAllocator returns a new IPAllocator singleton which
// NewIPAllocator returns a new [IPAllocator] singleton which
// can be used to hand out unique IP addresses within the
// provided IPv4 and IPv6 prefix. It needs to be created
// when headscale starts and needs to finish its read
@@ -272,7 +272,7 @@ func isTailscaleReservedIP(ip netip.Addr) bool {
}
// BackfillNodeIPs will take a database transaction, and
// iterate through all of the current nodes in headscale
// iterate through all of the current nodes ([types.Node]) in headscale
// and ensure it has IP addresses according to the current
// configuration.
// This means that if both IPv4 and IPv6 is set in the
@@ -346,7 +346,6 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) {
// Use Updates() with Select() to only update IP fields, avoiding overwriting
// other fields like Expiry. We need Select() because Updates() alone skips
// zero values, but we DO want to update IPv4/IPv6 to nil when removing them.
// See issue #2862.
err := tx.Model(node).Select("ipv4", "ipv6").Updates(node).Error
if err != nil {
return fmt.Errorf("saving node(%d) after adding IPs: %w", node.ID, err)

View File

@@ -109,7 +109,7 @@ func (hsdb *HSDatabase) getNode(uid types.UserID, name string) (*types.Node, err
})
}
// getNode finds a Node by name and user and returns the Node struct.
// getNode finds a [types.Node] by name and user and returns the [types.Node] struct.
func getNode(tx *gorm.DB, uid types.UserID, name string) (*types.Node, error) {
nodes, err := ListNodesByUser(tx, uid)
if err != nil {
@@ -129,7 +129,7 @@ func (hsdb *HSDatabase) GetNodeByID(id types.NodeID) (*types.Node, error) {
return GetNodeByID(hsdb.DB, id)
}
// GetNodeByID finds a Node by ID and returns the Node struct.
// GetNodeByID finds a [types.Node] by ID and returns the [types.Node] struct.
func GetNodeByID(tx *gorm.DB, id types.NodeID) (*types.Node, error) {
mach := types.Node{}
if result := tx.
@@ -147,7 +147,7 @@ func (hsdb *HSDatabase) GetNodeByMachineKey(machineKey key.MachinePublic) (*type
return GetNodeByMachineKey(hsdb.DB, machineKey)
}
// GetNodeByMachineKey finds a Node by its MachineKey and returns the Node struct.
// GetNodeByMachineKey finds a [types.Node] by its [key.MachinePublic] and returns the [types.Node] struct.
func GetNodeByMachineKey(
tx *gorm.DB,
machineKey key.MachinePublic,
@@ -168,7 +168,7 @@ func (hsdb *HSDatabase) GetNodeByNodeKey(nodeKey key.NodePublic) (*types.Node, e
return GetNodeByNodeKey(hsdb.DB, nodeKey)
}
// GetNodeByNodeKey finds a Node by its NodeKey and returns the Node struct.
// GetNodeByNodeKey finds a [types.Node] by its [key.NodePublic] and returns the [types.Node] struct.
func GetNodeByNodeKey(
tx *gorm.DB,
nodeKey key.NodePublic,
@@ -199,7 +199,7 @@ func SetLastSeen(tx *gorm.DB, nodeID types.NodeID, lastSeen time.Time) error {
return tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("last_seen", lastSeen).Error
}
// RenameNode takes a Node struct and a new GivenName for the nodes
// RenameNode takes a [types.Node] struct and a new [types.Node.GivenName] for the nodes
// and renames it. Validation should be done in the state layer before calling this function.
func RenameNode(tx *gorm.DB,
nodeID types.NodeID, newName string,
@@ -245,7 +245,7 @@ func (hsdb *HSDatabase) DeleteNode(node *types.Node) error {
})
}
// DeleteNode deletes a Node from the database.
// DeleteNode deletes a [types.Node] from the database.
// Caller is responsible for notifying all of change.
func DeleteNode(tx *gorm.DB,
node *types.Node,
@@ -259,7 +259,7 @@ func DeleteNode(tx *gorm.DB,
return nil
}
// DeleteEphemeralNode deletes a Node from the database, note that this method
// DeleteEphemeralNode deletes a [types.Node] from the database, note that this method
// will remove it straight, and not notify any changes or consider any routes.
// It is intended for Ephemeral nodes.
func (hsdb *HSDatabase) DeleteEphemeralNode(
@@ -276,7 +276,7 @@ func (hsdb *HSDatabase) DeleteEphemeralNode(
}
// RegisterNodeForTest is used only for testing purposes to register a node directly in the database.
// Production code should use state.HandleNodeFromAuthPath or state.HandleNodeFromPreAuthKey.
// Production code should use [state.State.HandleNodeFromAuthPath] or [state.State.HandleNodeFromPreAuthKey].
func RegisterNodeForTest(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *netip.Addr) (*types.Node, error) {
if !testing.Testing() {
panic("RegisterNodeForTest can only be called during tests")
@@ -387,7 +387,7 @@ func NodeSetMachineKey(
// EphemeralGarbageCollector is a garbage collector that will delete nodes after
// a certain amount of time.
// It is used to delete ephemeral nodes that have disconnected and should be
// It is used to delete ephemeral nodes ([types.Node.IsEphemeral]) that have disconnected and should be
// cleaned up.
type EphemeralGarbageCollector struct {
mu sync.Mutex
@@ -399,7 +399,7 @@ type EphemeralGarbageCollector struct {
cancelCh chan struct{}
}
// NewEphemeralGarbageCollector creates a new EphemeralGarbageCollector, it takes
// NewEphemeralGarbageCollector creates a new [EphemeralGarbageCollector], it takes
// a deleteFunc that will be called when a node is scheduled for deletion.
func NewEphemeralGarbageCollector(deleteFunc func(types.NodeID)) *EphemeralGarbageCollector {
return &EphemeralGarbageCollector{

View File

@@ -31,7 +31,7 @@ func (hsdb *HSDatabase) GetPolicy() (*types.Policy, error) {
}
// GetPolicy returns the latest policy from the database.
// This standalone function can be used in contexts where HSDatabase is not available,
// This standalone function can be used in contexts where [HSDatabase] is not available,
// such as during migrations.
func GetPolicy(tx *gorm.DB) (*types.Policy, error) {
var p types.Policy
@@ -55,7 +55,7 @@ func GetPolicy(tx *gorm.DB) (*types.Policy, error) {
// PolicyBytes loads policy configuration from file or database based on the configured mode.
// Returns nil if no policy is configured, which is valid.
// This standalone function can be used in contexts where HSDatabase is not available,
// This standalone function can be used in contexts where [HSDatabase] is not available,
// such as during migrations.
func PolicyBytes(tx *gorm.DB, cfg *types.Config) ([]byte, error) {
switch cfg.Policy.Mode {

View File

@@ -40,7 +40,7 @@ const (
authKeyLength = 64
)
// CreatePreAuthKey creates a new PreAuthKey in a user, and returns it.
// CreatePreAuthKey creates a new [types.PreAuthKey] in a user, and returns it.
// The uid parameter can be nil for system-created tagged keys.
// For tagged keys, uid tracks "created by" (who created the key).
// For user-owned keys, uid tracks the node owner.
@@ -158,7 +158,7 @@ func (hsdb *HSDatabase) ListPreAuthKeys() ([]types.PreAuthKey, error) {
return Read(hsdb.DB, ListPreAuthKeys)
}
// ListPreAuthKeys returns all PreAuthKeys in the database.
// ListPreAuthKeys returns all [types.PreAuthKey] values in the database.
func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
var keys []types.PreAuthKey
@@ -170,7 +170,7 @@ func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
return keys, nil
}
// ListPreAuthKeysByUser returns all PreAuthKeys belonging to a specific user.
// ListPreAuthKeysByUser returns all [types.PreAuthKey] values belonging to a specific user.
func ListPreAuthKeysByUser(tx *gorm.DB, uid types.UserID) ([]types.PreAuthKey, error) {
var keys []types.PreAuthKey
@@ -290,13 +290,13 @@ func (hsdb *HSDatabase) GetPreAuthKey(key string) (*types.PreAuthKey, error) {
return GetPreAuthKey(hsdb.DB, key)
}
// GetPreAuthKey returns a PreAuthKey for a given key. The caller is responsible
// GetPreAuthKey returns a [types.PreAuthKey] for a given key. The caller is responsible
// for checking if the key is usable (expired or used).
func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) {
return findAuthKey(tx, key)
}
// DestroyPreAuthKey destroys a preauthkey. Returns error if the PreAuthKey
// DestroyPreAuthKey destroys a preauthkey. Returns error if the [types.PreAuthKey]
// does not exist. This also clears the auth_key_id on any nodes that reference
// this key.
func DestroyPreAuthKey(tx *gorm.DB, id uint64) error {
@@ -331,10 +331,10 @@ func (hsdb *HSDatabase) DeletePreAuthKey(id uint64) error {
})
}
// UsePreAuthKey atomically marks a PreAuthKey as used. The UPDATE is
// UsePreAuthKey atomically marks a [types.PreAuthKey] as used. The UPDATE is
// guarded by `used = false` so two concurrent registrations racing for
// the same single-use key cannot both succeed: the first commits and
// the second returns PAKError("authkey already used"). Without the
// the second returns [types.PAKError]("authkey already used"). Without the
// guard the previous code (Update("used", true) with no WHERE) would
// silently let both transactions claim the key.
func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
@@ -354,7 +354,7 @@ func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
return nil
}
// ExpirePreAuthKey marks a PreAuthKey as expired.
// ExpirePreAuthKey marks a [types.PreAuthKey] as expired.
func ExpirePreAuthKey(tx *gorm.DB, id uint64) error {
now := time.Now()
return tx.Model(&types.PreAuthKey{}).Where("id = ?", id).Update("expiration", now).Error

View File

@@ -25,7 +25,7 @@ func (hsdb *HSDatabase) CreateUser(user types.User) (*types.User, error) {
})
}
// CreateUser creates a new User. Returns error if could not be created
// CreateUser creates a new [types.User]. Returns error if could not be created
// or another user already exists.
func CreateUser(tx *gorm.DB, user types.User) (*types.User, error) {
err := util.ValidateUsername(user.Name)
@@ -47,7 +47,7 @@ func (hsdb *HSDatabase) DestroyUser(uid types.UserID) error {
})
}
// DestroyUser destroys a User. Returns error if the User does
// DestroyUser destroys a [types.User]. Returns error if the [types.User] does
// not exist or if there are user-owned nodes associated with it.
// Tagged nodes have user_id = NULL so they do not block deletion.
func DestroyUser(tx *gorm.DB, uid types.UserID) error {
@@ -92,8 +92,8 @@ func (hsdb *HSDatabase) RenameUser(uid types.UserID, newName string) error {
var ErrCannotChangeOIDCUser = errors.New("cannot edit OIDC user")
// RenameUser renames a User. Returns error if the User does
// not exist or if another User exists with the new name.
// RenameUser renames a [types.User]. Returns error if the [types.User] does
// not exist or if another [types.User] exists with the new name.
func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error {
var err error

View File

@@ -84,7 +84,7 @@ func parseVersion(s string) (semver, error) {
}
// ensureDatabaseVersionTable creates the database_versions table if it
// does not already exist. Uses GORM AutoMigrate to handle dialect
// does not already exist. Uses [gorm.DB.AutoMigrate] to handle dialect
// differences between SQLite (datetime) and PostgreSQL (timestamp).
// This runs before gormigrate migrations.
func ensureDatabaseVersionTable(db *gorm.DB) error {

View File

@@ -73,11 +73,11 @@ func loadDERPMapFromURL(addr url.URL) (*tailcfg.DERPMap, error) {
return &derpMap, err
}
// mergeDERPMaps naively merges a list of DERPMaps into a single
// DERPMap, it will _only_ look at the Regions, an integer.
// If a region exists in two of the given DERPMaps, the region
// form the _last_ DERPMap will be preserved.
// An empty DERPMap list will result in a DERPMap with no regions.
// mergeDERPMaps naively merges a list of [tailcfg.DERPMap] values into a single
// [tailcfg.DERPMap], it will _only_ look at the Regions, an integer.
// If a region exists in two of the given [tailcfg.DERPMap] values, the region
// form the _last_ [tailcfg.DERPMap] will be preserved.
// An empty [tailcfg.DERPMap] list will result in a [tailcfg.DERPMap] with no regions.
func mergeDERPMaps(derpMaps []*tailcfg.DERPMap) *tailcfg.DERPMap {
result := tailcfg.DERPMap{
OmitDefaultRegions: false,

View File

@@ -30,7 +30,7 @@ type ExtraRecordsMan struct {
hashes map[string][32]byte
}
// NewExtraRecordsManager creates a new ExtraRecordsMan and starts watching the file at the given path.
// NewExtraRecordsManager creates a new [ExtraRecordsMan] and starts watching the file at the given path.
func NewExtraRecordsManager(path string) (*ExtraRecordsMan, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
@@ -177,7 +177,7 @@ func (e *ExtraRecordsMan) updateRecords() {
e.updateCh <- e.records.Slice()
}
// readExtraRecordsFromPath reads a JSON file of tailcfg.DNSRecord
// readExtraRecordsFromPath reads a JSON file of [tailcfg.DNSRecord]
// and returns the records and the hash of the file.
func readExtraRecordsFromPath(path string) ([]tailcfg.DNSRecord, [32]byte, error) {
b, err := os.ReadFile(path)

View File

@@ -59,7 +59,7 @@ func (api headscaleV1APIServer) CreateUser(
return nil, status.Errorf(codes.Internal, "creating user: %s", err)
}
// CreateUser returns a policy change response if the user creation affected policy.
// [state.State.CreateUser] returns a policy change response if the user creation affected policy.
// This triggers a full policy re-evaluation for all connected nodes.
api.h.Change(policyChanged)
@@ -105,7 +105,7 @@ func (api headscaleV1APIServer) DeleteUser(
return nil, err
}
// Use the change returned from DeleteUser which includes proper policy updates
// Use the change returned from [state.State.DeleteUser] which includes proper policy updates
api.h.Change(policyChanged)
return &v1.DeleteUserResponse{}, nil
@@ -293,7 +293,7 @@ func (api headscaleV1APIServer) RegisterNode(
return nil, fmt.Errorf("auto approving routes: %w", err)
}
// Send both changes. Empty changes are ignored by Change().
// Send both changes. Empty changes are ignored by [Headscale.Change].
api.h.Change(nodeChange, routeChange)
return &v1.RegisterNodeResponse{Node: node.Proto()}, nil
@@ -396,11 +396,11 @@ func (api headscaleV1APIServer) SetApprovedRoutes(
return nil, status.Error(codes.InvalidArgument, err.Error())
}
// Always propagate node changes from SetApprovedRoutes
// Always propagate node changes from [state.State.SetApprovedRoutes]
api.h.Change(nodeChange)
proto := node.Proto()
// Populate SubnetRoutes with PrimaryRoutes to ensure it includes only the
// Populate [types.Node.SubnetRoutes] with [tailcfg.Node.PrimaryRoutes] to ensure it includes only the
// routes that are actively served from the node (per architectural requirement in types/node.go)
primaryRoutes := api.h.state.GetNodePrimaryRoutes(node.ID())
proto.SubnetRoutes = util.PrefixesToString(primaryRoutes)
@@ -554,7 +554,7 @@ func nodesToProto(state *state.State, nodes views.Slice[types.NodeView]) []*v1.N
for index, node := range nodes.All() {
resp := node.Proto()
// Tags-as-identity: tagged nodes show as TaggedDevices user in API responses
// Tags-as-identity: tagged nodes show as [types.TaggedDevices] user in API responses
// (UserID may be set internally for "created by" tracking)
if node.IsTagged() {
resp.User = types.TaggedDevices.Proto()
@@ -852,9 +852,9 @@ func (api headscaleV1APIServer) DebugCreateNode(
authRegReq := types.NewRegisterAuthRequest(regData)
api.h.state.SetAuthCacheEntry(registrationId, authRegReq)
// Echo back a synthetic Node so the debug response surface stays
// stable. The actual node is created later by AuthApprove via
// HandleNodeFromAuthPath using the cached RegistrationData.
// Echo back a synthetic [types.Node] so the debug response surface stays
// stable. The actual node is created later by [headscaleV1APIServer.AuthApprove] via
// [state.State.HandleNodeFromAuthPath] using the cached [types.RegistrationData].
echoNode := types.Node{
NodeKey: regData.NodeKey,
MachineKey: regData.MachineKey,

View File

@@ -155,7 +155,7 @@ func TestSetTags_Conversion(t *testing.T) {
}
}
// TestSetTags_TaggedNode tests that SetTags correctly identifies tagged nodes
// TestSetTags_TaggedNode tests that [headscaleV1APIServer.SetTags] correctly identifies tagged nodes
// and doesn't reject them with the "user-owned nodes" error.
// Note: This test doesn't validate ACL tag authorization - that's tested elsewhere.
func TestSetTags_TaggedNode(t *testing.T) {
@@ -193,7 +193,7 @@ func TestSetTags_TaggedNode(t *testing.T) {
// Create API server instance
apiServer := newHeadscaleV1APIServer(app)
// Test: SetTags should work on tagged nodes.
// Test: [headscaleV1APIServer.SetTags] should work on tagged nodes.
resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(taggedNode.ID()),
Tags: []string{"tag:initial"}, // Keep existing tag to avoid ACL validation issues
@@ -212,7 +212,7 @@ func TestSetTags_TaggedNode(t *testing.T) {
}
}
// TestSetTags_CannotRemoveAllTags tests that SetTags rejects attempts to remove
// TestSetTags_CannotRemoveAllTags tests that [headscaleV1APIServer.SetTags] rejects attempts to remove
// all tags from a tagged node, enforcing Tailscale's requirement that tagged
// nodes must have at least one tag.
func TestSetTags_CannotRemoveAllTags(t *testing.T) {
@@ -265,9 +265,8 @@ func TestSetTags_CannotRemoveAllTags(t *testing.T) {
}
// TestSetTags_ClearsUserIDInDatabase tests that converting a user-owned node
// to a tagged node via SetTags correctly persists user_id = NULL in the
// to a tagged node via [headscaleV1APIServer.SetTags] correctly persists user_id = NULL in the
// database, not just in-memory.
// https://github.com/juanfont/headscale/issues/3161
func TestSetTags_ClearsUserIDInDatabase(t *testing.T) {
t.Parallel()
@@ -309,7 +308,7 @@ func TestSetTags_ClearsUserIDInDatabase(t *testing.T) {
nodeID := node.ID()
// Convert to tagged via SetTags API.
// Convert to tagged via [headscaleV1APIServer.SetTags] API.
apiServer := newHeadscaleV1APIServer(app)
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(nodeID),
@@ -404,9 +403,8 @@ func TestSetTags_NodeDisappearsFromUserListing(t *testing.T) {
assert.Contains(t, allResp.GetNodes()[0].GetTags(), "tag:web")
}
// TestSetTags_NodeStoreAndDBConsistency verifies that after SetTags, the
// in-memory NodeStore and the database agree on the node's ownership state.
// https://github.com/juanfont/headscale/issues/3161
// TestSetTags_NodeStoreAndDBConsistency verifies that after [headscaleV1APIServer.SetTags], the
// in-memory [state.NodeStore] and the database agree on the node's ownership state.
func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) {
t.Parallel()
@@ -478,9 +476,8 @@ func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) {
// TestSetTags_UserDeletionDoesNotCascadeToTaggedNode tests that deleting the
// original user does not cascade-delete a node that was converted to tagged
// via SetTags. This catches the real-world consequence of stale user_id:
// via [headscaleV1APIServer.SetTags]. This catches the real-world consequence of stale user_id:
// ON DELETE CASCADE would destroy the tagged node.
// https://github.com/juanfont/headscale/issues/3161
func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) {
t.Parallel()
@@ -531,7 +528,7 @@ func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) {
_, err = app.state.DeleteUser(*user.TypedID())
require.NoError(t, err)
// The tagged node must survive in both NodeStore and database.
// The tagged node must survive in both [state.NodeStore] and database.
nsNode, found := app.state.GetNodeByID(nodeID)
require.True(t, found, "tagged node must survive user deletion in NodeStore")
assert.True(t, nsNode.IsTagged())
@@ -555,7 +552,7 @@ func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) {
require.NotNil(t, user)
// Delete the user and verify a non-empty change is returned
// Issue #2967: Without the fix, DeleteUser returned an empty change,
// Without the fix, [state.State.DeleteUser] returned an empty change,
// causing stale policy state until another user operation triggered an update.
changeSignal, err := app.state.DeleteUser(*user.TypedID())
require.NoError(t, err, "DeleteUser should succeed")
@@ -564,8 +561,7 @@ func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) {
// TestDeleteUser_TaggedNodeSurvives tests that deleting a user succeeds when
// the user's only nodes are tagged, and that those nodes remain in the
// NodeStore with nil UserID.
// https://github.com/juanfont/headscale/issues/3077
// [state.NodeStore] with nil UserID.
func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
t.Parallel()
@@ -605,7 +601,7 @@ func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
nodeID := node.ID()
// NodeStore should not list the tagged node under any user.
// [state.NodeStore] should not list the tagged node under any user.
nodesForUser := app.state.ListNodesByUser(types.UserID(user.ID))
assert.Equal(t, 0, nodesForUser.Len(),
"tagged nodes should not appear in nodesByUser index")
@@ -615,7 +611,7 @@ func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
require.NoError(t, err)
assert.False(t, changeSignal.IsEmpty())
// Tagged node survives in the NodeStore.
// Tagged node survives in the [state.NodeStore].
nodeAfter, found := app.state.GetNodeByID(nodeID)
require.True(t, found, "tagged node should survive user deletion")
assert.True(t, nodeAfter.IsTagged())

View File

@@ -129,7 +129,7 @@ func parseCapabilityVersion(req *http.Request) (tailcfg.CapabilityVersion, error
}
// verifyBodyLimit caps the request body for /verify. The DERP verify
// protocol payload (tailcfg.DERPAdmitClientRequest) is a few hundred
// protocol payload ([tailcfg.DERPAdmitClientRequest]) is a few hundred
// bytes; 4 KiB is generous and prevents an unauthenticated client from
// OOMing the public router with arbitrarily large POSTs.
const verifyBodyLimit int64 = 4 * 1024
@@ -358,7 +358,7 @@ func authIDFromRequest(req *http.Request) (types.AuthID, error) {
// Listens in /register/:registration_id.
//
// This is not part of the Tailscale control API, as we could send whatever URL
// in the RegisterResponse.AuthURL field.
// in the [tailcfg.RegisterResponse.AuthURL] field.
func (a *AuthProviderWeb) RegisterHandler(
writer http.ResponseWriter,
req *http.Request,

View File

@@ -15,8 +15,8 @@ import (
var errTestUnexpected = errors.New("unexpected failure")
// TestHandleVerifyRequest_OversizedBodyRejected verifies that the
// /verify handler refuses POST bodies larger than verifyBodyLimit.
// The MaxBytesReader is applied in VerifyHandler, so we simulate
// /verify handler refuses POST bodies larger than [verifyBodyLimit].
// The [http.MaxBytesReader] is applied in [Headscale.VerifyHandler], so we simulate
// the same wrapping here.
func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
t.Parallel()
@@ -47,7 +47,7 @@ func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
"oversized body must surface 413")
}
// errorAsHTTPError is a small local helper that unwraps an HTTPError
// errorAsHTTPError is a small local helper that unwraps an [HTTPError]
// from an error chain.
func errorAsHTTPError(err error) (HTTPError, bool) {
var h HTTPError

View File

@@ -27,7 +27,7 @@ var (
)
// offlineNodeCleanupThreshold is how long a node must be disconnected
// before cleanupOfflineNodes removes its in-memory state.
// before [Batcher.cleanupOfflineNodes] removes its in-memory state.
const offlineNodeCleanupThreshold = 15 * time.Minute
var mapResponseGenerated = promauto.NewCounterVec(prometheus.CounterOpts{
@@ -49,7 +49,7 @@ func NewBatcher(batchTime time.Duration, workers int, mapper *mapper) *Batcher {
}
}
// NewBatcherAndMapper creates a new Batcher with its mapper.
// NewBatcherAndMapper creates a new [Batcher] with its [mapper].
func NewBatcherAndMapper(cfg *types.Config, state *state.State) *Batcher {
m := newMapper(cfg, state)
b := NewBatcher(cfg.Tuning.BatchChangeDelay, cfg.Tuning.BatcherWorkers, m)
@@ -69,7 +69,7 @@ type nodeConnection interface {
updateSentPeers(resp *tailcfg.MapResponse)
}
// generateMapResponse generates a [tailcfg.MapResponse] for the given NodeID based on the provided [change.Change].
// generateMapResponse generates a [tailcfg.MapResponse] for the given [types.NodeID] based on the provided [change.Change].
func generateMapResponse(nc nodeConnection, mapper *mapper, r change.Change) (*tailcfg.MapResponse, error) {
nodeID := nc.nodeID()
version := nc.version()
@@ -130,17 +130,19 @@ func generateMapResponse(nc nodeConnection, mapper *mapper, r change.Change) (*t
// When a full update (SendAllPeers=true) produces zero visible peers
// (e.g., a restrictive policy isolates this node), the resulting
// MapResponse has Peers: []*tailcfg.Node{} (empty non-nil slice).
// [tailcfg.MapResponse] has Peers: []*tailcfg.Node{} (empty non-nil slice).
//
// The Tailscale client only treats Peers as a full authoritative
// replacement when len(Peers) > 0 (controlclient/map.go:462).
// An empty Peers slice is indistinguishable from a delta response,
// so the client silently preserves its existing peer state.
//
// This matters when a FullUpdate() replaces a pending PolicyChange()
// in the batcher (addToBatch short-circuits on HasFull). The
// PolicyChange would have computed PeersRemoved via computePeerDiff,
// but the FullUpdate path uses WithPeers which sets Peers: [].
// This matters when a [change.FullUpdate] replaces a pending
// [change.PolicyChange] in the batcher ([Batcher.addToBatch]
// short-circuits on [change.HasFull]). The [change.PolicyChange]
// would have computed PeersRemoved via
// [multiChannelNodeConn.computePeerDiff], but the [change.FullUpdate]
// path uses [MapResponseBuilder.WithPeers] which sets Peers: [].
//
// Fix: when a full update results in zero peers, compute the diff
// against lastSentPeers and add explicit PeersRemoved entries so
@@ -206,7 +208,7 @@ type workResult struct {
// work represents a unit of work to be processed by workers.
// All pending changes for a node are bundled into a single work item
// so that one worker processes them sequentially. This prevents
// out-of-order MapResponse delivery and races on lastSentPeers
// out-of-order [tailcfg.MapResponse] delivery and races on lastSentPeers
// that occur when multiple workers process changes for the same node.
type work struct {
changes []change.Change
@@ -225,9 +227,9 @@ var (
// Batcher batches and distributes map responses to connected nodes.
// It uses concurrent maps, per-node mutexes, and a worker pool.
//
// Lifecycle: Call Start() to spawn workers, then Close() to shut down.
// Close() blocks until all workers have exited. A Batcher must not
// be reused after Close().
// Lifecycle: Call [Batcher.Start] to spawn workers, then [Batcher.Close]
// to shut down. [Batcher.Close] blocks until all workers have exited.
// A [Batcher] must not be reused after [Batcher.Close].
type Batcher struct {
tick *time.Ticker
mapper *mapper
@@ -551,11 +553,11 @@ func (b *Batcher) addToBatch(changes ...change.Change) {
// still has it registered. By cleaning up here, we prevent "node not found"
// errors when workers try to generate map responses for deleted nodes.
//
// Safety: change.Change.PeersRemoved is ONLY populated when nodes are actually
// deleted from the system (via change.NodeRemoved in state.DeleteNode). Policy
// changes that affect peer visibility do NOT use this field - they set
// Safety: [change.Change.PeersRemoved] is ONLY populated when nodes are actually
// deleted from the system (via [change.NodeRemoved] in [state.State.DeleteNode]).
// Policy changes that affect peer visibility do NOT use this field - they set
// RequiresRuntimePeerComputation=true and compute removed peers at runtime,
// putting them in tailcfg.MapResponse.PeersRemoved (a different struct).
// putting them in [tailcfg.MapResponse.PeersRemoved] (a different struct).
// Therefore, this cleanup only removes nodes that are truly being deleted,
// not nodes that are still connected but have lost visibility of certain peers.
//
@@ -638,8 +640,8 @@ func (b *Batcher) processBatchedChanges() {
}
// cleanupOfflineNodes removes nodes that have been offline for too long to prevent memory leaks.
// Uses Compute() for atomic check-and-delete to prevent TOCTOU races where a node
// reconnects between the hasActiveConnections() check and the Delete() call.
// Uses xsync.Map.Compute for atomic check-and-delete to prevent TOCTOU races where a node
// reconnects between the hasActiveConnections check and the Delete call.
func (b *Batcher) cleanupOfflineNodes() {
var nodesToCleanup []types.NodeID

View File

@@ -14,7 +14,7 @@ import (
"tailscale.com/util/multierr"
)
// MapResponseBuilder provides a fluent interface for building tailcfg.MapResponse.
// MapResponseBuilder provides a fluent interface for building [tailcfg.MapResponse].
type MapResponseBuilder struct {
resp *tailcfg.MapResponse
mapper *mapper
@@ -180,6 +180,10 @@ func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView])
}
// WithPacketFilters adds packet filter rules based on policy.
//
// [State.FilterForNode] returns rules already reduced to only those relevant for this node.
// For autogroup:self policies, it returns per-node compiled rules.
// For global policies, it returns the global filter reduced for this node.
func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder {
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
if !ok {
@@ -187,9 +191,6 @@ func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder {
return b
}
// FilterForNode returns rules already reduced to only those relevant for this node.
// For autogroup:self policies, it returns per-node compiled rules.
// For global policies, it returns the global filter reduced for this node.
filter, err := b.mapper.state.FilterForNode(node)
if err != nil {
b.addError(err)
@@ -233,7 +234,8 @@ func (b *MapResponseBuilder) WithPeerChanges(peers views.Slice[types.NodeView])
return b
}
// buildTailPeers converts views.Slice[types.NodeView] to []tailcfg.Node with policy filtering and sorting.
// buildTailPeers converts [views.Slice] of [types.NodeView] to a slice of [tailcfg.Node]
// with policy filtering and sorting.
func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ([]*tailcfg.Node, error) {
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
if !ok {
@@ -241,9 +243,10 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
}
// Get unreduced matchers for peer relationship determination.
// MatchersForNode returns unreduced matchers that include all rules where the node
// could be either source or destination. This is different from FilterForNode which
// returns reduced rules for packet filtering (only rules where node is destination).
// [State.MatchersForNode] returns unreduced matchers that include all rules where the
// node could be either source or destination. This is different from
// [State.FilterForNode] which returns reduced rules for packet filtering (only rules
// where node is destination).
matchers, err := b.mapper.state.MatchersForNode(node)
if err != nil {
return nil, err

View File

@@ -69,7 +69,7 @@ func newMapper(
}
}
// generateUserProfiles creates user profiles for MapResponse.
// generateUserProfiles creates user profiles for [tailcfg.MapResponse].
func generateUserProfiles(
node types.NodeView,
peers views.Slice[types.NodeView],
@@ -267,7 +267,7 @@ func addNextDNSMetadata(resolvers []*dnstype.Resolver, node types.NodeView) {
}
}
// fullMapResponse returns a MapResponse for the given node.
// fullMapResponse returns a [tailcfg.MapResponse] for the given node.
//
//nolint:unused
func (m *mapper) fullMapResponse(
@@ -312,7 +312,7 @@ func (m *mapper) selfMapResponse(
return ma, err
}
// policyChangeResponse creates a MapResponse for policy changes.
// policyChangeResponse creates a [tailcfg.MapResponse] for policy changes.
// It sends:
// - PeersRemoved for peers that are no longer visible after the policy change
// - PeersChanged for remaining peers (their AllowedIPs may have changed due to policy)
@@ -350,7 +350,7 @@ func (m *mapper) policyChangeResponse(
}
if len(removedPeers) > 0 {
// Convert tailcfg.NodeID to types.NodeID for WithPeersRemoved
// Convert [tailcfg.NodeID] to [types.NodeID] for [MapResponseBuilder.WithPeersRemoved]
removedIDs := make([]types.NodeID, len(removedPeers))
for i, id := range removedPeers {
removedIDs[i] = types.NodeID(id) //nolint:gosec // NodeID types are equivalent
@@ -371,7 +371,7 @@ func (m *mapper) policyChangeResponse(
return builder.Build()
}
// buildFromChange builds a MapResponse from a change.Change specification.
// buildFromChange builds a [tailcfg.MapResponse] from a [change.Change] specification.
// This provides fine-grained control over what gets included in the response.
func (m *mapper) buildFromChange(
nodeID types.NodeID,

View File

@@ -17,10 +17,10 @@ import (
"tailscale.com/tailcfg"
)
// errNoActiveConnections is returned by send when a node has no active
// connections (disconnected but kept in the batcher for rapid reconnection).
// Callers must not update peer tracking state (lastSentPeers) after this
// error because the data was never delivered to any client.
// errNoActiveConnections is returned by [multiChannelNodeConn.send] when a node
// has no active connections (disconnected but kept in the batcher for rapid
// reconnection). Callers must not update peer tracking state (lastSentPeers)
// after this error because the data was never delivered to any client.
var errNoActiveConnections = errors.New("no active connections")
// connectionEntry represents a single connection to a node.
@@ -51,9 +51,9 @@ type multiChannelNodeConn struct {
// workMu serializes change processing for this node across batch ticks.
// Without this, two workers could process consecutive ticks' bundles
// concurrently, causing out-of-order MapResponse delivery and races
// on lastSentPeers (Clear+Store in updateSentPeers vs Range in
// computePeerDiff).
// concurrently, causing out-of-order [tailcfg.MapResponse] delivery and races
// on lastSentPeers (Clear+Store in [multiChannelNodeConn.updateSentPeers] vs
// Range in [multiChannelNodeConn.computePeerDiff]).
workMu sync.Mutex
closeOnce sync.Once
@@ -62,7 +62,7 @@ type multiChannelNodeConn struct {
// disconnectedAt records when the last connection was removed.
// nil means the node is considered connected (or newly created);
// non-nil means the node disconnected at the stored timestamp.
// Used by cleanupOfflineNodes to evict stale entries.
// Used by [Batcher.cleanupOfflineNodes] to evict stale entries.
disconnectedAt atomic.Pointer[time.Time]
// lastSentPeers tracks which peers were last sent to this node.
@@ -182,8 +182,8 @@ func (mc *multiChannelNodeConn) markConnected() {
}
// markDisconnected records the current time as the moment the node
// lost its last connection. Used by cleanupOfflineNodes to determine
// how long the node has been offline.
// lost its last connection. Used by [Batcher.cleanupOfflineNodes] to
// determine how long the node has been offline.
func (mc *multiChannelNodeConn) markDisconnected() {
now := time.Now()
mc.disconnectedAt.Store(&now)
@@ -235,8 +235,8 @@ func (mc *multiChannelNodeConn) drainPending() []change.Change {
// connection can block for up to 50ms), the method snapshots connections under
// a read lock, sends without any lock held, then write-locks only to remove
// failures. New connections added between the snapshot and cleanup are safe:
// they receive a full initial map via AddNode, so missing this update causes
// no data loss.
// they receive a full initial map via [Batcher.AddNode], so missing this update
// causes no data loss.
func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error {
if data == nil {
return nil
@@ -389,7 +389,7 @@ func (mc *multiChannelNodeConn) version() tailcfg.CapabilityVersion {
return mc.connections[0].version
}
// updateSentPeers updates the tracked peer state based on a sent MapResponse.
// updateSentPeers updates the tracked peer state based on a sent [tailcfg.MapResponse].
// This must be called after successfully sending a response to keep track of
// what the client knows about, enabling accurate diffs for future updates.
func (mc *multiChannelNodeConn) updateSentPeers(resp *tailcfg.MapResponse) {

View File

@@ -65,14 +65,14 @@ const (
// The first 9 bytes from the server to client over Noise are either an HTTP/2
// settings frame (a normal HTTP/2 setup) or, as Tailscale added later, an "early payload"
// header that's also 9 bytes long: 5 bytes (earlyPayloadMagic) followed by 4 bytes
// of length. Then that many bytes of JSON-encoded tailcfg.EarlyNoise.
// header that's also 9 bytes long: 5 bytes ([earlyPayloadMagic]) followed by 4 bytes
// of length. Then that many bytes of JSON-encoded [tailcfg.EarlyNoise].
// The early payload is optional. Some servers may not send it... But we do!
earlyPayloadMagic = "\xff\xff\xffTS"
// noiseBodyLimit is the maximum allowed request body size for Noise protocol
// handlers. This prevents unauthenticated OOM attacks via unbounded io.ReadAll.
// No legitimate Noise request (MapRequest, RegisterRequest, etc.) comes close
// handlers. This prevents unauthenticated OOM attacks via unbounded [io.ReadAll].
// No legitimate Noise request ([tailcfg.MapRequest], [tailcfg.RegisterRequest], etc.) comes close
// to this limit; typical payloads are a few KB.
noiseBodyLimit int64 = 1048576 // 1 MiB
)
@@ -86,12 +86,12 @@ type noiseServer struct {
machineKey key.MachinePublic
nodeKey key.NodePublic
// EarlyNoise-related stuff
// [tailcfg.EarlyNoise]-related stuff
challenge key.ChallengePrivate
protocolVersion int
}
// NoiseUpgradeHandler is to upgrade the connection and hijack the net.Conn
// NoiseUpgradeHandler is to upgrade the connection and hijack the [net.Conn]
// in order to use the Noise-based TS2021 protocol. Listens in /ts2021.
func (h *Headscale) NoiseUpgradeHandler(
writer http.ResponseWriter,
@@ -136,7 +136,7 @@ func (h *Headscale) NoiseUpgradeHandler(
// This router is served only over the Noise connection, and exposes only the new API.
//
// The HTTP2 server that exposes this router is created for
// a single hijacked connection from /ts2021, using netutil.NewOneConnListener
// a single hijacked connection from /ts2021, using [netutil.NewOneConnListener]
r := chi.NewRouter()
@@ -300,7 +300,7 @@ func (ns *noiseServer) NotImplementedHandler(writer http.ResponseWriter, req *ht
}
// PingResponseHandler handles HEAD requests from clients responding to a
// PingRequest. The client calls this endpoint to prove connectivity.
// [tailcfg.PingRequest]. The client calls this endpoint to prove connectivity.
// The unguessable ping ID serves as authentication.
func (h *Headscale) PingResponseHandler(
writer http.ResponseWriter,
@@ -457,12 +457,12 @@ func (ns *noiseServer) SSHActionHandler(
}
// sshAction resolves the SSH action for the given request parameters.
// It returns the action to send to the client, or an HTTPError on failure.
// It returns the action to send to the client, or an [HTTPError] on failure.
//
// Three cases:
// 1. Initial request, auto-approved — source recently authenticated
// within the check period, accept immediately.
// 2. Initial request, needs auth — build a HoldAndDelegate URL and
// 2. Initial request, needs auth — build a [tailcfg.SSHAction.HoldAndDelegate] URL and
// wait for the user to authenticate.
// 3. Follow-up request — an auth_id is present, wait for the auth
// verdict and accept or reject.
@@ -514,7 +514,7 @@ func (ns *noiseServer) sshAction(
}
// sshActionHoldAndDelegate creates a new auth session bound to the
// (src, dst) pair and returns a HoldAndDelegate action that directs the
// (src, dst) pair and returns a [tailcfg.SSHAction.HoldAndDelegate] action that directs the
// client to authenticate.
func (ns *noiseServer) sshActionHoldAndDelegate(
reqLog zerolog.Logger,
@@ -636,8 +636,8 @@ func (ns *noiseServer) sshActionFollowUp(
case <-ctx.Done():
// The client disconnected (or its request timed out) before the
// auth session resolved. Return an error so the parked goroutine
// is freed; without this select sshActionFollowUp would block
// until the cache eviction callback signalled FinishAuth, which
// is freed; without this select [noiseServer.sshActionFollowUp] would block
// until the cache eviction callback signalled [types.AuthRequest.FinishAuth], which
// could be up to register_cache_expiration (15 minutes).
return nil, NewHTTPError(
http.StatusUnauthorized,
@@ -674,8 +674,8 @@ func (ns *noiseServer) sshActionFollowUp(
// This is the busiest endpoint, as it keeps the HTTP long poll that updates
// the clients when something in the network changes.
//
// The clients POST stuff like HostInfo and their Endpoints here, but
// only after their first request (marked with the ReadOnly field).
// The clients POST stuff like [tailcfg.Hostinfo] and their Endpoints here, but
// only after their first request (marked with the [tailcfg.MapRequest.ReadOnly] field).
//
// At this moment the updates are sent in a quite horrendous way, but they kinda work.
func (ns *noiseServer) PollNetMapHandler(

View File

@@ -21,8 +21,8 @@ import (
// newNoiseRouterWithBodyLimit builds a chi router with the same body-limit
// middleware used in the real Noise router but wired to a test handler that
// captures the io.ReadAll result. This lets us verify the limit without
// needing a full Headscale instance.
// captures the [io.ReadAll] result. This lets us verify the limit without
// needing a full [Headscale] instance.
func newNoiseRouterWithBodyLimit(readBody *[]byte, readErr *error) http.Handler {
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
@@ -159,7 +159,7 @@ func TestNoiseBodyLimit_AtExactLimit(t *testing.T) {
}
// TestPollNetMapHandler_OversizedBody calls the real handler with a
// MaxBytesReader-wrapped body to verify it fails gracefully (json decode
// [http.MaxBytesReader]-wrapped body to verify it fails gracefully (json decode
// error on truncated data) rather than consuming unbounded memory.
func TestPollNetMapHandler_OversizedBody(t *testing.T) {
t.Parallel()
@@ -173,12 +173,12 @@ func TestPollNetMapHandler_OversizedBody(t *testing.T) {
ns.PollNetMapHandler(rec, req)
// Body is truncated → json.Decode fails → httpError returns 500.
// Body is truncated → [json.Decoder.Decode] fails → [httpError] returns 500.
assert.Equal(t, http.StatusInternalServerError, rec.Code)
}
// TestRegistrationHandler_OversizedBody calls the real handler with a
// MaxBytesReader-wrapped body to verify it returns an error response
// [http.MaxBytesReader]-wrapped body to verify it returns an error response
// rather than consuming unbounded memory.
func TestRegistrationHandler_OversizedBody(t *testing.T) {
t.Parallel()
@@ -192,8 +192,8 @@ func TestRegistrationHandler_OversizedBody(t *testing.T) {
ns.RegistrationHandler(rec, req)
// json.Decode returns MaxBytesError → regErr wraps it → handler writes
// a RegisterResponse with the error and then rejectUnsupported kicks in
// [json.Decoder.Decode] returns [http.MaxBytesError][regErr] wraps it → handler writes
// a [tailcfg.RegisterResponse] with the error and then [rejectUnsupported] kicks in
// for version 0 → returns 400.
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
@@ -236,7 +236,7 @@ func TestSSHActionRoute_OldPathReturns404(t *testing.T) {
}
// newSSHActionRequest builds an httptest request with the chi URL params
// SSHActionHandler reads (src_node_id and dst_node_id), so the handler
// [noiseServer.SSHActionHandler] reads (src_node_id and dst_node_id), so the handler
// can be exercised directly without going through the chi router.
func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request {
t.Helper()
@@ -253,8 +253,8 @@ func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request {
}
// putTestNodeInStore creates a node via the database test helper and
// also stages it into the in-memory NodeStore so handlers that read
// NodeStore-backed APIs (e.g. State.GetNodeByID) can see it.
// also stages it into the in-memory [state.NodeStore] so handlers that read
// [state.NodeStore]-backed APIs (e.g. [state.State.GetNodeByID]) can see it.
func putTestNodeInStore(t *testing.T, app *Headscale, user *types.User, hostname string) *types.Node {
t.Helper()
@@ -276,7 +276,7 @@ func TestSSHActionHandler_RejectsRogueMachineKey(t *testing.T) {
src := putTestNodeInStore(t, app, user, "src-node")
dst := putTestNodeInStore(t, app, user, "dst-node")
// noiseServer carries the wrong machine key — a fresh throwaway key,
// [noiseServer] carries the wrong machine key — a fresh throwaway key,
// not dst.MachineKey.
rogue := key.NewMachine().Public()
require.NotEqual(t, dst.MachineKey, rogue, "test sanity: rogue key must differ from dst")

View File

@@ -44,13 +44,13 @@ func MatchesFromFilterRules(rules []tailcfg.FilterRule) []Match {
return matches
}
// MatchFromFilterRule derives a Match from a tailcfg.FilterRule. The
// destination IP set is the union of DstPorts[].IP and CapGrant[].Dsts:
// cap-grant-only rules (e.g. tailscale.com/cap/relay) carry their
// destinations in CapGrant.Dsts and would otherwise contribute nothing
// to peer-visibility derivation in BuildPeerMap / ReduceNodes, hiding
// the cap target from the source unless a companion IP-level rule
// also exists.
// MatchFromFilterRule derives a [Match] from a [tailcfg.FilterRule]. The
// destination IP set is the union of [tailcfg.FilterRule.DstPorts][].IP
// and [tailcfg.FilterRule.CapGrant][].Dsts: cap-grant-only rules (e.g.
// tailscale.com/cap/relay) carry their destinations in CapGrant.Dsts and
// would otherwise contribute nothing to peer-visibility derivation in
// [policy.BuildPeerMap] / [policy.ReduceNodes], hiding the cap target
// from the source unless a companion IP-level rule also exists.
func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
srcs := new(netipx.IPSetBuilder)
dests := new(netipx.IPSetBuilder)
@@ -80,11 +80,11 @@ func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
}
}
// MatchFromStrings builds a Match from raw source and destination
// MatchFromStrings builds a [Match] from raw source and destination
// strings. Unparseable entries are silently dropped (fail-open): the
// resulting Match is narrower than the input described, but never
// resulting [Match] is narrower than the input described, but never
// wider. Callers that need strict validation should pre-validate
// their inputs via util.ParseIPSet.
// their inputs via [util.ParseIPSet].
func MatchFromStrings(sources, destinations []string) Match {
srcs := new(netipx.IPSetBuilder)
dests := new(netipx.IPSetBuilder)
@@ -131,7 +131,7 @@ func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
// DestsIsTheInternet reports whether the destination covers "the
// internet" — the set represented by autogroup:internet, special-cased
// for exit nodes. Returns true if either family's /0 is contained
// (0.0.0.0/0 or ::/0), or if dests is a superset of TheInternet(). A
// (0.0.0.0/0 or ::/0), or if dests is a superset of [util.TheInternet]. A
// single-family /0 counts because operators may write it directly and
// it still denotes the whole internet for that family.
func (m *Match) DestsIsTheInternet() bool {
@@ -140,7 +140,7 @@ func (m *Match) DestsIsTheInternet() bool {
return true
}
// Superset-of-TheInternet check handles merged filter rules
// Superset-of-[util.TheInternet] check handles merged filter rules
// where the internet prefixes are combined with other dests.
theInternet := util.TheInternet()
for _, prefix := range theInternet.Prefixes() {

View File

@@ -68,7 +68,7 @@ type PolicyManager interface {
DebugString() string
}
// NewPolicyManager returns a new policy manager.
// NewPolicyManager returns a new [PolicyManager].
func NewPolicyManager(pol []byte, users []types.User, nodes views.Slice[types.NodeView]) (PolicyManager, error) {
var (
polMan PolicyManager
@@ -83,8 +83,8 @@ func NewPolicyManager(pol []byte, users []types.User, nodes views.Slice[types.No
return polMan, err
}
// PolicyManagersForTest returns all available PostureManagers to be used
// in tests to validate them in tests that try to determine that they
// PolicyManagersForTest returns all available [PolicyManager] implementations to
// be used in tests to validate them in tests that try to determine that they
// behave the same.
func PolicyManagersForTest(pol []byte, users []types.User, nodes views.Slice[types.NodeView]) ([]PolicyManager, error) {
var polMans []PolicyManager

View File

@@ -51,6 +51,11 @@ func ReduceRoutes(
}
// BuildPeerMap builds a map of all peers that can be accessed by each node.
//
// Compared to [ReduceNodes], which builds the list per node, we end up with
// doing the full work for every node (O(n^2)), while this will reduce the
// list as we see relationships while building the map, making it O(n^2/2)
// in the end, but with less work per node.
func BuildPeerMap(
nodes views.Slice[types.NodeView],
matchers []matcher.Match,
@@ -58,9 +63,6 @@ func BuildPeerMap(
ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
// Build the map of all peers according to the matchers.
// Compared to ReduceNodes, which builds the list per node, we end up with doing
// the full work for every node (On^2), while this will reduce the list as we see
// relationships while building the map, making it O(n^2/2) in the end, but with less work per node.
for i := range nodes.Len() {
for j := i + 1; j < nodes.Len(); j++ {
if nodes.At(i).ID() == nodes.At(j).ID() {
@@ -78,7 +80,8 @@ func BuildPeerMap(
}
// ApproveRoutesWithPolicy checks if the node can approve the announced routes
// and returns the new list of approved routes.
// and returns the new list of approved routes. The [PolicyManager] is consulted
// via [PolicyManager.NodeCanApproveRoute].
// The approved routes will include:
// 1. ALL previously approved routes (regardless of whether they're still advertised)
// 2. New routes from announcedRoutes that can be auto-approved by policy

View File

@@ -1,9 +1,9 @@
// Package policyutil contains pure functions that transform compiled
// policy rules for a specific node. The headline function is
// ReduceFilterRules, which filters global rules down to those relevant
// [ReduceFilterRules], which filters global rules down to those relevant
// to one node.
//
// A node's SubnetRoutes (approved, non-exit) participate in rule
// matching so subnet routers receive filter rules for destinations
// their subnets cover — the fix for issue #3169.
// A node's [types.NodeView.SubnetRoutes] (approved, non-exit) participate
// in rule matching so subnet routers receive filter rules for
// destinations their subnets cover.
package policyutil

View File

@@ -15,7 +15,8 @@ import (
//
// IMPORTANT: This function is designed for global filters only. Per-node filters
// (from autogroup:self policies) are already node-specific and should not be passed
// to this function. Use PolicyManager.FilterForNode() instead, which handles both cases.
// to this function. Use [policy.PolicyManager.FilterForNode] instead, which handles
// both cases.
func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcfg.FilterRule {
ret := []tailcfg.FilterRule{}
subnetRoutes := node.SubnetRoutes()
@@ -49,13 +50,14 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
}
// If the node has approved subnet routes, preserve
// filter rules targeting those routes. SubnetRoutes()
// returns only approved, non-exit routes — matching
// Tailscale SaaS behavior, which does not generate
// filter rules for advertised-but-unapproved routes.
// Exit routes (0.0.0.0/0, ::/0) are excluded by
// SubnetRoutes() and handled separately via
// AllowedIPs/routing.
// filter rules targeting those routes.
// [types.NodeView.SubnetRoutes] returns only approved,
// non-exit routes — matching Tailscale SaaS behavior,
// which does not generate filter rules for
// advertised-but-unapproved routes. Exit routes
// (0.0.0.0/0, ::/0) are excluded by
// [types.NodeView.SubnetRoutes] and handled separately
// via AllowedIPs/routing.
if slices.ContainsFunc(subnetRoutes, expanded.OverlapsPrefix) {
dests = append(dests, dest)
continue
@@ -95,11 +97,11 @@ func ipSetSubsetOf(candidate, container *netipx.IPSet) bool {
return true
}
// reduceCapGrantRule filters a CapGrant rule to only include CapGrant
// entries whose Dsts match the given node's IPs. When a broad prefix
// (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is
// reduceCapGrantRule filters a [tailcfg.CapGrant] rule to only include
// [tailcfg.CapGrant] entries whose Dsts match the given node's IPs. When a
// broad prefix (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is
// narrowed to the node's specific /32 or /128 prefix. Returns nil if
// no CapGrant entries are relevant to this node.
// no [tailcfg.CapGrant] entries are relevant to this node.
func reduceCapGrantRule(
node types.NodeView,
rule tailcfg.FilterRule,
@@ -136,9 +138,9 @@ func reduceCapGrantRule(
// prefixes to node-specific /32 or /128 so peers receive only
// the minimum routing surface. The route-match loop below
// preserves the original prefix so the subnet-serving node
// receives the full CapGrant scope. SubnetRoutes() excludes
// both unapproved and exit routes, matching Tailscale SaaS
// behavior.
// receives the full CapGrant scope. [types.NodeView.SubnetRoutes]
// excludes both unapproved and exit routes, matching Tailscale
// SaaS behavior.
for _, dst := range cg.Dsts {
for _, subnetRoute := range subnetRoutes {
if dst.Overlaps(subnetRoute) {
@@ -151,7 +153,7 @@ func reduceCapGrantRule(
if len(matchingDsts) > 0 {
// A Dst can be appended twice when a broad prefix both
// contains a node IP and overlaps one of its approved
// subnet routes. Sort + Compact dedups; netip.Prefix is
// subnet routes. Sort + Compact dedups; [netip.Prefix] is
// comparable so Compact works with ==.
slices.SortFunc(matchingDsts, netip.Prefix.Compare)
matchingDsts = slices.Compact(matchingDsts)

View File

@@ -18,7 +18,7 @@ type grantCategory int
const (
// grantCategoryRegular requires no per-node work. The pre-compiled
// rules are complete and only need ReduceFilterRules.
// rules are complete and only need [policyutil.ReduceFilterRules].
grantCategoryRegular grantCategory = iota
// grantCategorySelf has autogroup:self destinations that must be
@@ -80,7 +80,7 @@ type viaGrantData struct {
// resolveViaDestinations splits a via grant's destinations into the
// flat list of IP prefixes they resolve to plus a flag for
// autogroup:internet. Every alias kind goes through Alias.Resolve so
// autogroup:internet. Every alias kind goes through [Alias.Resolve] so
// adding a new alias type to the policy parser does not silently
// disappear from the via path. Non-IP alias kinds (tag, user, group,
// wildcard) resolve to /32 host IPs that never overlap with subnet
@@ -116,7 +116,7 @@ func resolveViaDestinations(
// userNodeIndex maps user IDs to their untagged nodes. Built once per
// policy or node-set change and read from many goroutines under
// PolicyManager.mu; readers must hold the lock (or the snapshot
// [PolicyManager.mu]; readers must hold the lock (or the snapshot
// returned to them).
type userNodeIndex map[uint][]types.NodeView
@@ -136,7 +136,7 @@ func buildUserNodeIndex(
}
// compileNodeAttrs returns the per-node CapMap derived from policy
// nodeAttrs plus the tailnet-wide RandomizeClientPort flag.
// nodeAttrs plus the tailnet-wide [Policy.RandomizeClientPort] flag.
//
// Returns an error when a target alias fails to resolve so the caller
// surfaces a corrupt policy instead of silently granting a partial set
@@ -163,18 +163,18 @@ func (pol *Policy) compileNodeAttrs(
result[id] = capMap
}
// nil RawMessage matches the wire format from a Tailscale-hosted
// control plane: capabilities without companion data marshal as
// `null` rather than `[]`. Storing nil keeps the merge stable
// and lets the compat test diff cleanly against captured
// netmaps.
// nil [tailcfg.RawMessage] matches the wire format from a
// Tailscale-hosted control plane: capabilities without companion
// data marshal as null rather than []. Storing nil keeps the
// merge stable and lets the compat test diff cleanly against
// captured netmaps.
if _, exists := capMap[attr]; !exists {
capMap[attr] = nil
}
}
// Cache each node's IPs once per call. Without the cache, the
// node-attr inner loop would call NodeView.IPs() once per attr
// node-attr inner loop would call [types.NodeView.IPs] once per attr
// per node — O(grants × nodes) allocations of a 2-element slice
// for what is invariant per node within a single policy compile.
type nodeIPs struct {
@@ -221,10 +221,11 @@ func (pol *Policy) compileNodeAttrs(
return result, nil
}
// compileGrants resolves all policy grants into compiledGrant structs.
// compileGrants resolves all policy grants into [compiledGrant] structs.
// Source resolution and non-self destination resolution happens once
// here. This is the single resolution path that replaces the
// duplicated work in compileFilterRules and compileGrantWithAutogroupSelf.
// duplicated work in [Policy.compileFilterRules] and the autogroup:self
// expansion.
func (pol *Policy) compileGrants(
users types.Users,
nodes views.Slice[types.NodeView],
@@ -256,7 +257,7 @@ func (pol *Policy) compileGrants(
return compiled
}
// compileOneGrant resolves a single grant into a compiledGrant.
// compileOneGrant resolves a single grant into a [compiledGrant].
// All source resolution happens here. Non-self, non-via destination
// resolution also happens here. Per-node data (self dests, via
// matching) is stored for deferred compilation.
@@ -341,7 +342,7 @@ func (pol *Policy) compileOneGrant(
// compileOneViaGrant resolves sources for a via grant and stores the
// deferred per-node data. The actual via-node matching and route
// intersection happens in compileViaForNode.
// intersection happens in [compileViaForNode].
func (pol *Policy) compileOneViaGrant(
grant Grant,
users types.Users,
@@ -404,8 +405,8 @@ func (pol *Policy) compileOneViaGrant(
// resolveSources resolves grant sources per-alias, returning the
// resolved addresses and a separate slice of non-wildcard sources.
// This is the canonical source-resolution path. Its output lands in
// compiledGrant.srcIPStrings (among other places) and callers on the
// hot path should prefer reading that over calling Resolve again.
// [compiledGrant.srcIPStrings] (among other places) and callers on the
// hot path should prefer reading that over calling [Alias.Resolve] again.
func resolveSources(
pol *Policy,
sources Aliases,
@@ -490,8 +491,9 @@ func buildSrcIPStrings(
}
// compileOtherDests compiles filter rules for non-self, non-via
// destinations. This produces both DstPorts rules (from
// InternetProtocols) and CapGrant rules (from App).
// destinations. This produces both [tailcfg.FilterRule.DstPorts] rules
// (from [Grant.InternetProtocols]) and [tailcfg.CapGrant] rules (from
// [Grant.App]).
func (pol *Policy) compileOtherDests(
users types.Users,
nodes views.Slice[types.NodeView],
@@ -580,7 +582,7 @@ func (pol *Policy) compileOtherDests(
return rules
}
// hasPerNodeGrants reports whether any compiled grant requires
// hasPerNodeGrants reports whether any [compiledGrant] requires
// per-node filter compilation (via grants or autogroup:self).
func hasPerNodeGrants(grants []compiledGrant) bool {
for i := range grants {
@@ -592,10 +594,10 @@ func hasPerNodeGrants(grants []compiledGrant) bool {
return false
}
// globalFilterRules extracts global filter rules from compiled
// grants. Via grants produce no global rules (they are per-node
// only); regular grants contribute their full pre-compiled ruleset;
// self grants contribute their non-self portion.
// globalFilterRules extracts global filter rules from [compiledGrant]s.
// Via grants produce no global rules (they are per-node only); regular
// grants contribute their full pre-compiled ruleset; self grants
// contribute their non-self portion.
func globalFilterRules(grants []compiledGrant) []tailcfg.FilterRule {
var rules []tailcfg.FilterRule
@@ -804,10 +806,11 @@ func compileViaForNode(
return nil
}
// SubnetRoutes excludes exit routes, so the overlap gate below sees
// only subnet advertisements. autogroup:internet on a via-tagged
// exit advertiser is handled separately because its eligibility is
// per-node (IsExitNode) rather than per-prefix overlap.
// [types.NodeView.SubnetRoutes] excludes exit routes, so the overlap
// gate below sees only subnet advertisements. autogroup:internet on
// a via-tagged exit advertiser is handled separately because its
// eligibility is per-node ([types.NodeView.IsExitNode]) rather than
// per-prefix overlap.
nodeSubnetRoutes := node.SubnetRoutes()
var viaDstPrefixes []netip.Prefix
@@ -826,11 +829,11 @@ func compileViaForNode(
}
// autogroup:internet on a via-tagged exit advertiser becomes a rule
// whose DstPorts enumerate util.TheInternet(). The matchers derived
// from this rule let Node.CanAccess surface the exit node to the
// grant source via DestsIsTheInternet. ReduceFilterRules strips the
// rule from the wire format on non-exit advertisers, preserving
// SaaS PacketFilter encoding.
// whose DstPorts enumerate [util.TheInternet]. The matchers derived
// from this rule let [types.NodeView.CanAccess] surface the exit node
// to the grant source via [matcher.Match.DestsIsTheInternet].
// [policyutil.ReduceFilterRules] strips the rule from the wire format
// on non-exit advertisers, preserving SaaS PacketFilter encoding.
if cg.via.hasAutoGroupInternet && node.IsExitNode() {
viaDstPrefixes = append(
viaDstPrefixes,

View File

@@ -735,10 +735,11 @@ func TestTagPropagationToPeerMap(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, matchersForUser2, "MatchersForNode should return non-empty matchers (at least self-access rule)")
// Test ReduceNodes logic with the updated nodes and matchers
// This is what buildTailPeers does - it takes peers from ListPeers (which might include user1)
// and filters them using ReduceNodes with the updated matchers
// Inline the ReduceNodes logic to avoid import cycle
// Test [policy.ReduceNodes] logic with the updated nodes and matchers
// This is what [mapper.MapResponseBuilder.buildTailPeers] does - it takes peers from
// [state.State.ListPeers] (which might include user1) and filters them using
// [policy.ReduceNodes] with the updated matchers
// Inline the [policy.ReduceNodes] logic to avoid import cycle
user2View := user2Node.View()
user1UpdatedView := user1NodeUpdated.View()

View File

@@ -22,7 +22,7 @@ import (
// - check: every listed user reaches every dst via a check-action
// rule specifically (accept-only matches fail the assertion).
// SSHPolicyTestResult is the outcome of a single SSHPolicyTest.
// SSHPolicyTestResult is the outcome of a single [SSHPolicyTest].
type SSHPolicyTestResult struct {
Src string `json:"src"`
Passed bool `json:"passed"`
@@ -122,7 +122,7 @@ func checkFailReason(res SSHPolicyTestResult, user, dst string) string {
}
// RunSSHTests evaluates the live policy's sshTests block and wraps any
// failure in errSSHPolicyTestsFailed.
// failure in [errSSHPolicyTestsFailed].
func (pm *PolicyManager) RunSSHTests() error {
if pm == nil || pm.pol == nil || len(pm.pol.SSHTests) == 0 {
return nil
@@ -162,7 +162,7 @@ func evaluateSSHTests(
}
// runSSHPolicyTests evaluates every sshTests entry. The cache is keyed
// by dst NodeID so repeat destinations only compile once per pass.
// by dst [types.NodeID] so repeat destinations only compile once per pass.
func runSSHPolicyTests(
pol *Policy,
users []types.User,
@@ -389,7 +389,7 @@ func appendUserDst(m map[string][]string, user, dst string) map[string][]string
// resolveSSHTestSource returns the src's principal addresses and, for
// user-shaped sources, the user ID (so autogroup:self can scope to it).
// Tag, host, and IP sources return userID 0.
// [Tag], [Host], and IP sources return userID 0.
func resolveSSHTestSource(
src Alias,
pol *Policy,
@@ -428,9 +428,10 @@ func resolveSSHTestSource(
}
// resolveSSHTestDestNodes maps each dst alias to its destination
// NodeViews. autogroup:self needs special handling: it cannot resolve
// without per-node context, so it walks the node set keyed on src's
// owning user. Other aliases resolve to an IPSet and match via InIPSet.
// [types.NodeView]s. autogroup:self needs special handling: it cannot
// resolve without per-node context, so it walks the node set keyed on
// src's owning user. Other aliases resolve to an [netipx.IPSet] and match
// via [types.NodeView.InIPSet].
func resolveSSHTestDestNodes(
dsts SSHTestDestinations,
pol *Policy,
@@ -527,8 +528,8 @@ func resolveSSHTestDestNodes(
return out, emptyDsts, nil
}
// prefixesToIPSet builds the IPSet that InIPSet expects on the node
// side.
// prefixesToIPSet builds the [netipx.IPSet] that [types.NodeView.InIPSet]
// expects on the node side.
func prefixesToIPSet(prefixes []netip.Prefix) (*netipx.IPSet, error) {
var b netipx.IPSetBuilder
@@ -539,9 +540,9 @@ func prefixesToIPSet(prefixes []netip.Prefix) (*netipx.IPSet, error) {
return b.IPSet()
}
// compiledSSHPolicy returns the per-node compiled SSH policy, caching
// compiledSSHPolicy returns the per-node compiled [tailcfg.SSHPolicy], caching
// on miss. baseURL is empty because reachability only checks for the
// presence of HoldAndDelegate, not its value.
// presence of [tailcfg.SSHAction.HoldAndDelegate], not its value.
func compiledSSHPolicy(
pol *Policy,
users []types.User,
@@ -607,8 +608,8 @@ func reachability(
return acceptHit, checkHit
}
// principalContainsAddr reports whether any principal's NodeIP matches
// srcAddr exactly (the SSH compiler emits one principal per source IP).
// principalContainsAddr reports whether any principal's [tailcfg.SSHPrincipal.NodeIP]
// matches srcAddr exactly (the SSH compiler emits one principal per source IP).
func principalContainsAddr(
principals []*tailcfg.SSHPrincipal,
srcAddr netip.Addr,
@@ -635,7 +636,7 @@ func principalContainsAddr(
return false
}
// sshUserMapAllows reports whether SSHUsers permits user. The SSHUsers
// sshUserMapAllows reports whether [SSHUsers] permits user. The [SSHUsers]
// wire shape (see filter.go compileSSHPolicy):
//
// - SSHUsers["root"] == "root" allows root; == "" disallows it.

View File

@@ -4,7 +4,7 @@ package v2
// Tailscale-hosted control plane emits where headscale has no
// equivalent concept yet. The compat test in
// tailscale_nodeattrs_compat_test.go builds the self-view CapMap via
// [types.NodeView.TailNode] -- the same call the mapper makes -- and
// [types.Node.TailNode] -- the same call the mapper makes -- and
// strips these from BOTH sides before [cmp.Diff]; every other cap is
// compared in full as it lands on the wire.
//
@@ -30,8 +30,9 @@ import (
// (suggest-exit-node, dns-subdomain-resolve — see
// ipn/ipnlocal/local.go:7534 and node_backend.go:745) are emitted only
// when the peer satisfies the cap's emission condition. This function
// encodes those conditions; the mapper calls it from buildTailPeers and
// the compat test calls it to compute the expected per-peer wire shape.
// encodes those conditions; the mapper calls it from
// [mapper.MapResponseBuilder.buildTailPeers] and the compat test calls
// it to compute the expected per-peer wire shape.
func PeerCapMap(peer types.NodeView, peerSelfCaps tailcfg.NodeCapMap) tailcfg.NodeCapMap {
if len(peerSelfCaps) == 0 {
return nil

View File

@@ -27,7 +27,7 @@ import (
// errPolicyTestsFailed and errSSHPolicyTestsFailed share the
// "test(s) failed" prefix but stay distinct so callers can use
// errors.Is to tell ACL-test and SSH-test failures apart.
// [errors.Is] to tell ACL-test and SSH-test failures apart.
var (
errPolicyTestsFailed = errors.New("test(s) failed")
errSSHPolicyTestsFailed = errors.New("test(s) failed")
@@ -55,7 +55,7 @@ type PolicyTest struct {
// SSHPolicyTest is one entry in the policy's `sshTests` block. The
// accept/deny/check arrays carry usernames, not destinations — every
// listed user is asserted against every entry in Dst.
// listed user is asserted against every entry in [SSHPolicyTest.Dst].
type SSHPolicyTest struct {
// Src is a single source alias (user, group, tag, host, or IP).
Src Alias `json:"src"`
@@ -78,7 +78,7 @@ type SSHPolicyTest struct {
}
// SSHTestDestinations is the typed list of destination aliases an
// sshTests entry targets. validateSSHTestDestination enforces the
// sshTests entry targets. [validateSSHTestDestination] enforces the
// SSH-specific shape rules (no :port, no CIDR, no autogroup:internet,
// known tag).
type SSHTestDestinations []Alias
@@ -100,7 +100,7 @@ func (d *SSHTestDestinations) UnmarshalJSON(b []byte) error {
}
// UnmarshalJSON parses each typed field. An empty src lands as a nil
// Alias so validation surfaces ErrSSHTestEmptySrc rather than a parser
// [Alias] so validation surfaces [ErrSSHTestEmptySrc] rather than a parser
// failure.
func (t *SSHPolicyTest) UnmarshalJSON(b []byte) error {
var raw struct {
@@ -134,7 +134,7 @@ func (t *SSHPolicyTest) UnmarshalJSON(b []byte) error {
return nil
}
// PolicyTestResult is the outcome of a single PolicyTest.
// PolicyTestResult is the outcome of a single [PolicyTest].
type PolicyTestResult struct {
Src string `json:"src"`
Proto Protocol `json:"proto,omitempty"`
@@ -216,7 +216,7 @@ func (pm *PolicyManager) RunTests() error {
}
// evaluateTests runs the `tests` block against a fresh compilation of pol.
// It is the user-write sandbox: the live PolicyManager state is left
// It is the user-write sandbox: the live [PolicyManager] state is left
// untouched, so a failing test rejects the write without side effects.
func evaluateTests(pol *Policy, users []types.User, nodes views.Slice[types.NodeView]) error {
if pol == nil || len(pol.Tests) == 0 {
@@ -262,7 +262,7 @@ func runPolicyTests(pol *Policy, filter []tailcfg.FilterRule, users []types.User
return results
}
// runPolicyTest evaluates one PolicyTest.
// runPolicyTest evaluates one [PolicyTest].
func runPolicyTest(test PolicyTest, pol *Policy, filter []tailcfg.FilterRule, users []types.User, nodes views.Slice[types.NodeView]) PolicyTestResult {
res := PolicyTestResult{
Src: test.Src,
@@ -322,8 +322,8 @@ func runPolicyTest(test PolicyTest, pol *Policy, filter []tailcfg.FilterRule, us
return res
}
// resolveTestSource resolves the Src alias of a PolicyTest into a slice of
// netip.Prefix. parseAlias + Alias.Resolve cover every alias type the rest
// resolveTestSource resolves the Src alias of a [PolicyTest] into a slice of
// [netip.Prefix]. [parseAlias] + [Alias.Resolve] cover every alias type the rest
// of the policy engine supports, so tests inherit alias semantics for free.
func resolveTestSource(src string, pol *Policy, users []types.User, nodes views.Slice[types.NodeView]) ([]netip.Prefix, error) {
alias, err := parseAlias(src)
@@ -377,13 +377,13 @@ func evalReachability(srcPrefixes []netip.Prefix, dst string, proto Protocol, po
return true, nil
}
// parseDestinationAlias is a thin wrapper over AliasWithPorts.UnmarshalJSON
// parseDestinationAlias is a thin wrapper over [AliasWithPorts.UnmarshalJSON]
// so callers can hand it a bare `"host:port"` string without re-implementing
// the parse logic.
func parseDestinationAlias(dst string) (*AliasWithPorts, error) {
var awp AliasWithPorts
// AliasWithPorts.UnmarshalJSON expects a quoted JSON string, so wrap.
// [AliasWithPorts.UnmarshalJSON] expects a quoted JSON string, so wrap.
err := awp.UnmarshalJSON([]byte(`"` + dst + `"`))
if err != nil {
return nil, err
@@ -425,9 +425,9 @@ func srcReachesDst(src netip.Prefix, dstPrefixes []netip.Prefix, ports []tailcfg
}
// ruleMatchesSource reports whether the rule's source list contains src.
// SrcIPs may be CIDR, single addresses, IP ranges (`a-b`), or `*`; we use
// util.ParseIPSet to cover all of those uniformly. Unparseable entries
// are skipped (the rule compiler emits well-formed strings, so this is
// [tailcfg.FilterRule.SrcIPs] may be CIDR, single addresses, IP ranges (`a-b`),
// or `*`; we use [util.ParseIPSet] to cover all of those uniformly. Unparseable
// entries are skipped (the rule compiler emits well-formed strings, so this is
// defence-in-depth, not error handling).
func ruleMatchesSource(rule tailcfg.FilterRule, src netip.Prefix) bool {
for _, raw := range rule.SrcIPs {
@@ -445,9 +445,10 @@ func ruleMatchesSource(rule tailcfg.FilterRule, src netip.Prefix) bool {
}
// ruleMatchesProto reports whether the rule permits any of requestedProtos.
// An unset rule.IPProto means "any protocol" and matches everything.
// requestedProtos is the per-test protocol set: a single proto for an
// explicit test.Proto, or the default set when test.Proto is empty.
// An unset [tailcfg.FilterRule.IPProto] means "any protocol" and matches
// everything. requestedProtos is the per-test protocol set: a single proto
// for an explicit [PolicyTest.Proto], or the default set when
// [PolicyTest.Proto] is empty.
func ruleMatchesProto(rule tailcfg.FilterRule, requestedProtos []int) bool {
if len(rule.IPProto) == 0 {
return true
@@ -479,7 +480,7 @@ func ruleAllowsAnyDest(rule tailcfg.FilterRule, dstPrefixes []netip.Prefix, port
return false
}
// destEntryMatchesPrefixes reports whether the rule's NetPortRange.IP
// destEntryMatchesPrefixes reports whether the rule's [tailcfg.NetPortRange.IP]
// (CIDR, single IP, IP range, or "*") covers any prefix in dstPrefixes.
func destEntryMatchesPrefixes(dp tailcfg.NetPortRange, dstPrefixes []netip.Prefix) bool {
set, err := util.ParseIPSet(dp.IP, nil)

View File

@@ -31,8 +31,8 @@ var (
//
// Brackets are only accepted around IPv6 addresses, not IPv4, hostnames, or other alias types.
// Bracket stripping reduces both forms to bare "addr:port" or "addr/prefix:port",
// which the normal LastIndex(":") split handles correctly because port strings
// never contain colons.
// which the normal [strings.LastIndex] of ":" split handles correctly because
// port strings never contain colons.
func splitDestinationAndPort(input string) (string, string, error) {
// Handle RFC 3986 bracketed IPv6 (e.g. "[::1]:80" or "[fd7a::1]/128:80,443").
// Strip brackets after validation and fall through to normal parsing.
@@ -82,7 +82,7 @@ func splitDestinationAndPort(input string) (string, string, error) {
return destination, port, nil
}
// parsePortRange parses a port definition string and returns a slice of PortRange structs.
// parsePortRange parses a port definition string and returns a slice of [tailcfg.PortRange] structs.
func parsePortRange(portDef string) ([]tailcfg.PortRange, error) {
if portDef == "*" {
return []tailcfg.PortRange{tailcfg.PortRangeAny}, nil

View File

@@ -115,7 +115,7 @@ func (m *mapSession) serve() {
// This is the mechanism where the node gives us information about its
// current configuration.
//
// Process the MapRequest to update node state (endpoints, hostinfo, etc.)
// Process the [tailcfg.MapRequest] to update node state (endpoints, hostinfo, etc.)
c, err := m.h.state.UpdateNodeFromMapRequest(m.node.ID, m.req)
if err != nil {
httpError(m.w, err)
@@ -148,9 +148,9 @@ func (m *mapSession) serveLongPoll() {
m.log.Trace().Caller().Msg("long poll session started")
// connectGen is set by Connect() below and captured by the deferred cleanup closure.
// It allows Disconnect() to reject stale calls from old sessions — if a newer session
// has called Connect() (incrementing the generation), the old session's Disconnect()
// connectGen is set by [state.State.Connect] below and captured by the deferred cleanup closure.
// It allows [state.State.Disconnect] to reject stale calls from old sessions — if a newer session
// has called [state.State.Connect] (incrementing the generation), the old session's [state.State.Disconnect]
// sees a mismatched generation and becomes a no-op.
var connectGen uint64
@@ -169,7 +169,7 @@ func (m *mapSession) serveLongPoll() {
// When a node disconnects, it might rapidly reconnect (e.g. mobile clients, network weather).
// Instead of immediately marking the node as offline, we wait a few seconds to see if it reconnects.
// If it does reconnect, the existing mapSession will be replaced and the node remains online.
// If it does reconnect, the existing [mapSession] will be replaced and the node remains online.
// If it doesn't reconnect within the timeout, we mark it as offline.
//
// This avoids flapping nodes in the UI and unnecessary churn in the network.
@@ -190,8 +190,8 @@ func (m *mapSession) serveLongPoll() {
}
if disconnected {
// Pass the generation from our Connect() call. If a newer session has
// connected since (bumping the generation), Disconnect() will detect
// Pass the generation from our [state.State.Connect] call. If a newer session has
// connected since (bumping the generation), [state.State.Disconnect] will detect
// the mismatch and skip the state update, preventing the race where
// an old grace period goroutine overwrites a newer session's online status.
disconnectChanges, err := m.h.state.Disconnect(m.node.ID, connectGen)
@@ -214,12 +214,12 @@ func (m *mapSession) serveLongPoll() {
m.keepAliveTicker = time.NewTicker(m.keepAlive)
// Process the initial MapRequest to update node state (endpoints, hostinfo, etc.)
// This must be done BEFORE calling Connect() to ensure routes are properly synchronized.
// When nodes reconnect, they send their hostinfo with announced routes in the MapRequest.
// We need this data in NodeStore before Connect() sets up the primary routes, because
// SubnetRoutes() calculates the intersection of announced and approved routes. If we
// call Connect() first, SubnetRoutes() returns empty (no announced routes yet), causing
// Process the initial [tailcfg.MapRequest] to update node state (endpoints, hostinfo, etc.)
// This must be done BEFORE calling [state.State.Connect] to ensure routes are properly synchronized.
// When nodes reconnect, they send their hostinfo with announced routes in the [tailcfg.MapRequest].
// We need this data in [state.NodeStore] before [state.State.Connect] sets up the primary routes, because
// [types.NodeView.SubnetRoutes] calculates the intersection of announced and approved routes. If we
// call [state.State.Connect] first, [types.NodeView.SubnetRoutes] returns empty (no announced routes yet), causing
// the node to be incorrectly removed from AvailableRoutes.
mapReqChange, err := m.h.state.UpdateNodeFromMapRequest(m.node.ID, m.req)
if err != nil {
@@ -229,8 +229,8 @@ func (m *mapSession) serveLongPoll() {
// Connect the node after its state has been updated.
// We send two separate change notifications because these are distinct operations:
// 1. UpdateNodeFromMapRequest: processes the client's reported state (routes, endpoints, hostinfo)
// 2. Connect: marks the node online and recalculates primary routes based on the updated state
// 1. [state.State.UpdateNodeFromMapRequest]: processes the client's reported state (routes, endpoints, hostinfo)
// 2. [state.State.Connect]: marks the node online and recalculates primary routes based on the updated state
// While this results in two notifications, it ensures route data is synchronized before
// primary route selection occurs, which is critical for proper HA subnet router failover.
var connectChanges []change.Change
@@ -308,8 +308,8 @@ func (m *mapSession) serveLongPoll() {
// writeMap writes the map response to the client.
// It handles compression if requested and any headers that need to be set.
// It also handles flushing the response if the ResponseWriter
// implements http.Flusher.
// It also handles flushing the response if the [http.ResponseWriter]
// implements [http.Flusher].
func (m *mapSession) writeMap(msg *tailcfg.MapResponse) error {
jsonBody, err := json.Marshal(msg)
if err != nil {

View File

@@ -100,7 +100,7 @@ func (w *delayedSuccessResponseWriter) WriteCount() int {
// 3. While that write is blocked, queue enough updates to fill the buffered
// channel and make the next batcher send hit the stale-send timeout.
// 4. That stale-send path removes the session from the batcher, so without an
// explicit teardown hook the old serveLongPoll goroutine would stay alive
// explicit teardown hook the old [mapSession.serveLongPoll] goroutine would stay alive
// but stop receiving future updates.
// 5. Release the blocked write and verify the batcher-side stop signal makes
// that stale session exit instead of lingering as an orphaned goroutine.

View File

@@ -20,7 +20,7 @@ var proxyHeaders = [...]string{headerTrueClientIP, headerXRealIP, headerXForward
// trustedProxyRealIP rewrites r.RemoteAddr from proxy headers when the
// peer is in trusted; for any other peer the headers are stripped so a
// downstream handler cannot read a spoofed value. X-Forwarded-For uses
// RightmostTrustedRangeStrategy so prepending a value cannot win in a
// [realclientip.RightmostTrustedRangeStrategy] so prepending a value cannot win in a
// proxy chain.
func trustedProxyRealIP(trusted []netip.Prefix) (func(http.Handler) http.Handler, error) {
ranges := make([]net.IPNet, 0, len(trusted))

View File

@@ -96,7 +96,7 @@ func AssertPeerGone(tb testing.TB, observer *TestClient, peerName string) {
}
// AssertPeerHasAllowedIPs checks that a peer has the expected
// AllowedIPs prefixes.
// [tailcfg.Node.AllowedIPs] prefixes.
func AssertPeerHasAllowedIPs(tb testing.TB, observer *TestClient, peerName string, want []netip.Prefix) {
tb.Helper()
@@ -211,7 +211,7 @@ func AssertSelfHasAddresses(tb testing.TB, client *TestClient) {
}
}
// EventuallyAssertMeshComplete retries AssertMeshComplete up to
// EventuallyAssertMeshComplete retries [AssertMeshComplete] up to
// timeout, useful when waiting for state to propagate.
func EventuallyAssertMeshComplete(tb testing.TB, clients []*TestClient, timeout time.Duration) {
tb.Helper()

View File

@@ -19,8 +19,8 @@ import (
"tailscale.com/util/eventbus"
)
// TestClient wraps a Tailscale controlclient.Direct connected to a
// TestServer. It tracks all received NetworkMap updates, providing
// TestClient wraps a Tailscale [controlclient.Direct] connected to a
// [TestServer]. It tracks all received [netmap.NetworkMap] updates, providing
// helpers to wait for convergence and inspect the client's view of
// the network.
type TestClient struct {
@@ -37,13 +37,13 @@ type TestClient struct {
pollCancel context.CancelFunc
pollDone chan struct{}
// Accumulated state from MapResponse callbacks.
// Accumulated state from [tailcfg.MapResponse] callbacks.
mu sync.RWMutex
netmap *netmap.NetworkMap
history []*netmap.NetworkMap
// updates is a buffered channel that receives a signal
// each time a new NetworkMap arrives.
// each time a new [netmap.NetworkMap] arrives.
updates chan *netmap.NetworkMap
bus *eventbus.Bus
@@ -51,7 +51,7 @@ type TestClient struct {
tracker *health.Tracker
}
// ClientOption configures a TestClient.
// ClientOption configures a [TestClient].
type ClientOption func(*clientConfig)
type clientConfig struct {
@@ -66,7 +66,7 @@ func WithEphemeral() ClientOption {
return func(c *clientConfig) { c.ephemeral = true }
}
// WithHostname sets the client's hostname in Hostinfo.
// WithHostname sets the client's hostname in [tailcfg.Hostinfo].
func WithHostname(name string) ClientOption {
return func(c *clientConfig) { c.hostname = name }
}
@@ -82,7 +82,7 @@ func WithUser(user *types.User) ClientOption {
return func(c *clientConfig) { c.user = user }
}
// NewClient creates a TestClient, registers it with the TestServer
// NewClient creates a [TestClient], registers it with the [TestServer]
// using a pre-auth key, and starts long-polling for map updates.
func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOption) *TestClient {
tb.Helper()
@@ -171,7 +171,7 @@ func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOpt
return tc
}
// register performs the initial TryLogin to register the client.
// register performs the initial [controlclient.Direct.TryLogin] to register the client.
func (c *TestClient) register(tb testing.TB) {
tb.Helper()
@@ -188,7 +188,7 @@ func (c *TestClient) register(tb testing.TB) {
}
}
// startPoll begins the long-poll MapRequest loop.
// startPoll begins the long-poll [tailcfg.MapRequest] loop.
func (c *TestClient) startPoll(tb testing.TB) {
tb.Helper()
@@ -197,14 +197,14 @@ func (c *TestClient) startPoll(tb testing.TB) {
go func() {
defer close(c.pollDone)
// PollNetMap blocks until ctx is cancelled or the server closes
// [controlclient.Direct.PollNetMap] blocks until ctx is cancelled or the server closes
// the connection.
_ = c.direct.PollNetMap(c.pollCtx, c)
}()
}
// UpdateFullNetmap implements controlclient.NetmapUpdater.
// Called by controlclient.Direct when a new NetworkMap is received.
// UpdateFullNetmap implements [controlclient.NetmapUpdater].
// Called by [controlclient.Direct] when a new [netmap.NetworkMap] is received.
func (c *TestClient) UpdateFullNetmap(nm *netmap.NetworkMap) {
c.mu.Lock()
c.netmap = nm
@@ -259,7 +259,7 @@ func (c *TestClient) Disconnect(tb testing.TB) {
}
// Reconnect registers and starts a new long-poll session.
// Call Disconnect first, or this will disconnect automatically.
// Call [TestClient.Disconnect] first, or this will disconnect automatically.
func (c *TestClient) Reconnect(tb testing.TB) {
tb.Helper()
@@ -274,7 +274,7 @@ func (c *TestClient) Reconnect(tb testing.TB) {
}
}
// Clear stale netmap data so that callers like WaitForPeers
// Clear stale netmap data so that callers like [TestClient.WaitForPeers]
// actually wait for the new session's map instead of returning
// immediately based on the old session's cached state.
c.mu.Lock()
@@ -282,7 +282,7 @@ func (c *TestClient) Reconnect(tb testing.TB) {
c.mu.Unlock()
// Drain any pending updates from the old session so they
// don't satisfy a subsequent WaitForPeers/WaitForUpdate.
// don't satisfy a subsequent [TestClient.WaitForPeers]/[TestClient.WaitForUpdate].
for {
select {
case <-c.updates:
@@ -315,7 +315,7 @@ func (c *TestClient) ReconnectAfter(tb testing.TB, d time.Duration) {
// --- State accessors ---
// Netmap returns the latest NetworkMap, or nil if none received yet.
// Netmap returns the latest [netmap.NetworkMap], or nil if none received yet.
func (c *TestClient) Netmap() *netmap.NetworkMap {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -425,7 +425,7 @@ func (c *TestClient) UpdateCount() int {
return len(c.history)
}
// History returns a copy of all NetworkMap snapshots in order.
// History returns a copy of all [netmap.NetworkMap] snapshots in order.
func (c *TestClient) History() []*netmap.NetworkMap {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -495,13 +495,13 @@ func (c *TestClient) WaitForCondition(tb testing.TB, desc string, timeout time.D
}
}
// Direct returns the underlying controlclient.Direct for
// advanced operations like SetHostinfo or SendUpdate.
// Direct returns the underlying [controlclient.Direct] for
// advanced operations like [controlclient.Direct.SetHostinfo] or SendUpdate.
func (c *TestClient) Direct() *controlclient.Direct {
return c.direct
}
// String implements fmt.Stringer for debug output.
// String implements [fmt.Stringer] for debug output.
func (c *TestClient) String() string {
nm := c.Netmap()
if nm == nil {

View File

@@ -15,16 +15,16 @@ import (
)
// TestConnectDisconnectRace targets the residual TOCTOU window in
// state.Disconnect: the connectGeneration check at state.go:644 is not
// atomic with the subsequent NodeStore.UpdateNode and
// primaryRoutes.SetRoutes calls. A new Connect that runs between the
// [state.State.Disconnect]: the connectGeneration check at state.go:644 is not
// atomic with the subsequent [state.NodeStore.UpdateNode] and
// primaryRoutes.SetRoutes calls. A new [state.State.Connect] that runs between the
// gen check and the mutations can have its effects overwritten by the
// stale Disconnect's SetRoutes(empty).
// stale [state.State.Disconnect]'s SetRoutes(empty).
//
// The poll.go grace-period flow protects against the most common case
// (RemoveNode + stillConnected). Connect/Disconnect on State directly
// ([state.State.RemoveNode] + stillConnected). Connect/Disconnect on [state.State] directly
// bypasses that protection and should still leave the state consistent
// — if it doesn't, that is the bug behind issue #3203.
// — if it doesn't, that is the bug behind the original race issue.
//
// Run with -race to also catch any data race exposed.
func TestConnectDisconnectRace(t *testing.T) {
@@ -33,15 +33,15 @@ func TestConnectDisconnectRace(t *testing.T) {
route := netip.MustParsePrefix("10.0.0.0/24")
// Use NewClient to get a node fully registered + Connected via the
// real noise/poll path. After this, NodeStore + primaryRoutes already
// have the node, and Connect has been called once.
// Use [servertest.NewClient] to get a node fully registered + Connected via the
// real noise/poll path. After this, [state.NodeStore] + primaryRoutes already
// have the node, and [state.State.Connect] has been called once.
//
// Only c2 advertises the route. PrimaryRoutes preserves a current
// Only c2 advertises the route. [tailcfg.NodeView.PrimaryRoutes] preserves a current
// primary across changes (anti-flap, see primary.go), so if both
// nodes were advertising, c1 (lower NodeID) would stay primary and
// the test could never observe the route slipping out of c2's
// PrimaryRoutes — it would never have been there in the first place.
// [tailcfg.NodeView.PrimaryRoutes] — it would never have been there in the first place.
c1 := servertest.NewClient(t, srv, "race-r1", servertest.WithUser(user))
c2 := servertest.NewClient(t, srv, "race-r2", servertest.WithUser(user))
@@ -65,19 +65,19 @@ func TestConnectDisconnectRace(t *testing.T) {
srv.App.Change(ch)
// Wait for advertisement + approval to be reflected as a primary
// route assignment in PrimaryRoutes; otherwise we'd be racing the
// initial steady-state setup, not the Connect/Disconnect window.
// route assignment in [tailcfg.NodeView.PrimaryRoutes]; otherwise we'd be racing the
// initial steady-state setup, not the [state.State.Connect]/[state.State.Disconnect] window.
require.Eventually(t, func() bool {
return slices.Contains(srv.State().GetNodePrimaryRoutes(r2ID), route)
}, 10*time.Second, 50*time.Millisecond,
"primary route should be assigned to r2 before driving the race")
// Drive the race repeatedly. Each iteration:
// 1. Call Connect(id) to obtain a fresh gen — this stands in for
// 1. Call [state.State.Connect](id) to obtain a fresh gen — this stands in for
// a session that "owns" the node.
// 2. Spawn a goroutine that issues Disconnect(id, gen) — the
// 2. Spawn a goroutine that issues [state.State.Disconnect](id, gen) — the
// stale deferred disconnect.
// 3. Concurrently spawn a goroutine that issues Connect(id) —
// 3. Concurrently spawn a goroutine that issues [state.State.Connect](id) —
// the new session arriving.
// 4. After both finish, check the state is consistent: the node
// should be online and primaryRoutes should hold the approved

View File

@@ -10,7 +10,7 @@ import (
"tailscale.com/types/netmap"
)
// TestContentVerification exercises the correctness of MapResponse
// TestContentVerification exercises the correctness of [tailcfg.MapResponse]
// content: that the self node, peers, DERP map, and other fields
// are populated correctly.
func TestContentVerification(t *testing.T) {

View File

@@ -23,7 +23,7 @@ import (
// channel is never closed (documented as a v3 TODO upstream).
// - https://github.com/hashicorp/golang-lru/blob/v2.0.7/expirable/expirable_lru.go#L78-L81
//
// 2. database/sql internal goroutines: Uses sync.RWMutex which is not
// 2. database/sql internal goroutines: Uses [sync.RWMutex] which is not
// durably blocking in synctest, causing hangs.
// - https://github.com/golang/go/issues/77687 (mutex as durably blocking)
//
@@ -77,7 +77,7 @@ func TestEphemeralNodes(t *testing.T) {
// Ensure the ephemeral node's long-poll session is fully
// established on the server before disconnecting. Without
// this, the Disconnect may cancel a PollNetMap that hasn't
// this, the [TestClient.Disconnect] may cancel a [controlclient.Direct.PollNetMap] that hasn't
// yet reached serveLongPoll, so no grace period or ephemeral
// GC would ever be scheduled.
ephemeral.WaitForPeers(t, 1, 10*time.Second)

View File

@@ -16,7 +16,7 @@ import (
// TestGrantPolicies verifies that grant-based policies propagate
// correctly through the full control plane (policy -> state -> mapper)
// and produce the expected packet filter rules in client netmaps.
// and produce the expected packet filter rules in client [netmap.NetworkMap]s.
func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
t.Parallel()
@@ -66,7 +66,7 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
return c2.UpdateCount() > countC2
})
// Verify PacketFilter is populated with real rules from the grant.
// Verify [netmap.NetworkMap.PacketFilter] is populated with real rules from the grant.
nm1 := c1.Netmap()
require.NotNil(t, nm1)
assert.NotNil(t, nm1.PacketFilter,
@@ -132,7 +132,7 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
srv.App.Change(changes...)
}
// Wait for PacketFilter with cap match rules to arrive.
// Wait for [netmap.NetworkMap.PacketFilter] with cap match rules to arrive.
c1.WaitForCondition(t, "packet filter with cap grants",
10*time.Second,
func(nm *netmap.NetworkMap) bool {
@@ -143,7 +143,7 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
nm1 := c1.Netmap()
require.NotNil(t, nm1)
// Check that the packet filter has CapMatch entries.
// Check that the packet filter has [filtertype.CapMatch] entries.
// The main grant produces cap/drive and cap/relay.
// Companion caps (drive-sharer and relay-target) are
// generated with reversed direction.
@@ -657,9 +657,9 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
}
// TestGrantViaSubnetFilterRules verifies that routers with via grants
// receive PacketFilter rules that allow the steered subnet traffic.
// receive [netmap.NetworkMap.PacketFilter] rules that allow the steered subnet traffic.
// This is a regression test: without per-node filter compilation for
// via grants, the router's PacketFilter would lack rules for the
// via grants, the router's [netmap.NetworkMap.PacketFilter] would lack rules for the
// via-steered subnet destinations, causing traffic to be dropped.
func TestGrantViaSubnetFilterRules(t *testing.T) {
t.Parallel()
@@ -748,7 +748,7 @@ func TestGrantViaSubnetFilterRules(t *testing.T) {
return false
})
// Critical: the router's PacketFilter MUST contain rules with
// Critical: the router's [netmap.NetworkMap.PacketFilter] MUST contain rules with
// the via-steered subnet (10.0.0.0/24) as a destination.
// Without this, the router drops traffic forwarded through it.
routerNM := routerA.Netmap()
@@ -1101,7 +1101,7 @@ func hasCapMatches(matches []filtertype.Match) bool {
return false
}
// hasDstRules returns true if any Match in the slice contains a
// hasDstRules returns true if any [filtertype.Match] in the slice contains a
// non-empty Dsts list.
func hasDstRules(matches []filtertype.Match) bool {
for _, m := range matches {

View File

@@ -13,13 +13,13 @@ import (
)
// Dynamic HA failover scenarios, observed from a viewer client's
// perspective. Unlike the static TestViaGrantHACompat golden tests,
// perspective. Unlike the static [TestViaGrantHACompat] golden tests,
// these exercise runtime transitions: a primary going unhealthy,
// revoking its approved route, or losing its tag, and verify that
// the viewer's netmap converges to the new primary. These are the
// the viewer's [netmap.NetworkMap] converges to the new primary. These are the
// end-to-end signals that static captures cannot cover.
// hasPeerPrimaryRoute reports whether the viewer's current netmap
// hasPeerPrimaryRoute reports whether the viewer's current [netmap.NetworkMap]
// lists route as a PrimaryRoute on the peer with the given hostname.
func hasPeerPrimaryRoute(nm *netmap.NetworkMap, peerHost string, route netip.Prefix) bool {
if nm == nil {
@@ -43,7 +43,7 @@ func hasPeerPrimaryRoute(nm *netmap.NetworkMap, peerHost string, route netip.Pre
}
// TestHAFailover_ViewerSeesPrimaryFlip verifies that when an HA
// primary is marked unhealthy, the viewer's netmap flips the route's
// primary is marked unhealthy, the viewer's [netmap.NetworkMap] flips the route's
// primary assignment from the old primary to the standby.
func TestHAFailover_ViewerSeesPrimaryFlip(t *testing.T) {
t.Parallel()
@@ -90,7 +90,7 @@ func TestHAFailover_ViewerSeesPrimaryFlip(t *testing.T) {
}
// TestHAFailover_ViewerSeesRouteRevoke verifies that when the primary
// revokes its approved route, the viewer's netmap re-elects the
// revokes its approved route, the viewer's [netmap.NetworkMap] re-elects the
// standby and the old primary no longer advertises the route.
func TestHAFailover_ViewerSeesRouteRevoke(t *testing.T) {
t.Parallel()

View File

@@ -15,7 +15,7 @@ import (
"tailscale.com/tailcfg"
)
// advertiseAndApproveRoute sets RoutableIPs on a client and approves
// advertiseAndApproveRoute sets [tailcfg.Hostinfo.RoutableIPs] on a client and approves
// the route on the server. Returns the node ID.
func advertiseAndApproveRoute(
t *testing.T,
@@ -87,7 +87,7 @@ func TestHAHealthProbe_HealthyNodes(t *testing.T) {
}
// TestHAHealthProbe_UnhealthyFailover verifies that marking a primary
// node unhealthy via the PrimaryRoutes API triggers failover to the
// node unhealthy via the [state.State.SetNodeUnhealthy] API triggers failover to the
// standby.
func TestHAHealthProbe_UnhealthyFailover(t *testing.T) {
t.Parallel()
@@ -176,7 +176,7 @@ func TestHAHealthProbe_ConnectClearsUnhealthy(t *testing.T) {
srv.State().SetNodeHealth(nodeID1, false)
assert.False(t, srv.State().IsNodeHealthy(nodeID1))
// Reconnect clears unhealthy via State.Connect → ClearUnhealthy.
// Reconnect clears unhealthy via [state.State.Connect][state.State.ClearUnhealthy].
c1.Disconnect(t)
c1.Reconnect(t)
@@ -190,7 +190,7 @@ func TestHAHealthProbe_ConnectClearsUnhealthy(t *testing.T) {
// that clearing a node's approved routes also clears any stale
// Unhealthy bit, mirroring the legacy routes.SetRoutes(empty)
// auto-clear. Without this, a probe timeout that lands just before
// SetApprovedRoutes would surface as a stale unhealthy node forever.
// [state.State.SetApprovedRoutes] would surface as a stale unhealthy node forever.
func TestHAHealthProbe_SetApprovedRoutesEmptyClearsUnhealthy(t *testing.T) {
t.Parallel()
@@ -223,7 +223,7 @@ func TestHAHealthProbe_SetApprovedRoutesEmptyClearsUnhealthy(t *testing.T) {
// HA candidate; carrying the bit forward leaks into DebugRoutes.
//
// The poll handler waits a 10s grace period before calling
// state.Disconnect, so the assertion is wrapped in Eventually with a
// [state.State.Disconnect], so the assertion is wrapped in Eventually with a
// generous timeout.
func TestHAHealthProbe_DisconnectClearsUnhealthy(t *testing.T) {
t.Parallel()
@@ -255,7 +255,7 @@ func TestHAHealthProbe_DisconnectClearsUnhealthy(t *testing.T) {
// TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp verifies the
// defensive guard for the still-online-but-no-routes case: a probe
// that fires after SetApprovedRoutes(empty) should not be allowed
// that fires after [state.State.SetApprovedRoutes](empty) should not be allowed
// to install a stale Unhealthy bit either.
func TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp(t *testing.T) {
t.Parallel()

View File

@@ -69,8 +69,8 @@ func (c *checkTB) runCleanups() {
c.cleanups = nil
c.mu.Unlock()
for i := len(cs) - 1; i >= 0; i-- {
cs[i]()
for _, v := range slices.Backward(cs) {
v()
}
}

View File

@@ -8,7 +8,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
)
// TestHarness orchestrates a TestServer with multiple TestClients,
// TestHarness orchestrates a [TestServer] with multiple [TestClient] instances,
// providing a convenient setup for multi-node control plane tests.
type TestHarness struct {
Server *TestServer
@@ -18,7 +18,7 @@ type TestHarness struct {
defaultUser *types.User
}
// HarnessOption configures a TestHarness.
// HarnessOption configures a [TestHarness].
type HarnessOption func(*harnessConfig)
type harnessConfig struct {
@@ -33,24 +33,24 @@ func defaultHarnessConfig() *harnessConfig {
}
}
// WithServerOptions passes ServerOptions through to the underlying
// TestServer.
// WithServerOptions passes [ServerOption] values through to the underlying
// [TestServer].
func WithServerOptions(opts ...ServerOption) HarnessOption {
return func(c *harnessConfig) { c.serverOpts = append(c.serverOpts, opts...) }
}
// WithDefaultClientOptions applies ClientOptions to every client
// created by NewHarness.
// WithDefaultClientOptions applies [ClientOption] values to every client
// created by [NewHarness].
func WithDefaultClientOptions(opts ...ClientOption) HarnessOption {
return func(c *harnessConfig) { c.clientOpts = append(c.clientOpts, opts...) }
}
// WithConvergenceTimeout sets how long WaitForMeshComplete waits.
// WithConvergenceTimeout sets how long [TestHarness.WaitForMeshComplete] waits.
func WithConvergenceTimeout(d time.Duration) HarnessOption {
return func(c *harnessConfig) { c.convergenceMax = d }
}
// NewHarness creates a TestServer and numClients connected clients.
// NewHarness creates a [TestServer] and numClients connected clients.
// All clients share a default user and are registered with reusable
// pre-auth keys. The harness waits for all clients to form a
// complete mesh before returning.
@@ -146,7 +146,7 @@ func (h *TestHarness) WaitForMeshComplete(tb testing.TB, timeout time.Duration)
}
// WaitForConvergence waits until all connected clients have a
// non-nil NetworkMap and their peer counts have stabilised.
// non-nil [netmap.NetworkMap] and their peer counts have stabilised.
func (h *TestHarness) WaitForConvergence(tb testing.TB, timeout time.Duration) {
tb.Helper()
h.WaitForMeshComplete(tb, timeout)

View File

@@ -19,14 +19,14 @@ import (
// These tests are intentionally strict about expected behavior.
// Failures surface real issues in the control plane.
// TestIssuesMapContent tests issues with MapResponse content correctness.
// TestIssuesMapContent tests issues with [tailcfg.MapResponse] content correctness.
func TestIssuesMapContent(t *testing.T) {
t.Parallel()
// After mesh formation, all peers should have a known Online status.
// The Online field is set when Connect() sends a NodeOnline PeerChange
// patch. The initial MapResponse (from auth handler) may have Online=nil
// because Connect() hasn't run yet, so we wait for the status to propagate.
// The Online field is set when [state.State.Connect] sends a NodeOnline [tailcfg.PeerChange]
// patch. The initial [tailcfg.MapResponse] (from auth handler) may have Online=nil
// because [state.State.Connect] hasn't run yet, so we wait for the status to propagate.
t.Run("initial_map_should_include_peer_online_status", func(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 3)
@@ -55,7 +55,7 @@ func TestIssuesMapContent(t *testing.T) {
t.Parallel()
h := servertest.NewHarness(t, 2)
// The DiscoKey is sent in the first MapRequest (not the RegisterRequest),
// The DiscoKey is sent in the first [tailcfg.MapRequest] (not the [tailcfg.RegisterRequest]),
// so it may take an extra map update to propagate to peers. Wait for
// the condition rather than checking the initial netmap.
h.Client(0).WaitForCondition(t, "peer has non-zero DiscoKey",
@@ -92,7 +92,7 @@ func TestIssuesMapContent(t *testing.T) {
}
})
// Each peer should have a valid user profile in the netmap.
// Each peer should have a valid user profile in the [netmap.NetworkMap].
t.Run("all_peers_have_user_profiles", func(t *testing.T) {
t.Parallel()
@@ -174,7 +174,7 @@ func TestIssuesRoutes(t *testing.T) {
// Approving a route via API without the node announcing it must NOT
// make the route visible in AllowedIPs. Tailscale uses a strict
// advertise-then-approve model: routes are only distributed when the
// node advertises them (Hostinfo.RoutableIPs) AND they are approved.
// node advertises them ([tailcfg.Hostinfo.RoutableIPs]) AND they are approved.
// An approval without advertisement is a dormant pre-approval that
// activates once the node starts advertising.
t.Run("approved_route_without_announcement_not_distributed", func(t *testing.T) {
@@ -253,7 +253,7 @@ func TestIssuesRoutes(t *testing.T) {
})
})
// Hostinfo route advertisement should be stored on server.
// [tailcfg.Hostinfo] route advertisement should be stored on server.
t.Run("hostinfo_route_advertisement_stored_on_server", func(t *testing.T) {
t.Parallel()
@@ -491,7 +491,7 @@ func TestIssuesServerMutations(t *testing.T) {
assert.Len(t, c3.Peers(), 1)
})
// Hostinfo changes should propagate to peers.
// [tailcfg.Hostinfo] changes should propagate to peers.
t.Run("hostinfo_changes_propagate_to_peers", func(t *testing.T) {
t.Parallel()
@@ -530,11 +530,11 @@ func TestIssuesServerMutations(t *testing.T) {
})
}
// TestIssuesNodeStoreConsistency tests NodeStore + DB consistency.
// TestIssuesNodeStoreConsistency tests [state.NodeStore] + DB consistency.
func TestIssuesNodeStoreConsistency(t *testing.T) {
t.Parallel()
// NodeStore and DB should agree after mutations.
// [state.NodeStore] and DB should agree after mutations.
t.Run("nodestore_db_consistency_after_operations", func(t *testing.T) {
t.Parallel()
@@ -569,7 +569,7 @@ func TestIssuesNodeStoreConsistency(t *testing.T) {
"NodeStore and DB should agree on approved routes")
})
// After rapid reconnect, NodeStore should reflect correct state.
// After rapid reconnect, [state.NodeStore] should reflect correct state.
t.Run("nodestore_correct_after_rapid_reconnect", func(t *testing.T) {
t.Parallel()
@@ -673,7 +673,7 @@ func TestIssuesGracePeriod(t *testing.T) {
// Ensure the ephemeral node's long-poll session is fully
// established on the server before disconnecting. Without
// this, the Disconnect may cancel a PollNetMap that hasn't
// this, the [TestClient.Disconnect] may cancel a [controlclient.Direct.PollNetMap] that hasn't
// yet reached serveLongPoll, so no grace period or ephemeral
// GC would ever be scheduled.
ephemeral.WaitForPeers(t, 1, 10*time.Second)

View File

@@ -15,7 +15,7 @@ import (
)
// TestPingNode verifies the full ping round-trip: the server sends a
// PingRequest via MapResponse, the real controlclient.Direct handles it
// [tailcfg.PingRequest] via [tailcfg.MapResponse], the real [controlclient.Direct] handles it
// by making a HEAD request back over Noise, and the ping tracker records
// the latency.
func TestPingNode(t *testing.T) {
@@ -105,7 +105,7 @@ func TestPingTwoSameNode(t *testing.T) {
require.NotEqual(t, pingID1, pingID2)
// Send both PingRequests.
// Send both [tailcfg.PingRequest]s.
url1 := h.Server.URL + "/machine/ping-response?id=" + pingID1
url2 := h.Server.URL + "/machine/ping-response?id=" + pingID2
@@ -136,7 +136,7 @@ func TestPingTwoSameNode(t *testing.T) {
}
}
// TestPingResolveByHostname verifies that ResolveNode can find a node
// TestPingResolveByHostname verifies that [state.State.ResolveNode] can find a node
// by hostname and that the resolved node can be pinged.
func TestPingResolveByHostname(t *testing.T) {
t.Parallel()

View File

@@ -156,9 +156,9 @@ func TestPolicyChanges(t *testing.T) {
// (Prefix, Host) resolve to exactly the literal prefix and do NOT expand
// to include the matching node's other IP addresses.
//
// PacketFilter rules are INBOUND: they tell the destination node what
// [netmap.NetworkMap.PacketFilter] rules are INBOUND: they tell the destination node what
// traffic to accept. So the IPv6 destination rule appears in test2's
// PacketFilter (the destination), not test1's (the source).
// [netmap.NetworkMap.PacketFilter] (the destination), not test1's (the source).
func TestIPv6OnlyPrefixACL(t *testing.T) {
t.Parallel()
@@ -193,7 +193,7 @@ func TestIPv6OnlyPrefixACL(t *testing.T) {
c1.WaitForPeers(t, 1, 10*time.Second)
c2.WaitForPeers(t, 1, 10*time.Second)
// PacketFilter is an INBOUND filter: test2 (the destination) should
// [netmap.NetworkMap.PacketFilter] is an INBOUND filter: test2 (the destination) should
// have the rule allowing traffic FROM test1's IPv6.
nm2 := c2.Netmap()
require.NotNil(t, nm2)

View File

@@ -13,7 +13,7 @@ import (
)
// TestPollRace targets logical race conditions specifically in the
// poll.go session lifecycle and the batcher's handling of concurrent
// poll.go session lifecycle and the [mapper.Batcher]'s handling of concurrent
// sessions for the same node.
func TestPollRace(t *testing.T) {
@@ -22,9 +22,9 @@ func TestPollRace(t *testing.T) {
// The core race: when a node disconnects, poll.go starts a
// grace period goroutine (10s ticker loop). If the node
// reconnects during this period, the new session calls
// Connect() to mark the node online. But the old grace period
// goroutine is still running and may call Disconnect() AFTER
// the new Connect(), setting IsOnline=false incorrectly.
// [state.State.Connect] to mark the node online. But the old grace period
// goroutine is still running and may call [state.State.Disconnect] AFTER
// the new [state.State.Connect], setting IsOnline=false incorrectly.
//
// This test verifies the exact symptom: after reconnect within
// the grace period, the server-side node state should be online.
@@ -99,7 +99,7 @@ func TestPollRace(t *testing.T) {
// Wait the full grace period (10s) after reconnect. The old
// grace period goroutine should have checked IsConnected
// and found the node connected, so should NOT have called
// Disconnect().
// [state.State.Disconnect].
t.Run("server_state_online_12s_after_reconnect", func(t *testing.T) {
t.Parallel()
@@ -195,8 +195,8 @@ func TestPollRace(t *testing.T) {
}
})
// The batcher's IsConnected check: when the grace period
// goroutine calls IsConnected(), it should return true if
// The [mapper.Batcher]'s IsConnected check: when the grace period
// goroutine calls IsConnected, it should return true if
// a new session has been added for the same node.
t.Run("batcher_knows_reconnected_during_grace", func(t *testing.T) {
t.Parallel()

View File

@@ -19,7 +19,7 @@ import (
// TestRace contains tests designed to trigger race conditions in
// the control plane. Run with -race to detect data races.
// These tests stress concurrent access patterns in poll.go,
// the batcher, the NodeStore, and the mapper.
// the [mapper.Batcher], the [state.NodeStore], and the [mapper] subsystem.
// TestRacePollSessionReplacement tests the race between an old
// poll session's deferred cleanup and a new session starting.
@@ -28,8 +28,8 @@ func TestRacePollSessionReplacement(t *testing.T) {
// Rapidly replace the poll session by doing immediate
// disconnect+reconnect. This races the old session's
// deferred cleanup (RemoveNode, Disconnect, grace period
// goroutine) with the new session's setup (AddNode, Connect,
// deferred cleanup ([state.NodeStore.RemoveNode], [state.State.Disconnect], grace period
// goroutine) with the new session's setup ([state.NodeStore.AddNode], [state.State.Connect],
// initial map send).
t.Run("immediate_session_replace_10x", func(t *testing.T) {
t.Parallel()
@@ -393,13 +393,13 @@ func TestRaceConnectDuringGracePeriod(t *testing.T) {
})
}
// TestRaceBatcherContention tests race conditions in the batcher
// TestRaceBatcherContention tests race conditions in the [mapper.Batcher]
// when many changes arrive simultaneously.
func TestRaceBatcherContention(t *testing.T) {
t.Parallel()
// Many nodes connecting at the same time generates many
// concurrent Change() calls. The batcher must handle this
// concurrent [hscontrol.Headscale.Change] calls. The [mapper.Batcher] must handle this
// without dropping updates or panicking.
t.Run("many_simultaneous_connects", func(t *testing.T) {
t.Parallel()
@@ -427,8 +427,8 @@ func TestRaceBatcherContention(t *testing.T) {
})
// Rapid connect + disconnect + connect of different nodes
// generates interleaved AddNode/RemoveNode/AddNode in the
// batcher.
// generates interleaved [state.NodeStore.AddNode]/[state.NodeStore.RemoveNode]/[state.NodeStore.AddNode] in the
// [mapper.Batcher].
t.Run("interleaved_add_remove_add", func(t *testing.T) {
t.Parallel()
@@ -514,7 +514,7 @@ func TestRaceBatcherContention(t *testing.T) {
}
// TestRaceMapResponseDuringDisconnect tests what happens when a
// map response is being written while the session is being torn down.
// [tailcfg.MapResponse] is being written while the session is being torn down.
func TestRaceMapResponseDuringDisconnect(t *testing.T) {
t.Parallel()
@@ -587,12 +587,12 @@ func TestRaceMapResponseDuringDisconnect(t *testing.T) {
})
}
// TestRaceNodeStoreContention tests concurrent access to the NodeStore.
// TestRaceNodeStoreContention tests concurrent access to the [state.NodeStore].
func TestRaceNodeStoreContention(t *testing.T) {
t.Parallel()
// Many GetNodeByID calls while nodes are connecting and
// disconnecting. This tests the NodeStore's read/write locking.
// Many [state.State.GetNodeByID] calls while nodes are connecting and
// disconnecting. This tests the [state.NodeStore]'s read/write locking.
t.Run("concurrent_reads_during_mutations", func(t *testing.T) {
t.Parallel()
@@ -655,7 +655,7 @@ func TestRaceNodeStoreContention(t *testing.T) {
}
})
// ListNodes while nodes are being added and removed.
// [state.State.ListNodes] while nodes are being added and removed.
t.Run("list_nodes_during_churn", func(t *testing.T) {
t.Parallel()

View File

@@ -1,6 +1,6 @@
// Package servertest provides an in-process test harness for Headscale's
// control plane. It wires a real Headscale server to real Tailscale
// controlclient.Direct instances, enabling fast, deterministic tests
// [controlclient.Direct] instances, enabling fast, deterministic tests
// of the full control protocol without Docker or separate processes.
package servertest
@@ -19,7 +19,7 @@ import (
)
// TestServer is an in-process Headscale control server suitable for
// use with Tailscale's controlclient.Direct.
// use with Tailscale's [controlclient.Direct].
//
// Networking uses tailscale.com/net/memnet so that all TCP
// connections stay in-process — no real sockets are opened.
@@ -33,7 +33,7 @@ type TestServer struct {
st *state.State
}
// ServerOption configures a TestServer.
// ServerOption configures a [TestServer].
type ServerOption func(*serverConfig)
type serverConfig struct {
@@ -201,15 +201,15 @@ func (s *TestServer) State() *state.State {
// Close shuts down the in-memory HTTP server and listener.
// Subsystem cleanup (batcher, ephemeral GC) is handled by
// tb.Cleanup callbacks registered in StartBatcherForTest and
// StartEphemeralGCForTest.
// [testing.TB.Cleanup] callbacks registered in [hscontrol.Headscale.StartBatcherForTest] and
// [hscontrol.Headscale.StartEphemeralGCForTest].
func (s *TestServer) Close() {
s.httpServer.Close()
s.ln.Close()
}
// MemNet returns the in-memory network used by this server,
// so that TestClient dialers can be wired to it.
// so that [TestClient] dialers can be wired to it.
func (s *TestServer) MemNet() *memnet.Network {
return s.memNet
}

View File

@@ -20,7 +20,7 @@ import (
// consistency bugs.
// TestStressConnectDisconnect exercises rapid connect/disconnect
// patterns that stress the grace period, batcher, and NodeStore.
// patterns that stress the grace period, batcher, and [state.NodeStore].
func TestStressConnectDisconnect(t *testing.T) {
t.Parallel()
@@ -536,7 +536,7 @@ func TestStressDataIntegrity(t *testing.T) {
}
})
// MachineKey should be consistent: the server should track
// [netmap.NetworkMap.MachineKey] should be consistent: the server should track
// the same machine key the client registered with.
t.Run("machine_key_consistent", func(t *testing.T) {
t.Parallel()
@@ -551,7 +551,7 @@ func TestStressDataIntegrity(t *testing.T) {
nm := c1.Netmap()
require.NotNil(t, nm)
// The client's MachineKey in the netmap should be non-zero.
// The client's [netmap.NetworkMap.MachineKey] should be non-zero.
assert.False(t, nm.MachineKey.IsZero(),
"client's MachineKey should be non-zero")
@@ -564,7 +564,7 @@ func TestStressDataIntegrity(t *testing.T) {
"client and server should agree on MachineKey")
})
// NodeKey should be consistent between client and server.
// [netmap.NetworkMap.NodeKey] should be consistent between client and server.
t.Run("node_key_consistent", func(t *testing.T) {
t.Parallel()

View File

@@ -38,7 +38,7 @@ var viaCompatTests = []struct {
}
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and
// compares headscale's MapResponse structure against the captured netmap.
// compares headscale's [tailcfg.MapResponse] structure against the captured [netmap.NetworkMap].
//
// The comparison is IP-independent: it validates peer visibility, route
// prefixes in AllowedIPs, and PrimaryRoutes — not literal Tailscale IP
@@ -128,7 +128,7 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
// Determine which routes each node should advertise. If the golden
// topology has explicit routable_ips, use those. Otherwise infer
// from the netmap peer AllowedIPs and packet filter dst prefixes.
// from the [netmap.NetworkMap] peer AllowedIPs and packet filter dst prefixes.
nodeRoutes := inferNodeRoutes(t, c)
// Build approved routes from topology. The topology's approved_routes
@@ -181,7 +181,7 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
srv.App.Change(routeChange)
}
// Wait for peers based on golden netmap expected counts.
// Wait for peers based on golden [netmap.NetworkMap] expected counts.
for viewerName, cl := range clients {
capture := c.Captures[viewerName]
if capture.Netmap == nil {
@@ -202,8 +202,8 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
}
}
// Ensure all nodes have received at least one MapResponse,
// including nodes with 0 expected peers that skipped WaitForPeers.
// Ensure all nodes have received at least one [tailcfg.MapResponse],
// including nodes with 0 expected peers that skipped [TestClient.WaitForPeers].
for name, cl := range clients {
cl.WaitForCondition(t, name+" initial netmap", 15*time.Second,
func(nm *netmap.NetworkMap) bool {
@@ -211,7 +211,7 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
})
}
// Compare each viewer's MapResponse against the golden netmap.
// Compare each viewer's [tailcfg.MapResponse] against the golden [netmap.NetworkMap].
for viewerName, cl := range clients {
capture := c.Captures[viewerName]
if capture.Netmap == nil {
@@ -227,8 +227,8 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
}
}
// compareNetmap compares the headscale MapResponse against the
// captured netmap data in an IP-independent way. It validates:
// compareNetmap compares the headscale [tailcfg.MapResponse] against the
// captured [netmap.NetworkMap] data in an IP-independent way. It validates:
// - Peer visibility (which peers are present, by hostname)
// - Route prefixes in AllowedIPs (non-Tailscale-IP entries like 10.44.0.0/16)
// - Number of Tailscale IPs per peer (should be 2: one v4 + one v6)
@@ -430,7 +430,7 @@ func compareNetmap(
}
// saasAddrsByPeer builds a map from SaaS Tailscale address to peer
// hostname using each capture's SelfNode.Addresses. Peers not in
// hostname using each capture's [tailcfg.NodeView.Addresses]. Peers not in
// clients are skipped.
func saasAddrsByPeer(
want testcapture.Node,
@@ -442,7 +442,7 @@ func saasAddrsByPeer(
return out
}
// Walk peers listed in this netmap.
// Walk peers listed in this [netmap.NetworkMap].
for _, peer := range want.Netmap.Peers {
name := extractHostname(peer.Name())
if _, isOurs := clients[name]; !isOurs {
@@ -457,7 +457,7 @@ func saasAddrsByPeer(
}
}
// The viewer's own SelfNode addresses also appear as possible src.
// The viewer's own [tailcfg.NodeView] addresses also appear as possible src.
if want.Netmap.SelfNode.Valid() {
name := extractHostname(want.Netmap.SelfNode.Name())
@@ -533,8 +533,8 @@ func canonicaliseSrcStrings(
}
// canonicaliseSrcPrefixes is the headscale-side counterpart of
// canonicaliseSrcStrings, reading already-parsed netip.Prefix values
// from tailcfg.Match.Srcs.
// [canonicaliseSrcStrings], reading already-parsed [netip.Prefix] values
// from [tailcfg.Match.Srcs].
func canonicaliseSrcPrefixes(
t *testing.T,
srcs []netip.Prefix,
@@ -627,7 +627,7 @@ type peerSummary struct {
PrimaryRoutes []string // sorted
}
// parsePrefixOrAddr parses a string as a netip.Prefix. If the string
// parsePrefixOrAddr parses a string as a [netip.Prefix]. If the string
// is a bare IP address (no slash), it is converted to a single-host
// prefix (/32 for IPv4, /128 for IPv6). Golden data DstPorts.IP can
// contain either form.
@@ -704,7 +704,7 @@ func countTailscaleIPsView(allowedIPs interface {
// inferNodeRoutes determines which routes each node should advertise.
// If the topology has explicit routable_ips, those are used. Otherwise
// routes are inferred from the netmap peer AllowedIPs and packet
// routes are inferred from the [netmap.NetworkMap] peer AllowedIPs and packet
// filter destination prefixes.
func inferNodeRoutes(t *testing.T, c *testcapture.Capture) map[string][]netip.Prefix {
t.Helper()
@@ -725,7 +725,7 @@ func inferNodeRoutes(t *testing.T, c *testcapture.Capture) map[string][]netip.Pr
}
}
// Tier 2: infer from each capture's netmap — scan peers with
// Tier 2: infer from each capture's [netmap.NetworkMap] — scan peers with
// route prefixes in AllowedIPs. If node X appears as a peer with
// route prefix 10.44.0.0/16, then X should advertise that route.
for _, node := range c.Captures {

View File

@@ -46,7 +46,7 @@ var viaHACompatTests = []struct {
// TestViaGrantHACompat loads golden captures from Tailscale SaaS that
// test via grant steering combined with HA primary route election.
// Each capture uses an inline topology with 4-6 nodes (instead of the
// shared 15-node grant topology used by TestViaGrantMapCompat).
// shared 15-node grant topology used by [TestViaGrantMapCompat]).
func TestViaGrantHACompat(t *testing.T) {
t.Parallel()
@@ -175,7 +175,7 @@ func runViaHACompat(t *testing.T, c *testcapture.Capture) {
}
}
// Ensure all nodes have an initial netmap.
// Ensure all nodes have an initial [netmap.NetworkMap].
for name, cl := range clients {
cl.WaitForCondition(t, name+" initial netmap", 15*time.Second,
func(nm *netmap.NetworkMap) bool {
@@ -183,7 +183,7 @@ func runViaHACompat(t *testing.T, c *testcapture.Capture) {
})
}
// Compare each viewer's MapResponse against golden netmap.
// Compare each viewer's [tailcfg.MapResponse] against golden [netmap.NetworkMap].
for viewerName, cl := range clients {
capture := c.Captures[viewerName]
if capture.Netmap == nil {
@@ -196,9 +196,9 @@ func runViaHACompat(t *testing.T, c *testcapture.Capture) {
}
}
// compareCaptureNetmap compares headscale's MapResponse against a
// testcapture.Node's netmap data. Same logic as compareNetmap but
// reads from typed testcapture fields instead of goldenFile strings.
// compareCaptureNetmap compares headscale's [tailcfg.MapResponse] against a
// [testcapture.Node]'s [netmap.NetworkMap] data. Same logic as [compareNetmap] but
// reads from typed [testcapture] fields instead of goldenFile strings.
func compareCaptureNetmap(
t *testing.T,
viewer *servertest.TestClient,
@@ -250,7 +250,7 @@ func compareCaptureNetmap(
}
}
// Build peer summaries from headscale MapResponse.
// Build peer summaries from headscale [tailcfg.MapResponse].
gotPeers := map[string]capturePeerSummary{}
for _, peer := range nm.Peers {
@@ -335,7 +335,7 @@ type capturePeerSummary struct {
PrimaryRoutes []string
}
// captureNodeOrder returns node names from a testcapture.Capture
// captureNodeOrder returns node names from a [testcapture.Capture]
// sorted by SaaS node creation time, for deterministic DB ID assignment.
// SaaS elects HA primaries by registration order (first registered wins),
// which correlates with Created timestamp, not with the random snowflake
@@ -377,7 +377,7 @@ func captureNodeOrder(t *testing.T, c *testcapture.Capture) []string {
return names
}
// convertCapturePolicy converts a testcapture's policy for headscale,
// convertCapturePolicy converts a [testcapture.Capture]'s policy for headscale,
// replacing SaaS emails with headscale user format. Fails the test if
// none of the known SaaS emails are present: that would mean the
// capture was regenerated with a new tag-owner identity and this

View File

@@ -150,7 +150,7 @@ func (s *State) DebugOverview() string {
return sb.String()
}
// DebugNodeStore returns debug information about the NodeStore.
// DebugNodeStore returns debug information about the [NodeStore].
func (s *State) DebugNodeStore() string {
return s.nodeStore.DebugString()
}
@@ -257,7 +257,7 @@ func (s *State) DebugFilter() ([]tailcfg.FilterRule, error) {
}
// DebugRoutes returns the current primary routes information as a
// structured object built from the NodeStore snapshot.
// structured object built from the [NodeStore] snapshot.
func (s *State) DebugRoutes() types.DebugRoutes {
debug := types.DebugRoutes{
AvailableRoutes: make(map[types.NodeID][]netip.Prefix),
@@ -421,7 +421,7 @@ func (s *State) DebugDERPJSON() DebugDERPInfo {
return info
}
// DebugNodeStoreJSON returns the actual nodes map from the current NodeStore snapshot.
// DebugNodeStoreJSON returns the actual nodes map from the current [NodeStore] snapshot.
func (s *State) DebugNodeStoreJSON() map[types.NodeID]types.Node {
snapshot := s.nodeStore.data.Load()
return snapshot.nodesByID

View File

@@ -29,7 +29,7 @@ type HAHealthProber struct {
lastStableSession *xsync.Map[types.NodeID, uint64]
}
// NewHAHealthProber creates a prober that uses the given State for
// NewHAHealthProber creates a prober that uses the given [State] for
// ping tracking and primary route management.
// isConnected should return true if a node has an active map session.
func NewHAHealthProber(

View File

@@ -1,5 +1,5 @@
// Package state provides pure functions for processing MapRequest data.
// These functions are extracted from UpdateNodeFromMapRequest to improve
// Package state provides pure functions for processing [tailcfg.MapRequest] data.
// These functions are extracted from [State.UpdateNodeFromMapRequest] to improve
// testability and maintainability.
package state
@@ -10,19 +10,19 @@ import (
"tailscale.com/tailcfg"
)
// netInfoFromMapRequest determines the correct NetInfo to use.
// Returns the NetInfo that should be used for this request.
// netInfoFromMapRequest determines the correct [tailcfg.NetInfo] to use.
// Returns the [tailcfg.NetInfo] that should be used for this request.
func netInfoFromMapRequest(
nodeID types.NodeID,
currentHostinfo *tailcfg.Hostinfo,
reqHostinfo *tailcfg.Hostinfo,
) *tailcfg.NetInfo {
// If request has NetInfo, use it
// If request has [tailcfg.NetInfo], use it
if reqHostinfo != nil && reqHostinfo.NetInfo != nil {
return reqHostinfo.NetInfo
}
// Otherwise, use current NetInfo if available
// Otherwise, use current [tailcfg.NetInfo] if available
if currentHostinfo != nil && currentHostinfo.NetInfo != nil {
log.Debug().
Caller().
@@ -33,7 +33,7 @@ func netInfoFromMapRequest(
return currentHostinfo.NetInfo
}
// No NetInfo available anywhere - log for debugging
// No [tailcfg.NetInfo] available anywhere - log for debugging
var hostname string
if reqHostinfo != nil {
hostname = reqHostinfo.Hostname

View File

@@ -75,7 +75,7 @@ func TestNetInfoPreservationInRegistrationFlow(t *testing.T) {
nodeID := types.NodeID(1)
// This test reproduces the bug in registration flows where NetInfo was lost
// because we used the wrong hostinfo reference when calling NetInfoFromMapRequest
// because we used the wrong hostinfo reference when calling [netInfoFromMapRequest]
t.Run("registration_flow_bug_reproduction", func(t *testing.T) {
// Simulate existing node with NetInfo (before re-registration)
existingNodeHostinfo := &tailcfg.Hostinfo{

View File

@@ -21,12 +21,12 @@ import (
)
// fallbackGivenName is the DNS label used when a node is written with
// an empty GivenName. Matches Tailscale SaaS behaviour for empty
// sanitised labels.
// an empty [types.Node.GivenName]. Matches Tailscale SaaS behaviour
// for empty sanitised labels.
const fallbackGivenName = "node"
// Errors returned by SetGivenName. ErrNodeNotFound is defined in
// state.go and reused here.
// Errors returned by [NodeStore.SetGivenName]. [ErrNodeNotFound] is defined
// in state.go and reused here.
var (
ErrGivenNameTaken = errors.New("given name already in use by another node")
ErrGivenNameInvalid = errors.New("given name is not a valid DNS label")
@@ -132,10 +132,10 @@ func NewNodeStore(allNodes types.Nodes, peersFunc PeersFunc, batchSize int, batc
return store
}
// Snapshot is the representation of the current state of the NodeStore.
// Snapshot is the representation of the current state of the [NodeStore].
// It contains all nodes and their relationships.
// It is a copy-on-write structure, meaning that when a write occurs,
// a new Snapshot is created with the updated state,
// a new [Snapshot] is created with the updated state,
// and replaces the old one atomically.
type Snapshot struct {
// nodesByID is the main source of truth for nodes.
@@ -161,7 +161,7 @@ type Snapshot struct {
// based on the current policy.
type PeersFunc func(nodes []types.NodeView) map[types.NodeID][]types.NodeView
// work represents a single operation to be performed on the NodeStore.
// work represents a single operation to be performed on the [NodeStore].
type work struct {
op int
nodeID types.NodeID
@@ -211,18 +211,19 @@ func (s *NodeStore) PutNode(n types.Node) types.NodeView {
return resultNode
}
// UpdateNodeFunc is a function type that takes a pointer to a Node and modifies it.
// UpdateNodeFunc is a function type that takes a pointer to a [types.Node] and modifies it.
type UpdateNodeFunc func(n *types.Node)
// UpdateNode applies a function to modify a specific node in the
// store. Single-node convenience wrapper around [NodeStore.UpdateNodes]
// — the writer goroutine signals completion only after the post-batch
// snapshot has been stored, so the follow-up GetNode read sees the
// applied update. Returns the resulting node and whether it exists.
// snapshot has been stored, so the follow-up [NodeStore.GetNode] read
// sees the applied update. Returns the resulting node and whether it
// exists.
//
// Callers that need to change several nodes atomically should call
// UpdateNodes directly; collecting changes into one batch keeps the
// election from running on a half-applied snapshot.
// [NodeStore.UpdateNodes] directly; collecting changes into one batch
// keeps the election from running on a half-applied snapshot.
func (s *NodeStore) UpdateNode(nodeID types.NodeID, updateFn UpdateNodeFunc) (types.NodeView, bool) {
timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("update"))
defer timer.ObserveDuration()
@@ -287,19 +288,20 @@ func (s *NodeStore) DeleteNode(id types.NodeID) {
nodeStoreOperations.WithLabelValues("delete").Inc()
}
// SetGivenName sets node.GivenName on the node identified by id,
// SetGivenName sets [types.Node.GivenName] on the node identified by id,
// rejecting the write if the name is already held by another node.
// Intended for the admin rename path, where auto-bumping a
// user-supplied name would be surprising.
//
// Returns:
// - the stored NodeView and nil on success
// - ErrGivenNameInvalid if name is not a valid DNS label
// - ErrGivenNameTaken if another node already holds name
// - ErrNodeNotFound if no node with id exists
// - the stored [types.NodeView] and nil on success
// - [ErrGivenNameInvalid] if name is not a valid DNS label
// - [ErrGivenNameTaken] if another node already holds name
// - [ErrNodeNotFound] if no node with id exists
//
// Runs as a single writer-goroutine op, so the uniqueness check and
// the write are atomic with respect to concurrent PutNode/UpdateNode.
// Runs as a single writer-goroutine op, so the uniqueness check and the
// write are atomic with respect to concurrent
// [NodeStore.PutNode]/[NodeStore.UpdateNode].
func (s *NodeStore) SetGivenName(id types.NodeID, name string) (types.NodeView, error) {
timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("set_name"))
defer timer.ObserveDuration()
@@ -330,13 +332,13 @@ func (s *NodeStore) SetGivenName(id types.NodeID, name string) (types.NodeView,
return <-w.nodeResult, nil
}
// Start initializes the NodeStore and starts processing the write queue.
// Start initializes the [NodeStore] and starts processing the write queue.
func (s *NodeStore) Start() {
s.writeQueue = make(chan work)
go s.processWrite()
}
// Stop stops the NodeStore.
// Stop stops the [NodeStore].
func (s *NodeStore) Stop() {
close(s.writeQueue)
}
@@ -536,14 +538,14 @@ func (s *NodeStore) applyBatch(batch []work) {
// resolveGivenName returns a unique DNS label for the node identified
// by self, based on the caller-supplied base label. If base is empty
// it falls back to fallbackGivenName ("node"). The label's own holder
// it falls back to [fallbackGivenName] ("node"). The label's own holder
// (self) is excluded from the collision scan so an idempotent write
// keeps the current label.
//
// On collision the label is bumped as base, base-1, base-2, …, first
// unused wins. Must be called from the NodeStore writer goroutine
// (inside applyBatch) so the nodes map reflects all earlier ops in
// the batch and no other writer can interleave.
// unused wins. Must be called from the [NodeStore] writer goroutine
// (inside [NodeStore.applyBatch]) so the nodes map reflects all earlier
// ops in the batch and no other writer can interleave.
func resolveGivenName(nodes map[types.NodeID]types.Node, self types.NodeID, base string) string {
if base == "" {
base = fallbackGivenName
@@ -569,7 +571,7 @@ func resolveGivenName(nodes map[types.NodeID]types.Node, self types.NodeID, base
}
// snapshotFromNodes builds the index maps and primary-route table for
// a new Snapshot. prevRoutes carries forward the previous primary
// a new [Snapshot]. prevRoutes carries forward the previous primary
// assignment so a still-valid choice survives unrelated batches.
func snapshotFromNodes(
nodes map[types.NodeID]types.Node,
@@ -719,8 +721,8 @@ func electPrimaryRoutes(
// GetNode retrieves a node by its ID.
// The bool indicates if the node exists or is available (like "err not found").
// The NodeView might be invalid, so it must be checked with .Valid(), which must be used to ensure
// it isn't an invalid node (this is more of a node error or node is broken).
// The [types.NodeView] might be invalid, so it must be checked with .Valid(), which must
// be used to ensure it isn't an invalid node (this is more of a node error or node is broken).
func (s *NodeStore) GetNode(id types.NodeID) (types.NodeView, bool) {
timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("get"))
defer timer.ObserveDuration()
@@ -735,10 +737,10 @@ func (s *NodeStore) GetNode(id types.NodeID) (types.NodeView, bool) {
return n.View(), true
}
// GetNodeByNodeKey retrieves a node by its NodeKey.
// GetNodeByNodeKey retrieves a node by its [key.NodePublic].
// The bool indicates if the node exists or is available (like "err not found").
// The NodeView might be invalid, so it must be checked with .Valid(), which must be used to ensure
// it isn't an invalid node (this is more of a node error or node is broken).
// The [types.NodeView] might be invalid, so it must be checked with .Valid(), which must
// be used to ensure it isn't an invalid node (this is more of a node error or node is broken).
func (s *NodeStore) GetNodeByNodeKey(nodeKey key.NodePublic) (types.NodeView, bool) {
timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("get_by_key"))
defer timer.ObserveDuration()
@@ -790,7 +792,7 @@ func (s *NodeStore) GetNodeByMachineKeyAnyUser(machineKey key.MachinePublic) (ty
return types.NodeView{}, false
}
// DebugString returns debug information about the NodeStore.
// DebugString returns debug information about the [NodeStore].
func (s *NodeStore) DebugString() string {
snapshot := s.data.Load()
@@ -971,8 +973,8 @@ func (s *NodeStore) PrimaryRoutesString() string {
return b.String()
}
// RebuildPeerMaps rebuilds the peer relationship map using the current peersFunc.
// This must be called after policy changes because peersFunc uses PolicyManager's
// RebuildPeerMaps rebuilds the peer relationship map using the current [PeersFunc].
// This must be called after policy changes because [PeersFunc] uses [policy.PolicyManager]'s
// filters to determine which nodes can see each other. Without rebuilding, the
// peer map would use stale filter data until the next node add/delete.
func (s *NodeStore) RebuildPeerMaps() {

View File

@@ -10,10 +10,10 @@ import (
const pingIDLength = 16
// pingTracker correlates outgoing PingRequests with incoming HEAD
// pingTracker correlates outgoing [tailcfg.PingRequest]s with incoming HEAD
// callbacks. Entries have no server-side TTL: callers are responsible
// for cleaning up via CancelPing or by reading from the response channel
// within their own timeout.
// for cleaning up via [pingTracker.cancel] or by reading from the response
// channel within their own timeout.
type pingTracker struct {
mu sync.Mutex
pending map[string]*pendingPing
@@ -80,7 +80,7 @@ func (pt *pingTracker) cancel(pingID string) {
}
// drain closes every outstanding response channel and clears the map.
// Called from State.Close to unblock any caller still waiting on a
// Called from [State.Close] to unblock any caller still waiting on a
// channel that will never receive.
func (pt *pingTracker) drain() {
pt.mu.Lock()
@@ -93,7 +93,7 @@ func (pt *pingTracker) drain() {
}
// RegisterPing tracks a pending ping and returns its ID and a channel
// for the latency. Callers must defer CancelPing or read the channel
// for the latency. Callers must defer [State.CancelPing] or read the channel
// within their own timeout; there is no server-side TTL.
func (s *State) RegisterPing(nodeID types.NodeID) (string, <-chan time.Duration) {
return s.pings.register(nodeID)

View File

@@ -20,12 +20,13 @@ var (
ErrRequestedTagsInvalidOrNotPermitted = errors.New("requested tags")
)
// ErrTaggedNodeHasUser is returned when a tagged node has a UserID set.
// ErrTaggedNodeHasUser is returned when a tagged node has a [types.Node.UserID] set.
var ErrTaggedNodeHasUser = errors.New("tagged node must not have user_id set")
// validateNodeOwnership ensures proper node ownership model.
// A node must be either user-owned or tagged, and these are mutually exclusive:
// tagged nodes must not have a UserID, and user-owned nodes must not have tags.
// tagged nodes must not have a [types.Node.UserID], and user-owned nodes must
// not have tags.
func validateNodeOwnership(node *types.Node) error {
if node.IsTagged() {
if len(node.Tags) == 0 {

View File

@@ -4,7 +4,7 @@ import (
"time"
)
// Test configuration for NodeStore batching.
// Test configuration for [NodeStore] batching.
// These values are optimized for test speed rather than production use.
const (
TestBatchSize = 5

View File

@@ -9,7 +9,7 @@ import (
// mdTypesetBody creates a body element with md-typeset styling
// that matches the official Headscale documentation design.
// Uses CSS classes with styles defined in assets.CSS.
// Uses CSS classes with styles defined in [assets.CSS].
func mdTypesetBody(children ...elem.Node) *elem.Element {
return elem.Body(attrs.Props{
attrs.Style: styles.Props{
@@ -34,7 +34,7 @@ func mdTypesetBody(children ...elem.Node) *elem.Element {
// Styled Element Wrappers
// These functions wrap elem-go elements using CSS classes.
// Styling is handled by the CSS in assets.CSS.
// Styling is handled by the CSS in [assets.CSS].
// H1 creates a H1 element styled by .md-typeset h1
func H1(children ...elem.Node) *elem.Element {
@@ -86,17 +86,17 @@ func PreCode(code string) *elem.Element {
return elem.Code(nil, elem.Text(code))
}
// Deprecated: use H1, H2, H3 instead
// Deprecated: use [H1], [H2], [H3] instead
func headerOne(text string) *elem.Element {
return H1(elem.Text(text))
}
// Deprecated: use H1, H2, H3 instead
// Deprecated: use [H1], [H2], [H3] instead
func headerTwo(text string) *elem.Element {
return H2(elem.Text(text))
}
// Deprecated: use H1, H2, H3 instead
// Deprecated: use [H1], [H2], [H3] instead
func headerThree(text string) *elem.Element {
return H3(elem.Text(text))
}

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)

View File

@@ -9,7 +9,7 @@ import (
"go4.org/netipx"
)
// This is borrowed from, and updated to use IPSet
// This is borrowed from, and updated to use [netipx.IPSet]
// https://github.com/tailscale/tailscale/blob/71029cea2ddf82007b80f465b256d027eab0f02d/wgengine/filter/tailcfg.go#L97-L162
// TODO(kradalby): contribute upstream and make public.
var (
@@ -24,7 +24,7 @@ var (
// - a CIDR (e.g. "192.168.0.0/16")
// - a range of two IPs, inclusive, separated by hyphen ("2eff::1-2eff::0800")
//
// bits, if non-nil, is the legacy SrcBits CIDR length to make a IP
// bits, if non-nil, is the legacy [tailcfg.FilterRule.SrcBits] CIDR length to make a IP
// address (without a slash) treated as a CIDR of *bits length.
// nolint
func ParseIPSet(arg string, bits *int) (*netipx.IPSet, error) {
@@ -114,7 +114,7 @@ func StringToIPPrefix(prefixes []string) ([]netip.Prefix, error) {
return result, nil
}
// IPSetAddrIter returns a function that iterates over all the IPs in the IPSet.
// IPSetAddrIter returns a function that iterates over all the IPs in the [netipx.IPSet].
func IPSetAddrIter(ipSet *netipx.IPSet) iter.Seq[netip.Addr] {
return func(yield func(netip.Addr) bool) {
for _, rng := range ipSet.Ranges() {

View File

@@ -23,7 +23,7 @@ const (
)
// DNS validation errors. Hostname-side validation lives on
// `tailscale.com/util/dnsname` and NodeStore collision handling; only
// `tailscale.com/util/dnsname` and [state.NodeStore] collision handling; only
// the username-side errors stay in this package.
var (
ErrUsernameTooShort = errors.New("username must be at least 2 characters long")
@@ -71,12 +71,12 @@ func ValidateUsername(username string) error {
return nil
}
// generateMagicDNSRootDomains generates a list of DNS entries to be included in `Routes` in `MapResponse`.
// generateMagicDNSRootDomains generates a list of DNS entries to be included in [tailcfg.DNSConfig.Routes] in [tailcfg.MapResponse].
// This list of reverse DNS entries instructs the OS on what subnets and domains the Tailscale embedded DNS
// server (listening in 100.100.100.100 udp/53) should be used for.
//
// Tailscale.com includes in the list:
// - the `BaseDomain` of the user
// - the [types.DNSConfig.BaseDomain] of the user
// - the reverse DNS entry for IPv6 (0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa., see below more on IPv6)
// - the reverse DNS entries for the IPv4 subnets covered by the user's `IPPrefix`.
// In the public SaaS this is [64-127].100.in-addr.arpa.
@@ -93,7 +93,7 @@ func ValidateUsername(username string) error {
// From the netmask we can find out the wildcard bits (the bits that are not set in the netmask).
// This allows us to then calculate the subnets included in the subsequent class block and generate the entries.
func GenerateIPv4DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN {
// Conversion to the std lib net.IPnet, a bit easier to operate
// Conversion to the std lib [net.IPNet], a bit easier to operate
netRange := netipx.PrefixIPNet(ipPrefix)
maskBits, _ := netRange.Mask.Size()
@@ -130,12 +130,12 @@ func GenerateIPv4DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN {
return fqdns
}
// generateMagicDNSRootDomains generates a list of DNS entries to be included in `Routes` in `MapResponse`.
// generateMagicDNSRootDomains generates a list of DNS entries to be included in [tailcfg.DNSConfig.Routes] in [tailcfg.MapResponse].
// This list of reverse DNS entries instructs the OS on what subnets and domains the Tailscale embedded DNS
// server (listening in 100.100.100.100 udp/53) should be used for.
//
// Tailscale.com includes in the list:
// - the `BaseDomain` of the user
// - the [types.DNSConfig.BaseDomain] of the user
// - the reverse DNS entry for IPv6 (0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa., see below more on IPv6)
// - the reverse DNS entries for the IPv4 subnets covered by the user's `IPPrefix`.
// In the public SaaS this is [64-127].100.in-addr.arpa.

View File

@@ -9,7 +9,6 @@ import (
"tailscale.com/util/must"
)
func TestMagicDNSRootDomains100(t *testing.T) {
domains := GenerateIPv4DNSRootDomain(netip.MustParsePrefix("100.64.0.0/10"))

View File

@@ -35,7 +35,7 @@ func MustStringsToPrefixes(strings []string) []netip.Prefix {
return ret
}
// TheInternet returns the IPSet for the Internet.
// TheInternet returns the [netipx.IPSet] for the Internet.
// https://www.youtube.com/watch?v=iDbyYGrswtg
var TheInternet = sync.OnceValue(func() *netipx.IPSet {
var internetBuilder netipx.IPSetBuilder

View File

@@ -94,7 +94,7 @@ type Traceroute struct {
Err error
}
// ParseTraceroute parses the output of the traceroute command and returns a Traceroute struct.
// ParseTraceroute parses the output of the traceroute command and returns a [Traceroute] struct.
func ParseTraceroute(output string) (Traceroute, error) {
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) < 1 {

View File

@@ -796,7 +796,6 @@ over a maximum of 30 hops:
}
}
func TestGenerateRegistrationKey(t *testing.T) {
t.Parallel()

View File

@@ -1,8 +1,8 @@
// Package zlog provides zerolog utilities for safe and consistent logging.
//
// This package contains:
// - Safe wrapper types for external types (tailcfg.Hostinfo, tailcfg.MapRequest)
// that implement LogObjectMarshaler with security-conscious field redaction
// - Safe wrapper types for external types ([tailcfg.Hostinfo], [tailcfg.MapRequest])
// that implement [zerolog.LogObjectMarshaler] with security-conscious field redaction
//
// For field name constants, use the zf subpackage:
//

Some files were not shown because too many files have changed in this diff Show More