all: apply godoc [Name] link conventions across comments

Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.

Pattern applied across the tree:
  In-package         [Foo], [Foo.Bar]
  Cross-package      [pkg.Foo], [pkg.Foo.Bar]
  Stdlib             [netip.Prefix], [errors.Is], [context.Context]
  Tailscale          [tailcfg.MapResponse], [tailcfg.Node.CapMap],
                     [tailcfg.NodeAttrSuggestExitNode]

Skip rules:
  - File:line refs left as plain text
  - HuJSON wire keys inside backtick raw strings untouched
  - ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
    symbols, left as plain text
  - JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
    link tags, decorative dividers — all left as-is
This commit is contained in:
Kristoffer Dalby 2026-05-18 18:35:53 +00:00
parent 17236fd284
commit 4cca63155d
124 changed files with 1037 additions and 1011 deletions

View File

@ -14,7 +14,7 @@ import (
// Key is the test function name, value is a list of subtest prefixes. // Key is the test function name, value is a list of subtest prefixes.
// Each prefix becomes a separate CI job as "TestName/prefix". // 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/authkey-tag-advertiseduringup-false-pol-database
// - TestAutoApproveMultiNetwork/webauth-user-advertiseduringup-true-pol-file // - TestAutoApproveMultiNetwork/webauth-user-advertiseduringup-true-pol-file
// //

View File

@ -830,7 +830,7 @@ func extractContainerLogs(ctx context.Context, cli *client.Client, containerID,
// extractContainerFiles extracts database file and directories from headscale containers. // extractContainerFiles extracts database file and directories from headscale containers.
// Note: The actual file extraction is now handled by the integration tests themselves // 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 { func extractContainerFiles(ctx context.Context, cli *client.Client, containerID, containerName, logsDir string, verbose bool) error {
// Files are now extracted directly by the integration tests // Files are now extracted directly by the integration tests
// This function is kept for potential future use or other file types // This function is kept for potential future use or other file types

View File

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

View File

@ -94,7 +94,7 @@ func detectGoVersion() string {
return "1.26.1" 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 { func splitLines(s string) []string {
var ( var (
lines []string lines []string

View File

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

View File

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

View File

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

View File

@ -60,7 +60,7 @@ func createTestAppWithNodeExpiry(t *testing.T, nodeExpiry time.Duration) *Headsc
// a tagged node with: // a tagged node with:
// - Tags from the PreAuthKey // - Tags from the PreAuthKey
// - Nil UserID (tagged nodes are owned by tags, not a user) // - Nil UserID (tagged nodes are owned by tags, not a user)
// - IsTagged() returns true. // - [types.Node.IsTagged] returns true.
func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) { func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) {
app := createTestApp(t) app := createTestApp(t)
@ -113,7 +113,7 @@ func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) {
// authentication. This is critical for the container restart scenario (#2830). // authentication. This is critical for the container restart scenario (#2830).
// //
// NOTE: This test verifies that re-authentication preserves the node's current tags // 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) { func TestReAuthDoesNotReapplyTags(t *testing.T) {
app := createTestApp(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 // 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. // user-owned nodes to tagged nodes while preserving UserID.
// TestCannotRemoveAllTags tests that attempting to remove all tags from a // 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 // TestExpiryDuringPersonalToTaggedConversion tests that when a personal node
// is converted to tagged via reauth with RequestTags, the expiry is cleared to nil. // 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 // Previously expiry was NOT cleared because expiry handling ran
// BEFORE processReauthTags. // BEFORE [state.State.processReauthTags].
func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) { func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
app := createTestApp(t) app := createTestApp(t)
user := app.state.CreateUserForTest("expiry-test-user") user := app.state.CreateUserForTest("expiry-test-user")
@ -886,8 +886,8 @@ func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
// TestExpiryDuringTaggedToPersonalConversion tests that when a tagged node // TestExpiryDuringTaggedToPersonalConversion tests that when a tagged node
// is converted to personal via reauth with empty RequestTags, expiry is set // is converted to personal via reauth with empty RequestTags, expiry is set
// from the client request. // from the client request.
// BUG #3048: Previously expiry was NOT set because expiry handling ran // Previously expiry was NOT set because expiry handling ran
// BEFORE processReauthTags (node was still tagged at check time). // BEFORE [state.State.processReauthTags] (node was still tagged at check time).
func TestExpiryDuringTaggedToPersonalConversion(t *testing.T) { func TestExpiryDuringTaggedToPersonalConversion(t *testing.T) {
app := createTestApp(t) app := createTestApp(t)
user := app.state.CreateUserForTest("expiry-test-user2") 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") assert.False(t, node.IsExpired(), "node should not be expired")
// With node.expiry=0 and zero client expiry, the node gets a zero expiry // 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() { if node.Expiry().Valid() {
assert.True(t, node.Expiry().Get().IsZero(), assert.True(t, node.Expiry().Get().IsZero(),
"with node.expiry=0 and zero client expiry, expiry should be zero time") "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), // 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 // 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 // time. Regression test for the else branch introduced in commit 6337a3db
// which assigned `&regReq.Expiry` (pointer to time.Time{}) instead of nil, // which assigned `&regReq.Expiry` (pointer to [time.Time]{}) instead of nil,
// causing the database row to hold `0001-01-01 00:00:00` instead of NULL. // 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 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 // when the client sends zero expiry), so this regression is covered for
// tagged nodes by inspection. // tagged nodes by inspection.
func TestReregistrationZeroExpiryStaysNil(t *testing.T) { func TestReregistrationZeroExpiryStaysNil(t *testing.T) {

View File

@ -31,7 +31,7 @@ type interactiveStep struct {
stepType string // stepTypeInitialRequest, stepTypeAuthCompletion, or stepTypeFollowupRequest stepType string // stepTypeInitialRequest, stepTypeAuthCompletion, or stepTypeFollowupRequest
expectAuthURL bool expectAuthURL bool
expectCacheEntry 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 //nolint:gocyclo // comprehensive test function with many scenarios
@ -140,7 +140,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err 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) { require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public()) _, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore") assert.True(c, found, "node should be available in NodeStore")
@ -209,7 +209,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err 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) { require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public()) _, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore") 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) 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 var attemptCount int
require.EventuallyWithT(t, func(c *assert.CollectT) { require.EventuallyWithT(t, func(c *assert.CollectT) {
@ -470,7 +470,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err 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) { require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public()) _, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore") assert.True(c, found, "node should be available in NodeStore")
@ -520,7 +520,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err 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) { require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public()) _, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore") assert.True(c, found, "node should be available in NodeStore")
@ -637,7 +637,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err 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) { require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public()) _, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore") assert.True(c, found, "node should be available in NodeStore")
@ -687,7 +687,7 @@ func TestAuthenticationFlows(t *testing.T) {
app.state.SetAuthCacheEntry(regID, nodeToRegister) app.state.SetAuthCacheEntry(regID, nodeToRegister)
// Simulate successful registration // Simulate successful registration
// handleRegister will receive the value when it starts waiting // [Headscale.handleRegister] will receive the value when it starts waiting
go func() { go func() {
user := app.state.CreateUserForTest("followup-user") user := app.state.CreateUserForTest("followup-user")
@ -826,7 +826,7 @@ func TestAuthenticationFlows(t *testing.T) {
}, },
// TEST: Nil hostinfo is handled with defensive code // TEST: Nil hostinfo is handled with defensive code
// WHAT: Tests that nil hostinfo in register request is handled gracefully // 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-" // EXPECTED: Node registers successfully with generated hostname starting with "node-"
// WHY: Defensive code prevents nil pointer panics; creates valid default hostinfo // 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 validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper //nolint:thelper
assert.True(t, resp.MachineAuthorized) 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. // falls back to the literal "node" per the SaaS spec.
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public()) node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(t, found) assert.True(t, found)
@ -954,7 +954,7 @@ func TestAuthenticationFlows(t *testing.T) {
// TEST: PreAuthKey registration rejects client-provided RequestTags // TEST: PreAuthKey registration rejects client-provided RequestTags
// WHAT: Tests that PreAuthKey registrations cannot use client-provided tags // 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 // 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 // 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 // TEST: Zero-time expiry is handled correctly
// WHAT: Tests registration with expiry set to zero time value // 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 // EXPECTED: Node registers successfully; zero time treated as no expiry
// WHY: Zero time is valid Go default; should be handled gracefully // 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 // TEST: Malformed hostinfo with very long hostname is truncated
// WHAT: Tests that excessively long hostname is truncated to DNS label limit // 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 // EXPECTED: Node registers successfully; hostname truncated to 63 characters
// WHY: Defensive code enforces DNS label limit (RFC 1123); prevents errors // 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 // TEST: Logout with expiry exactly at current time
// WHAT: Tests logout when expiry is set to exact current time (boundary case) // 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) // EXPECTED: Node is logged out (treated as expired)
// WHY: Edge case: current time should be 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 // TEST: Interactive workflow with nil hostinfo
// WHAT: Tests interactive registration when request has 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 // EXPECTED: Node registers successfully with generated default hostname
// WHY: Defensive code handles nil hostinfo in interactive flow // 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) 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 var node types.NodeView
require.EventuallyWithT(t, func(c *assert.CollectT) { 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) { 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.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") 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()) 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 // 1. Node registers successfully with a single-use pre-auth key
// 2. Node is running fine // 2. Node is running fine
// 3. Node restarts (e.g., after headscale upgrade or tailscale container restart) // 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" // 5. BUG: Headscale rejects the request with "authkey expired" or "authkey already used"
// //
// Expected behavior: // Expected behavior:
@ -3223,7 +3223,7 @@ func TestGitHubIssue2830_NodeRestartWithUsedPreAuthKey(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.True(t, usedPak.Used, "pre-auth key should be marked as used after initial registration") 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: // This happens when:
// - Tailscale container restarts // - Tailscale container restarts
// - Tailscaled service restarts // - Tailscaled service restarts
@ -3508,7 +3508,7 @@ func TestGitHubIssue2830_ExistingNodeCanReregisterWithUsedPreAuthKey(t *testing.
// WITHOUT THE FIX: This would fail with "authkey already used" error // 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 // 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 // This is exactly what happens when a container restarts
reregisterReq := tailcfg.RegisterRequest{ reregisterReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{ Auth: &tailcfg.RegisterResponseAuth{
@ -3787,15 +3787,15 @@ func TestAuthKeyTaggedToUserOwnedViaReauth(t *testing.T) {
nodeAfterReauth.IsTagged(), nodeAfterReauth.UserID().Get()) nodeAfterReauth.IsTagged(), nodeAfterReauth.UserID().Get())
} }
// TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate tests that when a PreAuthKey is deleted, // TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate tests that when a [types.PreAuthKey] is deleted,
// subsequent node updates (like those triggered by MapRequests) do not recreate the key. // subsequent node updates (like those triggered by [tailcfg.MapRequest]s) do not recreate the key.
// //
// This reproduces the bug where: // This reproduces the bug where:
// 1. Create a tagged preauthkey and register a node // 1. Create a tagged preauthkey and register a node
// 2. Delete the preauthkey (confirmed gone from pre_auth_keys DB table) // 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 // 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 // The fix is to use Omit("AuthKey") on all node Updates() calls to prevent GORM
// from touching the AuthKey association. // 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") require.Nil(t, dbNode.AuthKeyID, "node's AuthKeyID should be NULL after PreAuthKey deletion")
t.Log("Node's AuthKeyID is NULL in database") t.Log("Node's AuthKeyID is NULL in database")
// The NodeStore may still have stale AuthKey data in memory. // The [state.NodeStore] may still have stale AuthKey data in memory.
// Now simulate what happens when the node sends a MapRequest after a tailscaled restart. // Now simulate what happens when the node sends a [tailcfg.MapRequest] after a tailscaled restart.
// This triggers persistNodeToDB which calls GORM's Updates(). // 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 // This mimics what poll.go does when processing MapRequests
mapReq := tailcfg.MapRequest{ mapReq := tailcfg.MapRequest{
NodeKey: nodeKey.Public(), NodeKey: nodeKey.Public(),
@ -3879,8 +3879,8 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
}, },
} }
// Process the MapRequest-like update // Process the [tailcfg.MapRequest]-like update
// This calls UpdateNodeFromMapRequest which eventually calls persistNodeToDB // This calls [state.State.UpdateNodeFromMapRequest] which eventually calls [state.State.persistNodeToDB]
_, err = app.state.UpdateNodeFromMapRequest(node.ID(), mapReq) _, err = app.state.UpdateNodeFromMapRequest(node.ID(), mapReq)
require.NoError(t, err, "UpdateNodeFromMapRequest should succeed") require.NoError(t, err, "UpdateNodeFromMapRequest should succeed")
t.Log("Simulated MapRequest update completed") t.Log("Simulated MapRequest update completed")
@ -3943,7 +3943,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
alice := app.state.CreateUserForTest("alice") alice := app.state.CreateUserForTest("alice")
require.NotNil(t, alice, "Alice user should be created") require.NotNil(t, alice, "Alice user should be created")
// Step 4: Re-register the node to alice via HandleNodeFromAuthPath // Step 4: Re-register the node to alice via [state.State.HandleNodeFromAuthPath]
// This is what happens when running: headscale auth register --auth-id <id> --user alice // This is what happens when running: headscale auth register --auth-id <id> --user alice
nodeKey2 := key.NewNode() nodeKey2 := key.NewNode()
registrationID := types.MustAuthID() registrationID := types.MustAuthID()
@ -3960,7 +3960,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
// This should NOT panic - before the fix, this would panic with: // This should NOT panic - before the fix, this would panic with:
// panic: runtime error: invalid memory address or nil pointer dereference // 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( nodeAfterReauth, _, err := app.state.HandleNodeFromAuthPath(
registrationID, registrationID,
types.UserID(alice.ID), 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.False(t, nodeAfterReauth.IsTagged(), "Node should no longer be tagged")
require.Empty(t, nodeAfterReauth.Tags().AsSlice(), "Node should have no tags") require.Empty(t, nodeAfterReauth.Tags().AsSlice(), "Node should have no tags")
// Verify Owner() works without panicking - this is what the mapper's // Verify [types.NodeView.Owner] works without panicking - this is what the mapper's
// generateUserProfiles calls, and it would panic with a nil pointer // [generateUserProfiles] calls, and it would panic with a nil pointer
// dereference if node.User was not set during the tag→user conversion. // dereference if node.User was not set during the tag→user conversion.
owner := nodeAfterReauth.Owner() owner := nodeAfterReauth.Owner()
require.True(t, owner.Valid(), "Owner should be valid after conversion (mapper would panic if nil)") require.True(t, owner.Valid(), "Owner should be valid after conversion (mapper would panic if nil)")

View File

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

View File

@ -28,7 +28,7 @@ var (
ErrAPIKeyInvalidGeneration = errors.New("generated API key failed validation") 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( func (hsdb *HSDatabase) CreateAPIKey(
expiration *time.Time, expiration *time.Time,
) (string, *types.APIKey, error) { ) (string, *types.APIKey, error) {
@ -84,7 +84,7 @@ func (hsdb *HSDatabase) CreateAPIKey(
return keyStr, &key, nil 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) { func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
keys := []types.APIKey{} keys := []types.APIKey{}
@ -96,7 +96,7 @@ func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
return keys, nil 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) { func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) {
key := types.APIKey{} key := types.APIKey{}
if result := hsdb.DB.First(&key, "prefix = ?", prefix); result.Error != nil { 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 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) { func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
key := types.APIKey{} key := types.APIKey{}
if result := hsdb.DB.Find(&types.APIKey{ID: id}).First(&key); result.Error != nil { 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 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. // does not exist.
func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error { func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
if result := hsdb.DB.Unscoped().Delete(key); result.Error != nil { if result := hsdb.DB.Unscoped().Delete(key); result.Error != nil {
@ -126,7 +126,7 @@ func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
return nil return nil
} }
// ExpireAPIKey marks a ApiKey as expired. // ExpireAPIKey marks a [types.APIKey] as expired.
func (hsdb *HSDatabase) ExpireAPIKey(key *types.APIKey) error { func (hsdb *HSDatabase) ExpireAPIKey(key *types.APIKey) error {
err := hsdb.DB.Model(&key).Update("Expiration", time.Now()).Error err := hsdb.DB.Model(&key).Update("Expiration", time.Now()).Error
if err != nil { if err != nil {

View File

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

View File

@ -49,7 +49,7 @@ type IPAllocator struct {
usedIPs netipx.IPSetBuilder 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 // can be used to hand out unique IP addresses within the
// provided IPv4 and IPv6 prefix. It needs to be created // provided IPv4 and IPv6 prefix. It needs to be created
// when headscale starts and needs to finish its read // 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 // 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 // and ensure it has IP addresses according to the current
// configuration. // configuration.
// This means that if both IPv4 and IPv6 is set in the // 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 // Use Updates() with Select() to only update IP fields, avoiding overwriting
// other fields like Expiry. We need Select() because Updates() alone skips // 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. // 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 err := tx.Model(node).Select("ipv4", "ipv6").Updates(node).Error
if err != nil { if err != nil {
return fmt.Errorf("saving node(%d) after adding IPs: %w", node.ID, err) return fmt.Errorf("saving node(%d) after adding IPs: %w", node.ID, err)

View File

@ -109,7 +109,7 @@ func (hsdb *HSDatabase) getNode(uid types.UserID, name string) (*types.Node, err
}) })
} }
// getNode finds a Node by name and user and returns the Node struct. // getNode finds a [types.Node] by name and user and returns the [types.Node] struct.
func getNode(tx *gorm.DB, uid types.UserID, name string) (*types.Node, error) { func getNode(tx *gorm.DB, uid types.UserID, name string) (*types.Node, error) {
nodes, err := ListNodesByUser(tx, uid) nodes, err := ListNodesByUser(tx, uid)
if err != nil { if err != nil {
@ -129,7 +129,7 @@ func (hsdb *HSDatabase) GetNodeByID(id types.NodeID) (*types.Node, error) {
return GetNodeByID(hsdb.DB, id) 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) { func GetNodeByID(tx *gorm.DB, id types.NodeID) (*types.Node, error) {
mach := types.Node{} mach := types.Node{}
if result := tx. if result := tx.
@ -147,7 +147,7 @@ func (hsdb *HSDatabase) GetNodeByMachineKey(machineKey key.MachinePublic) (*type
return GetNodeByMachineKey(hsdb.DB, machineKey) 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( func GetNodeByMachineKey(
tx *gorm.DB, tx *gorm.DB,
machineKey key.MachinePublic, machineKey key.MachinePublic,
@ -168,7 +168,7 @@ func (hsdb *HSDatabase) GetNodeByNodeKey(nodeKey key.NodePublic) (*types.Node, e
return GetNodeByNodeKey(hsdb.DB, nodeKey) 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( func GetNodeByNodeKey(
tx *gorm.DB, tx *gorm.DB,
nodeKey key.NodePublic, 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 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. // and renames it. Validation should be done in the state layer before calling this function.
func RenameNode(tx *gorm.DB, func RenameNode(tx *gorm.DB,
nodeID types.NodeID, newName string, 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. // Caller is responsible for notifying all of change.
func DeleteNode(tx *gorm.DB, func DeleteNode(tx *gorm.DB,
node *types.Node, node *types.Node,
@ -259,7 +259,7 @@ func DeleteNode(tx *gorm.DB,
return nil 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. // will remove it straight, and not notify any changes or consider any routes.
// It is intended for Ephemeral nodes. // It is intended for Ephemeral nodes.
func (hsdb *HSDatabase) DeleteEphemeralNode( 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. // 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) { func RegisterNodeForTest(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *netip.Addr) (*types.Node, error) {
if !testing.Testing() { if !testing.Testing() {
panic("RegisterNodeForTest can only be called during tests") panic("RegisterNodeForTest can only be called during tests")
@ -387,7 +387,7 @@ func NodeSetMachineKey(
// EphemeralGarbageCollector is a garbage collector that will delete nodes after // EphemeralGarbageCollector is a garbage collector that will delete nodes after
// a certain amount of time. // 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. // cleaned up.
type EphemeralGarbageCollector struct { type EphemeralGarbageCollector struct {
mu sync.Mutex mu sync.Mutex
@ -399,7 +399,7 @@ type EphemeralGarbageCollector struct {
cancelCh chan 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. // a deleteFunc that will be called when a node is scheduled for deletion.
func NewEphemeralGarbageCollector(deleteFunc func(types.NodeID)) *EphemeralGarbageCollector { func NewEphemeralGarbageCollector(deleteFunc func(types.NodeID)) *EphemeralGarbageCollector {
return &EphemeralGarbageCollector{ return &EphemeralGarbageCollector{

View File

@ -31,7 +31,7 @@ func (hsdb *HSDatabase) GetPolicy() (*types.Policy, error) {
} }
// GetPolicy returns the latest policy from the database. // 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. // such as during migrations.
func GetPolicy(tx *gorm.DB) (*types.Policy, error) { func GetPolicy(tx *gorm.DB) (*types.Policy, error) {
var p types.Policy 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. // PolicyBytes loads policy configuration from file or database based on the configured mode.
// Returns nil if no policy is configured, which is valid. // 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. // such as during migrations.
func PolicyBytes(tx *gorm.DB, cfg *types.Config) ([]byte, error) { func PolicyBytes(tx *gorm.DB, cfg *types.Config) ([]byte, error) {
switch cfg.Policy.Mode { switch cfg.Policy.Mode {

View File

@ -40,7 +40,7 @@ const (
authKeyLength = 64 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. // The uid parameter can be nil for system-created tagged keys.
// For tagged keys, uid tracks "created by" (who created the key). // For tagged keys, uid tracks "created by" (who created the key).
// For user-owned keys, uid tracks the node owner. // 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) 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) { func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
var keys []types.PreAuthKey var keys []types.PreAuthKey
@ -170,7 +170,7 @@ func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
return keys, nil 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) { func ListPreAuthKeysByUser(tx *gorm.DB, uid types.UserID) ([]types.PreAuthKey, error) {
var keys []types.PreAuthKey var keys []types.PreAuthKey
@ -290,13 +290,13 @@ func (hsdb *HSDatabase) GetPreAuthKey(key string) (*types.PreAuthKey, error) {
return GetPreAuthKey(hsdb.DB, key) 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). // for checking if the key is usable (expired or used).
func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) { func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) {
return findAuthKey(tx, key) 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 // does not exist. This also clears the auth_key_id on any nodes that reference
// this key. // this key.
func DestroyPreAuthKey(tx *gorm.DB, id uint64) error { 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 // guarded by `used = false` so two concurrent registrations racing for
// the same single-use key cannot both succeed: the first commits and // 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 // guard the previous code (Update("used", true) with no WHERE) would
// silently let both transactions claim the key. // silently let both transactions claim the key.
func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error { func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
@ -354,7 +354,7 @@ func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
return nil return nil
} }
// ExpirePreAuthKey marks a PreAuthKey as expired. // ExpirePreAuthKey marks a [types.PreAuthKey] as expired.
func ExpirePreAuthKey(tx *gorm.DB, id uint64) error { func ExpirePreAuthKey(tx *gorm.DB, id uint64) error {
now := time.Now() now := time.Now()
return tx.Model(&types.PreAuthKey{}).Where("id = ?", id).Update("expiration", now).Error return tx.Model(&types.PreAuthKey{}).Where("id = ?", id).Update("expiration", now).Error

View File

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

View File

@ -84,7 +84,7 @@ func parseVersion(s string) (semver, error) {
} }
// ensureDatabaseVersionTable creates the database_versions table if it // 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). // differences between SQLite (datetime) and PostgreSQL (timestamp).
// This runs before gormigrate migrations. // This runs before gormigrate migrations.
func ensureDatabaseVersionTable(db *gorm.DB) error { func ensureDatabaseVersionTable(db *gorm.DB) error {

View File

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

View File

@ -30,7 +30,7 @@ type ExtraRecordsMan struct {
hashes map[string][32]byte 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) { func NewExtraRecordsManager(path string) (*ExtraRecordsMan, error) {
watcher, err := fsnotify.NewWatcher() watcher, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
@ -177,7 +177,7 @@ func (e *ExtraRecordsMan) updateRecords() {
e.updateCh <- e.records.Slice() 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. // and returns the records and the hash of the file.
func readExtraRecordsFromPath(path string) ([]tailcfg.DNSRecord, [32]byte, error) { func readExtraRecordsFromPath(path string) ([]tailcfg.DNSRecord, [32]byte, error) {
b, err := os.ReadFile(path) b, err := os.ReadFile(path)

View File

@ -59,7 +59,7 @@ func (api headscaleV1APIServer) CreateUser(
return nil, status.Errorf(codes.Internal, "creating user: %s", err) 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. // This triggers a full policy re-evaluation for all connected nodes.
api.h.Change(policyChanged) api.h.Change(policyChanged)
@ -105,7 +105,7 @@ func (api headscaleV1APIServer) DeleteUser(
return nil, err 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) api.h.Change(policyChanged)
return &v1.DeleteUserResponse{}, nil return &v1.DeleteUserResponse{}, nil
@ -293,7 +293,7 @@ func (api headscaleV1APIServer) RegisterNode(
return nil, fmt.Errorf("auto approving routes: %w", err) 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) api.h.Change(nodeChange, routeChange)
return &v1.RegisterNodeResponse{Node: node.Proto()}, nil return &v1.RegisterNodeResponse{Node: node.Proto()}, nil
@ -396,11 +396,11 @@ func (api headscaleV1APIServer) SetApprovedRoutes(
return nil, status.Error(codes.InvalidArgument, err.Error()) 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) api.h.Change(nodeChange)
proto := node.Proto() 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) // routes that are actively served from the node (per architectural requirement in types/node.go)
primaryRoutes := api.h.state.GetNodePrimaryRoutes(node.ID()) primaryRoutes := api.h.state.GetNodePrimaryRoutes(node.ID())
proto.SubnetRoutes = util.PrefixesToString(primaryRoutes) 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() { for index, node := range nodes.All() {
resp := node.Proto() 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) // (UserID may be set internally for "created by" tracking)
if node.IsTagged() { if node.IsTagged() {
resp.User = types.TaggedDevices.Proto() resp.User = types.TaggedDevices.Proto()
@ -852,9 +852,9 @@ func (api headscaleV1APIServer) DebugCreateNode(
authRegReq := types.NewRegisterAuthRequest(regData) authRegReq := types.NewRegisterAuthRequest(regData)
api.h.state.SetAuthCacheEntry(registrationId, authRegReq) api.h.state.SetAuthCacheEntry(registrationId, authRegReq)
// Echo back a synthetic Node so the debug response surface stays // Echo back a synthetic [types.Node] so the debug response surface stays
// stable. The actual node is created later by AuthApprove via // stable. The actual node is created later by [headscaleV1APIServer.AuthApprove] via
// HandleNodeFromAuthPath using the cached RegistrationData. // [state.State.HandleNodeFromAuthPath] using the cached [types.RegistrationData].
echoNode := types.Node{ echoNode := types.Node{
NodeKey: regData.NodeKey, NodeKey: regData.NodeKey,
MachineKey: regData.MachineKey, MachineKey: regData.MachineKey,

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ import (
"tailscale.com/util/multierr" "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 { type MapResponseBuilder struct {
resp *tailcfg.MapResponse resp *tailcfg.MapResponse
mapper *mapper mapper *mapper
@ -180,6 +180,10 @@ func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView])
} }
// WithPacketFilters adds packet filter rules based on policy. // 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 { func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder {
node, ok := b.mapper.state.GetNodeByID(b.nodeID) node, ok := b.mapper.state.GetNodeByID(b.nodeID)
if !ok { if !ok {
@ -187,9 +191,6 @@ func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder {
return b 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) filter, err := b.mapper.state.FilterForNode(node)
if err != nil { if err != nil {
b.addError(err) b.addError(err)
@ -233,7 +234,8 @@ func (b *MapResponseBuilder) WithPeerChanges(peers views.Slice[types.NodeView])
return b 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) { func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ([]*tailcfg.Node, error) {
node, ok := b.mapper.state.GetNodeByID(b.nodeID) node, ok := b.mapper.state.GetNodeByID(b.nodeID)
if !ok { if !ok {
@ -241,9 +243,10 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
} }
// Get unreduced matchers for peer relationship determination. // Get unreduced matchers for peer relationship determination.
// MatchersForNode returns unreduced matchers that include all rules where the node // [State.MatchersForNode] returns unreduced matchers that include all rules where the
// could be either source or destination. This is different from FilterForNode which // node could be either source or destination. This is different from
// returns reduced rules for packet filtering (only rules where node is destination). // [State.FilterForNode] which returns reduced rules for packet filtering (only rules
// where node is destination).
matchers, err := b.mapper.state.MatchersForNode(node) matchers, err := b.mapper.state.MatchersForNode(node)
if err != nil { if err != nil {
return nil, err return nil, err

View File

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

View File

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

View File

@ -65,14 +65,14 @@ const (
// The first 9 bytes from the server to client over Noise are either an HTTP/2 // 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" // 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 // 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. // 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! // The early payload is optional. Some servers may not send it... But we do!
earlyPayloadMagic = "\xff\xff\xffTS" earlyPayloadMagic = "\xff\xff\xffTS"
// noiseBodyLimit is the maximum allowed request body size for Noise protocol // noiseBodyLimit is the maximum allowed request body size for Noise protocol
// handlers. This prevents unauthenticated OOM attacks via unbounded io.ReadAll. // handlers. This prevents unauthenticated OOM attacks via unbounded [io.ReadAll].
// No legitimate Noise request (MapRequest, RegisterRequest, etc.) comes close // No legitimate Noise request ([tailcfg.MapRequest], [tailcfg.RegisterRequest], etc.) comes close
// to this limit; typical payloads are a few KB. // to this limit; typical payloads are a few KB.
noiseBodyLimit int64 = 1048576 // 1 MiB noiseBodyLimit int64 = 1048576 // 1 MiB
) )
@ -86,12 +86,12 @@ type noiseServer struct {
machineKey key.MachinePublic machineKey key.MachinePublic
nodeKey key.NodePublic nodeKey key.NodePublic
// EarlyNoise-related stuff // [tailcfg.EarlyNoise]-related stuff
challenge key.ChallengePrivate challenge key.ChallengePrivate
protocolVersion int 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. // in order to use the Noise-based TS2021 protocol. Listens in /ts2021.
func (h *Headscale) NoiseUpgradeHandler( func (h *Headscale) NoiseUpgradeHandler(
writer http.ResponseWriter, 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. // 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 // 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() 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 // 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. // The unguessable ping ID serves as authentication.
func (h *Headscale) PingResponseHandler( func (h *Headscale) PingResponseHandler(
writer http.ResponseWriter, writer http.ResponseWriter,
@ -457,12 +457,12 @@ func (ns *noiseServer) SSHActionHandler(
} }
// sshAction resolves the SSH action for the given request parameters. // 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: // Three cases:
// 1. Initial request, auto-approved — source recently authenticated // 1. Initial request, auto-approved — source recently authenticated
// within the check period, accept immediately. // 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. // wait for the user to authenticate.
// 3. Follow-up request — an auth_id is present, wait for the auth // 3. Follow-up request — an auth_id is present, wait for the auth
// verdict and accept or reject. // verdict and accept or reject.
@ -514,7 +514,7 @@ func (ns *noiseServer) sshAction(
} }
// sshActionHoldAndDelegate creates a new auth session bound to the // 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. // client to authenticate.
func (ns *noiseServer) sshActionHoldAndDelegate( func (ns *noiseServer) sshActionHoldAndDelegate(
reqLog zerolog.Logger, reqLog zerolog.Logger,
@ -636,8 +636,8 @@ func (ns *noiseServer) sshActionFollowUp(
case <-ctx.Done(): case <-ctx.Done():
// The client disconnected (or its request timed out) before the // The client disconnected (or its request timed out) before the
// auth session resolved. Return an error so the parked goroutine // auth session resolved. Return an error so the parked goroutine
// is freed; without this select sshActionFollowUp would block // is freed; without this select [noiseServer.sshActionFollowUp] would block
// until the cache eviction callback signalled FinishAuth, which // until the cache eviction callback signalled [types.AuthRequest.FinishAuth], which
// could be up to register_cache_expiration (15 minutes). // could be up to register_cache_expiration (15 minutes).
return nil, NewHTTPError( return nil, NewHTTPError(
http.StatusUnauthorized, http.StatusUnauthorized,
@ -674,8 +674,8 @@ func (ns *noiseServer) sshActionFollowUp(
// This is the busiest endpoint, as it keeps the HTTP long poll that updates // This is the busiest endpoint, as it keeps the HTTP long poll that updates
// the clients when something in the network changes. // the clients when something in the network changes.
// //
// The clients POST stuff like HostInfo and their Endpoints here, but // The clients POST stuff like [tailcfg.Hostinfo] and their Endpoints here, but
// only after their first request (marked with the ReadOnly field). // 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. // At this moment the updates are sent in a quite horrendous way, but they kinda work.
func (ns *noiseServer) PollNetMapHandler( func (ns *noiseServer) PollNetMapHandler(

View File

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

View File

@ -44,13 +44,13 @@ func MatchesFromFilterRules(rules []tailcfg.FilterRule) []Match {
return matches return matches
} }
// MatchFromFilterRule derives a Match from a tailcfg.FilterRule. The // MatchFromFilterRule derives a [Match] from a [tailcfg.FilterRule]. The
// destination IP set is the union of DstPorts[].IP and CapGrant[].Dsts: // destination IP set is the union of [tailcfg.FilterRule.DstPorts][].IP
// cap-grant-only rules (e.g. tailscale.com/cap/relay) carry their // and [tailcfg.FilterRule.CapGrant][].Dsts: cap-grant-only rules (e.g.
// destinations in CapGrant.Dsts and would otherwise contribute nothing // tailscale.com/cap/relay) carry their destinations in CapGrant.Dsts and
// to peer-visibility derivation in BuildPeerMap / ReduceNodes, hiding // would otherwise contribute nothing to peer-visibility derivation in
// the cap target from the source unless a companion IP-level rule // [policy.BuildPeerMap] / [policy.ReduceNodes], hiding the cap target
// also exists. // from the source unless a companion IP-level rule also exists.
func MatchFromFilterRule(rule tailcfg.FilterRule) Match { func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
srcs := new(netipx.IPSetBuilder) srcs := new(netipx.IPSetBuilder)
dests := 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 // 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 // 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 { func MatchFromStrings(sources, destinations []string) Match {
srcs := new(netipx.IPSetBuilder) srcs := new(netipx.IPSetBuilder)
dests := 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 // DestsIsTheInternet reports whether the destination covers "the
// internet" — the set represented by autogroup:internet, special-cased // internet" — the set represented by autogroup:internet, special-cased
// for exit nodes. Returns true if either family's /0 is contained // 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 // single-family /0 counts because operators may write it directly and
// it still denotes the whole internet for that family. // it still denotes the whole internet for that family.
func (m *Match) DestsIsTheInternet() bool { func (m *Match) DestsIsTheInternet() bool {
@ -140,7 +140,7 @@ func (m *Match) DestsIsTheInternet() bool {
return true 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. // where the internet prefixes are combined with other dests.
theInternet := util.TheInternet() theInternet := util.TheInternet()
for _, prefix := range theInternet.Prefixes() { for _, prefix := range theInternet.Prefixes() {

View File

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

View File

@ -51,6 +51,11 @@ func ReduceRoutes(
} }
// BuildPeerMap builds a map of all peers that can be accessed by each node. // 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( func BuildPeerMap(
nodes views.Slice[types.NodeView], nodes views.Slice[types.NodeView],
matchers []matcher.Match, matchers []matcher.Match,
@ -58,9 +63,6 @@ func BuildPeerMap(
ret := make(map[types.NodeID][]types.NodeView, nodes.Len()) ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
// Build the map of all peers according to the matchers. // 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 i := range nodes.Len() {
for j := i + 1; j < nodes.Len(); j++ { for j := i + 1; j < nodes.Len(); j++ {
if nodes.At(i).ID() == nodes.At(j).ID() { 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 // 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: // The approved routes will include:
// 1. ALL previously approved routes (regardless of whether they're still advertised) // 1. ALL previously approved routes (regardless of whether they're still advertised)
// 2. New routes from announcedRoutes that can be auto-approved by policy // 2. New routes from announcedRoutes that can be auto-approved by policy

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,8 +31,8 @@ var (
// //
// Brackets are only accepted around IPv6 addresses, not IPv4, hostnames, or other alias types. // 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", // Bracket stripping reduces both forms to bare "addr:port" or "addr/prefix:port",
// which the normal LastIndex(":") split handles correctly because port strings // which the normal [strings.LastIndex] of ":" split handles correctly because
// never contain colons. // port strings never contain colons.
func splitDestinationAndPort(input string) (string, string, error) { func splitDestinationAndPort(input string) (string, string, error) {
// Handle RFC 3986 bracketed IPv6 (e.g. "[::1]:80" or "[fd7a::1]/128:80,443"). // 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. // 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 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) { func parsePortRange(portDef string) ([]tailcfg.PortRange, error) {
if portDef == "*" { if portDef == "*" {
return []tailcfg.PortRange{tailcfg.PortRangeAny}, nil return []tailcfg.PortRange{tailcfg.PortRangeAny}, nil

View File

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

View File

@ -100,7 +100,7 @@ func (w *delayedSuccessResponseWriter) WriteCount() int {
// 3. While that write is blocked, queue enough updates to fill the buffered // 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. // 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 // 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. // but stop receiving future updates.
// 5. Release the blocked write and verify the batcher-side stop signal makes // 5. Release the blocked write and verify the batcher-side stop signal makes
// that stale session exit instead of lingering as an orphaned goroutine. // that stale session exit instead of lingering as an orphaned goroutine.

View File

@ -20,7 +20,7 @@ var proxyHeaders = [...]string{headerTrueClientIP, headerXRealIP, headerXForward
// trustedProxyRealIP rewrites r.RemoteAddr from proxy headers when the // trustedProxyRealIP rewrites r.RemoteAddr from proxy headers when the
// peer is in trusted; for any other peer the headers are stripped so a // 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 // 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. // proxy chain.
func trustedProxyRealIP(trusted []netip.Prefix) (func(http.Handler) http.Handler, error) { func trustedProxyRealIP(trusted []netip.Prefix) (func(http.Handler) http.Handler, error) {
ranges := make([]net.IPNet, 0, len(trusted)) ranges := make([]net.IPNet, 0, len(trusted))

View File

@ -96,7 +96,7 @@ func AssertPeerGone(tb testing.TB, observer *TestClient, peerName string) {
} }
// AssertPeerHasAllowedIPs checks that a peer has the expected // 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) { func AssertPeerHasAllowedIPs(tb testing.TB, observer *TestClient, peerName string, want []netip.Prefix) {
tb.Helper() 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. // timeout, useful when waiting for state to propagate.
func EventuallyAssertMeshComplete(tb testing.TB, clients []*TestClient, timeout time.Duration) { func EventuallyAssertMeshComplete(tb testing.TB, clients []*TestClient, timeout time.Duration) {
tb.Helper() tb.Helper()

View File

@ -19,8 +19,8 @@ import (
"tailscale.com/util/eventbus" "tailscale.com/util/eventbus"
) )
// TestClient wraps a Tailscale controlclient.Direct connected to a // TestClient wraps a Tailscale [controlclient.Direct] connected to a
// TestServer. It tracks all received NetworkMap updates, providing // [TestServer]. It tracks all received [netmap.NetworkMap] updates, providing
// helpers to wait for convergence and inspect the client's view of // helpers to wait for convergence and inspect the client's view of
// the network. // the network.
type TestClient struct { type TestClient struct {
@ -37,13 +37,13 @@ type TestClient struct {
pollCancel context.CancelFunc pollCancel context.CancelFunc
pollDone chan struct{} pollDone chan struct{}
// Accumulated state from MapResponse callbacks. // Accumulated state from [tailcfg.MapResponse] callbacks.
mu sync.RWMutex mu sync.RWMutex
netmap *netmap.NetworkMap netmap *netmap.NetworkMap
history []*netmap.NetworkMap history []*netmap.NetworkMap
// updates is a buffered channel that receives a signal // 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 updates chan *netmap.NetworkMap
bus *eventbus.Bus bus *eventbus.Bus
@ -51,7 +51,7 @@ type TestClient struct {
tracker *health.Tracker tracker *health.Tracker
} }
// ClientOption configures a TestClient. // ClientOption configures a [TestClient].
type ClientOption func(*clientConfig) type ClientOption func(*clientConfig)
type clientConfig struct { type clientConfig struct {
@ -66,7 +66,7 @@ func WithEphemeral() ClientOption {
return func(c *clientConfig) { c.ephemeral = true } 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 { func WithHostname(name string) ClientOption {
return func(c *clientConfig) { c.hostname = name } return func(c *clientConfig) { c.hostname = name }
} }
@ -82,7 +82,7 @@ func WithUser(user *types.User) ClientOption {
return func(c *clientConfig) { c.user = user } 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. // using a pre-auth key, and starts long-polling for map updates.
func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOption) *TestClient { func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOption) *TestClient {
tb.Helper() tb.Helper()
@ -171,7 +171,7 @@ func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOpt
return tc 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) { func (c *TestClient) register(tb testing.TB) {
tb.Helper() 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) { func (c *TestClient) startPoll(tb testing.TB) {
tb.Helper() tb.Helper()
@ -197,14 +197,14 @@ func (c *TestClient) startPoll(tb testing.TB) {
go func() { go func() {
defer close(c.pollDone) 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. // the connection.
_ = c.direct.PollNetMap(c.pollCtx, c) _ = c.direct.PollNetMap(c.pollCtx, c)
}() }()
} }
// UpdateFullNetmap implements controlclient.NetmapUpdater. // UpdateFullNetmap implements [controlclient.NetmapUpdater].
// Called by controlclient.Direct when a new NetworkMap is received. // Called by [controlclient.Direct] when a new [netmap.NetworkMap] is received.
func (c *TestClient) UpdateFullNetmap(nm *netmap.NetworkMap) { func (c *TestClient) UpdateFullNetmap(nm *netmap.NetworkMap) {
c.mu.Lock() c.mu.Lock()
c.netmap = nm c.netmap = nm
@ -259,7 +259,7 @@ func (c *TestClient) Disconnect(tb testing.TB) {
} }
// Reconnect registers and starts a new long-poll session. // 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) { func (c *TestClient) Reconnect(tb testing.TB) {
tb.Helper() 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 // actually wait for the new session's map instead of returning
// immediately based on the old session's cached state. // immediately based on the old session's cached state.
c.mu.Lock() c.mu.Lock()
@ -282,7 +282,7 @@ func (c *TestClient) Reconnect(tb testing.TB) {
c.mu.Unlock() c.mu.Unlock()
// Drain any pending updates from the old session so they // 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 { for {
select { select {
case <-c.updates: case <-c.updates:
@ -315,7 +315,7 @@ func (c *TestClient) ReconnectAfter(tb testing.TB, d time.Duration) {
// --- State accessors --- // --- 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 { func (c *TestClient) Netmap() *netmap.NetworkMap {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@ -425,7 +425,7 @@ func (c *TestClient) UpdateCount() int {
return len(c.history) 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 { func (c *TestClient) History() []*netmap.NetworkMap {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() 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 // Direct returns the underlying [controlclient.Direct] for
// advanced operations like SetHostinfo or SendUpdate. // advanced operations like [controlclient.Direct.SetHostinfo] or SendUpdate.
func (c *TestClient) Direct() *controlclient.Direct { func (c *TestClient) Direct() *controlclient.Direct {
return c.direct return c.direct
} }
// String implements fmt.Stringer for debug output. // String implements [fmt.Stringer] for debug output.
func (c *TestClient) String() string { func (c *TestClient) String() string {
nm := c.Netmap() nm := c.Netmap()
if nm == nil { if nm == nil {

View File

@ -15,16 +15,16 @@ import (
) )
// TestConnectDisconnectRace targets the residual TOCTOU window in // TestConnectDisconnectRace targets the residual TOCTOU window in
// state.Disconnect: the connectGeneration check at state.go:644 is not // [state.State.Disconnect]: the connectGeneration check at state.go:644 is not
// atomic with the subsequent NodeStore.UpdateNode and // atomic with the subsequent [state.NodeStore.UpdateNode] and
// primaryRoutes.SetRoutes calls. A new Connect that runs between the // primaryRoutes.SetRoutes calls. A new [state.State.Connect] that runs between the
// gen check and the mutations can have its effects overwritten by 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 // 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 // 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. // Run with -race to also catch any data race exposed.
func TestConnectDisconnectRace(t *testing.T) { func TestConnectDisconnectRace(t *testing.T) {
@ -33,15 +33,15 @@ func TestConnectDisconnectRace(t *testing.T) {
route := netip.MustParsePrefix("10.0.0.0/24") route := netip.MustParsePrefix("10.0.0.0/24")
// Use NewClient to get a node fully registered + Connected via the // Use [servertest.NewClient] to get a node fully registered + Connected via the
// real noise/poll path. After this, NodeStore + primaryRoutes already // real noise/poll path. After this, [state.NodeStore] + primaryRoutes already
// have the node, and Connect has been called once. // 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 // primary across changes (anti-flap, see primary.go), so if both
// nodes were advertising, c1 (lower NodeID) would stay primary and // nodes were advertising, c1 (lower NodeID) would stay primary and
// the test could never observe the route slipping out of c2's // 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)) c1 := servertest.NewClient(t, srv, "race-r1", servertest.WithUser(user))
c2 := servertest.NewClient(t, srv, "race-r2", 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) srv.App.Change(ch)
// Wait for advertisement + approval to be reflected as a primary // Wait for advertisement + approval to be reflected as a primary
// route assignment in PrimaryRoutes; otherwise we'd be racing the // route assignment in [tailcfg.NodeView.PrimaryRoutes]; otherwise we'd be racing the
// initial steady-state setup, not the Connect/Disconnect window. // initial steady-state setup, not the [state.State.Connect]/[state.State.Disconnect] window.
require.Eventually(t, func() bool { require.Eventually(t, func() bool {
return slices.Contains(srv.State().GetNodePrimaryRoutes(r2ID), route) return slices.Contains(srv.State().GetNodePrimaryRoutes(r2ID), route)
}, 10*time.Second, 50*time.Millisecond, }, 10*time.Second, 50*time.Millisecond,
"primary route should be assigned to r2 before driving the race") "primary route should be assigned to r2 before driving the race")
// Drive the race repeatedly. Each iteration: // 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. // 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. // 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. // the new session arriving.
// 4. After both finish, check the state is consistent: the node // 4. After both finish, check the state is consistent: the node
// should be online and primaryRoutes should hold the approved // should be online and primaryRoutes should hold the approved

View File

@ -10,7 +10,7 @@ import (
"tailscale.com/types/netmap" "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 // content: that the self node, peers, DERP map, and other fields
// are populated correctly. // are populated correctly.
func TestContentVerification(t *testing.T) { func TestContentVerification(t *testing.T) {

View File

@ -23,7 +23,7 @@ import (
// channel is never closed (documented as a v3 TODO upstream). // 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 // - 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. // durably blocking in synctest, causing hangs.
// - https://github.com/golang/go/issues/77687 (mutex as durably blocking) // - 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 // Ensure the ephemeral node's long-poll session is fully
// established on the server before disconnecting. Without // 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 // yet reached serveLongPoll, so no grace period or ephemeral
// GC would ever be scheduled. // GC would ever be scheduled.
ephemeral.WaitForPeers(t, 1, 10*time.Second) ephemeral.WaitForPeers(t, 1, 10*time.Second)

View File

@ -16,7 +16,7 @@ import (
// TestGrantPolicies verifies that grant-based policies propagate // TestGrantPolicies verifies that grant-based policies propagate
// correctly through the full control plane (policy -> state -> mapper) // 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 func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
t.Parallel() t.Parallel()
@ -66,7 +66,7 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
return c2.UpdateCount() > countC2 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() nm1 := c1.Netmap()
require.NotNil(t, nm1) require.NotNil(t, nm1)
assert.NotNil(t, nm1.PacketFilter, assert.NotNil(t, nm1.PacketFilter,
@ -132,7 +132,7 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
srv.App.Change(changes...) 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", c1.WaitForCondition(t, "packet filter with cap grants",
10*time.Second, 10*time.Second,
func(nm *netmap.NetworkMap) bool { func(nm *netmap.NetworkMap) bool {
@ -143,7 +143,7 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
nm1 := c1.Netmap() nm1 := c1.Netmap()
require.NotNil(t, nm1) 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. // The main grant produces cap/drive and cap/relay.
// Companion caps (drive-sharer and relay-target) are // Companion caps (drive-sharer and relay-target) are
// generated with reversed direction. // generated with reversed direction.
@ -657,9 +657,9 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
} }
// TestGrantViaSubnetFilterRules verifies that routers with via grants // 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 // 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. // via-steered subnet destinations, causing traffic to be dropped.
func TestGrantViaSubnetFilterRules(t *testing.T) { func TestGrantViaSubnetFilterRules(t *testing.T) {
t.Parallel() t.Parallel()
@ -748,7 +748,7 @@ func TestGrantViaSubnetFilterRules(t *testing.T) {
return false 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. // the via-steered subnet (10.0.0.0/24) as a destination.
// Without this, the router drops traffic forwarded through it. // Without this, the router drops traffic forwarded through it.
routerNM := routerA.Netmap() routerNM := routerA.Netmap()
@ -1101,7 +1101,7 @@ func hasCapMatches(matches []filtertype.Match) bool {
return false 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. // non-empty Dsts list.
func hasDstRules(matches []filtertype.Match) bool { func hasDstRules(matches []filtertype.Match) bool {
for _, m := range matches { for _, m := range matches {

View File

@ -13,13 +13,13 @@ import (
) )
// Dynamic HA failover scenarios, observed from a viewer client's // 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, // these exercise runtime transitions: a primary going unhealthy,
// revoking its approved route, or losing its tag, and verify that // 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. // 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. // lists route as a PrimaryRoute on the peer with the given hostname.
func hasPeerPrimaryRoute(nm *netmap.NetworkMap, peerHost string, route netip.Prefix) bool { func hasPeerPrimaryRoute(nm *netmap.NetworkMap, peerHost string, route netip.Prefix) bool {
if nm == nil { if nm == nil {
@ -43,7 +43,7 @@ func hasPeerPrimaryRoute(nm *netmap.NetworkMap, peerHost string, route netip.Pre
} }
// TestHAFailover_ViewerSeesPrimaryFlip verifies that when an HA // 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. // primary assignment from the old primary to the standby.
func TestHAFailover_ViewerSeesPrimaryFlip(t *testing.T) { func TestHAFailover_ViewerSeesPrimaryFlip(t *testing.T) {
t.Parallel() t.Parallel()
@ -90,7 +90,7 @@ func TestHAFailover_ViewerSeesPrimaryFlip(t *testing.T) {
} }
// TestHAFailover_ViewerSeesRouteRevoke verifies that when the primary // 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. // standby and the old primary no longer advertises the route.
func TestHAFailover_ViewerSeesRouteRevoke(t *testing.T) { func TestHAFailover_ViewerSeesRouteRevoke(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -15,7 +15,7 @@ import (
"tailscale.com/tailcfg" "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. // the route on the server. Returns the node ID.
func advertiseAndApproveRoute( func advertiseAndApproveRoute(
t *testing.T, t *testing.T,
@ -87,7 +87,7 @@ func TestHAHealthProbe_HealthyNodes(t *testing.T) {
} }
// TestHAHealthProbe_UnhealthyFailover verifies that marking a primary // 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. // standby.
func TestHAHealthProbe_UnhealthyFailover(t *testing.T) { func TestHAHealthProbe_UnhealthyFailover(t *testing.T) {
t.Parallel() t.Parallel()
@ -176,7 +176,7 @@ func TestHAHealthProbe_ConnectClearsUnhealthy(t *testing.T) {
srv.State().SetNodeHealth(nodeID1, false) srv.State().SetNodeHealth(nodeID1, false)
assert.False(t, srv.State().IsNodeHealthy(nodeID1)) 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.Disconnect(t)
c1.Reconnect(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 // that clearing a node's approved routes also clears any stale
// Unhealthy bit, mirroring the legacy routes.SetRoutes(empty) // Unhealthy bit, mirroring the legacy routes.SetRoutes(empty)
// auto-clear. Without this, a probe timeout that lands just before // 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) { func TestHAHealthProbe_SetApprovedRoutesEmptyClearsUnhealthy(t *testing.T) {
t.Parallel() t.Parallel()
@ -223,7 +223,7 @@ func TestHAHealthProbe_SetApprovedRoutesEmptyClearsUnhealthy(t *testing.T) {
// HA candidate; carrying the bit forward leaks into DebugRoutes. // HA candidate; carrying the bit forward leaks into DebugRoutes.
// //
// The poll handler waits a 10s grace period before calling // 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. // generous timeout.
func TestHAHealthProbe_DisconnectClearsUnhealthy(t *testing.T) { func TestHAHealthProbe_DisconnectClearsUnhealthy(t *testing.T) {
t.Parallel() t.Parallel()
@ -255,7 +255,7 @@ func TestHAHealthProbe_DisconnectClearsUnhealthy(t *testing.T) {
// TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp verifies the // TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp verifies the
// defensive guard for the still-online-but-no-routes case: a probe // 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. // to install a stale Unhealthy bit either.
func TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp(t *testing.T) { func TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp(t *testing.T) {
t.Parallel() t.Parallel()

View File

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

View File

@ -8,7 +8,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types" "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. // providing a convenient setup for multi-node control plane tests.
type TestHarness struct { type TestHarness struct {
Server *TestServer Server *TestServer
@ -18,7 +18,7 @@ type TestHarness struct {
defaultUser *types.User defaultUser *types.User
} }
// HarnessOption configures a TestHarness. // HarnessOption configures a [TestHarness].
type HarnessOption func(*harnessConfig) type HarnessOption func(*harnessConfig)
type harnessConfig struct { type harnessConfig struct {
@ -33,24 +33,24 @@ func defaultHarnessConfig() *harnessConfig {
} }
} }
// WithServerOptions passes ServerOptions through to the underlying // WithServerOptions passes [ServerOption] values through to the underlying
// TestServer. // [TestServer].
func WithServerOptions(opts ...ServerOption) HarnessOption { func WithServerOptions(opts ...ServerOption) HarnessOption {
return func(c *harnessConfig) { c.serverOpts = append(c.serverOpts, opts...) } return func(c *harnessConfig) { c.serverOpts = append(c.serverOpts, opts...) }
} }
// WithDefaultClientOptions applies ClientOptions to every client // WithDefaultClientOptions applies [ClientOption] values to every client
// created by NewHarness. // created by [NewHarness].
func WithDefaultClientOptions(opts ...ClientOption) HarnessOption { func WithDefaultClientOptions(opts ...ClientOption) HarnessOption {
return func(c *harnessConfig) { c.clientOpts = append(c.clientOpts, opts...) } 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 { func WithConvergenceTimeout(d time.Duration) HarnessOption {
return func(c *harnessConfig) { c.convergenceMax = d } 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 // All clients share a default user and are registered with reusable
// pre-auth keys. The harness waits for all clients to form a // pre-auth keys. The harness waits for all clients to form a
// complete mesh before returning. // 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 // 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) { func (h *TestHarness) WaitForConvergence(tb testing.TB, timeout time.Duration) {
tb.Helper() tb.Helper()
h.WaitForMeshComplete(tb, timeout) h.WaitForMeshComplete(tb, timeout)

View File

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

View File

@ -15,7 +15,7 @@ import (
) )
// TestPingNode verifies the full ping round-trip: the server sends a // 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 // by making a HEAD request back over Noise, and the ping tracker records
// the latency. // the latency.
func TestPingNode(t *testing.T) { func TestPingNode(t *testing.T) {
@ -105,7 +105,7 @@ func TestPingTwoSameNode(t *testing.T) {
require.NotEqual(t, pingID1, pingID2) require.NotEqual(t, pingID1, pingID2)
// Send both PingRequests. // Send both [tailcfg.PingRequest]s.
url1 := h.Server.URL + "/machine/ping-response?id=" + pingID1 url1 := h.Server.URL + "/machine/ping-response?id=" + pingID1
url2 := h.Server.URL + "/machine/ping-response?id=" + pingID2 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. // by hostname and that the resolved node can be pinged.
func TestPingResolveByHostname(t *testing.T) { func TestPingResolveByHostname(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -156,9 +156,9 @@ func TestPolicyChanges(t *testing.T) {
// (Prefix, Host) resolve to exactly the literal prefix and do NOT expand // (Prefix, Host) resolve to exactly the literal prefix and do NOT expand
// to include the matching node's other IP addresses. // 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 // 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) { func TestIPv6OnlyPrefixACL(t *testing.T) {
t.Parallel() t.Parallel()
@ -193,7 +193,7 @@ func TestIPv6OnlyPrefixACL(t *testing.T) {
c1.WaitForPeers(t, 1, 10*time.Second) c1.WaitForPeers(t, 1, 10*time.Second)
c2.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. // have the rule allowing traffic FROM test1's IPv6.
nm2 := c2.Netmap() nm2 := c2.Netmap()
require.NotNil(t, nm2) require.NotNil(t, nm2)

View File

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

View File

@ -19,7 +19,7 @@ import (
// TestRace contains tests designed to trigger race conditions in // TestRace contains tests designed to trigger race conditions in
// the control plane. Run with -race to detect data races. // the control plane. Run with -race to detect data races.
// These tests stress concurrent access patterns in poll.go, // 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 // TestRacePollSessionReplacement tests the race between an old
// poll session's deferred cleanup and a new session starting. // 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 // Rapidly replace the poll session by doing immediate
// disconnect+reconnect. This races the old session's // disconnect+reconnect. This races the old session's
// deferred cleanup (RemoveNode, Disconnect, grace period // deferred cleanup ([state.NodeStore.RemoveNode], [state.State.Disconnect], grace period
// goroutine) with the new session's setup (AddNode, Connect, // goroutine) with the new session's setup ([state.NodeStore.AddNode], [state.State.Connect],
// initial map send). // initial map send).
t.Run("immediate_session_replace_10x", func(t *testing.T) { t.Run("immediate_session_replace_10x", func(t *testing.T) {
t.Parallel() 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. // when many changes arrive simultaneously.
func TestRaceBatcherContention(t *testing.T) { func TestRaceBatcherContention(t *testing.T) {
t.Parallel() t.Parallel()
// Many nodes connecting at the same time generates many // 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. // without dropping updates or panicking.
t.Run("many_simultaneous_connects", func(t *testing.T) { t.Run("many_simultaneous_connects", func(t *testing.T) {
t.Parallel() t.Parallel()
@ -427,8 +427,8 @@ func TestRaceBatcherContention(t *testing.T) {
}) })
// Rapid connect + disconnect + connect of different nodes // Rapid connect + disconnect + connect of different nodes
// generates interleaved AddNode/RemoveNode/AddNode in the // generates interleaved [state.NodeStore.AddNode]/[state.NodeStore.RemoveNode]/[state.NodeStore.AddNode] in the
// batcher. // [mapper.Batcher].
t.Run("interleaved_add_remove_add", func(t *testing.T) { t.Run("interleaved_add_remove_add", func(t *testing.T) {
t.Parallel() t.Parallel()
@ -514,7 +514,7 @@ func TestRaceBatcherContention(t *testing.T) {
} }
// TestRaceMapResponseDuringDisconnect tests what happens when a // 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) { func TestRaceMapResponseDuringDisconnect(t *testing.T) {
t.Parallel() 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) { func TestRaceNodeStoreContention(t *testing.T) {
t.Parallel() t.Parallel()
// Many GetNodeByID calls while nodes are connecting and // Many [state.State.GetNodeByID] calls while nodes are connecting and
// disconnecting. This tests the NodeStore's read/write locking. // disconnecting. This tests the [state.NodeStore]'s read/write locking.
t.Run("concurrent_reads_during_mutations", func(t *testing.T) { t.Run("concurrent_reads_during_mutations", func(t *testing.T) {
t.Parallel() 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.Run("list_nodes_during_churn", func(t *testing.T) {
t.Parallel() t.Parallel()

View File

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

View File

@ -20,7 +20,7 @@ import (
// consistency bugs. // consistency bugs.
// TestStressConnectDisconnect exercises rapid connect/disconnect // 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) { func TestStressConnectDisconnect(t *testing.T) {
t.Parallel() 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. // the same machine key the client registered with.
t.Run("machine_key_consistent", func(t *testing.T) { t.Run("machine_key_consistent", func(t *testing.T) {
t.Parallel() t.Parallel()
@ -551,7 +551,7 @@ func TestStressDataIntegrity(t *testing.T) {
nm := c1.Netmap() nm := c1.Netmap()
require.NotNil(t, nm) 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(), assert.False(t, nm.MachineKey.IsZero(),
"client's MachineKey should be non-zero") "client's MachineKey should be non-zero")
@ -564,7 +564,7 @@ func TestStressDataIntegrity(t *testing.T) {
"client and server should agree on MachineKey") "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.Run("node_key_consistent", func(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -38,7 +38,7 @@ var viaCompatTests = []struct {
} }
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and // 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 // The comparison is IP-independent: it validates peer visibility, route
// prefixes in AllowedIPs, and PrimaryRoutes — not literal Tailscale IP // 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 // Determine which routes each node should advertise. If the golden
// topology has explicit routable_ips, use those. Otherwise infer // 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) nodeRoutes := inferNodeRoutes(t, c)
// Build approved routes from topology. The topology's approved_routes // 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) 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 { for viewerName, cl := range clients {
capture := c.Captures[viewerName] capture := c.Captures[viewerName]
if capture.Netmap == nil { 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, // Ensure all nodes have received at least one [tailcfg.MapResponse],
// including nodes with 0 expected peers that skipped WaitForPeers. // including nodes with 0 expected peers that skipped [TestClient.WaitForPeers].
for name, cl := range clients { for name, cl := range clients {
cl.WaitForCondition(t, name+" initial netmap", 15*time.Second, cl.WaitForCondition(t, name+" initial netmap", 15*time.Second,
func(nm *netmap.NetworkMap) bool { 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 { for viewerName, cl := range clients {
capture := c.Captures[viewerName] capture := c.Captures[viewerName]
if capture.Netmap == nil { if capture.Netmap == nil {
@ -227,8 +227,8 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
} }
} }
// compareNetmap compares the headscale MapResponse against the // compareNetmap compares the headscale [tailcfg.MapResponse] against the
// captured netmap data in an IP-independent way. It validates: // captured [netmap.NetworkMap] data in an IP-independent way. It validates:
// - Peer visibility (which peers are present, by hostname) // - Peer visibility (which peers are present, by hostname)
// - Route prefixes in AllowedIPs (non-Tailscale-IP entries like 10.44.0.0/16) // - 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) // - 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 // 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. // clients are skipped.
func saasAddrsByPeer( func saasAddrsByPeer(
want testcapture.Node, want testcapture.Node,
@ -442,7 +442,7 @@ func saasAddrsByPeer(
return out return out
} }
// Walk peers listed in this netmap. // Walk peers listed in this [netmap.NetworkMap].
for _, peer := range want.Netmap.Peers { for _, peer := range want.Netmap.Peers {
name := extractHostname(peer.Name()) name := extractHostname(peer.Name())
if _, isOurs := clients[name]; !isOurs { 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() { if want.Netmap.SelfNode.Valid() {
name := extractHostname(want.Netmap.SelfNode.Name()) name := extractHostname(want.Netmap.SelfNode.Name())
@ -533,8 +533,8 @@ func canonicaliseSrcStrings(
} }
// canonicaliseSrcPrefixes is the headscale-side counterpart of // canonicaliseSrcPrefixes is the headscale-side counterpart of
// canonicaliseSrcStrings, reading already-parsed netip.Prefix values // [canonicaliseSrcStrings], reading already-parsed [netip.Prefix] values
// from tailcfg.Match.Srcs. // from [tailcfg.Match.Srcs].
func canonicaliseSrcPrefixes( func canonicaliseSrcPrefixes(
t *testing.T, t *testing.T,
srcs []netip.Prefix, srcs []netip.Prefix,
@ -627,7 +627,7 @@ type peerSummary struct {
PrimaryRoutes []string // sorted 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 // 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 // prefix (/32 for IPv4, /128 for IPv6). Golden data DstPorts.IP can
// contain either form. // contain either form.
@ -704,7 +704,7 @@ func countTailscaleIPsView(allowedIPs interface {
// inferNodeRoutes determines which routes each node should advertise. // inferNodeRoutes determines which routes each node should advertise.
// If the topology has explicit routable_ips, those are used. Otherwise // 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. // filter destination prefixes.
func inferNodeRoutes(t *testing.T, c *testcapture.Capture) map[string][]netip.Prefix { func inferNodeRoutes(t *testing.T, c *testcapture.Capture) map[string][]netip.Prefix {
t.Helper() 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 prefixes in AllowedIPs. If node X appears as a peer with
// route prefix 10.44.0.0/16, then X should advertise that route. // route prefix 10.44.0.0/16, then X should advertise that route.
for _, node := range c.Captures { for _, node := range c.Captures {

View File

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

View File

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

View File

@ -29,7 +29,7 @@ type HAHealthProber struct {
lastStableSession *xsync.Map[types.NodeID, uint64] 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. // ping tracking and primary route management.
// isConnected should return true if a node has an active map session. // isConnected should return true if a node has an active map session.
func NewHAHealthProber( func NewHAHealthProber(

View File

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

View File

@ -75,7 +75,7 @@ func TestNetInfoPreservationInRegistrationFlow(t *testing.T) {
nodeID := types.NodeID(1) nodeID := types.NodeID(1)
// This test reproduces the bug in registration flows where NetInfo was lost // 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) { t.Run("registration_flow_bug_reproduction", func(t *testing.T) {
// Simulate existing node with NetInfo (before re-registration) // Simulate existing node with NetInfo (before re-registration)
existingNodeHostinfo := &tailcfg.Hostinfo{ existingNodeHostinfo := &tailcfg.Hostinfo{

View File

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

View File

@ -10,10 +10,10 @@ import (
const pingIDLength = 16 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 // callbacks. Entries have no server-side TTL: callers are responsible
// for cleaning up via CancelPing or by reading from the response channel // for cleaning up via [pingTracker.cancel] or by reading from the response
// within their own timeout. // channel within their own timeout.
type pingTracker struct { type pingTracker struct {
mu sync.Mutex mu sync.Mutex
pending map[string]*pendingPing pending map[string]*pendingPing
@ -80,7 +80,7 @@ func (pt *pingTracker) cancel(pingID string) {
} }
// drain closes every outstanding response channel and clears the map. // 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. // channel that will never receive.
func (pt *pingTracker) drain() { func (pt *pingTracker) drain() {
pt.mu.Lock() pt.mu.Lock()
@ -93,7 +93,7 @@ func (pt *pingTracker) drain() {
} }
// RegisterPing tracks a pending ping and returns its ID and a channel // 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. // within their own timeout; there is no server-side TTL.
func (s *State) RegisterPing(nodeID types.NodeID) (string, <-chan time.Duration) { func (s *State) RegisterPing(nodeID types.NodeID) (string, <-chan time.Duration) {
return s.pings.register(nodeID) return s.pings.register(nodeID)

View File

@ -20,12 +20,13 @@ var (
ErrRequestedTagsInvalidOrNotPermitted = errors.New("requested tags") 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") var ErrTaggedNodeHasUser = errors.New("tagged node must not have user_id set")
// validateNodeOwnership ensures proper node ownership model. // validateNodeOwnership ensures proper node ownership model.
// A node must be either user-owned or tagged, and these are mutually exclusive: // 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 { func validateNodeOwnership(node *types.Node) error {
if node.IsTagged() { if node.IsTagged() {
if len(node.Tags) == 0 { if len(node.Tags) == 0 {

View File

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

View File

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

View File

@ -67,7 +67,7 @@ func (k *APIKey) maskedPrefix() string {
return k.Prefix + "***" 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. // SECURITY: This method intentionally does NOT log the full key or hash.
// Only the masked prefix is logged for identification purposes. // Only the masked prefix is logged for identification purposes.
func (k *APIKey) MarshalZerologObject(e *zerolog.Event) { func (k *APIKey) MarshalZerologObject(e *zerolog.Event) {

View File

@ -1,6 +1,6 @@
// Package change declares the Change type: a compact description of // Package change declares the [Change] type: a compact description of
// what must land in a MapResponse. The mapper reads Change values to // what must land in a [tailcfg.MapResponse]. The mapper reads [Change] values to
// build responses without inspecting state, and Merge combines // build responses without inspecting state, and [Change.Merge] combines
// multiple pending changes for a single tick. // multiple pending changes for a single tick.
package change package change
@ -13,7 +13,7 @@ import (
"tailscale.com/tailcfg" "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. // The mapper uses this to build the response without guessing.
type Change struct { type Change struct {
// Reason is a human-readable description for logging/debugging. // Reason is a human-readable description for logging/debugging.
@ -26,12 +26,12 @@ type Change struct {
// Used for self-update detection and filtering. // Used for self-update detection and filtering.
OriginNode types.NodeID OriginNode types.NodeID
// Content flags - what to include in the MapResponse. // Content flags - what to include in the [tailcfg.MapResponse].
IncludeSelf bool IncludeSelf bool
IncludeDERPMap bool IncludeDERPMap bool
IncludeDNS bool IncludeDNS bool
IncludeDomain 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. // Peer changes.
PeersChanged []types.NodeID PeersChanged []types.NodeID
@ -46,12 +46,12 @@ type Change struct {
// PingRequest, if non-nil, is a ping request to send to the node. // PingRequest, if non-nil, is a ping request to send to the node.
// Used by the debug ping endpoint to verify node connectivity. // 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 PingRequest *tailcfg.PingRequest
} }
// boolFieldNames returns all boolean field names for exhaustive testing. // 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. // Tests use reflection to verify this matches the struct.
func (r Change) boolFieldNames() []string { func (r Change) boolFieldNames() []string {
return []string{ return []string{
@ -80,16 +80,16 @@ func (r Change) Merge(other Change) Change {
merged.PeersRemoved = uniqueNodeIDs(slices.Concat(r.PeersRemoved, other.PeersRemoved)) merged.PeersRemoved = uniqueNodeIDs(slices.Concat(r.PeersRemoved, other.PeersRemoved))
merged.PeerPatches = slices.Concat(r.PeerPatches, other.PeerPatches) merged.PeerPatches = slices.Concat(r.PeerPatches, other.PeerPatches)
// Preserve OriginNode for self-update detection. // Preserve [Change.OriginNode] for self-update detection.
// If either change has OriginNode set, keep it so the mapper // If either change has [Change.OriginNode] set, keep it so the mapper
// can detect self-updates and send the node its own changes. // can detect self-updates and send the node its own changes.
if merged.OriginNode == 0 { if merged.OriginNode == 0 {
merged.OriginNode = other.OriginNode 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 // 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. // would cause the other target's content to be misrouted.
if merged.TargetNode != 0 && other.TargetNode != 0 && merged.TargetNode != other.TargetNode { if merged.TargetNode != 0 && other.TargetNode != 0 && merged.TargetNode != other.TargetNode {
panic(fmt.Sprintf( panic(fmt.Sprintf(
@ -102,9 +102,9 @@ func (r Change) Merge(other Change) Change {
merged.TargetNode = other.TargetNode 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 // same tick, only the first is emitted. The client-side
// isUniquePingRequest check then suppresses the second when it // isUniquePingRequest check then suppresses the second when it
// eventually arrives, and the caller waits out the full // eventually arrives, and the caller waits out the full
@ -154,7 +154,7 @@ func (r Change) IsSelfOnly() bool {
return true 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 { func (r Change) IsTargetedToNode() bool {
return r.TargetNode != 0 return r.TargetNode != 0
} }
@ -167,7 +167,7 @@ func (r Change) IsFull() bool {
// Type returns a categorized type string for metrics. // Type returns a categorized type string for metrics.
// This provides a bounded set of values suitable for Prometheus labels, // 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 { func (r Change) Type() string {
if r.IsFull() { if r.IsFull() {
return "full" return "full"
@ -211,7 +211,7 @@ func (r Change) ShouldSendToNode(nodeID types.NodeID) bool {
return true 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 { func HasFull(rs []Change) bool {
for _, r := range rs { for _, r := range rs {
if r.IsFull() { if r.IsFull() {
@ -349,7 +349,7 @@ func DERPMap() Change {
} }
// PolicyChange creates a response for policy changes. // 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 { func PolicyChange() Change {
return Change{ return Change{
Reason: "policy change", Reason: "policy change",
@ -407,8 +407,8 @@ func KeyExpiry(nodeID types.NodeID, expiry *time.Time) Change {
// High-level change constructors // High-level change constructors
// NodeAdded returns a Change for when a node is added or updated. // NodeAdded returns a [Change] for when a node is added or updated.
// The OriginNode field enables self-update detection by the mapper. // The [Change.OriginNode] field enables self-update detection by the mapper.
func NodeAdded(id types.NodeID) Change { func NodeAdded(id types.NodeID) Change {
c := PeersChanged("node added", id) c := PeersChanged("node added", id)
c.OriginNode = id c.OriginNode = id
@ -416,12 +416,12 @@ func NodeAdded(id types.NodeID) Change {
return c 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 { func NodeRemoved(id types.NodeID) Change {
return PeersRemoved(id) 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. // If the node is a subnet router, a full update is sent instead of a patch.
func NodeOnlineFor(node types.NodeView) Change { func NodeOnlineFor(node types.NodeView) Change {
if node.IsSubnetRouter() { if node.IsSubnetRouter() {
@ -434,7 +434,7 @@ func NodeOnlineFor(node types.NodeView) Change {
return NodeOnline(node.ID()) 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. // If the node is a subnet router, a full update is sent instead of a patch.
func NodeOfflineFor(node types.NodeView) Change { func NodeOfflineFor(node types.NodeView) Change {
if node.IsSubnetRouter() { if node.IsSubnetRouter() {
@ -447,8 +447,8 @@ func NodeOfflineFor(node types.NodeView) Change {
return NodeOffline(node.ID()) return NodeOffline(node.ID())
} }
// KeyExpiryFor returns a Change for when a node's key expiry changes. // KeyExpiryFor returns a [Change] for when a node's key expiry changes.
// The OriginNode field enables self-update detection by the mapper. // The [Change.OriginNode] field enables self-update detection by the mapper.
func KeyExpiryFor(id types.NodeID, expiry time.Time) Change { func KeyExpiryFor(id types.NodeID, expiry time.Time) Change {
c := KeyExpiry(id, &expiry) c := KeyExpiry(id, &expiry)
c.OriginNode = id c.OriginNode = id
@ -456,8 +456,8 @@ func KeyExpiryFor(id types.NodeID, expiry time.Time) Change {
return c return c
} }
// EndpointOrDERPUpdate returns a Change for when a node's endpoints or DERP region changes. // EndpointOrDERPUpdate returns a [Change] for when a node's endpoints or DERP region changes.
// The OriginNode field enables self-update detection by the mapper. // The [Change.OriginNode] field enables self-update detection by the mapper.
func EndpointOrDERPUpdate(id types.NodeID, patch *tailcfg.PeerChange) Change { func EndpointOrDERPUpdate(id types.NodeID, patch *tailcfg.PeerChange) Change {
c := PeerPatched("endpoint/DERP update", patch) c := PeerPatched("endpoint/DERP update", patch)
c.OriginNode = id c.OriginNode = id
@ -465,7 +465,7 @@ func EndpointOrDERPUpdate(id types.NodeID, patch *tailcfg.PeerChange) Change {
return c 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. // A full update is sent to refresh user profiles on all nodes.
func UserAdded() Change { func UserAdded() Change {
c := FullUpdate() c := FullUpdate()
@ -474,7 +474,7 @@ func UserAdded() Change {
return c 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. // A full update is sent to refresh user profiles on all nodes.
func UserRemoved() Change { func UserRemoved() Change {
c := FullUpdate() c := FullUpdate()
@ -483,9 +483,9 @@ func UserRemoved() Change {
return c 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 // 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 { func PingNode(nodeID types.NodeID, pr *tailcfg.PingRequest) Change {
return Change{ return Change{
Reason: "ping node", 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 { func ExtraRecords() Change {
c := DNSConfig() c := DNSConfig()
c.Reason = "extra records update" c.Reason = "extra records update"

View File

@ -69,35 +69,35 @@ const (
// StateUpdate is an internal message containing information about // StateUpdate is an internal message containing information about
// a state change that has happened to the network. // 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 { type StateUpdate struct {
// The type of update // The type of update
Type StateUpdateType Type StateUpdateType
// ChangeNodes must be set when Type is StatePeerAdded // 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. // object for added nodes.
ChangeNodes []NodeID ChangeNodes []NodeID
// ChangePatches must be set when Type is StatePeerChangedPatch // ChangePatches must be set when Type is [StatePeerChangedPatch]
// and contains a populated PeerChange object. // and contains a populated [tailcfg.PeerChange] object.
ChangePatches []*tailcfg.PeerChange 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 // contain a list of the nodes that has been removed from
// the network. // the network.
Removed []NodeID 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. // contain the new DERP Map.
DERPMap *tailcfg.DERPMap DERPMap *tailcfg.DERPMap
// Additional message for tracking origin or what being // Additional message for tracking origin or what being
// updated, useful for ambiguous updates like StatePeerChanged. // updated, useful for ambiguous updates like [StatePeerChanged].
Message string 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 { func (su *StateUpdate) Empty() bool {
switch su.Type { switch su.Type {
case StatePeerChanged: case StatePeerChanged:
@ -233,7 +233,7 @@ type SSHCheckBinding struct {
// PendingRegistrationConfirmation captures the server-side state needed // PendingRegistrationConfirmation captures the server-side state needed
// to finalise a node registration after the user has confirmed it on // to finalise a node registration after the user has confirmed it on
// the OIDC interstitial. The OIDC callback resolves the user identity // 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 // a confirmation page; only when the user POSTs the confirmation form
// does the actual node registration run. // 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 // node. It carries the minimum data needed to either complete a node
// registration (regData populated) or an SSH check-mode auth (sshBinding // registration (regData populated) or an SSH check-mode auth (sshBinding
// populated), and signals the verdict via the finished channel. The closed // 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 // have a single canonical instance even when stored in caches that
// internally copy values. // internally copy values.
type AuthRequest struct { type AuthRequest struct {
@ -260,7 +260,7 @@ type AuthRequest struct {
// or OIDC). It carries the cached registration payload that the // or OIDC). It carries the cached registration payload that the
// auth callback uses to promote this request into a real node. // 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. // safely.
regData *RegistrationData regData *RegistrationData
@ -269,7 +269,7 @@ type AuthRequest struct {
// and OIDC callback can refuse to record a verdict for any other // and OIDC callback can refuse to record a verdict for any other
// pair. // 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. // safely.
sshBinding *SSHCheckBinding sshBinding *SSHCheckBinding
@ -294,7 +294,7 @@ func NewAuthRequest() *AuthRequest {
} }
// NewRegisterAuthRequest creates a pending auth request carrying the // 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. // stored by pointer; callers must not mutate it after handing it off.
func NewRegisterAuthRequest(data *RegistrationData) *AuthRequest { func NewRegisterAuthRequest(data *RegistrationData) *AuthRequest {
return &AuthRequest{ return &AuthRequest{
@ -320,8 +320,8 @@ func NewSSHCheckAuthRequest(src, dst NodeID) *AuthRequest {
} }
// RegistrationData returns the cached registration payload. It panics if // RegistrationData returns the cached registration payload. It panics if
// called on an AuthRequest that was not created via // called on an [AuthRequest] that was not created via
// NewRegisterAuthRequest. // [NewRegisterAuthRequest].
func (rn *AuthRequest) RegistrationData() *RegistrationData { func (rn *AuthRequest) RegistrationData() *RegistrationData {
if rn.regData == nil { if rn.regData == nil {
panic("RegistrationData can only be used in registration requests") 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 // SSHCheckBinding returns the (src, dst) node pair an SSH check-mode
// auth request is bound to. It panics if called on an AuthRequest that // auth request is bound to. It panics if called on an [AuthRequest] that
// was not created via NewSSHCheckAuthRequest. // was not created via [NewSSHCheckAuthRequest].
func (rn *AuthRequest) SSHCheckBinding() *SSHCheckBinding { func (rn *AuthRequest) SSHCheckBinding() *SSHCheckBinding {
if rn.sshBinding == nil { if rn.sshBinding == nil {
panic("SSHCheckBinding can only be used in SSH check-mode requests") 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 // 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 { func (rn *AuthRequest) IsRegistration() bool {
return rn.regData != nil return rn.regData != nil
} }
// IsSSHCheck reports whether this auth request is bound to an SSH // IsSSHCheck reports whether this auth request is bound to an SSH
// check-mode (src, dst) pair (i.e. it was created via // check-mode (src, dst) pair (i.e. it was created via
// NewSSHCheckAuthRequest). // [NewSSHCheckAuthRequest]).
func (rn *AuthRequest) IsSSHCheck() bool { func (rn *AuthRequest) IsSSHCheck() bool {
return rn.sshBinding != nil 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 // OIDC-resolved user that is waiting to confirm the registration on
// the interstitial. The OIDC callback should call this and then render // the interstitial. The OIDC callback should call this and then render
// the confirmation page; the /register/confirm POST handler reads the // 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 // PendingConfirmation returns the pending OIDC-resolved registration
// state captured by SetPendingConfirmation, or nil if no OIDC callback // state captured by [AuthRequest.SetPendingConfirmation], or nil if no OIDC callback
// has yet resolved an identity for this AuthRequest. // has yet resolved an identity for this [AuthRequest].
func (rn *AuthRequest) PendingConfirmation() *PendingRegistrationConfirmation { func (rn *AuthRequest) PendingConfirmation() *PendingRegistrationConfirmation {
return rn.pendingConfirmation return rn.pendingConfirmation
} }

View File

@ -7,9 +7,9 @@ import (
"github.com/stretchr/testify/require" "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 // 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) { func TestNewSSHCheckAuthRequestBinding(t *testing.T) {
const src, dst NodeID = 7, 11 const src, dst NodeID = 7, 11
@ -28,7 +28,7 @@ func TestNewSSHCheckAuthRequestBinding(t *testing.T) {
} }
// TestNewRegisterAuthRequestPayload verifies that a registration // 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. // that try to read SSH-check binding from it.
func TestNewRegisterAuthRequestPayload(t *testing.T) { func TestNewRegisterAuthRequestPayload(t *testing.T) {
data := &RegistrationData{Hostname: "node-a"} data := &RegistrationData{Hostname: "node-a"}
@ -45,7 +45,7 @@ func TestNewRegisterAuthRequestPayload(t *testing.T) {
} }
// TestNewAuthRequestEmptyPayload verifies that a payload-less // 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. // payload accessor.
func TestNewAuthRequestEmptyPayload(t *testing.T) { func TestNewAuthRequestEmptyPayload(t *testing.T) {
req := NewAuthRequest() req := NewAuthRequest()
@ -58,7 +58,7 @@ func TestNewAuthRequestEmptyPayload(t *testing.T) {
} }
// TestPendingRegistrationConfirmation verifies that the OIDC callback // 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. // /register/confirm POST handler can read it back unchanged.
func TestPendingRegistrationConfirmation(t *testing.T) { func TestPendingRegistrationConfirmation(t *testing.T) {
req := NewRegisterAuthRequest(&RegistrationData{Hostname: "phish-test"}) req := NewRegisterAuthRequest(&RegistrationData{Hostname: "phish-test"})

View File

@ -68,7 +68,7 @@ type HARouteConfig struct {
ProbeInterval time.Duration ProbeInterval time.Duration
// ProbeTimeout is the maximum time to wait for a probe response // 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 ProbeTimeout time.Duration
} }
@ -120,7 +120,7 @@ type Config struct {
// DNSConfig is the headscale representation of the DNS configuration. // DNSConfig is the headscale representation of the DNS configuration.
// It is kept in the config update for some settings that are // 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 DNSConfig DNSConfig
// TailcfgDNSConfig is the tailcfg representation of the DNS configuration, // 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 // NodeStoreBatchTimeout is the maximum time to wait before processing a
// partial batch of node operations. // 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 // writes don't wait indefinitely. The batch processes when either the size
// threshold is reached OR this timeout expires, whichever comes first. // threshold is reached OR this timeout expires, whichever comes first.
// //
@ -355,8 +355,8 @@ func validatePKCEMethod(method string) error {
return nil return nil
} }
// Domain returns the hostname/domain part of the ServerURL. // Domain returns the hostname/domain part of the [Config.ServerURL].
// If the ServerURL is not a valid URL, it returns the BaseDomain. // If the [Config.ServerURL] is not a valid URL, it returns the [Config.BaseDomain].
func (c *Config) Domain() string { func (c *Config) Domain() string {
u, err := url.Parse(c.ServerURL) u, err := url.Parse(c.ServerURL)
if err != nil { if err != nil {
@ -369,7 +369,7 @@ func (c *Config) Domain() string {
// LoadConfig prepares and loads the Headscale configuration into Viper. // LoadConfig prepares and loads the Headscale configuration into Viper.
// This means it sets the default values, reads the configuration file and // This means it sets the default values, reads the configuration file and
// environment variables, and handles deprecated configuration options. // 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 // The configuration is not validated and the caller should check for errors
// using a validation function. // using a validation function.
func LoadConfig(path string, isFile bool) error { func LoadConfig(path string, isFile bool) error {

View File

@ -475,8 +475,8 @@ func TestSafeServerURL(t *testing.T) {
} }
} }
// TestConfigJSONOmitsSecrets verifies that marshalling a Config to JSON // TestConfigJSONOmitsSecrets verifies that marshalling a [Config] to JSON
// (as /debug/config does via state.DebugConfig) does not leak the // (as /debug/config does via [state.State.DebugConfig]) does not leak the
// Postgres password, the OIDC client secret, or the headscale admin // Postgres password, the OIDC client secret, or the headscale admin
// API key. Operators who widen metrics_listen_addr to 0.0.0.0 should // 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 // not be able to read these back via debug endpoints reachable over

View File

@ -33,7 +33,7 @@ var (
) )
// RouteFunc is a function that takes a node ID and returns a list of // 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 type RouteFunc func(id NodeID) []netip.Prefix
// nodeAttrDisableIPv4 is the policy nodeAttr key that suppresses the // 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. // 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. // prefixes must additionally defer to HA primary election.
type ViaRouteResult struct { type ViaRouteResult struct {
// Include contains prefixes this peer should serve to this viewer (via-designated). // Include contains prefixes this peer should serve to this viewer (via-designated).
Include []netip.Prefix Include []netip.Prefix
// Exclude contains prefixes steered to OTHER peers (suppress from global primary). // Exclude contains prefixes steered to OTHER peers (suppress from global primary).
Exclude []netip.Prefix 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 // (non-via) grant also covers the prefix. In these cases HA
// primary election wins — only the primary router should get // 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. // per-viewer via steering applies.
UsePrimary []netip.Prefix UsePrimary []netip.Prefix
} }
@ -132,8 +132,8 @@ type Node struct {
Hostname string Hostname string
// Givenname represents either: // Givenname represents either:
// a DNS normalized version of Hostname // a DNS normalized version of [Node.Hostname]
// a valid name set by the User // a valid name set by the [User]
// //
// GivenName is the name used in all DNS related // GivenName is the name used in all DNS related
// parts of headscale. // parts of headscale.
@ -152,7 +152,7 @@ type Node struct {
// Tags cannot be removed once set (one-way transition). // Tags cannot be removed once set (one-way transition).
Tags Strings `gorm:"column:tags;serializer:json"` 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. // prevent the preauthkey from being deleted before the node.
// The preauthkey can define "tags" of the node so we need it // The preauthkey can define "tags" of the node so we need it
// around. // around.
@ -263,8 +263,8 @@ func (node *Node) HasTag(tag string) bool {
return slices.Contains(node.Tags, tag) return slices.Contains(node.Tags, tag)
} }
// TypedUserID returns the UserID as a typed UserID type. // TypedUserID returns the [Node.UserID] as a typed [UserID] type.
// Returns 0 if UserID is nil. // Returns 0 if [Node.UserID] is nil.
func (node *Node) TypedUserID() UserID { func (node *Node) TypedUserID() UserID {
if node.UserID == nil { if node.UserID == nil {
return 0 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 // ExitRoutes returns the node's approved exit routes (0.0.0.0/0
// and/or ::/0). Consumed unconditionally by RoutesForPeer when the // 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. // handles non-exit routing.
func (node *Node) ExitRoutes() []netip.Prefix { func (node *Node) ExitRoutes() []netip.Prefix {
var routes []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. // IsExitNode reports whether the node has any approved exit routes.
// Approval is required: an advertised-but-unapproved exit route does // 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 { func (node *Node) IsExitNode() bool {
return len(node.ExitRoutes()) > 0 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 // 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 // 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) { func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
if node.IPv4 != nil { if node.IPv4 != nil {
build.Add(*node.IPv4) 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: // matchers. A node owns two source identities for ACL purposes:
// - its own IPs (regular peer membership) // - its own IPs (regular peer membership)
// - any approved subnet routes it advertises (subnet-router-as-src, // - 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 // Either identity matching a rule's src — combined with the dst
// matching node2's IPs, node2's approved subnet routes, or "the // 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 // CanAccessRoute determines whether a specific route prefix should be
// visible to this node based on the given matchers. // visible to this node based on the given matchers.
// //
// Unlike CanAccess, this function intentionally does NOT check // Unlike [Node.CanAccess], this function intentionally does NOT check
// DestsIsTheInternet(). Exit routes (0.0.0.0/0, ::/0) are handled by // [matcher.Match.DestsIsTheInternet]. Exit routes (0.0.0.0/0, ::/0) are handled by
// RoutesForPeer (state.go) which adds them unconditionally from // RoutesForPeer (state.go) which adds them unconditionally from
// ExitRoutes(), not through ACL-based route filtering. The // [Node.ExitRoutes], not through ACL-based route filtering. The
// DestsIsTheInternet check in CanAccess exists solely for peer // [matcher.Match.DestsIsTheInternet] check in [Node.CanAccess] exists solely for peer
// visibility determination (should two nodes see each other), which // visibility determination (should two nodes see each other), which
// is a separate concern from route prefix authorization. // is a separate concern from route prefix authorization.
// //
// Additionally, autogroup:internet is explicitly skipped during filter // Additionally, autogroup:internet is explicitly skipped during filter
// rule compilation (filter.go), so no matchers ever contain "the // rule compilation (filter.go), so no matchers ever contain "the
// internet" from internet-targeted ACLs. Wildcard "*" dests produce // 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. // true, so the check would be redundant for that case.
func (node *Node) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool { func (node *Node) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
src := node.IPs() src := node.IPs()
@ -500,8 +500,8 @@ func (node *Node) Proto() *v1.Node {
} }
// Set User field based on node ownership // Set User field based on node ownership
// Note: User will be set to TaggedDevices in the gRPC layer (grpcv1.go) // Note: User will be set to [TaggedDevices] in the gRPC layer (grpcv1.go)
// for proper MapResponse formatting // for proper [tailcfg.MapResponse] formatting
if node.User != nil { if node.User != nil {
nodeProto.User = node.User.Proto() 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 // AnnouncedRoutes returns the list of routes the node announces, as
// reported by the client in Hostinfo.RoutableIPs. Announcement alone // reported by the client in [tailcfg.Hostinfo.RoutableIPs]. Announcement alone
// does not grant visibility — see SubnetRoutes for approval-gated // does not grant visibility — see [Node.SubnetRoutes] for approval-gated
// access. // access.
func (node *Node) AnnouncedRoutes() []netip.Prefix { func (node *Node) AnnouncedRoutes() []netip.Prefix {
if node.Hostinfo == nil { 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 // SubnetRoutes returns the list of routes (excluding exit routes) that the node
// announces and are approved. Also used by CanAccess and CanAccessRoute as part // announces and are approved. Also used by [Node.CanAccess] and [Node.CanAccessRoute] as part
// of the subnet-router-as-source identity (issue #3157). // of the subnet-router-as-source identity.
// //
// IMPORTANT: This method is used for internal data structures and should NOT be // 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 // used for the gRPC Proto conversion. For Proto, SubnetRoutes must be populated
// manually with PrimaryRoutes to ensure it includes only routes actively served // 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. // grpcv1.go/nodesToProto.
func (node *Node) SubnetRoutes() []netip.Prefix { func (node *Node) SubnetRoutes() []netip.Prefix {
var routes []netip.Prefix var routes []netip.Prefix
@ -589,7 +589,7 @@ func (node *Node) IsSubnetRouter() bool {
return len(node.SubnetRoutes()) > 0 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 { func (node *Node) AllApprovedRoutes() []netip.Prefix {
return append(node.SubnetRoutes(), node.ExitRoutes()...) return append(node.SubnetRoutes(), node.ExitRoutes()...)
} }
@ -598,9 +598,9 @@ func (node *Node) String() string {
return node.Hostname return node.Hostname
} }
// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging. // MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
// This method is used with zerolog's EmbedObject() for flat field embedding // This method is used with [zerolog.Event.EmbedObject] for flat field embedding
// or Object() for nested logging when multiple nodes are logged. // or [zerolog.Event.Object] for nested logging when multiple nodes are logged.
func (node *Node) MarshalZerologObject(e *zerolog.Event) { func (node *Node) MarshalZerologObject(e *zerolog.Event) {
if node == nil { if node == nil {
return return
@ -628,11 +628,11 @@ func (node *Node) MarshalZerologObject(e *zerolog.Event) {
} }
} }
// PeerChangeFromMapRequest takes a MapRequest and compares it to the node // PeerChangeFromMapRequest takes a [tailcfg.MapRequest] and compares it to the node
// to produce a PeerChange struct that can be used to updated the node and // to produce a [tailcfg.PeerChange] struct that can be used to updated the node and
// inform peers about smaller changes to the node. // inform peers about smaller changes to the node.
// When a field is added to this function, remember to also add it to: // When a field is added to this function, remember to also add it to:
// - node.ApplyPeerChange // - [Node.ApplyPeerChange]
// - logTracePeerChange in poll.go. // - logTracePeerChange in poll.go.
func (node *Node) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange { func (node *Node) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
ret := 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) { func (node *Node) ApplyPeerChange(change *tailcfg.PeerChange) {
if change.Key != nil { if change.Key != nil {
node.NodeKey = *change.Key node.NodeKey = *change.Key
@ -812,8 +812,8 @@ func (node *Node) DebugString() string {
return sb.String() return sb.String()
} }
// MarshalZerologObject implements zerolog.LogObjectMarshaler for NodeView. // MarshalZerologObject implements [zerolog.LogObjectMarshaler] for [NodeView].
// This delegates to the underlying Node's implementation. // This delegates to the underlying [Node]'s implementation.
func (nv NodeView) MarshalZerologObject(e *zerolog.Event) { func (nv NodeView) MarshalZerologObject(e *zerolog.Event) {
if !nv.Valid() { if !nv.Valid() {
return return
@ -823,8 +823,8 @@ func (nv NodeView) MarshalZerologObject(e *zerolog.Event) {
} }
// Owner returns the owner for display purposes. // Owner returns the owner for display purposes.
// For tagged nodes, returns TaggedDevices. For user-owned nodes, returns the 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). // 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. // Callers should check .Valid() on the result before accessing fields.
func (nv NodeView) Owner() UserView { func (nv NodeView) Owner() UserView {
if nv.IsTagged() { if nv.IsTagged() {
@ -950,8 +950,8 @@ func (nv NodeView) IsEphemeral() bool {
return nv.ж.IsEphemeral() return nv.ж.IsEphemeral()
} }
// PeerChangeFromMapRequest takes a MapRequest and compares it to the node // PeerChangeFromMapRequest takes a [tailcfg.MapRequest] and compares it to the node
// to produce a PeerChange struct that can be used to updated the node and // to produce a [tailcfg.PeerChange] struct that can be used to updated the node and
// inform peers about smaller changes to the node. // inform peers about smaller changes to the node.
func (nv NodeView) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange { func (nv NodeView) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
if !nv.Valid() { if !nv.Valid() {
@ -998,7 +998,7 @@ func (nv NodeView) RequestTags() []string {
return nv.Hostinfo().RequestTags().AsSlice() 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 { func (nv NodeView) Proto() *v1.Node {
if !nv.Valid() { if !nv.Valid() {
return nil return nil
@ -1025,8 +1025,8 @@ func (nv NodeView) HasTag(tag string) bool {
return nv.ж.HasTag(tag) return nv.ж.HasTag(tag)
} }
// TypedUserID returns the UserID as a typed UserID type. // TypedUserID returns the [Node.UserID] as a typed [UserID] type.
// Returns 0 if UserID is nil or node is invalid. // Returns 0 if [Node.UserID] is nil or node is invalid.
func (nv NodeView) TypedUserID() UserID { func (nv NodeView) TypedUserID() UserID {
if !nv.Valid() { if !nv.Valid() {
return 0 return 0
@ -1036,8 +1036,8 @@ func (nv NodeView) TypedUserID() UserID {
} }
// TailscaleUserID returns the user ID to use in Tailscale protocol. // TailscaleUserID returns the user ID to use in Tailscale protocol.
// Tagged nodes always return TaggedDevices.ID, user-owned nodes return their actual 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 UserID). // Returns 0 for nodes in an orphaned state (no tags, no [Node.UserID]).
func (nv NodeView) TailscaleUserID() tailcfg.UserID { func (nv NodeView) TailscaleUserID() tailcfg.UserID {
if !nv.Valid() { if !nv.Valid() {
return 0 return 0
@ -1056,7 +1056,7 @@ func (nv NodeView) TailscaleUserID() tailcfg.UserID {
return tailcfg.UserID(int64(nv.UserID().Get())) 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 { func (nv NodeView) Prefixes() []netip.Prefix {
if !nv.Valid() { if !nv.Valid() {
return nil return nil
@ -1118,7 +1118,7 @@ func equalPrefixesUnordered(a, b []netip.Prefix) bool {
// HasPolicyChange reports whether the node has changes that affect // HasPolicyChange reports whether the node has changes that affect
// policy evaluation. Includes approved subnet routes because they act // 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 { func (nv NodeView) HasPolicyChange(other NodeView) bool {
if nv.UserID() != other.UserID() { if nv.UserID() != other.UserID() {
return true return true
@ -1139,7 +1139,7 @@ func (nv NodeView) HasPolicyChange(other NodeView) bool {
return false 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( func TailNodes(
nodes views.Slice[NodeView], nodes views.Slice[NodeView],
capVer tailcfg.CapabilityVersion, capVer tailcfg.CapabilityVersion,
@ -1162,7 +1162,7 @@ func TailNodes(
return tNodes, nil 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] // 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 // and is merged into the baseline. Pass it when building the self view of the

View File

@ -8,7 +8,7 @@ import (
"gorm.io/gorm" "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) { func TestNodeIsTagged(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -44,9 +44,9 @@ func TestNodeIsTagged(t *testing.T) {
want: false, want: false,
}, },
{ {
// Tags should be copied from AuthKey during registration, so a node // Tags should be copied from [Node.AuthKey] during registration, so a node
// with only AuthKey.Tags and no Tags would be invalid in practice. // with only [PreAuthKey.Tags] and no [Node.Tags] would be invalid in practice.
// IsTagged() only checks node.Tags, not AuthKey.Tags. // [Node.IsTagged] only checks [Node.Tags], not [PreAuthKey.Tags].
name: "node registered with tagged authkey only - not tagged (tags should be copied)", name: "node registered with tagged authkey only - not tagged (tags should be copied)",
node: Node{ node: Node{
AuthKey: &PreAuthKey{ 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) { func TestNodeViewIsTagged(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -98,15 +98,15 @@ func TestNodeViewIsTagged(t *testing.T) {
want: true, want: true,
}, },
{ {
// Tags should be copied from AuthKey during registration, so a node // Tags should be copied from [Node.AuthKey] during registration, so a node
// with only AuthKey.Tags and no Tags would be invalid in practice. // 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)", name: "node with only AuthKey tags - not tagged (tags should be copied)",
node: Node{ node: Node{
AuthKey: &PreAuthKey{ AuthKey: &PreAuthKey{
Tags: []string{"tag:web"}, Tags: []string{"tag:web"},
}, },
}, },
want: false, // IsTagged() only checks node.Tags want: false, // [Node.IsTagged] only checks [Node.Tags]
}, },
{ {
name: "user-owned node", 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) { func TestNodeHasTag(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -151,8 +151,8 @@ func TestNodeHasTag(t *testing.T) {
want: false, want: false,
}, },
{ {
// Tags should be copied from AuthKey during registration // Tags should be copied from [Node.AuthKey] during registration
// HasTag() only checks node.Tags, not AuthKey.Tags // [Node.HasTag] only checks [Node.Tags], not [PreAuthKey.Tags]
name: "node has tag only in authkey - returns false", name: "node has tag only in authkey - returns false",
node: Node{ node: Node{
AuthKey: &PreAuthKey{ AuthKey: &PreAuthKey{
@ -163,7 +163,7 @@ func TestNodeHasTag(t *testing.T) {
want: false, 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", name: "node has tag in Tags but not in AuthKey",
node: Node{ node: Node{
Tags: []string{"tag:server"}, 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", description: "User-owned nodes are owned by the user, not by tags",
}, },
{ {
// Tags should be copied from AuthKey to Node during registration // Tags should be copied from [Node.AuthKey] to [Node] during registration
// IsTagged() only checks node.Tags, not AuthKey.Tags // [Node.IsTagged] only checks [Node.Tags], not [PreAuthKey.Tags]
name: "node with only authkey tags - not tagged (tags should be copied)", name: "node with only authkey tags - not tagged (tags should be copied)",
node: Node{ node: Node{
ID: 3, ID: 3,

View File

@ -113,7 +113,7 @@ func Test_NodeCanAccess(t *testing.T) {
}, },
want: true, want: true,
}, },
// Subnet-to-subnet tests for issue #3157. // Subnet-to-subnet tests.
// When ACL src and dst are both subnet CIDRs, subnet // When ACL src and dst are both subnet CIDRs, subnet
// routers advertising those subnets must see each other. // 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 // With a unidirectional ACL (src=A→dst=B), the dst
// router cannot access the src router. Bidirectional // 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). // both A.CanAccess(B) || B.CanAccess(A).
name: "subnet-to-subnet-unidirectional-dst-cannot-access-src-3157", name: "subnet-to-subnet-unidirectional-dst-cannot-access-src-3157",
node1: Node{ node1: Node{
@ -550,7 +550,6 @@ func TestPeerChangeFromMapRequest(t *testing.T) {
} }
} }
func TestApplyPeerChange(t *testing.T) { func TestApplyPeerChange(t *testing.T) {
tests := []struct { tests := []struct {
name string 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. // when a node's network properties have changed.
func TestHasNetworkChanges(t *testing.T) { func TestHasNetworkChanges(t *testing.T) {
mustIPPtr := func(s string) *netip.Addr { mustIPPtr := func(s string) *netip.Addr {

View File

@ -25,8 +25,8 @@ type PreAuthKey struct {
Prefix string Prefix string
Hash []byte // bcrypt Hash []byte // bcrypt
// For tagged keys: UserID tracks who created the key (informational) // For tagged keys: [PreAuthKey.UserID] tracks who created the key (informational)
// For user-owned keys: UserID tracks the node owner // For user-owned keys: [PreAuthKey.UserID] tracks the node owner
// Can be nil for system-created tagged keys // Can be nil for system-created tagged keys
UserID *uint UserID *uint
User *User `gorm:"constraint:OnDelete:SET NULL;"` User *User `gorm:"constraint:OnDelete:SET NULL;"`
@ -122,7 +122,7 @@ func (pak *PreAuthKey) Validate() error {
return PAKError("invalid authkey") 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(). log.Debug().
Caller(). Caller().
EmbedObject(pak). EmbedObject(pak).
@ -144,8 +144,8 @@ func (pak *PreAuthKey) Validate() error {
return nil return nil
} }
// IsTagged returns true if this PreAuthKey creates tagged nodes. // IsTagged returns true if this [PreAuthKey] creates tagged nodes.
// When a PreAuthKey has tags, nodes registered with it will be tagged nodes. // When a [PreAuthKey] has tags, nodes registered with it will be tagged nodes.
func (pak *PreAuthKey) IsTagged() bool { func (pak *PreAuthKey) IsTagged() bool {
return len(pak.Tags) > 0 return len(pak.Tags) > 0
} }
@ -160,7 +160,7 @@ func (pak *PreAuthKey) maskedPrefix() string {
return "" 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. // SECURITY: This method intentionally does NOT log the full key or hash.
// Only the masked prefix is logged for identification purposes. // Only the masked prefix is logged for identification purposes.
func (pak *PreAuthKey) MarshalZerologObject(e *zerolog.Event) { func (pak *PreAuthKey) MarshalZerologObject(e *zerolog.Event) {

View File

@ -9,7 +9,7 @@ import (
) )
// RegistrationData is the payload cached for a pending node registration. // 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 // only the fields the registration callback path actually consumes when
// promoting a pending registration to a real node. // promoting a pending registration to a real node.
// //
@ -34,18 +34,18 @@ type RegistrationData struct {
// Already validated/normalised by EnsureHostname at producer time. // Already validated/normalised by EnsureHostname at producer time.
Hostname string 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 // 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 // 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 // May be nil if the client did not send [tailcfg.Hostinfo] in the original
// RegisterRequest. // [tailcfg.RegisterRequest].
Hostinfo *tailcfg.Hostinfo Hostinfo *tailcfg.Hostinfo
// Endpoints is the initial set of WireGuard endpoints the node // 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. // this with the live set.
Endpoints []netip.AddrPort Endpoints []netip.AddrPort

View File

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

View File

@ -2,20 +2,20 @@ package types
import "net/netip" 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 // that serialise as JSON. GORM v2's struct-based Updates skips fields
// it considers zero — for unnamed slice types that is nil — and the // 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 // each slice an IsZero() that always returns false, the column is
// always included in UPDATE statements regardless of whether the // always included in UPDATE statements regardless of whether the
// caller is clearing the field. JSON marshalling is unchanged: a nil // caller is clearing the field. JSON marshalling is unchanged: a nil
// value serialises to null and an empty value serialises to []. // value serialises to null and an empty value serialises to [].
// //
// The .List() helpers return the underlying unnamed slice for the // 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. // 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 type Strings []string
// IsZero implements GORM's zeroer interface to keep the column in the // 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. // List returns the underlying []string.
func (s Strings) List() []string { return []string(s) } 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 type Prefixes []netip.Prefix
// IsZero implements GORM's zeroer interface to keep the column in the // 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. // List returns the underlying []netip.Prefix.
func (s Prefixes) List() []netip.Prefix { return []netip.Prefix(s) } 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 type AddrPorts []netip.AddrPort
// IsZero implements GORM's zeroer interface to keep the column in the // IsZero implements GORM's zeroer interface to keep the column in the

View File

@ -6,7 +6,7 @@ import (
) )
// CommentHeader returns the // comment header that gets prepended to // 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: // informational; consumers ignore it. Format:
// //
// <TestID> // <TestID>
@ -20,7 +20,7 @@ import (
// schema version: <SchemaVersion> // schema version: <SchemaVersion>
// //
// Both `tool_version` and `schema_version` are also stored as // 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 // exist purely so the values are visible at a glance without
// parsing the file. // parsing the file.
// //
@ -68,8 +68,8 @@ func CommentHeader(c *Capture) string {
// captures at all. // captures at all.
// //
// The phrasing depends on which fields the capture uses: // The phrasing depends on which fields the capture uses:
// - SSH captures populate SSHRules // - SSH captures populate [NodeCapture.SSHRules]
// - other captures populate PacketFilterRules // - other captures populate [NodeCapture.PacketFilterRules]
// //
// If both fields appear (mixed/unusual), filter rules wins. // If both fields appear (mixed/unusual), filter rules wins.
func captureStats(c *Capture) string { func captureStats(c *Capture) string {

View File

@ -9,17 +9,17 @@ import (
"github.com/tailscale/hujson" "github.com/tailscale/hujson"
) )
// ErrUnsupportedSchemaVersion is returned by Read when a capture // ErrUnsupportedSchemaVersion is returned by [Read] when a capture
// advertises a SchemaVersion newer than the current binary supports. // advertises a [Capture.SchemaVersion] newer than the current binary supports.
var ErrUnsupportedSchemaVersion = errors.New("testcapture: unsupported schema version") 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 // Comments and trailing commas in the file are stripped before
// unmarshaling. Files advertising a SchemaVersion newer than the // unmarshaling. Files advertising a [Capture.SchemaVersion] newer than the
// current binary's are rejected with ErrUnsupportedSchemaVersion; // current binary's are rejected with [ErrUnsupportedSchemaVersion];
// SchemaVersion == 0 (pre-versioning) is accepted for backwards compat. // [Capture.SchemaVersion] == 0 (pre-versioning) is accepted for backwards compat.
// The returned Capture's CapturedAt is the value recorded in the file // The returned [Capture]'s [Capture.CapturedAt] is the value recorded in the file
// (not "now"). // (not "now").
func Read(path string) (*Capture, error) { func Read(path string) (*Capture, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
@ -43,8 +43,8 @@ func Read(path string) (*Capture, error) {
} }
// unmarshalHuJSON parses HuJSON bytes (JSON with comments / trailing // unmarshalHuJSON parses HuJSON bytes (JSON with comments / trailing
// commas) into v. Comments are stripped via hujson.Standardize before // commas) into v. Comments are stripped via [hujson.Value.Standardize] before
// json.Unmarshal is called. // [json.Unmarshal] is called.
func unmarshalHuJSON(data []byte, v any) error { func unmarshalHuJSON(data []byte, v any) error {
ast, err := hujson.Parse(data) ast, err := hujson.Parse(data)
if err != nil { if err != nil {

View File

@ -4,15 +4,15 @@
// //
// Files are HuJSON. Wire-format Tailscale data (filter rules, netmap, // Files are HuJSON. Wire-format Tailscale data (filter rules, netmap,
// whois, SSH rules) is stored as proper tailcfg/netmap/filtertype/ // 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 // between the capture tool and headscale becomes a compile error
// rather than a silent test failure, and so that consumers don't // rather than a silent test failure, and so that consumers don't
// have to repeat json.Unmarshal at every read site. Storing data as // have to repeat [json.Unmarshal] at every read site. Storing data as
// json.RawMessage previously hid a serious capture-pipeline bug (the // [json.RawMessage] previously hid a serious capture-pipeline bug (the
// IPN bus initial notification returns a stale Peers slice — see 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 // shape. SSH scenarios populate Captures[name].SSHRules; the others
// populate Captures[name].PacketFilterRules + Captures[name].Netmap. // populate Captures[name].PacketFilterRules + Captures[name].Netmap.
package testcapture package testcapture
@ -37,11 +37,11 @@ const SchemaVersion = 1
// Capture is one captured run of one scenario. // Capture is one captured run of one scenario.
// //
// All four capture types (acl, routes, grant, ssh) use this same shape. // All four capture types (acl, routes, grant, ssh) use this same shape.
// SSH scenarios populate Captures[name].SSHRules; the others populate // SSH scenarios populate [Capture.Captures][name].SSHRules; the others populate
// Captures[name].PacketFilterRules + Captures[name].Netmap. // [Capture.Captures][name].PacketFilterRules + [Capture.Captures][name].Netmap.
type Capture struct { type Capture struct {
// SchemaVersion identifies the on-disk format version. Always set // SchemaVersion identifies the on-disk format version. Always set
// to testcapture.SchemaVersion when written. // to [SchemaVersion] when written.
SchemaVersion int `json:"schema_version"` SchemaVersion int `json:"schema_version"`
// TestID is the stable identifier of the scenario, derived from // TestID is the stable identifier of the scenario, derived from
@ -67,15 +67,15 @@ type Capture struct {
Tailnet string `json:"tailnet"` Tailnet string `json:"tailnet"`
// Error is true when the SaaS API rejected the policy or when // Error is true when the SaaS API rejected the policy or when
// capture itself failed. In the rejection case, Captures reflects // capture itself failed. In the rejection case, [Capture.Captures] reflects
// the pre-push baseline (deny-all default) and Input.APIResponseBody // the pre-push baseline (deny-all default) and [Input.APIResponseBody]
// is populated. // is populated.
Error bool `json:"error,omitempty"` Error bool `json:"error,omitempty"`
// CaptureError is set when the capture itself failed (timeout, // 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 // 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"` CaptureError string `json:"capture_error,omitempty"`
// Input is everything that was sent to the tailnet to produce // 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 // Input describes everything that was sent to the tailnet to produce
// the captured state. // 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-encoded string) and the legacy
// shape (where full_policy is a JSON object). The legacy shape is // 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 // 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 is the HTTP status code of the policy POST.
APIResponseCode int `json:"api_response_code"` 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"` APIResponseBody *APIResponseBody `json:"api_response_body,omitempty"`
// Tailnet describes the tailnet-wide settings the capture tool applied // Tailnet describes the tailnet-wide settings the capture tool applied
@ -126,10 +126,10 @@ type Input struct {
ScenarioPath string `json:"scenario_path,omitempty"` 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 // double-quoted string. Consumers (including via_compat_test.go which
// uses its own local types) expect to parse full_policy as a JSON // 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. // forms on read so old and new captures are interchangeable.
func (i Input) MarshalJSON() ([]byte, error) { func (i Input) MarshalJSON() ([]byte, error) {
type alias Input 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 // as a JSON-encoded string) and the legacy shape (full_policy as a
// JSON object). Legacy objects are re-marshaled into a string at // JSON object). Legacy objects are re-marshaled into a string at
// load time so consumers see the typed field uniformly. New captures // 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 { func (i *Input) UnmarshalJSON(data []byte) error {
type alias Input type alias Input
@ -243,7 +243,7 @@ type SettingsInput struct {
// Topology describes the users and nodes present in the tailnet at // Topology describes the users and nodes present in the tailnet at
// capture time. Headscale's compat tests use this to construct // 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 { type Topology struct {
// Users in the tailnet. Always populated by the capture tool. // Users in the tailnet. Always populated by the capture tool.
Users []TopologyUser `json:"users"` Users []TopologyUser `json:"users"`
@ -266,22 +266,22 @@ type TopologyNode struct {
IPv4 string `json:"ipv4"` IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"` 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. // tagged nodes.
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
// RoutableIPs is what the node advertised // 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. // May include 0.0.0.0/0 + ::/0 for exit nodes.
RoutableIPs []string `json:"routable_ips"` 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. // approved. Used by Headscale's NodeCanApproveRoute test.
ApprovedRoutes []string `json:"approved_routes"` ApprovedRoutes []string `json:"approved_routes"`
} }
// Node is the captured state for one node, keyed by GivenName in // 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 // All four capture types populate the same struct. Different fields are
// used by different test types: // used by different test types:
@ -300,7 +300,7 @@ type Node struct {
// PacketFilterMatches is the compiled filter matches (with // PacketFilterMatches is the compiled filter matches (with
// CapMatch) returned by tailscaled localapi // CapMatch) returned by tailscaled localapi
// /debug-packet-filter-matches. Captured alongside // /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. // compiled form.
PacketFilterMatches []filtertype.Match `json:"packet_filter_matches,omitempty"` 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 // settle on a fresh delta-triggered notification, NOT by reading
// the WatchIPNBus(NotifyInitialNetMap) initial notification. // the WatchIPNBus(NotifyInitialNetMap) initial notification.
// The initial notification carries cn.NetMap() which returns // 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 // set at full-sync time and never re-synchronized from the
// authoritative nb.peers map. The capture tool previously used the initial // authoritative nb.peers map. The capture tool previously used the initial
// notification and silently captured netmaps with mostly-empty // notification and silently captured netmaps with mostly-empty
@ -327,6 +327,6 @@ type Node struct {
Whois map[string]*apitype.WhoIsResponse `json:"whois,omitempty"` Whois map[string]*apitype.WhoIsResponse `json:"whois,omitempty"`
// SSHRules is the SSH rules slice extracted from // 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"` SSHRules []*tailcfg.SSHRule `json:"ssh_rules,omitempty"`
} }

View File

@ -138,9 +138,9 @@ func sampleSSHCapture() *testcapture.Capture {
} }
// equalViaJSON compares two captures by JSON-marshaling them and // 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 // 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. // equivalent values.
func equalViaJSON(t *testing.T, want, got *testcapture.Capture) { func equalViaJSON(t *testing.T, want, got *testcapture.Capture) {
t.Helper() t.Helper()
@ -416,7 +416,7 @@ func TestCommentHeader_EmptyFilterRulesCountAsEmpty(t *testing.T) {
// TestInputUnmarshal_LegacyObjectForm asserts that a legacy capture // TestInputUnmarshal_LegacyObjectForm asserts that a legacy capture
// file written with full_policy as a raw JSON object (not a // 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 // the policy re-marshaled to a compact string so downstream consumers
// see a uniform typed field. // see a uniform typed field.
func TestInputUnmarshal_LegacyObjectForm(t *testing.T) { 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) t.Errorf("FullPolicy:\n got %q\nwant %q", got.FullPolicy, want)
} }
// Round-trip: the new MarshalJSON must emit the object form so // Round-trip: the new [testcapture.Input.MarshalJSON] must emit the object form so
// UnmarshalJSON re-reads it identically. // [testcapture.Input.UnmarshalJSON] re-reads it identically.
out, err := json.Marshal(got) out, err := json.Marshal(got)
if err != nil { if err != nil {
t.Fatalf("re-marshal: %v", err) t.Fatalf("re-marshal: %v", err)

View File

@ -11,7 +11,7 @@ import (
"github.com/tailscale/hujson" "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") var ErrNilCapture = errors.New("testcapture: nil capture")
// Write serializes c as a HuJSON file with a comment header. The // 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 // and is then renamed into place, so concurrent regeneration cannot
// leave a half-written file behind. // leave a half-written file behind.
// //
// The comment header is built by CommentHeader from c's TestID, // The comment header is built by [CommentHeader] from c's [Capture.TestID],
// Description, and Captures. The file's parent directory must // [Capture.Description], and [Capture.Captures]. The file's parent directory must
// already exist; callers should MkdirAll first. // already exist; callers should [os.MkdirAll] first.
func Write(path string, c *Capture) error { func Write(path string, c *Capture) error {
if c == nil { if c == nil {
return fmt.Errorf("testcapture: Write %s: %w", path, ErrNilCapture) 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 // 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. // consistent indentation/whitespace.
func marshalHuJSON(v any) ([]byte, error) { func marshalHuJSON(v any) ([]byte, error) {
raw, err := json.Marshal(v) raw, err := json.Marshal(v)

View File

@ -17,18 +17,18 @@ const EnvTestLogLevel = "HEADSCALE_TEST_LOG_LEVEL"
// emits zerolog output, so this init() runs once per test binary and is // emits zerolog output, so this init() runs once per test binary and is
// the only place that needs to know about test logging configuration. // 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). // Override: HEADSCALE_TEST_LOG_LEVEL=debug (or trace, info, warn, disabled).
// //
// Production binaries are unaffected because testing.Testing() returns false // Production binaries are unaffected because [testing.Testing] returns false
// outside of test execution. The same testing.Testing() pattern is already // 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 // used in hscontrol/db/users.go and hscontrol/db/node.go, so importing the
// testing package here is consistent with existing project conventions. // testing package here is consistent with existing project conventions.
// //
// Pitfalls: // 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. // 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 // level. Tests that assert on log output (currently only
// hscontrol/util/zlog) re-enable trace level via their own init_test.go. // hscontrol/util/zlog) re-enable trace level via their own init_test.go.
func init() { func init() {

View File

@ -92,22 +92,22 @@ func (v *UserView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
func (v UserView) Model() gorm.Model { return v.ж.Model } func (v UserView) Model() gorm.Model { return v.ж.Model }
// Name (username) for the user, is used if email is empty // Name (username) for the user, is used if email is empty
// Should not be used, please use Username(). // Should not be used, please use [User.Username].
// It is unique if ProviderIdentifier is not set. // It is unique if [User.ProviderIdentifier] is not set.
func (v UserView) Name() string { return v.ж.Name } func (v UserView) Name() string { return v.ж.Name }
// Typically the full name of the user // Typically the full name of the user
func (v UserView) DisplayName() string { return v.ж.DisplayName } func (v UserView) DisplayName() string { return v.ж.DisplayName }
// Email of the user // 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 } func (v UserView) Email() string { return v.ж.Email }
// ProviderIdentifier is a unique or not set identifier of the // ProviderIdentifier is a unique or not set identifier of the
// user from OIDC. It is the combination of `iss` // user from OIDC. It is the combination of `iss`
// and `sub` claim in the OIDC token. // and `sub` claim in the OIDC token.
// It is unique if set. // 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 } func (v UserView) ProviderIdentifier() sql.NullString { return v.ж.ProviderIdentifier }
// Provider is the origin of the user account, // 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 } func (v NodeView) Hostname() string { return v.ж.Hostname }
// Givenname represents either: // Givenname represents either:
// a DNS normalized version of Hostname // a DNS normalized version of [Node.Hostname]
// a valid name set by the User // a valid name set by the [User]
// //
// GivenName is the name used in all DNS related // GivenName is the name used in all DNS related
// parts of headscale. // parts of headscale.
@ -228,7 +228,7 @@ func (v NodeView) RegisterMethod() string { return v.ж.RegisterMethod }
// Tags cannot be removed once set (one-way transition). // Tags cannot be removed once set (one-way transition).
func (v NodeView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) } 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. // prevent the preauthkey from being deleted before the node.
// The preauthkey can define "tags" of the node so we need it // The preauthkey can define "tags" of the node so we need it
// around. // around.
@ -376,8 +376,8 @@ func (v PreAuthKeyView) Prefix() string { return v.ж.Prefix }
// bcrypt // bcrypt
func (v PreAuthKeyView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Hash) } func (v PreAuthKeyView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Hash) }
// For tagged keys: UserID tracks who created the key (informational) // For tagged keys: [PreAuthKey.UserID] tracks who created the key (informational)
// For user-owned keys: UserID tracks the node owner // For user-owned keys: [PreAuthKey.UserID] tracks the node owner
// Can be nil for system-created tagged keys // Can be nil for system-created tagged keys
func (v PreAuthKeyView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) } func (v PreAuthKeyView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) }

View File

@ -37,7 +37,7 @@ const (
TaggedDevicesUserID = 2147455555 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. // 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. // This special user ID is used when rendering tagged nodes in the Tailscale protocol.
var TaggedDevices = User{ var TaggedDevices = User{
@ -72,22 +72,22 @@ type User struct {
// but not if you only run with CLI users. // but not if you only run with CLI users.
// Name (username) for the user, is used if email is empty // Name (username) for the user, is used if email is empty
// Should not be used, please use Username(). // Should not be used, please use [User.Username].
// It is unique if ProviderIdentifier is not set. // It is unique if [User.ProviderIdentifier] is not set.
Name string Name string
// Typically the full name of the user // Typically the full name of the user
DisplayName string DisplayName string
// Email of the user // Email of the user
// Should not be used, please use Username(). // Should not be used, please use [User.Username].
Email string Email string
// ProviderIdentifier is a unique or not set identifier of the // ProviderIdentifier is a unique or not set identifier of the
// user from OIDC. It is the combination of `iss` // user from OIDC. It is the combination of `iss`
// and `sub` claim in the OIDC token. // and `sub` claim in the OIDC token.
// It is unique if set. // It is unique if set.
// It is unique together with Name. // It is unique together with [User.Name].
ProviderIdentifier sql.NullString ProviderIdentifier sql.NullString
// Provider is the origin of the user account, // Provider is the origin of the user account,
@ -105,7 +105,7 @@ func (u *User) StringID() string {
return strconv.FormatUint(uint64(u.ID), 10) 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)). // This is a convenience method to avoid ugly casting like ptr.To(types.UserID(user.ID)).
func (u *User) TypedID() *UserID { func (u *User) TypedID() *UserID {
uid := UserID(u.ID) uid := UserID(u.ID)
@ -128,8 +128,8 @@ func (u *User) Username() string {
) )
} }
// Display returns the DisplayName if it exists, otherwise // Display returns the [User.DisplayName] if it exists, otherwise
// it will return the Username. // it will return the [User.Username].
func (u *User) Display() string { func (u *User) Display() string {
return cmp.Or(u.DisplayName, u.Username()) return cmp.Or(u.DisplayName, u.Username())
} }
@ -153,7 +153,7 @@ func (u UserView) TailscaleUser() tailcfg.User {
} }
// ID returns the user's ID. // 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. // and the viewer generator doesn't always produce it.
func (u UserView) ID() uint { func (u UserView) ID() uint {
return u.ж.ID 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) { func (u *User) MarshalZerologObject(e *zerolog.Event) {
if u == nil { if u == nil {
return 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) { func (u UserView) MarshalZerologObject(e *zerolog.Event) {
if !u.Valid() { if !u.Valid() {
return return
@ -239,6 +239,7 @@ type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
var arr []string var arr []string
err := json.Unmarshal(data, &arr) err := json.Unmarshal(data, &arr)
if err == nil { if err == nil {
*f = arr *f = arr
@ -246,6 +247,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
} }
var single string var single string
err = json.Unmarshal(data, &single) err = json.Unmarshal(data, &single)
if err == nil { if err == nil {
*f = []string{single} *f = []string{single}
@ -259,7 +261,6 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
// string "true" or "false" instead of a boolean. // string "true" or "false" instead of a boolean.
// This maps bool to a specific type with a custom unmarshaler to // This maps bool to a specific type with a custom unmarshaler to
// ensure we can decode it from a string. // ensure we can decode it from a string.
// https://github.com/juanfont/headscale/issues/2293
type FlexibleBoolean bool type FlexibleBoolean bool
func (bit *FlexibleBoolean) UnmarshalJSON(data []byte) error { func (bit *FlexibleBoolean) UnmarshalJSON(data []byte) error {
@ -294,23 +295,23 @@ type OIDCClaims struct {
Iss string `json:"iss"` Iss string `json:"iss"`
// Name is the user's full name. // Name is the user's full name.
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Groups FlexibleStringSlice `json:"groups,omitempty"` Groups FlexibleStringSlice `json:"groups,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"` EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
ProfilePictureURL string `json:"picture,omitempty"` ProfilePictureURL string `json:"picture,omitempty"`
Username string `json:"preferred_username,omitempty"` Username string `json:"preferred_username,omitempty"`
} }
// Identifier returns a unique identifier string combining the Iss and Sub claims. // Identifier returns a unique identifier string combining the [OIDCClaims.Iss] and [OIDCClaims.Sub] claims.
// The format depends on whether Iss is a URL or not: // 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 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 non-URLs: Joins with a slash (e.g., "oidc/sub")
// - For empty Iss: Returns just "sub" // - For empty [OIDCClaims.Iss]: Returns just "sub"
// - For empty Sub: Returns just the Issuer // - For empty [OIDCClaims.Sub]: Returns just the Issuer
// - For both empty: Returns empty string // - 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 { func (c *OIDCClaims) Identifier() string {
// Handle empty components special cases // Handle empty components special cases
if c.Iss == "" && c.Sub == "" { if c.Iss == "" && c.Sub == "" {
@ -407,18 +408,18 @@ func CleanIdentifier(identifier string) string {
} }
type OIDCUserInfo struct { type OIDCUserInfo struct {
Sub string `json:"sub"` Sub string `json:"sub"`
Name string `json:"name"` Name string `json:"name"`
GivenName string `json:"given_name"` GivenName string `json:"given_name"`
FamilyName string `json:"family_name"` FamilyName string `json:"family_name"`
PreferredUsername string `json:"preferred_username"` PreferredUsername string `json:"preferred_username"`
Email string `json:"email"` Email string `json:"email"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"` EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
Groups FlexibleStringSlice `json:"groups"` Groups FlexibleStringSlice `json:"groups"`
Picture string `json:"picture"` 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. // All fields will be updated, except for the ID.
func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) { func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) {
err := util.ValidateUsername(claims.Username) err := util.ValidateUsername(claims.Username)

View File

@ -9,7 +9,7 @@ import (
"go4.org/netipx" "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 // https://github.com/tailscale/tailscale/blob/71029cea2ddf82007b80f465b256d027eab0f02d/wgengine/filter/tailcfg.go#L97-L162
// TODO(kradalby): contribute upstream and make public. // TODO(kradalby): contribute upstream and make public.
var ( var (
@ -24,7 +24,7 @@ var (
// - a CIDR (e.g. "192.168.0.0/16") // - a CIDR (e.g. "192.168.0.0/16")
// - a range of two IPs, inclusive, separated by hyphen ("2eff::1-2eff::0800") // - 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. // address (without a slash) treated as a CIDR of *bits length.
// nolint // nolint
func ParseIPSet(arg string, bits *int) (*netipx.IPSet, error) { func ParseIPSet(arg string, bits *int) (*netipx.IPSet, error) {
@ -114,7 +114,7 @@ func StringToIPPrefix(prefixes []string) ([]netip.Prefix, error) {
return result, nil 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] { func IPSetAddrIter(ipSet *netipx.IPSet) iter.Seq[netip.Addr] {
return func(yield func(netip.Addr) bool) { return func(yield func(netip.Addr) bool) {
for _, rng := range ipSet.Ranges() { for _, rng := range ipSet.Ranges() {

View File

@ -23,7 +23,7 @@ const (
) )
// DNS validation errors. Hostname-side validation lives on // 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. // the username-side errors stay in this package.
var ( var (
ErrUsernameTooShort = errors.New("username must be at least 2 characters long") ErrUsernameTooShort = errors.New("username must be at least 2 characters long")
@ -71,12 +71,12 @@ func ValidateUsername(username string) error {
return nil 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 // 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. // server (listening in 100.100.100.100 udp/53) should be used for.
// //
// Tailscale.com includes in the list: // 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 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`. // - 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. // 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). // 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. // 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 { 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) netRange := netipx.PrefixIPNet(ipPrefix)
maskBits, _ := netRange.Mask.Size() maskBits, _ := netRange.Mask.Size()
@ -130,12 +130,12 @@ func GenerateIPv4DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN {
return fqdns 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 // 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. // server (listening in 100.100.100.100 udp/53) should be used for.
// //
// Tailscale.com includes in the list: // 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 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`. // - 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. // In the public SaaS this is [64-127].100.in-addr.arpa.

View File

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

View File

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

View File

@ -94,7 +94,7 @@ type Traceroute struct {
Err error 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) { func ParseTraceroute(output string) (Traceroute, error) {
lines := strings.Split(strings.TrimSpace(output), "\n") lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) < 1 { if len(lines) < 1 {

View File

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

View File

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

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