diff --git a/.github/workflows/gh-action-integration-generator.go b/.github/workflows/gh-action-integration-generator.go index 3c1e25ca..5bb8318b 100644 --- a/.github/workflows/gh-action-integration-generator.go +++ b/.github/workflows/gh-action-integration-generator.go @@ -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 // diff --git a/cmd/hi/docker.go b/cmd/hi/docker.go index 03778bf7..1ed8709c 100644 --- a/cmd/hi/docker.go +++ b/cmd/hi/docker.go @@ -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 diff --git a/cmd/hi/doctor.go b/cmd/hi/doctor.go index e235cf09..9291674d 100644 --- a/cmd/hi/doctor.go +++ b/cmd/hi/doctor.go @@ -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 = "❓" diff --git a/cmd/hi/run.go b/cmd/hi/run.go index c297c6d8..30002208 100644 --- a/cmd/hi/run.go +++ b/cmd/hi/run.go @@ -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 diff --git a/cmd/hi/stats.go b/cmd/hi/stats.go index 8ab8b7b3..e8b8e28c 100644 --- a/cmd/hi/stats.go +++ b/cmd/hi/stats.go @@ -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 } diff --git a/cmd/vendorhash/main.go b/cmd/vendorhash/main.go index ca6e35fc..2a385c12 100644 --- a/cmd/vendorhash/main.go +++ b/cmd/vendorhash/main.go @@ -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 ") } -// 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") diff --git a/hscontrol/auth.go b/hscontrol/auth.go index 910a59e7..699872a8 100644 --- a/hscontrol/auth.go +++ b/hscontrol/auth.go @@ -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. diff --git a/hscontrol/auth_tags_test.go b/hscontrol/auth_tags_test.go index 42f12b16..048b6311 100644 --- a/hscontrol/auth_tags_test.go +++ b/hscontrol/auth_tags_test.go @@ -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 `®Req.Expiry` (pointer to time.Time{}) instead of nil, +// which assigned `®Req.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) { diff --git a/hscontrol/auth_test.go b/hscontrol/auth_test.go index 5e13a4d7..4eb028a3 100644 --- a/hscontrol/auth_test.go +++ b/hscontrol/auth_test.go @@ -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 --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)") diff --git a/hscontrol/capver/capver.go b/hscontrol/capver/capver.go index 61d67444..e145b935 100644 --- a/hscontrol/capver/capver.go +++ b/hscontrol/capver/capver.go @@ -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 diff --git a/hscontrol/db/api_key.go b/hscontrol/db/api_key.go index 9eeaf7e6..f53ad6f1 100644 --- a/hscontrol/db/api_key.go +++ b/hscontrol/db/api_key.go @@ -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 { diff --git a/hscontrol/db/ephemeral_garbage_collector_test.go b/hscontrol/db/ephemeral_garbage_collector_test.go index 10bb235a..2771172e 100644 --- a/hscontrol/db/ephemeral_garbage_collector_test.go +++ b/hscontrol/db/ephemeral_garbage_collector_test.go @@ -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() diff --git a/hscontrol/db/ip.go b/hscontrol/db/ip.go index 7402f473..b369df65 100644 --- a/hscontrol/db/ip.go +++ b/hscontrol/db/ip.go @@ -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) diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index 1b46ca61..7d93373c 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -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{ diff --git a/hscontrol/db/policy.go b/hscontrol/db/policy.go index 83bb4812..3c60534d 100644 --- a/hscontrol/db/policy.go +++ b/hscontrol/db/policy.go @@ -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 { diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index d2fb0265..00676726 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -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 diff --git a/hscontrol/db/users.go b/hscontrol/db/users.go index b8bb01af..3eee704c 100644 --- a/hscontrol/db/users.go +++ b/hscontrol/db/users.go @@ -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 diff --git a/hscontrol/db/versioncheck.go b/hscontrol/db/versioncheck.go index d78b00d3..5c028f43 100644 --- a/hscontrol/db/versioncheck.go +++ b/hscontrol/db/versioncheck.go @@ -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 { diff --git a/hscontrol/derp/derp.go b/hscontrol/derp/derp.go index 3dc06d07..6f929c4a 100644 --- a/hscontrol/derp/derp.go +++ b/hscontrol/derp/derp.go @@ -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, diff --git a/hscontrol/dns/extrarecords.go b/hscontrol/dns/extrarecords.go index b02a3c27..7096fafd 100644 --- a/hscontrol/dns/extrarecords.go +++ b/hscontrol/dns/extrarecords.go @@ -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) diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go index 953ae0d7..687fa857 100644 --- a/hscontrol/grpcv1.go +++ b/hscontrol/grpcv1.go @@ -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, diff --git a/hscontrol/grpcv1_test.go b/hscontrol/grpcv1_test.go index bd4289cb..1bb4cbe2 100644 --- a/hscontrol/grpcv1_test.go +++ b/hscontrol/grpcv1_test.go @@ -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()) diff --git a/hscontrol/handlers.go b/hscontrol/handlers.go index 88107903..750915ec 100644 --- a/hscontrol/handlers.go +++ b/hscontrol/handlers.go @@ -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, diff --git a/hscontrol/handlers_test.go b/hscontrol/handlers_test.go index 12ab7f96..c1a4a094 100644 --- a/hscontrol/handlers_test.go +++ b/hscontrol/handlers_test.go @@ -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 diff --git a/hscontrol/mapper/batcher.go b/hscontrol/mapper/batcher.go index ce569732..66b7b582 100644 --- a/hscontrol/mapper/batcher.go +++ b/hscontrol/mapper/batcher.go @@ -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 diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index 99d3185a..ee3653ae 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -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 diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go index 4f7a17fd..8e99467e 100644 --- a/hscontrol/mapper/mapper.go +++ b/hscontrol/mapper/mapper.go @@ -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, diff --git a/hscontrol/mapper/node_conn.go b/hscontrol/mapper/node_conn.go index faa893fa..63f105f5 100644 --- a/hscontrol/mapper/node_conn.go +++ b/hscontrol/mapper/node_conn.go @@ -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) { diff --git a/hscontrol/noise.go b/hscontrol/noise.go index 9ad9a274..603e5256 100644 --- a/hscontrol/noise.go +++ b/hscontrol/noise.go @@ -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( diff --git a/hscontrol/noise_test.go b/hscontrol/noise_test.go index cf97a216..6c427180 100644 --- a/hscontrol/noise_test.go +++ b/hscontrol/noise_test.go @@ -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") diff --git a/hscontrol/policy/matcher/matcher.go b/hscontrol/policy/matcher/matcher.go index 24f2b87a..cb7eabc7 100644 --- a/hscontrol/policy/matcher/matcher.go +++ b/hscontrol/policy/matcher/matcher.go @@ -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() { diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go index 641146d3..5709a3f5 100644 --- a/hscontrol/policy/pm.go +++ b/hscontrol/policy/pm.go @@ -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 diff --git a/hscontrol/policy/policy.go b/hscontrol/policy/policy.go index 53cc241e..1778b79f 100644 --- a/hscontrol/policy/policy.go +++ b/hscontrol/policy/policy.go @@ -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 diff --git a/hscontrol/policy/policyutil/doc.go b/hscontrol/policy/policyutil/doc.go index 902a43e4..73a20c90 100644 --- a/hscontrol/policy/policyutil/doc.go +++ b/hscontrol/policy/policyutil/doc.go @@ -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 diff --git a/hscontrol/policy/policyutil/reduce.go b/hscontrol/policy/policyutil/reduce.go index dcd5bee6..66ee2e67 100644 --- a/hscontrol/policy/policyutil/reduce.go +++ b/hscontrol/policy/policyutil/reduce.go @@ -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) diff --git a/hscontrol/policy/v2/compiled.go b/hscontrol/policy/v2/compiled.go index 89824ee1..6019baa6 100644 --- a/hscontrol/policy/v2/compiled.go +++ b/hscontrol/policy/v2/compiled.go @@ -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, diff --git a/hscontrol/policy/v2/policy_test.go b/hscontrol/policy/v2/policy_test.go index 74a24f27..bb90afc1 100644 --- a/hscontrol/policy/v2/policy_test.go +++ b/hscontrol/policy/v2/policy_test.go @@ -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() diff --git a/hscontrol/policy/v2/sshtest.go b/hscontrol/policy/v2/sshtest.go index f24ce5ff..d2b07a63 100644 --- a/hscontrol/policy/v2/sshtest.go +++ b/hscontrol/policy/v2/sshtest.go @@ -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. diff --git a/hscontrol/policy/v2/tailnet_state_caps.go b/hscontrol/policy/v2/tailnet_state_caps.go index cc3fdf8e..ddb2ac31 100644 --- a/hscontrol/policy/v2/tailnet_state_caps.go +++ b/hscontrol/policy/v2/tailnet_state_caps.go @@ -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 diff --git a/hscontrol/policy/v2/test.go b/hscontrol/policy/v2/test.go index 8e0f906a..5f1189f2 100644 --- a/hscontrol/policy/v2/test.go +++ b/hscontrol/policy/v2/test.go @@ -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) diff --git a/hscontrol/policy/v2/utils.go b/hscontrol/policy/v2/utils.go index 10641259..370ec43e 100644 --- a/hscontrol/policy/v2/utils.go +++ b/hscontrol/policy/v2/utils.go @@ -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 diff --git a/hscontrol/poll.go b/hscontrol/poll.go index bb98f6ac..fe02c11c 100644 --- a/hscontrol/poll.go +++ b/hscontrol/poll.go @@ -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 { diff --git a/hscontrol/poll_test.go b/hscontrol/poll_test.go index 4fa78f47..e294d5e7 100644 --- a/hscontrol/poll_test.go +++ b/hscontrol/poll_test.go @@ -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. diff --git a/hscontrol/realip.go b/hscontrol/realip.go index 2b363060..6cfd1a1a 100644 --- a/hscontrol/realip.go +++ b/hscontrol/realip.go @@ -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)) diff --git a/hscontrol/servertest/assertions.go b/hscontrol/servertest/assertions.go index 2dcfab3d..351659cb 100644 --- a/hscontrol/servertest/assertions.go +++ b/hscontrol/servertest/assertions.go @@ -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() diff --git a/hscontrol/servertest/client.go b/hscontrol/servertest/client.go index 3ff73cf3..d88e4d23 100644 --- a/hscontrol/servertest/client.go +++ b/hscontrol/servertest/client.go @@ -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 { diff --git a/hscontrol/servertest/connect_race_test.go b/hscontrol/servertest/connect_race_test.go index eebf8aa5..b9110ad9 100644 --- a/hscontrol/servertest/connect_race_test.go +++ b/hscontrol/servertest/connect_race_test.go @@ -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 diff --git a/hscontrol/servertest/content_test.go b/hscontrol/servertest/content_test.go index 144db00e..9faeb8e2 100644 --- a/hscontrol/servertest/content_test.go +++ b/hscontrol/servertest/content_test.go @@ -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) { diff --git a/hscontrol/servertest/ephemeral_test.go b/hscontrol/servertest/ephemeral_test.go index e65cd1d0..bccb3f34 100644 --- a/hscontrol/servertest/ephemeral_test.go +++ b/hscontrol/servertest/ephemeral_test.go @@ -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) diff --git a/hscontrol/servertest/grants_test.go b/hscontrol/servertest/grants_test.go index a1531083..63820bce 100644 --- a/hscontrol/servertest/grants_test.go +++ b/hscontrol/servertest/grants_test.go @@ -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 { diff --git a/hscontrol/servertest/ha_dynamic_test.go b/hscontrol/servertest/ha_dynamic_test.go index 1ceb505c..475b34dc 100644 --- a/hscontrol/servertest/ha_dynamic_test.go +++ b/hscontrol/servertest/ha_dynamic_test.go @@ -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() diff --git a/hscontrol/servertest/ha_health_test.go b/hscontrol/servertest/ha_health_test.go index c56eab63..c1904724 100644 --- a/hscontrol/servertest/ha_health_test.go +++ b/hscontrol/servertest/ha_health_test.go @@ -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() diff --git a/hscontrol/servertest/ha_property_test.go b/hscontrol/servertest/ha_property_test.go index 712a1cf0..565e36af 100644 --- a/hscontrol/servertest/ha_property_test.go +++ b/hscontrol/servertest/ha_property_test.go @@ -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() } } diff --git a/hscontrol/servertest/harness.go b/hscontrol/servertest/harness.go index 17706cbb..212c5412 100644 --- a/hscontrol/servertest/harness.go +++ b/hscontrol/servertest/harness.go @@ -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) diff --git a/hscontrol/servertest/issues_test.go b/hscontrol/servertest/issues_test.go index b687d512..4b06f080 100644 --- a/hscontrol/servertest/issues_test.go +++ b/hscontrol/servertest/issues_test.go @@ -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) diff --git a/hscontrol/servertest/ping_test.go b/hscontrol/servertest/ping_test.go index 3ab5dc04..eba4add6 100644 --- a/hscontrol/servertest/ping_test.go +++ b/hscontrol/servertest/ping_test.go @@ -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() diff --git a/hscontrol/servertest/policy_test.go b/hscontrol/servertest/policy_test.go index a6952bc7..5bc6bc4a 100644 --- a/hscontrol/servertest/policy_test.go +++ b/hscontrol/servertest/policy_test.go @@ -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) diff --git a/hscontrol/servertest/poll_race_test.go b/hscontrol/servertest/poll_race_test.go index 500bf3fb..aea59e5c 100644 --- a/hscontrol/servertest/poll_race_test.go +++ b/hscontrol/servertest/poll_race_test.go @@ -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() diff --git a/hscontrol/servertest/race_test.go b/hscontrol/servertest/race_test.go index 81f30f23..bf6e6f0e 100644 --- a/hscontrol/servertest/race_test.go +++ b/hscontrol/servertest/race_test.go @@ -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() diff --git a/hscontrol/servertest/server.go b/hscontrol/servertest/server.go index c50b1da4..7de2c401 100644 --- a/hscontrol/servertest/server.go +++ b/hscontrol/servertest/server.go @@ -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 } diff --git a/hscontrol/servertest/stress_test.go b/hscontrol/servertest/stress_test.go index ad22a3ac..e330aa3d 100644 --- a/hscontrol/servertest/stress_test.go +++ b/hscontrol/servertest/stress_test.go @@ -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() diff --git a/hscontrol/servertest/via_compat_test.go b/hscontrol/servertest/via_compat_test.go index b87f520f..859e5fd4 100644 --- a/hscontrol/servertest/via_compat_test.go +++ b/hscontrol/servertest/via_compat_test.go @@ -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 { diff --git a/hscontrol/servertest/via_ha_compat_test.go b/hscontrol/servertest/via_ha_compat_test.go index 9c652a1a..0d166500 100644 --- a/hscontrol/servertest/via_ha_compat_test.go +++ b/hscontrol/servertest/via_ha_compat_test.go @@ -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 diff --git a/hscontrol/state/debug.go b/hscontrol/state/debug.go index 623bb860..3fbd0902 100644 --- a/hscontrol/state/debug.go +++ b/hscontrol/state/debug.go @@ -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 diff --git a/hscontrol/state/ha_health.go b/hscontrol/state/ha_health.go index 6442902a..e6482b52 100644 --- a/hscontrol/state/ha_health.go +++ b/hscontrol/state/ha_health.go @@ -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( diff --git a/hscontrol/state/maprequest.go b/hscontrol/state/maprequest.go index d8cddaa1..e8571e0a 100644 --- a/hscontrol/state/maprequest.go +++ b/hscontrol/state/maprequest.go @@ -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 diff --git a/hscontrol/state/maprequest_test.go b/hscontrol/state/maprequest_test.go index 8a842e49..ef321701 100644 --- a/hscontrol/state/maprequest_test.go +++ b/hscontrol/state/maprequest_test.go @@ -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{ diff --git a/hscontrol/state/node_store.go b/hscontrol/state/node_store.go index 47f9f605..c27ec3c2 100644 --- a/hscontrol/state/node_store.go +++ b/hscontrol/state/node_store.go @@ -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() { diff --git a/hscontrol/state/ping.go b/hscontrol/state/ping.go index 43cf6914..9406e0e1 100644 --- a/hscontrol/state/ping.go +++ b/hscontrol/state/ping.go @@ -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) diff --git a/hscontrol/state/tags.go b/hscontrol/state/tags.go index fbc05d9b..9a7b2517 100644 --- a/hscontrol/state/tags.go +++ b/hscontrol/state/tags.go @@ -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 { diff --git a/hscontrol/state/test_helpers.go b/hscontrol/state/test_helpers.go index 95203106..c582b4db 100644 --- a/hscontrol/state/test_helpers.go +++ b/hscontrol/state/test_helpers.go @@ -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 diff --git a/hscontrol/templates/general.go b/hscontrol/templates/general.go index 392f974d..a9e1ac6e 100644 --- a/hscontrol/templates/general.go +++ b/hscontrol/templates/general.go @@ -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)) } diff --git a/hscontrol/types/api_key.go b/hscontrol/types/api_key.go index 2dac537d..f58a9f31 100644 --- a/hscontrol/types/api_key.go +++ b/hscontrol/types/api_key.go @@ -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) { diff --git a/hscontrol/types/change/change.go b/hscontrol/types/change/change.go index 0e38e498..abfcaee2 100644 --- a/hscontrol/types/change/change.go +++ b/hscontrol/types/change/change.go @@ -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" diff --git a/hscontrol/types/common.go b/hscontrol/types/common.go index b0558d07..ee94593a 100644 --- a/hscontrol/types/common.go +++ b/hscontrol/types/common.go @@ -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 } diff --git a/hscontrol/types/common_test.go b/hscontrol/types/common_test.go index 54028493..d381d872 100644 --- a/hscontrol/types/common_test.go +++ b/hscontrol/types/common_test.go @@ -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"}) diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index bb0a8918..8295cd05 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -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 { diff --git a/hscontrol/types/config_test.go b/hscontrol/types/config_test.go index 8943683a..07756b55 100644 --- a/hscontrol/types/config_test.go +++ b/hscontrol/types/config_test.go @@ -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 diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 63cdffbb..328a3b28 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -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 diff --git a/hscontrol/types/node_tags_test.go b/hscontrol/types/node_tags_test.go index 97e01b2a..a401d71d 100644 --- a/hscontrol/types/node_tags_test.go +++ b/hscontrol/types/node_tags_test.go @@ -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, diff --git a/hscontrol/types/node_test.go b/hscontrol/types/node_test.go index 89bd25ba..0bec4aef 100644 --- a/hscontrol/types/node_test.go +++ b/hscontrol/types/node_test.go @@ -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 { diff --git a/hscontrol/types/preauth_key.go b/hscontrol/types/preauth_key.go index d7d8d741..a78ff3d7 100644 --- a/hscontrol/types/preauth_key.go +++ b/hscontrol/types/preauth_key.go @@ -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) { diff --git a/hscontrol/types/registration.go b/hscontrol/types/registration.go index e6991b4d..669114d9 100644 --- a/hscontrol/types/registration.go +++ b/hscontrol/types/registration.go @@ -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 diff --git a/hscontrol/types/routes.go b/hscontrol/types/routes.go index 3ff56027..5f3958f4 100644 --- a/hscontrol/types/routes.go +++ b/hscontrol/types/routes.go @@ -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 diff --git a/hscontrol/types/slices.go b/hscontrol/types/slices.go index faf29d1b..9e744cb4 100644 --- a/hscontrol/types/slices.go +++ b/hscontrol/types/slices.go @@ -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 diff --git a/hscontrol/types/testcapture/header.go b/hscontrol/types/testcapture/header.go index cfe67655..142937d5 100644 --- a/hscontrol/types/testcapture/header.go +++ b/hscontrol/types/testcapture/header.go @@ -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: // // @@ -20,7 +20,7 @@ import ( // schema version: // // 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 { diff --git a/hscontrol/types/testcapture/read.go b/hscontrol/types/testcapture/read.go index 03ec4809..2c153bbb 100644 --- a/hscontrol/types/testcapture/read.go +++ b/hscontrol/types/testcapture/read.go @@ -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 { diff --git a/hscontrol/types/testcapture/testcapture.go b/hscontrol/types/testcapture/testcapture.go index 97ac8090..75e18ff8 100644 --- a/hscontrol/types/testcapture/testcapture.go +++ b/hscontrol/types/testcapture/testcapture.go @@ -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"` } diff --git a/hscontrol/types/testcapture/testcapture_test.go b/hscontrol/types/testcapture/testcapture_test.go index b87bd0c7..7cc98351 100644 --- a/hscontrol/types/testcapture/testcapture_test.go +++ b/hscontrol/types/testcapture/testcapture_test.go @@ -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) diff --git a/hscontrol/types/testcapture/write.go b/hscontrol/types/testcapture/write.go index f6d21a57..13e2d08b 100644 --- a/hscontrol/types/testcapture/write.go +++ b/hscontrol/types/testcapture/write.go @@ -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) diff --git a/hscontrol/types/testlog.go b/hscontrol/types/testlog.go index af7f5b42..07429eda 100644 --- a/hscontrol/types/testlog.go +++ b/hscontrol/types/testlog.go @@ -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() { diff --git a/hscontrol/types/types_view.go b/hscontrol/types/types_view.go index f8beb9a9..3a9b3b42 100644 --- a/hscontrol/types/types_view.go +++ b/hscontrol/types/types_view.go @@ -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) } diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index 8b87a6ce..e5a9e7a5 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -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) diff --git a/hscontrol/util/addr.go b/hscontrol/util/addr.go index 782f15e6..b0b39944 100644 --- a/hscontrol/util/addr.go +++ b/hscontrol/util/addr.go @@ -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() { diff --git a/hscontrol/util/dns.go b/hscontrol/util/dns.go index c78a68d4..e41f23ed 100644 --- a/hscontrol/util/dns.go +++ b/hscontrol/util/dns.go @@ -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. diff --git a/hscontrol/util/dns_test.go b/hscontrol/util/dns_test.go index a70a1520..f16ab8dd 100644 --- a/hscontrol/util/dns_test.go +++ b/hscontrol/util/dns_test.go @@ -9,7 +9,6 @@ import ( "tailscale.com/util/must" ) - func TestMagicDNSRootDomains100(t *testing.T) { domains := GenerateIPv4DNSRootDomain(netip.MustParsePrefix("100.64.0.0/10")) diff --git a/hscontrol/util/net.go b/hscontrol/util/net.go index e28bb00b..9d57c394 100644 --- a/hscontrol/util/net.go +++ b/hscontrol/util/net.go @@ -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 diff --git a/hscontrol/util/util.go b/hscontrol/util/util.go index 134434f9..0767283a 100644 --- a/hscontrol/util/util.go +++ b/hscontrol/util/util.go @@ -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 { diff --git a/hscontrol/util/util_test.go b/hscontrol/util/util_test.go index 117fb76f..656c5b36 100644 --- a/hscontrol/util/util_test.go +++ b/hscontrol/util/util_test.go @@ -796,7 +796,6 @@ over a maximum of 30 hops: } } - func TestGenerateRegistrationKey(t *testing.T) { t.Parallel() diff --git a/hscontrol/util/zlog/fields.go b/hscontrol/util/zlog/fields.go index 978b311f..1944f76e 100644 --- a/hscontrol/util/zlog/fields.go +++ b/hscontrol/util/zlog/fields.go @@ -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: // diff --git a/hscontrol/util/zlog/hostinfo.go b/hscontrol/util/zlog/hostinfo.go index 3152b8b2..c8d74db4 100644 --- a/hscontrol/util/zlog/hostinfo.go +++ b/hscontrol/util/zlog/hostinfo.go @@ -6,7 +6,7 @@ import ( "tailscale.com/tailcfg" ) -// SafeHostinfo wraps tailcfg.Hostinfo for safe logging. +// SafeHostinfo wraps [tailcfg.Hostinfo] for safe logging. // // SECURITY: This wrapper intentionally redacts device fingerprinting data // that could be used to identify or track specific devices: @@ -24,12 +24,12 @@ type SafeHostinfo struct { hi *tailcfg.Hostinfo } -// Hostinfo creates a SafeHostinfo wrapper for safe logging. +// Hostinfo creates a [SafeHostinfo] wrapper for safe logging. func Hostinfo(hi *tailcfg.Hostinfo) SafeHostinfo { return SafeHostinfo{hi: hi} } -// MarshalZerologObject implements zerolog.LogObjectMarshaler. +// MarshalZerologObject implements [zerolog.LogObjectMarshaler]. func (s SafeHostinfo) MarshalZerologObject(e *zerolog.Event) { if s.hi == nil { return diff --git a/hscontrol/util/zlog/init_test.go b/hscontrol/util/zlog/init_test.go index ce001334..76951d25 100644 --- a/hscontrol/util/zlog/init_test.go +++ b/hscontrol/util/zlog/init_test.go @@ -2,9 +2,9 @@ package zlog import "github.com/rs/zerolog" -// init pins zerolog to TraceLevel for the zlog test binary. +// init pins zerolog to [zerolog.TraceLevel] for the zlog test binary. // -// zlog's tests use zerolog.New(&buf) and assert on Info-level output. zerolog's +// zlog's tests use [zerolog.New] with a buffer and assert on Info-level output. zerolog's // (*Logger).should() gates emission on the global level, so any global level // above Info would silently break the assertions. // diff --git a/hscontrol/util/zlog/maprequest.go b/hscontrol/util/zlog/maprequest.go index 86cff8fa..0c5a68bb 100644 --- a/hscontrol/util/zlog/maprequest.go +++ b/hscontrol/util/zlog/maprequest.go @@ -6,11 +6,11 @@ import ( "tailscale.com/tailcfg" ) -// SafeMapRequest wraps tailcfg.MapRequest for safe logging. +// SafeMapRequest wraps [tailcfg.MapRequest] for safe logging. // // SECURITY: This wrapper does not log sensitive information: // - Endpoints: Client IP addresses and ports -// - Hostinfo: Device fingerprinting data (handled by SafeHostinfo) +// - Hostinfo: Device fingerprinting data (handled by [SafeHostinfo]) // - DERPForceWebsockets: Network configuration details // // Only safe fields are logged: @@ -23,12 +23,12 @@ type SafeMapRequest struct { req *tailcfg.MapRequest } -// MapRequest creates a SafeMapRequest wrapper for safe logging. +// MapRequest creates a [SafeMapRequest] wrapper for safe logging. func MapRequest(req *tailcfg.MapRequest) SafeMapRequest { return SafeMapRequest{req: req} } -// MarshalZerologObject implements zerolog.LogObjectMarshaler. +// MarshalZerologObject implements [zerolog.LogObjectMarshaler]. func (s SafeMapRequest) MarshalZerologObject(e *zerolog.Event) { if s.req == nil { return @@ -46,6 +46,6 @@ func (s SafeMapRequest) MarshalZerologObject(e *zerolog.Event) { // SECURITY: The following fields are intentionally NOT logged: // - Endpoints: Client IP addresses and ports - // - Hostinfo: Device fingerprinting data (use SafeHostinfo separately if needed) + // - Hostinfo: Device fingerprinting data (use [SafeHostinfo] separately if needed) // - DERPForceWebsockets: Network configuration details } diff --git a/integration/acl_test.go b/integration/acl_test.go index d090390d..54273566 100644 --- a/integration/acl_test.go +++ b/integration/acl_test.go @@ -1268,9 +1268,9 @@ func TestACLAutogroupTagged(t *testing.T) { } // Create the tailscale node with appropriate options. - // CACert and HeadscaleName are passed explicitly because - // nodes created via CreateTailscaleNode are not part of - // the standard CreateHeadscaleEnv flow. + // [tsic.WithCACert] and [tsic.WithHeadscaleName] are passed explicitly because + // nodes created via [Scenario.CreateTailscaleNode] are not part of + // the standard [Scenario.CreateHeadscaleEnv] flow. opts := []tsic.Option{ tsic.WithCACert(headscale.GetCert()), tsic.WithHeadscaleName(headscale.GetHostname()), @@ -1558,9 +1558,9 @@ func TestACLAutogroupSelf(t *testing.T) { require.NoError(t, err) // Create router node (tags come from the PreAuthKey). - // CACert and HeadscaleName are passed explicitly because - // nodes created via tsic.New are not part of the standard - // CreateHeadscaleEnv flow. + // [tsic.WithCACert] and [tsic.WithHeadscaleName] are passed explicitly because + // nodes created via [tsic.New] are not part of the standard + // [Scenario.CreateHeadscaleEnv] flow. routerClient, err := tsic.New( scenario.Pool(), "unstable", @@ -2084,9 +2084,9 @@ func TestACLPolicyPropagationOverTime(t *testing.T) { err = headscale.SetPolicy(user1ToUser2Policy) require.NoError(t, err) - // Note: Cannot use WaitForTailscaleSync() here because directional policy means + // Note: Cannot use [Scenario.WaitForTailscaleSync] here because directional policy means // user2 nodes don't see user1 nodes in their peer list (asymmetric visibility). - // The EventuallyWithT block below will handle waiting for policy propagation. + // The [assert.EventuallyWithT] block below will handle waiting for policy propagation. // Test ALL connectivity (positive and negative) in one block after policy settles t.Logf("Iteration %d: Phase 3 - Testing all connectivity with directional policy", iteration) @@ -2625,9 +2625,9 @@ func TestACLTagPropagation(t *testing.T) { }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "verifying tag change applied") // Step 3: Verify final NetMap visibility first (fast signal that - // the MapResponse propagated to the client). + // the [tailcfg.MapResponse] propagated to the client). // The full propagation chain (docker exec → gRPC → state update → - // batcher delay → MapResponse → noise transport → client processing) + // batcher delay → [tailcfg.MapResponse] → noise transport → client processing) // can take over 120s on congested CI runners, so use a generous // base timeout. t.Logf("Step 3: Verifying final NetMap visibility (expect visible=%v)", tt.finalAccess) @@ -2653,7 +2653,7 @@ func TestACLTagPropagation(t *testing.T) { }, integrationutil.HASlowConvergeTimeout, integrationutil.SlowPoll, "verifying NetMap visibility propagated after tag change") // Step 4: Verify final access state (this is the key test for #2389). - // Even though Step 3 confirmed the MapResponse arrived, the full + // Even though Step 3 confirmed the [tailcfg.MapResponse] arrived, the full // WireGuard handshake and tunnel establishment can take significant // time on congested CI runners, so use the same generous base // timeout as Step 3. @@ -2858,7 +2858,7 @@ func TestACLTagPropagationPortSpecific(t *testing.T) { // Step 4: Verify HTTP on port 80 now fails (tag:sshonly only allows port 22). // Port-specific filter changes are harder than peer removal because // the WireGuard tunnel stays up and both endpoints must process - // the new PacketFilter from the MapResponse. + // the new [tailcfg.PacketFilter] from the [tailcfg.MapResponse]. t.Log("Step 4: Verifying HTTP access is now blocked (tag:sshonly only allows port 22)") assert.EventuallyWithT(t, func(c *assert.CollectT) { assertCurlFailWithCollect(c, user2Node, targetURL, "HTTP should fail with tag:sshonly (only port 22 allowed)") @@ -3099,7 +3099,7 @@ func TestACLGroupAfterUserDeletion(t *testing.T) { assertCurlDockerHostname(c, user1, url, "user1 should still be able to reach user2 after user3 deletion (stale cache)") }, integrationutil.HAConvergeTimeout, integrationutil.SlowPoll, "user1 -> user2 after user3 deletion") - // Step 4: Create a NEW user - this triggers updatePolicyManagerUsers() which + // Step 4: Create a NEW user - this triggers [State.updatePolicyManagerUsers] which // re-evaluates the policy. According to issue #2967, this is when the bug manifests: // the deleted user3@ in the group causes the entire group to fail resolution. t.Log("Step 4: Creating a new user (user4) to trigger policy re-evaluation") @@ -3275,7 +3275,7 @@ func TestACLGroupDeletionExactReproduction(t *testing.T) { t.Log("Step 3: PASSED - connectivity works after user2 deletion") - // Step 4: Create a NEW user - this triggers updatePolicyManagerUsers() + // Step 4: Create a NEW user - this triggers [State.updatePolicyManagerUsers] // According to the reporter, this is when the bug manifests t.Log("Step 4: Creating new user (user4) - this triggers policy re-evaluation") @@ -3283,8 +3283,8 @@ func TestACLGroupDeletionExactReproduction(t *testing.T) { require.NoError(t, err) // Step 5: THE CRITICAL TEST - verify connectivity STILL works - // Without the fix: DeleteUser didn't update policy, so when CreateUser - // triggers updatePolicyManagerUsers(), the stale user2@ is now unknown, + // Without the fix: [state.State.DeleteUser] didn't update policy, so when [state.State.CreateUser] + // triggers [State.updatePolicyManagerUsers], the stale user2@ is now unknown, // potentially breaking the group. t.Log("Step 5: Verifying connectivity AFTER creating new user (BUG trigger point)") diff --git a/integration/api_auth_test.go b/integration/api_auth_test.go index 33e4b49a..8dfa6fd5 100644 --- a/integration/api_auth_test.go +++ b/integration/api_auth_test.go @@ -522,8 +522,8 @@ func TestGRPCAuthenticationBypass(t *testing.T) { require.NoError(t, err, "gRPC connection with valid API key should succeed, output: %s", output) - // CLI outputs the users array directly, not wrapped in ListUsersResponse - // Parse as JSON array (CLI uses json.Marshal, not protojson) + // CLI outputs the users array directly, not wrapped in [v1.ListUsersResponse] + // Parse as JSON array (CLI uses [json.Marshal], not protojson) var users []*v1.User err = json.Unmarshal([]byte(output), &users) @@ -681,8 +681,8 @@ cli: require.NoError(t, err, "CLI with valid API key should succeed") - // CLI outputs the users array directly, not wrapped in ListUsersResponse - // Parse as JSON array (CLI uses json.Marshal, not protojson) + // CLI outputs the users array directly, not wrapped in [v1.ListUsersResponse] + // Parse as JSON array (CLI uses [json.Marshal], not protojson) var users []*v1.User err = json.Unmarshal([]byte(output), &users) diff --git a/integration/auth_key_test.go b/integration/auth_key_test.go index 3c2d2fce..7aa928c5 100644 --- a/integration/auth_key_test.go +++ b/integration/auth_key_test.go @@ -57,7 +57,7 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) { expectedNodes := collectExpectedNodeIDs(t, allClients) requireAllClientsOnline(t, headscale, expectedNodes, true, "all clients should be connected", integrationutil.ScaledTimeout(120*time.Second)) - // Validate that all nodes have NetInfo and DERP servers before logout + // Validate that all nodes have [tailcfg.NetInfo] and DERP servers before logout requireAllClientsNetInfoAndDERP(t, headscale, expectedNodes, "all clients should have NetInfo and DERP before logout", 3*time.Minute) // assertClientsState(t, allClients) @@ -161,11 +161,11 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) { requireAllClientsOnline(t, headscale, expectedNodes, true, "all clients should be connected to batcher", integrationutil.ScaledTimeout(120*time.Second)) - // Wait for Tailscale sync before validating NetInfo to ensure proper state propagation + // Wait for Tailscale sync before validating [tailcfg.NetInfo] to ensure proper state propagation err = scenario.WaitForTailscaleSync() requireNoErrSync(t, err) - // Validate that all nodes have NetInfo and DERP servers after reconnection + // Validate that all nodes have [tailcfg.NetInfo] and DERP servers after reconnection requireAllClientsNetInfoAndDERP(t, headscale, expectedNodes, "all clients should have NetInfo and DERP after reconnection", 3*time.Minute) err = scenario.WaitForTailscaleSync() @@ -475,7 +475,7 @@ func TestAuthKeyLogoutAndReloginSameUserExpiredKey(t *testing.T) { // Steps: // 1. Create node with auth key // 2. DELETE the auth key from database (completely remove it) -// 3. Restart node - should successfully reconnect using MachineKey identity. +// 3. Restart node - should successfully reconnect using [tailcfg.Node.MachineKey] identity. func TestAuthKeyDeleteKey(t *testing.T) { IntegrationSkip(t) @@ -561,7 +561,7 @@ func TestAuthKeyDeleteKey(t *testing.T) { // Verify node comes back online // This will FAIL without the fix because auth key validation will reject deleted key - // With the fix, MachineKey identity allows reconnection even with deleted key + // With the fix, [tailcfg.Node.MachineKey] identity allows reconnection even with deleted key requireAllClientsOnline(t, headscale, []types.NodeID{types.NodeID(nodeID)}, true, "node should reconnect after restart despite deleted key", integrationutil.ScaledTimeout(120*time.Second)) t.Logf("✓ Node successfully reconnected after its auth key was deleted") @@ -725,9 +725,9 @@ func TestAuthKeyLogoutAndReloginRoutesPreserved(t *testing.T) { node.GetAvailableRoutes(), node.GetApprovedRoutes(), node.GetSubnetRoutes()) // This is where issue #2896 manifests: - // - Available shows the route (from Hostinfo.RoutableIPs) - // - Approved shows the route (from ApprovedRoutes) - // - BUT Serving (SubnetRoutes/PrimaryRoutes) is EMPTY! + // - Available shows the route (from [tailcfg.Hostinfo.RoutableIPs]) + // - Approved shows the route (from [tailcfg.Node.ApprovedRoutes]) + // - BUT Serving ([tailcfg.Node.SubnetRoutes]/[ipnstate.PeerStatus.PrimaryRoutes]) is EMPTY! assert.Lenf(c, node.GetAvailableRoutes(), 1, "Node should have 1 available route after relogin, got %v", node.GetAvailableRoutes()) assert.Lenf(c, node.GetApprovedRoutes(), 1, diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go index 2f0c0ea9..96bd980e 100644 --- a/integration/auth_oidc_test.go +++ b/integration/auth_oidc_test.go @@ -28,7 +28,7 @@ import ( func TestOIDCAuthenticationPingAll(t *testing.T) { IntegrationSkip(t) - // Logins to MockOIDC is served by a queue with a strict order, + // Logins to [mockoidc.MockOIDC] is served by a queue with a strict order, // if we use more than one node per user, the order of the logins // will not be deterministic and the test will fail. spec := ScenarioSpec{ @@ -202,10 +202,10 @@ func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) { t.Logf("Waiting %v for OIDC tokens to expire (TTL: %v, spread: %v, buffer: %v)", totalWaitTime, shortAccessTTL, loginTimeSpread, safetyBuffer) - // EventuallyWithT retries the test function until it passes or times out. - // IMPORTANT: Use 'ct' (CollectT) for all assertions inside the function, not 't'. + // [assert.EventuallyWithT] retries the test function until it passes or times out. + // IMPORTANT: Use 'ct' ([assert.CollectT]) for all assertions inside the function, not 't'. // Using 't' would cause immediate test failure without retries, defeating the purpose - // of EventuallyWithT which is designed to handle timing-dependent conditions. + // of [assert.EventuallyWithT] which is designed to handle timing-dependent conditions. assert.EventuallyWithT(t, func(ct *assert.CollectT) { // Check each client's status individually to provide better diagnostics expiredCount := 0 @@ -718,7 +718,7 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { }, integrationutil.StatusReadyTimeout, 1*time.Second, "waiting for user2 logout to complete before user1 relogin") // Before logging back in, ensure we still have exactly 2 nodes - // Note: We skip validateLogoutComplete here since it expects all nodes to be offline, + // Note: We skip [validateLogoutComplete] here since it expects all nodes to be offline, // but in OIDC scenario we maintain both nodes in DB with only active user online // Additional validation that nodes are properly maintained during logout @@ -1468,8 +1468,8 @@ func TestOIDCExpiryAfterRestart(t *testing.T) { // 4. Verifies that the OIDC user's node IMMEDIATELY sees the advertised route // // Expected behavior: -// - Without fix: OIDC node cannot see the route (PrimaryRoutes is nil/empty) -// - With fix: OIDC node immediately sees the route in PrimaryRoutes +// - Without fix: OIDC node cannot see the route ([ipnstate.PeerStatus.PrimaryRoutes] is nil/empty) +// - With fix: OIDC node immediately sees the route in [ipnstate.PeerStatus.PrimaryRoutes] // // Root cause: The buggy code called a.h.Change(c) immediately after user // creation but BEFORE node registration completed, creating a race condition @@ -1618,8 +1618,8 @@ func TestOIDCACLPolicyOnJoin(t *testing.T) { // see the gateway's advertised route WITHOUT needing a client restart. // // This is where the bug manifests: - // - Without fix: PrimaryRoutes will be nil/empty - // - With fix: PrimaryRoutes immediately contains the advertised route + // - Without fix: [ipnstate.PeerStatus.PrimaryRoutes] will be nil/empty + // - With fix: [ipnstate.PeerStatus.PrimaryRoutes] immediately contains the advertised route t.Logf("Verifying OIDC user can immediately see advertised routes at %s", time.Now().Format(TimestampFormat)) assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -1641,7 +1641,7 @@ func TestOIDCACLPolicyOnJoin(t *testing.T) { assert.NotNil(ct, gatewayPeer, "OIDC user should see gateway as peer") if gatewayPeer != nil { - // This is the critical assertion - PrimaryRoutes should NOT be nil + // This is the critical assertion - [ipnstate.PeerStatus.PrimaryRoutes] should NOT be nil assert.NotNil(ct, gatewayPeer.PrimaryRoutes, "BUG #2888: Gateway peer PrimaryRoutes is nil - ACL policy not applied to new OIDC node!") @@ -1652,7 +1652,7 @@ func TestOIDCACLPolicyOnJoin(t *testing.T) { t.Logf("SUCCESS: OIDC user can see advertised route %s in gateway's PrimaryRoutes", advertiseRoute) } - // Also verify AllowedIPs includes the route + // Also verify [ipnstate.PeerStatus.AllowedIPs] includes the route if gatewayPeer.AllowedIPs != nil && gatewayPeer.AllowedIPs.Len() > 0 { allowedIPs := gatewayPeer.AllowedIPs.AsSlice() t.Logf("Gateway peer AllowedIPs: %v", allowedIPs) @@ -1914,9 +1914,9 @@ func TestOIDCReloginSameUserRoutesPreserved(t *testing.T) { node.GetAvailableRoutes(), node.GetApprovedRoutes(), node.GetSubnetRoutes()) // This is where issue #2896 manifests: - // - Available shows the route (from Hostinfo.RoutableIPs) - // - Approved shows the route (from ApprovedRoutes) - // - BUT Serving (SubnetRoutes/PrimaryRoutes) is EMPTY! + // - Available shows the route (from [tailcfg.Hostinfo.RoutableIPs]) + // - Approved shows the route (from [tailcfg.Node.ApprovedRoutes]) + // - BUT Serving ([tailcfg.Node.SubnetRoutes]/[ipnstate.PeerStatus.PrimaryRoutes]) is EMPTY! assert.Lenf(c, node.GetAvailableRoutes(), 1, "Node should have 1 available route after relogin, got %v", node.GetAvailableRoutes()) assert.Lenf(c, node.GetApprovedRoutes(), 1, diff --git a/integration/cli_test.go b/integration/cli_test.go index fc40bc08..d04bd02d 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -339,7 +339,7 @@ func TestPreAuthKeyCommand(t *testing.T) { assert.NoError(c, err) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list") - // There is one key created by "scenario.CreateHeadscaleEnv" + // There is one key created by [Scenario.CreateHeadscaleEnv] assert.Len(t, listedPreAuthKeys, 4) assert.Equal( @@ -476,7 +476,7 @@ func TestPreAuthKeyCommandWithoutExpiry(t *testing.T) { assert.NoError(c, err) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list") - // There is one key created by "scenario.CreateHeadscaleEnv" + // There is one key created by [Scenario.CreateHeadscaleEnv] assert.Len(t, listedPreAuthKeys, 2) assert.True(t, listedPreAuthKeys[1].GetExpiration().AsTime().After(time.Now())) @@ -565,7 +565,7 @@ func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) { assert.NoError(c, err) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list after reusable/ephemeral creation") - // There is one key created by "scenario.CreateHeadscaleEnv" + // There is one key created by [Scenario.CreateHeadscaleEnv] assert.Len(t, listedPreAuthKeys, 3) } @@ -662,7 +662,7 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { status, err := client.Status() assert.NoError(ct, err) assert.Equal(ct, "Running", status.BackendState, "Expected node to be logged in, backend state: %s", status.BackendState) - // With tags-as-identity model, tagged nodes show as TaggedDevices user (2147455555) + // With tags-as-identity model, tagged nodes show as [types.TaggedDevices] user (2147455555) // The PreAuthKey was created with tags, so the node is tagged assert.Equal(ct, "userid:2147455555", status.Self.UserID.String(), "Expected node to be logged in as tagged-devices user") }, integrationutil.StatusReadyTimeout, 2*time.Second) @@ -761,7 +761,7 @@ func TestTaggedNodesCLIOutput(t *testing.T) { status, err := client.Status() assert.NoError(ct, err) assert.Equal(ct, "Running", status.BackendState, "Expected node to be logged in, backend state: %s", status.BackendState) - // With tags-as-identity model, tagged nodes show as TaggedDevices user (2147455555) + // With tags-as-identity model, tagged nodes show as [types.TaggedDevices] user (2147455555) assert.Equal(ct, "userid:2147455555", status.Self.UserID.String(), "Expected node to be logged in as tagged-devices user") }, integrationutil.StatusReadyTimeout, 2*time.Second) diff --git a/integration/derp_verify_endpoint_test.go b/integration/derp_verify_endpoint_test.go index 4abf6bec..85818912 100644 --- a/integration/derp_verify_endpoint_test.go +++ b/integration/derp_verify_endpoint_test.go @@ -72,12 +72,12 @@ func TestDERPVerifyEndpoint(t *testing.T) { }, } - // WithHostname is used instead of WithTestName because the hostname + // [hsic.WithHostname] is used instead of [hsic.WithTestName] because the hostname // must match the pre-generated TLS certificate created above. // The test name "derpverify" is embedded in the hostname variable. // - // WithCACert passes the external DERP server's certificate so - // tailscale clients trust it. WithCustomTLS and WithDERPConfig + // [tsic.WithCACert] passes the external DERP server's certificate so + // tailscale clients trust it. [hsic.WithCustomTLS] and [hsic.WithDERPConfig] // configure headscale to use the external DERP server created // above instead of the default embedded one. err = scenario.CreateHeadscaleEnv([]tsic.Option{tsic.WithCACert(derper.GetCert())}, diff --git a/integration/dockertestutil/config.go b/integration/dockertestutil/config.go index 88b2712c..1f77fede 100644 --- a/integration/dockertestutil/config.go +++ b/integration/dockertestutil/config.go @@ -22,9 +22,9 @@ func GetIntegrationRunID() string { return os.Getenv("HEADSCALE_INTEGRATION_RUN_ID") } -// DockerAddIntegrationLabels adds integration test labels to Docker RunOptions. +// DockerAddIntegrationLabels adds integration test labels to Docker [dockertest.RunOptions]. // This allows the hi tool to identify containers belonging to specific test runs. -// This function should be called before passing RunOptions to dockertest functions. +// This function should be called before passing [dockertest.RunOptions] to dockertest functions. func DockerAddIntegrationLabels(opts *dockertest.RunOptions, testType string) { runID := GetIntegrationRunID() if runID == "" { diff --git a/integration/dockertestutil/execute.go b/integration/dockertestutil/execute.go index da5b7c06..79511e2e 100644 --- a/integration/dockertestutil/execute.go +++ b/integration/dockertestutil/execute.go @@ -40,7 +40,7 @@ func ExecuteCommandTimeout(timeout time.Duration) ExecuteCommandOption { }) } -// buffer is a goroutine safe bytes.buffer. +// buffer is a goroutine safe [bytes.Buffer]. type buffer struct { store bytes.Buffer mutex sync.Mutex diff --git a/integration/dockertestutil/network.go b/integration/dockertestutil/network.go index dd3a8393..cbaf83b2 100644 --- a/integration/dockertestutil/network.go +++ b/integration/dockertestutil/network.go @@ -150,8 +150,9 @@ func DisconnectContainerFromNetwork( return waitContainerRouteAbsent(pool, containerID, network, DockerOpMaxElapsedTime) } -// ReconnectContainerToNetwork inverts DisconnectContainerFromNetwork -// and waits until libnetwork has wired up a fresh IPv4 address. +// ReconnectContainerToNetwork is the inverse of +// [DisconnectContainerFromNetwork] — re-attaches the container to the +// network so traffic can flow again. func ReconnectContainerToNetwork( pool *dockertest.Pool, network *dockertest.Network, diff --git a/integration/dsic/dsic.go b/integration/dsic/dsic.go index c442e655..442298a1 100644 --- a/integration/dsic/dsic.go +++ b/integration/dsic/dsic.go @@ -136,7 +136,7 @@ func (dsic *DERPServerInContainer) buildEntrypoint(derperArgs string) []string { return []string{"/bin/sh", "-c", strings.Join(commands, " ; ")} } -// New returns a new TailscaleInContainer instance. +// New returns a new [tsic.TailscaleInContainer] instance. func New( pool *dockertest.Pool, version string, @@ -179,7 +179,7 @@ func New( } // Install the CA cert so the DERP server trusts its own certificate - // and any headscale CA certs passed via WithCACert. + // and any headscale CA certs passed via [WithCACert]. dsic.caCerts = append(dsic.caCerts, tlsCACert) for _, opt := range opts { @@ -319,7 +319,7 @@ func (t *DERPServerInContainer) Version() string { return t.version } -// ID returns the Docker container ID of the DERPServerInContainer +// ID returns the Docker container ID of the [DERPServerInContainer] // instance. func (t *DERPServerInContainer) ID() string { return t.container.Container.ID diff --git a/integration/embedded_derp_test.go b/integration/embedded_derp_test.go index 64132290..c35057c8 100644 --- a/integration/embedded_derp_test.go +++ b/integration/embedded_derp_test.go @@ -180,7 +180,7 @@ func derpServerScenario( t.Logf("Run 1: %d successful pings out of %d", success, len(allClients)*len(allHostnames)) // Let the DERP updater run a couple of times to ensure it does not - // break the DERPMap. The updater runs on a 10s interval by default. + // break the [tailcfg.DERPMap]. The updater runs on a 10s interval by default. //nolint:forbidigo // Intentional delay: must wait for DERP updater to run multiple times (interval-based) time.Sleep(30 * time.Second) diff --git a/integration/general_test.go b/integration/general_test.go index 02a2b53b..27ce8197 100644 --- a/integration/general_test.go +++ b/integration/general_test.go @@ -767,8 +767,8 @@ func TestUpdateHostnameFromClient(t *testing.T) { // Pre-rewrite these were rejected by ApplyHostnameFromHostInfo with // "invalid characters" and the node was stuck on an invalid- // GivenName with the HostName update dropped. The assertions below - // verify both raw preservation (node.Name) and SaaS-matching sanitisation - // (node.GivenName) for each awkward input. + // verify both raw preservation ([v1.Node.Name]) and SaaS-matching sanitisation + // ([v1.Node.GivenName]) for each awkward input. hostnames := map[string]string{ "1": "Joe's Mac mini", "2": "Test@Host", @@ -813,7 +813,7 @@ func TestUpdateHostnameFromClient(t *testing.T) { requireNoErrSync(t, err) // Wait for nodestore batch processing to complete - // NodeStore batching timeout is 500ms, so we wait up to 1 second + // [state.NodeStore] batching timeout is 500ms, so we wait up to 1 second var nodes []*v1.Node assert.EventuallyWithT(t, func(ct *assert.CollectT) { err := executeAndUnmarshal( @@ -834,7 +834,7 @@ func TestUpdateHostnameFromClient(t *testing.T) { hostname := hostnames[strconv.FormatUint(node.GetId(), 10)] assert.Equal(ct, hostname, node.GetName(), "Node name should match hostname") - // GivenName is sanitised via dnsname.SanitizeHostname (SaaS algorithm). + // GivenName is sanitised via [dnsname.SanitizeHostname] (SaaS algorithm). assert.Equal(ct, dnsname.SanitizeHostname(hostname), node.GetGivenName(), "Given name should match SaaS hostname-sanitisation rules") } @@ -912,7 +912,7 @@ func TestUpdateHostnameFromClient(t *testing.T) { requireNoErrSync(t, err) // Wait for nodestore batch processing to complete - // NodeStore batching timeout is 500ms, so we wait up to 1 second + // [state.NodeStore] batching timeout is 500ms, so we wait up to 1 second assert.Eventually(t, func() bool { err = executeAndUnmarshal( headscale, @@ -987,7 +987,7 @@ func TestExpireNode(t *testing.T) { require.NoError(t, err) // TODO(kradalby): This is Headscale specific and would not play nicely - // with other implementations of the ControlServer interface + // with other implementations of the [ControlServer] interface result, err := headscale.Execute([]string{ "headscale", "nodes", "expire", "--identifier", "1", "--output", "json", }) diff --git a/integration/grant_cap_test.go b/integration/grant_cap_test.go index f5d00a72..4c3bc9bf 100644 --- a/integration/grant_cap_test.go +++ b/integration/grant_cap_test.go @@ -17,8 +17,8 @@ import ( "tailscale.com/wgengine/filter" ) -// hasCapMatchInPacketFilter checks if any Match entry in the packet -// filter contains a CapMatch with the given capability name. +// hasCapMatchInPacketFilter checks if any [filter.Match] entry in the packet +// filter contains a [filter.CapMatch] with the given capability name. func hasCapMatchInPacketFilter(pf []filter.Match, peerCap tailcfg.PeerCapability) bool { for _, m := range pf { for _, cm := range m.Caps { @@ -31,7 +31,7 @@ func hasCapMatchInPacketFilter(pf []filter.Match, peerCap tailcfg.PeerCapability return false } -// hasCapMatchForIP checks if any CapMatch with the given capability +// hasCapMatchForIP checks if any [filter.CapMatch] with the given capability // has a Dst prefix that contains the given IP. This validates that // the cap is directed at the correct node, not just present. func hasCapMatchForIP(pf []filter.Match, peerCap tailcfg.PeerCapability, ip netip.Addr) bool { @@ -47,7 +47,7 @@ func hasCapMatchForIP(pf []filter.Match, peerCap tailcfg.PeerCapability, ip neti } // parsePeerRelay parses a PeerRelay string of the form "ip:port:vni:N" -// and returns the address and VNI. Returns zero values on parse failure. +// and returns the [netip.AddrPort] and VNI. Returns zero values on parse failure. func parsePeerRelay(pr string) (netip.AddrPort, string, bool) { // Format: "172.18.0.4:58738:vni:1" // Split into: host part "172.18.0.4:58738" and vni part "vni:1" diff --git a/integration/helpers.go b/integration/helpers.go index aadb6309..197efab7 100644 --- a/integration/helpers.go +++ b/integration/helpers.go @@ -114,7 +114,7 @@ func requireNoErrLogout(t *testing.T, err error) { require.NoError(t, err, "failed to log out tailscale nodes") } -// collectExpectedNodeIDs extracts node IDs from a list of TailscaleClients for validation purposes. +// collectExpectedNodeIDs extracts node IDs from a list of [TailscaleClient]s for validation purposes. func collectExpectedNodeIDs(t *testing.T, clients []TailscaleClient) []types.NodeID { t.Helper() @@ -131,7 +131,7 @@ func collectExpectedNodeIDs(t *testing.T, clients []TailscaleClient) []types.Nod } // validateInitialConnection performs comprehensive validation after initial client login. -// Validates that all nodes are online and have proper NetInfo/DERP configuration, +// Validates that all nodes are online and have proper [tailcfg.NetInfo]/DERP configuration, // essential for ensuring successful initial connection state in relogin tests. func validateInitialConnection(t *testing.T, headscale ControlServer, expectedNodes []types.NodeID) { t.Helper() @@ -150,7 +150,7 @@ func validateLogoutComplete(t *testing.T, headscale ControlServer, expectedNodes } // validateReloginComplete performs comprehensive validation after client relogin. -// Validates that all nodes are back online with proper NetInfo/DERP configuration, +// Validates that all nodes are back online with proper [tailcfg.NetInfo]/DERP configuration, // ensuring successful relogin state restoration in integration tests. func validateReloginComplete(t *testing.T, headscale ControlServer, expectedNodes []types.NodeID) { t.Helper() @@ -256,7 +256,7 @@ func requireAllClientsOnlineWithSingleTimeout(t *testing.T, headscale ControlSer } } - // Check map responses using buildExpectedOnlineMap + // Check map responses using [integrationutil.BuildExpectedOnlineMap] onlineFromMaps := make(map[types.NodeID]bool) onlineMap := integrationutil.BuildExpectedOnlineMap(mapResponses) @@ -475,9 +475,9 @@ func requireAllClientsOfflineStaged(t *testing.T, headscale ControlServer, expec t.Logf("All stages completed: nodes are fully offline across all systems") } -// requireAllClientsNetInfoAndDERP validates that all nodes have NetInfo in the database -// and a valid DERP server based on the NetInfo. This function follows the pattern of -// requireAllClientsOnline by using hsic.DebugNodeStore to get the database state. +// requireAllClientsNetInfoAndDERP validates that all nodes have [tailcfg.NetInfo] in the database +// and a valid DERP server based on the [tailcfg.NetInfo]. This function follows the pattern of +// [requireAllClientsOnline] by using [hsic.HeadscaleInContainer.DebugNodeStore] to get the database state. // //nolint:unparam // timeout is configurable for flexibility even though callers currently use same value func requireAllClientsNetInfoAndDERP(t *testing.T, headscale ControlServer, expectedNodes []types.NodeID, message string, timeout time.Duration) { @@ -510,7 +510,7 @@ func requireAllClientsNetInfoAndDERP(t *testing.T, headscale ControlServer, expe continue } - // Validate that the node has Hostinfo + // Validate that the node has [tailcfg.Hostinfo] assert.NotNil(c, node.Hostinfo, "Node %d (%s) should have Hostinfo for NetInfo validation", nodeID, node.Hostname) if node.Hostinfo == nil { @@ -518,7 +518,7 @@ func requireAllClientsNetInfoAndDERP(t *testing.T, headscale ControlServer, expe continue } - // Validate that the node has NetInfo + // Validate that the node has [tailcfg.NetInfo] assert.NotNil(c, node.Hostinfo.NetInfo, "Node %d (%s) should have NetInfo in Hostinfo for DERP connectivity", nodeID, node.Hostname) if node.Hostinfo.NetInfo == nil { @@ -553,7 +553,7 @@ func assertLastSeenSetWithCollect(c *assert.CollectT, node *v1.Node) { } // assertCurlSuccessWithCollect asserts that a curl request succeeds with -// non-empty content. For use inside EventuallyWithT blocks. +// non-empty content. For use inside [assert.EventuallyWithT] blocks. func assertCurlSuccessWithCollect(c *assert.CollectT, client TailscaleClient, url, msg string) { result, err := client.Curl(url) assert.NoError(c, err, msg) //nolint:testifylint // CollectT requires assert, not require @@ -562,7 +562,7 @@ func assertCurlSuccessWithCollect(c *assert.CollectT, client TailscaleClient, ur // assertCurlDockerHostname curls url and asserts the body is the // 13-byte Docker auto-generated container hostname (12 hex chars + -// trailing newline from /etc/hostname). For use inside EventuallyWithT. +// trailing newline from /etc/hostname). For use inside [assert.EventuallyWithT]. func assertCurlDockerHostname(c *assert.CollectT, client TailscaleClient, url, msg string) { const dockerHostnameLen = 13 @@ -572,7 +572,7 @@ func assertCurlDockerHostname(c *assert.CollectT, client TailscaleClient, url, m } // snapshotClientFilters snapshots each client's current netmap -// PacketFilter keyed by hostname. Pair with waitForClientFilterChange. +// PacketFilter keyed by hostname. Pair with [waitForClientFilterChange]. func snapshotClientFilters(t *testing.T, clients []TailscaleClient) map[string][]filter.Match { t.Helper() @@ -611,9 +611,9 @@ func waitForClientFilterChange(t *testing.T, clients []TailscaleClient, baseline } // assertCurlFailWithCollect asserts that a curl request fails. Uses -// CurlFailFast internally for aggressive timeouts, avoiding wasted +// [tsic.TailscaleInContainer.CurlFailFast] internally for aggressive timeouts, avoiding wasted // time on retries when we expect the connection to be blocked. -// For use inside EventuallyWithT blocks. +// For use inside [assert.EventuallyWithT] blocks. func assertCurlFailWithCollect(c *assert.CollectT, client TailscaleClient, url, msg string) { _, err := client.CurlFailFast(url) assert.Error(c, err, msg) @@ -635,7 +635,7 @@ func assertTailscaleNodesLogout(t assert.TestingT, clients []TailscaleClient) { } // assertPingAll verifies that every client can ping every address. -// The entire ping matrix is retried via EventuallyWithT to handle +// The entire ping matrix is retried via [assert.EventuallyWithT] to handle // transient failures on slow CI runners. The timeout scales with // the number of pings since they run serially and each can take // up to ~2s on CI (docker exec overhead + ping timeout). @@ -660,9 +660,9 @@ func assertPingAll(t *testing.T, clients []TailscaleClient, addrs []string, opts } // assertPingAllWithCollect pings every address from every client and -// collects failures on the provided CollectT. Pings run serially to +// collects failures on the provided [assert.CollectT]. Pings run serially to // avoid overloading the Docker daemon on resource-constrained CI -// runners. For use inside EventuallyWithT blocks when the caller +// runners. For use inside [assert.EventuallyWithT] blocks when the caller // needs custom timeout or retry control. func assertPingAllWithCollect(c *assert.CollectT, clients []TailscaleClient, addrs []string, opts ...tsic.PingOption) { for _, client := range clients { @@ -897,7 +897,7 @@ func assertValidNetcheck(t *testing.T, client TailscaleClient) { // assertCommandOutputContains executes a command with exponential backoff retry until the output // contains the expected string or timeout is reached (10 seconds). -// This implements eventual consistency patterns and should be used instead of time.Sleep +// This implements eventual consistency patterns and should be used instead of [time.Sleep] // before executing commands that depend on network state propagation. // // Timeout: 10 seconds with exponential backoff @@ -982,42 +982,43 @@ func countMatchingLines(in io.Reader, predicate func(string) bool) (int, error) // wildcard returns a wildcard alias (*) for use in policy v2 configurations. // Provides a convenient helper for creating permissive policy rules. +// Returns [policyv2.Wildcard]. func wildcard() policyv2.Alias { return policyv2.Wildcard } -// usernamep returns a pointer to a Username as an Alias for policy v2 configurations. +// usernamep returns a pointer to a [policyv2.Username] as an [policyv2.Alias] for policy v2 configurations. // Used in ACL rules to reference specific users in network access policies. func usernamep(name string) policyv2.Alias { return new(policyv2.Username(name)) } -// hostp returns a pointer to a Host as an Alias for policy v2 configurations. +// hostp returns a pointer to a [policyv2.Host] as an [policyv2.Alias] for policy v2 configurations. // Used in ACL rules to reference specific hosts in network access policies. func hostp(name string) policyv2.Alias { return new(policyv2.Host(name)) } -// groupp returns a pointer to a Group as an Alias for policy v2 configurations. +// groupp returns a pointer to a [policyv2.Group] as an [policyv2.Alias] for policy v2 configurations. // Used in ACL rules to reference user groups in network access policies. func groupp(name string) policyv2.Alias { return new(policyv2.Group(name)) } -// tagp returns a pointer to a Tag as an Alias for policy v2 configurations. +// tagp returns a pointer to a [policyv2.Tag] as an [policyv2.Alias] for policy v2 configurations. // Used in ACL rules to reference node tags in network access policies. func tagp(name string) policyv2.Alias { return new(policyv2.Tag(name)) } -// prefixp returns a pointer to a Prefix from a CIDR string for policy v2 configurations. +// prefixp returns a pointer to a [policyv2.Prefix] from a CIDR string for policy v2 configurations. // Converts CIDR notation to policy prefix format for network range specifications. func prefixp(cidr string) policyv2.Alias { p := policyv2.Prefix(netip.MustParsePrefix(cidr)) return &p } -// aliasWithPorts creates an AliasWithPorts structure from an alias and port ranges. +// aliasWithPorts creates an [policyv2.AliasWithPorts] structure from an alias and port ranges. // Combines network targets with specific port restrictions for fine-grained // access control in policy v2 configurations. func aliasWithPorts(alias policyv2.Alias, ports ...tailcfg.PortRange) policyv2.AliasWithPorts { @@ -1027,13 +1028,13 @@ func aliasWithPorts(alias policyv2.Alias, ports ...tailcfg.PortRange) policyv2.A } } -// usernameOwner returns a Username as an Owner for use in TagOwners policies. +// usernameOwner returns a [policyv2.Username] as an [policyv2.Owner] for use in [policyv2.TagOwners] policies. // Specifies which users can assign and manage specific tags in ACL configurations. func usernameOwner(name string) policyv2.Owner { return new(policyv2.Username(name)) } -// groupOwner returns a Group as an Owner for use in TagOwners policies. +// groupOwner returns a [policyv2.Group] as an [policyv2.Owner] for use in [policyv2.TagOwners] policies. // Specifies which groups can assign and manage specific tags in ACL configurations. // //nolint:unused @@ -1041,25 +1042,25 @@ func groupOwner(name string) policyv2.Owner { return new(policyv2.Group(name)) } -// usernameApprover returns a Username as an AutoApprover for subnet route policies. +// usernameApprover returns a [policyv2.Username] as an [policyv2.AutoApprover] for subnet route policies. // Specifies which users can automatically approve subnet route advertisements. func usernameApprover(name string) policyv2.AutoApprover { return new(policyv2.Username(name)) } -// groupApprover returns a Group as an AutoApprover for subnet route policies. +// groupApprover returns a [policyv2.Group] as an [policyv2.AutoApprover] for subnet route policies. // Specifies which groups can automatically approve subnet route advertisements. func groupApprover(name string) policyv2.AutoApprover { return new(policyv2.Group(name)) } -// tagApprover returns a Tag as an AutoApprover for subnet route policies. +// tagApprover returns a [policyv2.Tag] as an [policyv2.AutoApprover] for subnet route policies. // Specifies which tagged nodes can automatically approve subnet route advertisements. func tagApprover(name string) policyv2.AutoApprover { return new(policyv2.Tag(name)) } -// oidcMockUser creates a MockUser for OIDC authentication testing. +// oidcMockUser creates a [mockoidc.MockUser] for OIDC authentication testing. // Generates consistent test user data with configurable email verification status // for validating OIDC integration flows in headscale authentication tests. func oidcMockUser(username string, emailVerified bool) mockoidc.MockUser { @@ -1193,7 +1194,7 @@ func (s *Scenario) AddAndLoginClient( return newClient, nil } -// MustAddAndLoginClient is like AddAndLoginClient but fails the test on error. +// MustAddAndLoginClient is like [Scenario.AddAndLoginClient] but fails the test on error. func (s *Scenario) MustAddAndLoginClient( t *testing.T, username string, diff --git a/integration/integrationutil/timeouts.go b/integration/integrationutil/timeouts.go index 593c7938..0e36041a 100644 --- a/integration/integrationutil/timeouts.go +++ b/integration/integrationutil/timeouts.go @@ -26,7 +26,7 @@ var ( StatusReadyTimeout = ScaledTimeout(30 * time.Second) ) -// Polling intervals for EventuallyWithT. +// Polling intervals for [assert.EventuallyWithT]. const ( // FastPoll: in-process reads (HA state, route table snapshots). FastPoll = 200 * time.Millisecond diff --git a/integration/route_test.go b/integration/route_test.go index aa48da12..beff6ff0 100644 --- a/integration/route_test.go +++ b/integration/route_test.go @@ -88,7 +88,7 @@ func TestEnablingRoutes(t *testing.T) { requireNoErrSync(t, err) var nodes []*v1.Node - // Wait for route advertisements to propagate to NodeStore + // Wait for route advertisements to propagate to [state.NodeStore] assert.EventuallyWithT(t, func(ct *assert.CollectT) { var err error @@ -125,7 +125,7 @@ func TestEnablingRoutes(t *testing.T) { require.NoError(t, err) } - // Wait for route approvals to propagate to NodeStore + // Wait for route approvals to propagate to [state.NodeStore] assert.EventuallyWithT(t, func(ct *assert.CollectT) { var err error @@ -361,7 +361,7 @@ func TestHASubnetRouterFailover(t *testing.T) { }, propagationTime, 200*time.Millisecond, "Verifying no routes are active before approval") } - // Declare variables that will be used across multiple EventuallyWithT blocks + // Declare variables that will be used across multiple [assert.EventuallyWithT] blocks var ( srs1, srs2, srs3 *ipnstate.Status clientStatus *ipnstate.Status @@ -956,7 +956,7 @@ func TestHASubnetRouterFailover(t *testing.T) { require.NoError(t, err) // Wait for nodestore batch processing to complete and online status to be updated - // NodeStore batching timeout is 500ms, so we wait up to 10 seconds for all routers to be online + // [state.NodeStore] batching timeout is 500ms, so we wait up to 10 seconds for all routers to be online assert.EventuallyWithT(t, func(c *assert.CollectT) { clientStatus, err = client.Status() assert.NoError(c, err) @@ -1033,7 +1033,7 @@ func TestHASubnetRouterFailover(t *testing.T) { _, err = headscale.ApproveRoutes(MustFindNode(subRouter3.Hostname(), nodes).GetId(), []netip.Prefix{}) // Wait for nodestore batch processing and route state changes to complete - // NodeStore batching timeout is 500ms, so we wait up to 10 seconds for route failover + // [state.NodeStore] batching timeout is 500ms, so we wait up to 10 seconds for route failover assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err = headscale.ListNodes() assert.NoError(c, err) @@ -1119,7 +1119,7 @@ func TestHASubnetRouterFailover(t *testing.T) { _, err = headscale.ApproveRoutes(MustFindNode(subRouter1.Hostname(), nodes).GetId(), []netip.Prefix{}) // Wait for nodestore batch processing and route state changes to complete - // NodeStore batching timeout is 500ms, so we wait up to 10 seconds for route failover + // [state.NodeStore] batching timeout is 500ms, so we wait up to 10 seconds for route failover assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err = headscale.ListNodes() assert.NoError(c, err) @@ -1453,7 +1453,7 @@ func TestSubnetRouteACL(t *testing.T) { assert.NotNil(c, routeNode, "could not find node that should have route") assert.NotNil(c, otherNode, "could not find node that should not have route") - // After NodeStore fix: routes are properly tracked in route manager + // After [state.NodeStore] fix: routes are properly tracked in route manager // This test uses a policy with NO auto-approvers, so routes should be: // announced=1, approved=0, subnet=0 (routes announced but not approved) requireNodeRouteCountWithCollect(c, routeNode, 1, 0, 0) @@ -1705,7 +1705,7 @@ func TestEnablingExitRoutes(t *testing.T) { // (verified against a live tailnet on 2026-04-28; see captures // routes-b17/b18 in tscap). The bug was that headscale stripped // autogroup:internet rules from both the client packet filter AND the -// matcher source used by Node.CanAccess, breaking exit-node visibility. +// matcher source used by [types.Node.CanAccess], breaking exit-node visibility. func TestExitRoutesWithAutogroupInternetACL(t *testing.T) { IntegrationSkip(t) @@ -1750,9 +1750,9 @@ func TestExitRoutesWithAutogroupInternetACL(t *testing.T) { requireNoErrGetHeadscale(t, err) // The autogroup:internet ACL grants no peer visibility until the - // exit routes are approved (Node.IsExitNode() flips on approval), + // exit routes are approved ([types.Node.IsExitNode] flips on approval), // so the standard WaitForTailscaleSync wait would deadlock here — - // the post-approval EventuallyWithT block below covers the peer + // the post-approval [assert.EventuallyWithT] block below covers the peer // state we actually care about. var nodes []*v1.Node @@ -1792,7 +1792,7 @@ func TestExitRoutesWithAutogroupInternetACL(t *testing.T) { // The end-to-end UX assertion: every client must see the OTHER // node as a peer carrying both default-route prefixes in - // AllowedIPs. Tailscale derives PeerStatus.ExitNodeOption from + // AllowedIPs. Tailscale derives [ipnstate.PeerStatus.ExitNodeOption] from // those AllowedIPs, which is what `tailscale exit-node list` // reads (see tailscale.com/ipn/ipnlocal/local.go). for _, client := range allClients { @@ -1903,7 +1903,7 @@ func TestSubnetRouterMultiNetwork(t *testing.T) { require.NoErrorf(t, err, "failed to advertise route: %s", err) var nodes []*v1.Node - // Wait for route advertisements to propagate to NodeStore + // Wait for route advertisements to propagate to [state.NodeStore] assert.EventuallyWithT(t, func(ct *assert.CollectT) { var err error @@ -2182,7 +2182,7 @@ func MustFindNode(hostname string, nodes []*v1.Node) *v1.Node { func TestAutoApproveMultiNetwork(t *testing.T) { IntegrationSkip(t) - // Timeout for EventuallyWithT assertions. + // Timeout for [assert.EventuallyWithT] assertions. // Set generously to account for CI infrastructure variability. assertTimeout := integrationutil.ScaledTimeout(60 * time.Second) @@ -2430,7 +2430,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { name := fmt.Sprintf("%s-advertiseduringup-%t-pol-%s", tt.name, advertiseDuringUp, polMode) t.Run(name, func(t *testing.T) { // Create a deep copy of the policy to avoid mutating the shared test case. - // Each subtest modifies AutoApprovers.Routes (add then delete), so we need + // Each subtest modifies [policyv2.AutoApproverPolicy.Routes] (add then delete), so we need // an isolated copy to prevent state leakage between sequential test runs. pol := &policyv2.Policy{ ACLs: slices.Clone(tt.pol.ACLs), @@ -3017,14 +3017,14 @@ func TestAutoApproveMultiNetwork(t *testing.T) { } } -// assertTracerouteViaIPWithCollect is a version of assertTracerouteViaIP that works with assert.CollectT. +// assertTracerouteViaIPWithCollect is a version of [assertTracerouteViaIP] that works with [assert.CollectT]. func assertTracerouteViaIPWithCollect(c *assert.CollectT, tr util.Traceroute, ip netip.Addr) { assert.NotNil(c, tr) assert.True(c, tr.Success) assert.NoError(c, tr.Err) //nolint:testifylint // using assert.CollectT assert.NotEmpty(c, tr.Route) - // Since we're inside EventuallyWithT, we can't use require.Greater with t - // but assert.NotEmpty above ensures len(tr.Route) > 0 + // Since we're inside [assert.EventuallyWithT], we can't use [require.Greater] with t + // but [assert.NotEmpty] above ensures len(tr.Route) > 0 if len(tr.Route) > 0 { assert.Equal(c, tr.Route[0].IP.String(), ip.String()) } @@ -3219,7 +3219,7 @@ func TestSubnetRouteACLFiltering(t *testing.T) { requireNoErrSync(t, err) var routerNode, nodeNode *v1.Node - // Wait for route advertisements to propagate to NodeStore + // Wait for route advertisements to propagate to [state.NodeStore] assert.EventuallyWithT(t, func(ct *assert.CollectT) { // List nodes and verify the router has 3 available routes nodes, err := headscale.NodesByUser() @@ -3858,7 +3858,7 @@ func TestHASubnetRouterPingFailover(t *testing.T) { // // Two assertion sets split the failure surface: // - R1: server-side primary route table restores after reconnect. -// If R1 fails, the bug is in state.Connect / primaryRoutes. +// If R1 fails, the bug is in [state.State.Connect] / primaryRoutes. // - R2: client's view shows r2 online with the route in PrimaryRoutes. // If R1 passes and R2 fails, the bug is in change broadcast / // mapBatcher / multiChannelNodeConn. diff --git a/integration/ssh_test.go b/integration/ssh_test.go index 0b5f661a..c24f37c6 100644 --- a/integration/ssh_test.go +++ b/integration/ssh_test.go @@ -459,7 +459,7 @@ func doSSHWithRetryAsUser( ) if retry { - // Use assert.EventuallyWithT to retry SSH connections for success cases + // Use [assert.EventuallyWithT] to retry SSH connections for success cases assert.EventuallyWithT(t, func(ct *assert.CollectT) { result, stderr, err = client.Execute(command) @@ -706,7 +706,7 @@ func findSSHCheckAuthID(t *testing.T, headscale ControlServer) string { return authID } -// sshCheckPolicy returns a policy with SSH "check" mode for group:integration-test +// sshCheckPolicy returns a [policyv2.Policy] with SSH "check" mode for group:integration-test // targeting autogroup:member and autogroup:tagged destinations. func sshCheckPolicy() *policyv2.Policy { return &policyv2.Policy{ @@ -739,7 +739,7 @@ func sshCheckPolicy() *policyv2.Policy { } } -// sshCheckPolicyWithPeriod returns a policy with SSH "check" mode and a +// sshCheckPolicyWithPeriod returns a [policyv2.Policy] with SSH "check" mode and a // specified checkPeriod for session duration. func sshCheckPolicyWithPeriod(period time.Duration) *policyv2.Policy { return &policyv2.Policy{ diff --git a/integration/tags_test.go b/integration/tags_test.go index dc0320b5..fa4739a0 100644 --- a/integration/tags_test.go +++ b/integration/tags_test.go @@ -1587,7 +1587,7 @@ func TestTagsUserLoginAddTagViaCLIReauth(t *testing.T) { _, stderr, err := client.Execute(command) t.Logf("CLI result: err=%v, stderr=%s", err, stderr) - // Check final state - EventuallyWithT handles waiting for propagation + // Check final state - [assert.EventuallyWithT] handles waiting for propagation assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err := headscale.ListNodes() assert.NoError(c, err) @@ -1678,7 +1678,7 @@ func TestTagsUserLoginRemoveTagViaCLIReauth(t *testing.T) { _, stderr, err := client.Execute(command) t.Logf("CLI result: err=%v, stderr=%s", err, stderr) - // Check final state - EventuallyWithT handles waiting for propagation + // Check final state - [assert.EventuallyWithT] handles waiting for propagation assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err := headscale.ListNodes() assert.NoError(c, err) @@ -3093,8 +3093,8 @@ func TestTagsAuthKeyWithoutUserRejectsAdvertisedTags(t *testing.T) { // TestTagsAuthKeyConvertToUserViaCLIRegister reproduces the panic from // issue #3038: register a node with a tags-only preauthkey (no user), then // convert it to a user-owned node via "headscale auth register --auth-id --user ". -// The crash happens in the mapper's generateUserProfiles when node.User is nil -// after the tag→user conversion in processReauthTags. +// The crash happens in the mapper's generateUserProfiles when [types.Node.User] is nil +// after the tag→user conversion in [State.processReauthTags]. // // The key detail is using a tags-only PreAuthKey (User: nil). When created under // a user, the node inherits User from the PreAuthKey and the bug is masked. @@ -3124,8 +3124,8 @@ func TestTagsAuthKeyConvertToUserViaCLIRegister(t *testing.T) { requireNoErrGetHeadscale(t, err) // Step 1: Create a tags-only preauthkey WITHOUT a user. - // This is the critical detail: when PreAuthKey.UserID is nil, the node - // enters the NodeStore with node.User == nil. The processReauthTags + // This is the critical detail: when [types.PreAuthKey.UserID] is nil, the node + // enters the [state.NodeStore] with [types.Node.User] == nil. The [state.State.processReauthTags] // conversion then sets UserID but not User, leaving it nil for the mapper. authKey, err := scenario.CreatePreAuthKeyWithOptions(hsic.AuthKeyOptions{ User: nil, @@ -3186,8 +3186,8 @@ func TestTagsAuthKeyConvertToUserViaCLIRegister(t *testing.T) { require.NoError(t, err) // Step 4: Verify node is now user-owned and the mapper didn't panic. - // The panic would occur when the mapper builds the MapResponse and calls - // node.Owner().Model().ID with a nil User pointer. + // The panic would occur when the mapper builds the [tailcfg.MapResponse] and calls + // [types.Node.Owner].Model().ID with a nil User pointer. // ShutdownAssertNoPanics in the defer catches any panics in headscale logs. assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err := headscale.ListNodes() diff --git a/integration/tsric/tsric.go b/integration/tsric/tsric.go index b7f19c72..873b04fa 100644 --- a/integration/tsric/tsric.go +++ b/integration/tsric/tsric.go @@ -65,7 +65,7 @@ func WithCACert(cert []byte) Option { } } -// WithNetwork sets the Docker container network. +// WithNetwork sets the Docker [dockertest.Network]. func WithNetwork(network *dockertest.Network) Option { return func(t *TailscaleRustInContainer) { t.network = network @@ -146,7 +146,7 @@ func (t *TailscaleRustInContainer) buildEntrypoint() []string { return []string{"/bin/sh", "-c", strings.Join(commands, " ; ")} } -// New creates and starts a new TailscaleRustInContainer instance. +// New creates and starts a new [TailscaleRustInContainer] instance. func New( pool *dockertest.Pool, opts ...Option, @@ -280,7 +280,7 @@ func New( return t, nil } -// Hostname returns the hostname of the TailscaleRustInContainer instance. +// Hostname returns the hostname of the [TailscaleRustInContainer] instance. func (t *TailscaleRustInContainer) Hostname() string { return t.hostname } @@ -310,7 +310,7 @@ func (t *TailscaleRustInContainer) SaveLog(path string) (string, string, error) } // WriteLogs writes the current stdout/stderr log of the container to -// the given io.Writers. +// the given [io.Writer]s. func (t *TailscaleRustInContainer) WriteLogs(stdout, stderr io.Writer) error { return dockertestutil.WriteLog(t.pool, t.container, stdout, stderr) } diff --git a/integration/tsric_test.go b/integration/tsric_test.go index 52510615..757e08b6 100644 --- a/integration/tsric_test.go +++ b/integration/tsric_test.go @@ -241,7 +241,7 @@ func TestTailscaleRustAxum(t *testing.T) { // Fire several more POSTs and verify the counter advances. // The axum handler returns {"count": N} where N is the pre-increment value. - // After the initial EventuallyWithT loop we don't know the exact counter, + // After the initial [assert.EventuallyWithT] loop we don't know the exact counter, // but two back-to-back POSTs should return consecutive values. t.Log("Verifying counter increments across multiple requests...") diff --git a/tools/capver/main.go b/tools/capver/main.go index 21c92d26..1cb3c363 100644 --- a/tools/capver/main.go +++ b/tools/capver/main.go @@ -197,7 +197,7 @@ func getCapabilityVersions(ctx context.Context) (map[string]tailcfg.CapabilityVe minorVersions := getMinorVersionsFromTags(tags) log.Printf("Found %d minor versions", len(minorVersions)) - // Regular expression to find the CurrentCapabilityVersion line + // Regular expression to find the [tailcfg.CurrentCapabilityVersion] line re := regexp.MustCompile(`const CurrentCapabilityVersion CapabilityVersion = (\d+)`) versions := make(map[string]tailcfg.CapabilityVersion) @@ -231,7 +231,7 @@ func getCapabilityVersions(ctx context.Context) (map[string]tailcfg.CapabilityVe continue } - // Find the CurrentCapabilityVersion + // Find the [tailcfg.CurrentCapabilityVersion] matches := re.FindStringSubmatch(string(body)) if len(matches) > 1 { capabilityVersionStr := matches[1] @@ -306,11 +306,11 @@ func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion content.WriteString("}\n\n") - // Add the SupportedMajorMinorVersions constant + // Add the [capver.SupportedMajorMinorVersions] constant content.WriteString("// SupportedMajorMinorVersions is the number of major.minor Tailscale versions supported.\n") fmt.Fprintf(&content, "const SupportedMajorMinorVersions = %d\n\n", supportedMajorMinorVersions) - // Add the MinSupportedCapabilityVersion constant + // Add the [capver.MinSupportedCapabilityVersion] constant content.WriteString("// MinSupportedCapabilityVersion represents the minimum capability version\n") content.WriteString("// supported by this Headscale instance (latest 10 minor versions)\n") fmt.Fprintf(&content, "const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = %d\n", minSupportedCapVer) @@ -348,7 +348,7 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport content.WriteString("// Generated DO NOT EDIT\n\n") content.WriteString("import \"tailscale.com/tailcfg\"\n\n") - // Generate complete test struct for TailscaleLatestMajorMinor + // Generate complete test struct for [capver.TailscaleLatestMajorMinor] content.WriteString("var tailscaleLatestMajorMinorTests = []struct {\n") content.WriteString("\tn int\n") content.WriteString("\tstripV bool\n") @@ -409,7 +409,7 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport } } - // Generate complete test struct for CapVerMinimumTailscaleVersion + // Generate complete test struct for [capver.CapVerMinimumTailscaleVersion] content.WriteString("var capVerMinimumTailscaleVersionTests = []struct {\n") content.WriteString("\tinput tailcfg.CapabilityVersion\n") content.WriteString("\texpected string\n")