mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-23 10:42:30 +09:00
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:
@@ -14,7 +14,7 @@ import (
|
||||
// Key is the test function name, value is a list of subtest prefixes.
|
||||
// Each prefix becomes a separate CI job as "TestName/prefix".
|
||||
//
|
||||
// Example: TestAutoApproveMultiNetwork has subtests like:
|
||||
// Example: [TestAutoApproveMultiNetwork] has subtests like:
|
||||
// - TestAutoApproveMultiNetwork/authkey-tag-advertiseduringup-false-pol-database
|
||||
// - TestAutoApproveMultiNetwork/webauth-user-advertiseduringup-true-pol-file
|
||||
//
|
||||
|
||||
@@ -830,7 +830,7 @@ func extractContainerLogs(ctx context.Context, cli *client.Client, containerID,
|
||||
|
||||
// extractContainerFiles extracts database file and directories from headscale containers.
|
||||
// Note: The actual file extraction is now handled by the integration tests themselves
|
||||
// via SaveProfile, SaveMapResponses, and SaveDatabase functions in hsic.go.
|
||||
// via [SaveProfile], [SaveMapResponses], and [SaveDatabase] functions in hsic.go.
|
||||
func extractContainerFiles(ctx context.Context, cli *client.Client, containerID, containerName, logsDir string, verbose bool) error {
|
||||
// Files are now extracted directly by the integration tests
|
||||
// This function is kept for potential future use or other file types
|
||||
|
||||
102
cmd/hi/doctor.go
102
cmd/hi/doctor.go
@@ -11,6 +11,18 @@ import (
|
||||
"github.com/juanfont/headscale/integration/dockertestutil"
|
||||
)
|
||||
|
||||
const (
|
||||
statusPass = "PASS"
|
||||
statusFail = "FAIL"
|
||||
statusWarn = "WARN"
|
||||
|
||||
nameDockerDaemon = "Docker Daemon"
|
||||
nameDockerContext = "Docker Context"
|
||||
nameDockerSocket = "Docker Socket"
|
||||
nameGolangImage = "Golang Image"
|
||||
nameGoInstall = "Go Installation"
|
||||
)
|
||||
|
||||
var ErrSystemChecksFailed = errors.New("system checks failed")
|
||||
|
||||
// DoctorResult represents the result of a single health check.
|
||||
@@ -33,7 +45,7 @@ func runDoctorCheck(ctx context.Context) error {
|
||||
results = append(results, dockerResult)
|
||||
|
||||
// If Docker is available, run additional checks
|
||||
if dockerResult.Status == "PASS" {
|
||||
if dockerResult.Status == statusPass {
|
||||
results = append(results, checkDockerContext(ctx))
|
||||
results = append(results, checkDockerSocket(ctx))
|
||||
results = append(results, checkDockerHubCredentials())
|
||||
@@ -54,7 +66,7 @@ func runDoctorCheck(ctx context.Context) error {
|
||||
|
||||
// Return error if any critical checks failed
|
||||
for _, result := range results {
|
||||
if result.Status == "FAIL" {
|
||||
if result.Status == statusFail {
|
||||
return fmt.Errorf("%w - see details above", ErrSystemChecksFailed)
|
||||
}
|
||||
}
|
||||
@@ -70,7 +82,7 @@ func checkDockerBinary() DoctorResult {
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Docker Binary",
|
||||
Status: "FAIL",
|
||||
Status: statusFail,
|
||||
Message: "Docker binary not found in PATH",
|
||||
Suggestions: []string{
|
||||
"Install Docker: https://docs.docker.com/get-docker/",
|
||||
@@ -82,7 +94,7 @@ func checkDockerBinary() DoctorResult {
|
||||
|
||||
return DoctorResult{
|
||||
Name: "Docker Binary",
|
||||
Status: "PASS",
|
||||
Status: statusPass,
|
||||
Message: "Docker binary found",
|
||||
}
|
||||
}
|
||||
@@ -92,8 +104,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
|
||||
cli, err := createDockerClient(ctx)
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Docker Daemon",
|
||||
Status: "FAIL",
|
||||
Name: nameDockerDaemon,
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("Cannot create Docker client: %v", err),
|
||||
Suggestions: []string{
|
||||
"Start Docker daemon/service",
|
||||
@@ -108,8 +120,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
|
||||
_, err = cli.Ping(ctx)
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Docker Daemon",
|
||||
Status: "FAIL",
|
||||
Name: nameDockerDaemon,
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("Cannot ping Docker daemon: %v", err),
|
||||
Suggestions: []string{
|
||||
"Ensure Docker daemon is running",
|
||||
@@ -120,8 +132,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
|
||||
}
|
||||
|
||||
return DoctorResult{
|
||||
Name: "Docker Daemon",
|
||||
Status: "PASS",
|
||||
Name: nameDockerDaemon,
|
||||
Status: statusPass,
|
||||
Message: "Docker daemon is running and accessible",
|
||||
}
|
||||
}
|
||||
@@ -131,8 +143,8 @@ func checkDockerContext(ctx context.Context) DoctorResult {
|
||||
contextInfo, err := getCurrentDockerContext(ctx)
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Docker Context",
|
||||
Status: "WARN",
|
||||
Name: nameDockerContext,
|
||||
Status: statusWarn,
|
||||
Message: "Could not detect Docker context, using default settings",
|
||||
Suggestions: []string{
|
||||
"Check: docker context ls",
|
||||
@@ -143,15 +155,15 @@ func checkDockerContext(ctx context.Context) DoctorResult {
|
||||
|
||||
if contextInfo == nil {
|
||||
return DoctorResult{
|
||||
Name: "Docker Context",
|
||||
Status: "PASS",
|
||||
Name: nameDockerContext,
|
||||
Status: statusPass,
|
||||
Message: "Using default Docker context",
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorResult{
|
||||
Name: "Docker Context",
|
||||
Status: "PASS",
|
||||
Name: nameDockerContext,
|
||||
Status: statusPass,
|
||||
Message: "Using Docker context: " + contextInfo.Name,
|
||||
}
|
||||
}
|
||||
@@ -161,8 +173,8 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
|
||||
cli, err := createDockerClient(ctx)
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Docker Socket",
|
||||
Status: "FAIL",
|
||||
Name: nameDockerSocket,
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("Cannot access Docker socket: %v", err),
|
||||
Suggestions: []string{
|
||||
"Check Docker socket permissions",
|
||||
@@ -176,8 +188,8 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
|
||||
info, err := cli.Info(ctx)
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Docker Socket",
|
||||
Status: "FAIL",
|
||||
Name: nameDockerSocket,
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("Cannot get Docker info: %v", err),
|
||||
Suggestions: []string{
|
||||
"Check Docker daemon status",
|
||||
@@ -187,8 +199,8 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
|
||||
}
|
||||
|
||||
return DoctorResult{
|
||||
Name: "Docker Socket",
|
||||
Status: "PASS",
|
||||
Name: nameDockerSocket,
|
||||
Status: statusPass,
|
||||
Message: fmt.Sprintf("Docker socket accessible (Server: %s)", info.ServerVersion),
|
||||
}
|
||||
}
|
||||
@@ -222,8 +234,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
|
||||
cli, err := createDockerClient(ctx)
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Golang Image",
|
||||
Status: "FAIL",
|
||||
Name: nameGolangImage,
|
||||
Status: statusFail,
|
||||
Message: "Cannot create Docker client for image check",
|
||||
}
|
||||
}
|
||||
@@ -236,8 +248,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
|
||||
available, err := checkImageAvailableLocally(ctx, cli, imageName)
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Golang Image",
|
||||
Status: "FAIL",
|
||||
Name: nameGolangImage,
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("Cannot check golang image %s: %v", imageName, err),
|
||||
Suggestions: []string{
|
||||
"Check Docker daemon status",
|
||||
@@ -248,8 +260,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
|
||||
|
||||
if available {
|
||||
return DoctorResult{
|
||||
Name: "Golang Image",
|
||||
Status: "PASS",
|
||||
Name: nameGolangImage,
|
||||
Status: statusPass,
|
||||
Message: fmt.Sprintf("Golang image %s is available locally", imageName),
|
||||
}
|
||||
}
|
||||
@@ -258,8 +270,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
|
||||
err = ensureImageAvailable(ctx, cli, imageName, false)
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Golang Image",
|
||||
Status: "FAIL",
|
||||
Name: nameGolangImage,
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("Golang image %s not available locally and cannot pull: %v", imageName, err),
|
||||
Suggestions: []string{
|
||||
"Check internet connectivity",
|
||||
@@ -271,8 +283,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
|
||||
}
|
||||
|
||||
return DoctorResult{
|
||||
Name: "Golang Image",
|
||||
Status: "PASS",
|
||||
Name: nameGolangImage,
|
||||
Status: statusPass,
|
||||
Message: fmt.Sprintf("Golang image %s is now available", imageName),
|
||||
}
|
||||
}
|
||||
@@ -282,8 +294,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
|
||||
_, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Go Installation",
|
||||
Status: "FAIL",
|
||||
Name: nameGoInstall,
|
||||
Status: statusFail,
|
||||
Message: "Go binary not found in PATH",
|
||||
Suggestions: []string{
|
||||
"Install Go: https://golang.org/dl/",
|
||||
@@ -297,8 +309,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Go Installation",
|
||||
Status: "FAIL",
|
||||
Name: nameGoInstall,
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("Cannot get Go version: %v", err),
|
||||
}
|
||||
}
|
||||
@@ -306,8 +318,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
|
||||
version := strings.TrimSpace(string(output))
|
||||
|
||||
return DoctorResult{
|
||||
Name: "Go Installation",
|
||||
Status: "PASS",
|
||||
Name: nameGoInstall,
|
||||
Status: statusPass,
|
||||
Message: version,
|
||||
}
|
||||
}
|
||||
@@ -320,7 +332,7 @@ func checkGitRepository(ctx context.Context) DoctorResult {
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "Git Repository",
|
||||
Status: "FAIL",
|
||||
Status: statusFail,
|
||||
Message: "Not in a Git repository",
|
||||
Suggestions: []string{
|
||||
"Run from within the headscale git repository",
|
||||
@@ -331,7 +343,7 @@ func checkGitRepository(ctx context.Context) DoctorResult {
|
||||
|
||||
return DoctorResult{
|
||||
Name: "Git Repository",
|
||||
Status: "PASS",
|
||||
Status: statusPass,
|
||||
Message: "Running in Git repository",
|
||||
}
|
||||
}
|
||||
@@ -358,7 +370,7 @@ func checkRequiredFiles(ctx context.Context) DoctorResult {
|
||||
if len(missingFiles) > 0 {
|
||||
return DoctorResult{
|
||||
Name: "Required Files",
|
||||
Status: "FAIL",
|
||||
Status: statusFail,
|
||||
Message: "Missing required files: " + strings.Join(missingFiles, ", "),
|
||||
Suggestions: []string{
|
||||
"Ensure you're in the headscale project root directory",
|
||||
@@ -370,7 +382,7 @@ func checkRequiredFiles(ctx context.Context) DoctorResult {
|
||||
|
||||
return DoctorResult{
|
||||
Name: "Required Files",
|
||||
Status: "PASS",
|
||||
Status: statusPass,
|
||||
Message: "All required files found",
|
||||
}
|
||||
}
|
||||
@@ -384,11 +396,11 @@ func displayDoctorResults(results []DoctorResult) {
|
||||
var icon string
|
||||
|
||||
switch result.Status {
|
||||
case "PASS":
|
||||
case statusPass:
|
||||
icon = "✅"
|
||||
case "WARN":
|
||||
case statusWarn:
|
||||
icon = "⚠️"
|
||||
case "FAIL":
|
||||
case statusFail:
|
||||
icon = "❌"
|
||||
default:
|
||||
icon = "❓"
|
||||
|
||||
@@ -94,7 +94,7 @@ func detectGoVersion() string {
|
||||
return "1.26.1"
|
||||
}
|
||||
|
||||
// splitLines splits a string into lines without using strings.Split.
|
||||
// splitLines splits a string into lines without using [strings.Split].
|
||||
func splitLines(s string) []string {
|
||||
var (
|
||||
lines []string
|
||||
|
||||
@@ -160,7 +160,7 @@ func (sc *StatsCollector) monitorDockerEvents(ctx context.Context, runID string,
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert to types.Container format for consistency
|
||||
// Convert to [types.Container] format for consistency
|
||||
cont := types.Container{ //nolint:staticcheck // SA1019: use container.Summary
|
||||
ID: containerInfo.ID,
|
||||
Names: []string{containerInfo.Name},
|
||||
@@ -256,7 +256,7 @@ func (sc *StatsCollector) collectStatsForContainer(ctx context.Context, containe
|
||||
|
||||
err := decoder.Decode(&stats)
|
||||
if err != nil {
|
||||
// EOF is expected when container stops or stream ends
|
||||
// [io.EOF] is expected when container stops or stream ends
|
||||
if err.Error() != "EOF" && verbose {
|
||||
log.Printf("Failed to decode stats for container %s: %v", containerID[:12], err)
|
||||
}
|
||||
@@ -312,7 +312,7 @@ func calculateCPUPercent(prevStats, stats *container.Stats) float64 { //nolint:s
|
||||
// Calculate CPU percentage: (container CPU delta / system CPU delta) * number of CPUs * 100
|
||||
numCPUs := float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
|
||||
if numCPUs == 0 {
|
||||
// Fallback: if PercpuUsage is not available, assume 1 CPU
|
||||
// Fallback: if [PercpuUsage] is not available, assume 1 CPU
|
||||
numCPUs = 1.0
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// vendorhash check exit non-zero if flakehashes.json is stale
|
||||
// vendorhash update recompute and rewrite flakehashes.json
|
||||
//
|
||||
// The JSON schema and goModFingerprint algorithm mirror upstream
|
||||
// The JSON schema and [goModFingerprint] algorithm mirror upstream
|
||||
// tailscale's tool/updateflakes so a future shared library extraction
|
||||
// is straightforward.
|
||||
package main
|
||||
@@ -82,8 +82,8 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage: vendorhash <check|update>")
|
||||
}
|
||||
|
||||
// errStale signals to main that the check found a mismatch; it has
|
||||
// already printed a remediation message, so main should exit 1
|
||||
// errStale signals to [main] that the check found a mismatch; it has
|
||||
// already printed a remediation message, so [main] should exit 1
|
||||
// silently.
|
||||
var errStale = errors.New("vendor hash stale")
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ func (h *Headscale) handleRegister(
|
||||
// the Noise session's machine key matches the cached node.
|
||||
// Without this check anyone holding a target's NodeKey could
|
||||
// open a Noise session with a throwaway machine key and read
|
||||
// the owner's User/Login back through nodeToRegisterResponse.
|
||||
// handleLogout enforces the same check on its own path.
|
||||
// the owner's User/Login back through [nodeToRegisterResponse].
|
||||
// [Headscale.handleLogout] enforces the same check on its own path.
|
||||
if node.MachineKey() != machineKey {
|
||||
return nil, NewHTTPError(
|
||||
http.StatusUnauthorized,
|
||||
@@ -83,9 +83,8 @@ func (h *Headscale) handleRegister(
|
||||
)
|
||||
}
|
||||
|
||||
// When tailscaled restarts, it sends RegisterRequest with Auth=nil and Expiry=zero.
|
||||
// When tailscaled restarts, it sends [tailcfg.RegisterRequest] with Auth=nil and Expiry=zero.
|
||||
// Return the current node state without modification.
|
||||
// See: https://github.com/juanfont/headscale/issues/2862
|
||||
if req.Expiry.IsZero() && !node.IsExpired() {
|
||||
return nodeToRegisterResponse(node), nil
|
||||
}
|
||||
@@ -192,7 +191,7 @@ func (h *Headscale) handleLogout(
|
||||
}
|
||||
|
||||
// If the request expiry is in the past, we consider it a logout.
|
||||
// Zero expiry is handled in handleRegister() before calling this function.
|
||||
// Zero expiry is handled in [Headscale.handleRegister] before calling this function.
|
||||
if req.Expiry.Before(time.Now()) {
|
||||
log.Debug().
|
||||
EmbedObject(node).
|
||||
@@ -254,7 +253,7 @@ func nodeToRegisterResponse(node types.NodeView) *tailcfg.RegisterResponse {
|
||||
MachineAuthorized: true,
|
||||
}
|
||||
|
||||
// For tagged nodes, use the TaggedDevices special user
|
||||
// For tagged nodes, use the [types.TaggedDevices] special user
|
||||
// For user-owned nodes, include User and Login information from the actual user
|
||||
if node.IsTagged() {
|
||||
resp.User = types.TaggedDevices.View().TailscaleUser()
|
||||
@@ -303,8 +302,8 @@ func (h *Headscale) waitForFollowup(
|
||||
}
|
||||
|
||||
// reqToNewRegisterResponse refreshes the registration flow by creating a new
|
||||
// registration ID and returning the corresponding AuthURL so the client can
|
||||
// restart the authentication process.
|
||||
// registration ID and returning the corresponding [tailcfg.RegisterResponse.AuthURL]
|
||||
// so the client can restart the authentication process.
|
||||
func (h *Headscale) reqToNewRegisterResponse(
|
||||
req tailcfg.RegisterRequest,
|
||||
machineKey key.MachinePublic,
|
||||
@@ -326,8 +325,8 @@ func (h *Headscale) reqToNewRegisterResponse(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// registrationDataFromRequest builds the RegistrationData payload stored
|
||||
// in the auth cache for a pending registration. The original Hostinfo is
|
||||
// registrationDataFromRequest builds the [types.RegistrationData] payload stored
|
||||
// in the auth cache for a pending registration. The original [tailcfg.Hostinfo] is
|
||||
// retained so that consumers (auth callback, observability) see the
|
||||
// fields the client originally announced; the bounded-LRU cap on the
|
||||
// cache is what bounds the unauthenticated cache-fill DoS surface.
|
||||
|
||||
@@ -60,7 +60,7 @@ func createTestAppWithNodeExpiry(t *testing.T, nodeExpiry time.Duration) *Headsc
|
||||
// a tagged node with:
|
||||
// - Tags from the PreAuthKey
|
||||
// - Nil UserID (tagged nodes are owned by tags, not a user)
|
||||
// - IsTagged() returns true.
|
||||
// - [types.Node.IsTagged] returns true.
|
||||
func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) {
|
||||
// authentication. This is critical for the container restart scenario (#2830).
|
||||
//
|
||||
// NOTE: This test verifies that re-authentication preserves the node's current tags
|
||||
// without testing tag modification via SetNodeTags (which requires ACL policy setup).
|
||||
// without testing tag modification via [state.State.SetNodeTags] (which requires ACL policy setup).
|
||||
func TestReAuthDoesNotReapplyTags(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestReAuthDoesNotReapplyTags(t *testing.T) {
|
||||
}
|
||||
|
||||
// NOTE: TestSetTagsOnUserOwnedNode functionality is covered by gRPC tests in grpcv1_test.go
|
||||
// which properly handle ACL policy setup. The test verifies that SetTags can convert
|
||||
// which properly handle ACL policy setup. The test verifies that [headscaleV1APIServer.SetTags] can convert
|
||||
// user-owned nodes to tagged nodes while preserving UserID.
|
||||
|
||||
// TestCannotRemoveAllTags tests that attempting to remove all tags from a
|
||||
@@ -813,8 +813,8 @@ func TestUntaggedNodeRestartPreservesNilExpiry(t *testing.T) {
|
||||
|
||||
// TestExpiryDuringPersonalToTaggedConversion tests that when a personal node
|
||||
// is converted to tagged via reauth with RequestTags, the expiry is cleared to nil.
|
||||
// BUG #3048: Previously expiry was NOT cleared because expiry handling ran
|
||||
// BEFORE processReauthTags.
|
||||
// Previously expiry was NOT cleared because expiry handling ran
|
||||
// BEFORE [state.State.processReauthTags].
|
||||
func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
user := app.state.CreateUserForTest("expiry-test-user")
|
||||
@@ -886,8 +886,8 @@ func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
|
||||
// TestExpiryDuringTaggedToPersonalConversion tests that when a tagged node
|
||||
// is converted to personal via reauth with empty RequestTags, expiry is set
|
||||
// from the client request.
|
||||
// BUG #3048: Previously expiry was NOT set because expiry handling ran
|
||||
// BEFORE processReauthTags (node was still tagged at check time).
|
||||
// Previously expiry was NOT set because expiry handling ran
|
||||
// BEFORE [state.State.processReauthTags] (node was still tagged at check time).
|
||||
func TestExpiryDuringTaggedToPersonalConversion(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
user := app.state.CreateUserForTest("expiry-test-user2")
|
||||
@@ -1145,7 +1145,7 @@ func TestNodeExpiryZeroDisablesDefault(t *testing.T) {
|
||||
assert.False(t, node.IsExpired(), "node should not be expired")
|
||||
|
||||
// With node.expiry=0 and zero client expiry, the node gets a zero expiry
|
||||
// which IsExpired() treats as "never expires" — backwards compatible.
|
||||
// which [types.Node.IsExpired] treats as "never expires" — backwards compatible.
|
||||
if node.Expiry().Valid() {
|
||||
assert.True(t, node.Expiry().Get().IsZero(),
|
||||
"with node.expiry=0 and zero client expiry, expiry should be zero time")
|
||||
@@ -1266,11 +1266,11 @@ func TestReregistrationAppliesDefaultExpiry(t *testing.T) {
|
||||
// re-registers with zero client expiry and node.expiry is disabled (0),
|
||||
// the node's expiry stays nil rather than being set to a pointer to zero
|
||||
// time. Regression test for the else branch introduced in commit 6337a3db
|
||||
// which assigned `®Req.Expiry` (pointer to time.Time{}) instead of nil,
|
||||
// which assigned `®Req.Expiry` (pointer to [time.Time]{}) instead of nil,
|
||||
// causing the database row to hold `0001-01-01 00:00:00` instead of NULL.
|
||||
//
|
||||
// The same !regReq.Expiry.IsZero() gate at state.go:2221-2228 is shared by
|
||||
// the tags-only PreAuthKey path (createAndSaveNewNode also receives nil
|
||||
// the tags-only PreAuthKey path ([state.State.createAndSaveNewNode] also receives nil
|
||||
// when the client sends zero expiry), so this regression is covered for
|
||||
// tagged nodes by inspection.
|
||||
func TestReregistrationZeroExpiryStaysNil(t *testing.T) {
|
||||
|
||||
@@ -31,7 +31,7 @@ type interactiveStep struct {
|
||||
stepType string // stepTypeInitialRequest, stepTypeAuthCompletion, or stepTypeFollowupRequest
|
||||
expectAuthURL bool
|
||||
expectCacheEntry bool
|
||||
callAuthPath bool // Real call to HandleNodeFromAuthPath, not mocked
|
||||
callAuthPath bool // Real call to [state.State.HandleNodeFromAuthPath], not mocked
|
||||
}
|
||||
|
||||
//nolint:gocyclo // comprehensive test function with many scenarios
|
||||
@@ -140,7 +140,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Wait for node to be available in NodeStore
|
||||
// Wait for node to be available in [state.NodeStore]
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(c, found, "node should be available in NodeStore")
|
||||
@@ -209,7 +209,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Wait for node to be available in NodeStore
|
||||
// Wait for node to be available in [state.NodeStore]
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(c, found, "node should be available in NodeStore")
|
||||
@@ -409,7 +409,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
|
||||
t.Logf("Setup registered node: %+v", resp)
|
||||
|
||||
// Wait for node to be available in NodeStore with debug info
|
||||
// Wait for node to be available in [state.NodeStore] with debug info
|
||||
var attemptCount int
|
||||
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
@@ -470,7 +470,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Wait for node to be available in NodeStore
|
||||
// Wait for node to be available in [state.NodeStore]
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(c, found, "node should be available in NodeStore")
|
||||
@@ -520,7 +520,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Wait for node to be available in NodeStore
|
||||
// Wait for node to be available in [state.NodeStore]
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(c, found, "node should be available in NodeStore")
|
||||
@@ -637,7 +637,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Wait for node to be available in NodeStore
|
||||
// Wait for node to be available in [state.NodeStore]
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(c, found, "node should be available in NodeStore")
|
||||
@@ -687,7 +687,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
app.state.SetAuthCacheEntry(regID, nodeToRegister)
|
||||
|
||||
// Simulate successful registration
|
||||
// handleRegister will receive the value when it starts waiting
|
||||
// [Headscale.handleRegister] will receive the value when it starts waiting
|
||||
go func() {
|
||||
user := app.state.CreateUserForTest("followup-user")
|
||||
|
||||
@@ -826,7 +826,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
},
|
||||
// TEST: Nil hostinfo is handled with defensive code
|
||||
// WHAT: Tests that nil hostinfo in register request is handled gracefully
|
||||
// INPUT: Register request with Hostinfo field set to nil
|
||||
// INPUT: Register request with [tailcfg.Hostinfo] field set to nil
|
||||
// EXPECTED: Node registers successfully with generated hostname starting with "node-"
|
||||
// WHY: Defensive code prevents nil pointer panics; creates valid default hostinfo
|
||||
{
|
||||
@@ -856,7 +856,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper //nolint:thelper
|
||||
assert.True(t, resp.MachineAuthorized)
|
||||
|
||||
// With nil Hostinfo the raw hostname stays empty and GivenName
|
||||
// With nil [tailcfg.Hostinfo] the raw hostname stays empty and GivenName
|
||||
// falls back to the literal "node" per the SaaS spec.
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(t, found)
|
||||
@@ -954,7 +954,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
|
||||
// TEST: PreAuthKey registration rejects client-provided RequestTags
|
||||
// WHAT: Tests that PreAuthKey registrations cannot use client-provided tags
|
||||
// INPUT: PreAuthKey registration with RequestTags in Hostinfo
|
||||
// INPUT: PreAuthKey registration with [tailcfg.Hostinfo.RequestTags] set
|
||||
// EXPECTED: Registration fails with "requested tags [...] are invalid or not permitted" error
|
||||
// WHY: PreAuthKey nodes get their tags from the key itself, not from client requests
|
||||
{
|
||||
@@ -1240,7 +1240,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
|
||||
// TEST: Zero-time expiry is handled correctly
|
||||
// WHAT: Tests registration with expiry set to zero time value
|
||||
// INPUT: Register request with Expiry set to time.Time{} (zero value)
|
||||
// INPUT: Register request with Expiry set to [time.Time]{} (zero value)
|
||||
// EXPECTED: Node registers successfully; zero time treated as no expiry
|
||||
// WHY: Zero time is valid Go default; should be handled gracefully
|
||||
{
|
||||
@@ -1280,7 +1280,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
},
|
||||
// TEST: Malformed hostinfo with very long hostname is truncated
|
||||
// WHAT: Tests that excessively long hostname is truncated to DNS label limit
|
||||
// INPUT: Hostinfo with 110-character hostname (exceeds 63-char DNS limit)
|
||||
// INPUT: [tailcfg.Hostinfo] with 110-character hostname (exceeds 63-char DNS limit)
|
||||
// EXPECTED: Node registers successfully; hostname truncated to 63 characters
|
||||
// WHY: Defensive code enforces DNS label limit (RFC 1123); prevents errors
|
||||
{
|
||||
@@ -1845,7 +1845,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
},
|
||||
// TEST: Logout with expiry exactly at current time
|
||||
// WHAT: Tests logout when expiry is set to exact current time (boundary case)
|
||||
// INPUT: Existing node sends request with expiry=time.Now() (not past, not future)
|
||||
// INPUT: Existing node sends request with expiry=[time.Now]() (not past, not future)
|
||||
// EXPECTED: Node is logged out (treated as expired)
|
||||
// WHY: Edge case: current time should be treated as expired
|
||||
{
|
||||
@@ -2225,7 +2225,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
},
|
||||
// TEST: Interactive workflow with nil hostinfo
|
||||
// WHAT: Tests interactive registration when request has nil hostinfo
|
||||
// INPUT: Interactive registration request with Hostinfo=nil
|
||||
// INPUT: Interactive registration request with [tailcfg.Hostinfo]=nil
|
||||
// EXPECTED: Node registers successfully with generated default hostname
|
||||
// WHY: Defensive code handles nil hostinfo in interactive flow
|
||||
{
|
||||
@@ -2761,7 +2761,7 @@ func TestNodeStoreLookup(t *testing.T) {
|
||||
|
||||
t.Logf("Registered node successfully: %+v", resp)
|
||||
|
||||
// Wait for node to be available in NodeStore
|
||||
// Wait for node to be available in [state.NodeStore]
|
||||
var node types.NodeView
|
||||
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
@@ -3072,7 +3072,7 @@ func TestWebFlowReauthDifferentUser(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("returned_node_is_user2_new_node", func(t *testing.T) {
|
||||
// The node returned from HandleNodeFromAuthPath should be user2's NEW node
|
||||
// The node returned from [state.State.HandleNodeFromAuthPath] should be user2's NEW node
|
||||
assert.Equal(t, user2.ID, node.UserID().Get(), "Returned node should belong to user2")
|
||||
assert.NotEqual(t, user1NodeID, node.ID(), "Returned node should be NEW, not transferred from user1")
|
||||
t.Logf("✓ HandleNodeFromAuthPath returned user2's new node (ID: %d)", node.ID())
|
||||
@@ -3166,7 +3166,7 @@ func createTestApp(t *testing.T) *Headscale {
|
||||
// 1. Node registers successfully with a single-use pre-auth key
|
||||
// 2. Node is running fine
|
||||
// 3. Node restarts (e.g., after headscale upgrade or tailscale container restart)
|
||||
// 4. Node sends RegisterRequest with the same pre-auth key
|
||||
// 4. Node sends [tailcfg.RegisterRequest] with the same pre-auth key
|
||||
// 5. BUG: Headscale rejects the request with "authkey expired" or "authkey already used"
|
||||
//
|
||||
// Expected behavior:
|
||||
@@ -3223,7 +3223,7 @@ func TestGitHubIssue2830_NodeRestartWithUsedPreAuthKey(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.True(t, usedPak.Used, "pre-auth key should be marked as used after initial registration")
|
||||
|
||||
// STEP 2: Simulate node restart - node sends RegisterRequest again with same pre-auth key
|
||||
// STEP 2: Simulate node restart - node sends [tailcfg.RegisterRequest] again with same pre-auth key
|
||||
// This happens when:
|
||||
// - Tailscale container restarts
|
||||
// - Tailscaled service restarts
|
||||
@@ -3508,7 +3508,7 @@ func TestGitHubIssue2830_ExistingNodeCanReregisterWithUsedPreAuthKey(t *testing.
|
||||
// WITHOUT THE FIX: This would fail with "authkey already used" error
|
||||
// WITH THE FIX: This succeeds because it's the same node re-registering with its own key
|
||||
|
||||
// Simulate sending the same RegisterRequest again (same MachineKey, same AuthKey)
|
||||
// Simulate sending the same [tailcfg.RegisterRequest] again (same MachineKey, same AuthKey)
|
||||
// This is exactly what happens when a container restarts
|
||||
reregisterReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
@@ -3787,15 +3787,15 @@ func TestAuthKeyTaggedToUserOwnedViaReauth(t *testing.T) {
|
||||
nodeAfterReauth.IsTagged(), nodeAfterReauth.UserID().Get())
|
||||
}
|
||||
|
||||
// TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate tests that when a PreAuthKey is deleted,
|
||||
// subsequent node updates (like those triggered by MapRequests) do not recreate the key.
|
||||
// TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate tests that when a [types.PreAuthKey] is deleted,
|
||||
// subsequent node updates (like those triggered by [tailcfg.MapRequest]s) do not recreate the key.
|
||||
//
|
||||
// This reproduces the bug where:
|
||||
// 1. Create a tagged preauthkey and register a node
|
||||
// 2. Delete the preauthkey (confirmed gone from pre_auth_keys DB table)
|
||||
// 3. Node sends MapRequest (e.g., after tailscaled restart)
|
||||
// 3. Node sends [tailcfg.MapRequest] (e.g., after tailscaled restart)
|
||||
// 4. BUG: The preauthkey reappears because GORM's Updates() upserts the stale AuthKey
|
||||
// data that still exists in the NodeStore's in-memory cache.
|
||||
// data that still exists in the [state.NodeStore]'s in-memory cache.
|
||||
//
|
||||
// The fix is to use Omit("AuthKey") on all node Updates() calls to prevent GORM
|
||||
// from touching the AuthKey association.
|
||||
@@ -3864,11 +3864,11 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
|
||||
require.Nil(t, dbNode.AuthKeyID, "node's AuthKeyID should be NULL after PreAuthKey deletion")
|
||||
t.Log("Node's AuthKeyID is NULL in database")
|
||||
|
||||
// The NodeStore may still have stale AuthKey data in memory.
|
||||
// Now simulate what happens when the node sends a MapRequest after a tailscaled restart.
|
||||
// This triggers persistNodeToDB which calls GORM's Updates().
|
||||
// The [state.NodeStore] may still have stale AuthKey data in memory.
|
||||
// Now simulate what happens when the node sends a [tailcfg.MapRequest] after a tailscaled restart.
|
||||
// This triggers [state.State.persistNodeToDB] which calls GORM's Updates().
|
||||
|
||||
// Simulate a MapRequest by updating the node through the state layer
|
||||
// Simulate a [tailcfg.MapRequest] by updating the node through the state layer
|
||||
// This mimics what poll.go does when processing MapRequests
|
||||
mapReq := tailcfg.MapRequest{
|
||||
NodeKey: nodeKey.Public(),
|
||||
@@ -3879,8 +3879,8 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// Process the MapRequest-like update
|
||||
// This calls UpdateNodeFromMapRequest which eventually calls persistNodeToDB
|
||||
// Process the [tailcfg.MapRequest]-like update
|
||||
// This calls [state.State.UpdateNodeFromMapRequest] which eventually calls [state.State.persistNodeToDB]
|
||||
_, err = app.state.UpdateNodeFromMapRequest(node.ID(), mapReq)
|
||||
require.NoError(t, err, "UpdateNodeFromMapRequest should succeed")
|
||||
t.Log("Simulated MapRequest update completed")
|
||||
@@ -3943,7 +3943,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
|
||||
alice := app.state.CreateUserForTest("alice")
|
||||
require.NotNil(t, alice, "Alice user should be created")
|
||||
|
||||
// Step 4: Re-register the node to alice via HandleNodeFromAuthPath
|
||||
// Step 4: Re-register the node to alice via [state.State.HandleNodeFromAuthPath]
|
||||
// This is what happens when running: headscale auth register --auth-id <id> --user alice
|
||||
nodeKey2 := key.NewNode()
|
||||
registrationID := types.MustAuthID()
|
||||
@@ -3960,7 +3960,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
|
||||
|
||||
// This should NOT panic - before the fix, this would panic with:
|
||||
// panic: runtime error: invalid memory address or nil pointer dereference
|
||||
// at UserView.Name() because the existing node has no User
|
||||
// at [types.UserView.Name] because the existing node has no User
|
||||
nodeAfterReauth, _, err := app.state.HandleNodeFromAuthPath(
|
||||
registrationID,
|
||||
types.UserID(alice.ID),
|
||||
@@ -3977,8 +3977,8 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
|
||||
require.False(t, nodeAfterReauth.IsTagged(), "Node should no longer be tagged")
|
||||
require.Empty(t, nodeAfterReauth.Tags().AsSlice(), "Node should have no tags")
|
||||
|
||||
// Verify Owner() works without panicking - this is what the mapper's
|
||||
// generateUserProfiles calls, and it would panic with a nil pointer
|
||||
// Verify [types.NodeView.Owner] works without panicking - this is what the mapper's
|
||||
// [generateUserProfiles] calls, and it would panic with a nil pointer
|
||||
// dereference if node.User was not set during the tag→user conversion.
|
||||
owner := nodeAfterReauth.Owner()
|
||||
require.True(t, owner.Valid(), "Owner should be valid after conversion (mapper would panic if nil)")
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
|
||||
// CanOldCodeBeCleanedUp is intended to be called on startup to see if
|
||||
// there are old code that can ble cleaned up, entries should contain
|
||||
// a CapVer where something can be cleaned up and a panic if it can.
|
||||
// a [tailcfg.CapabilityVersion] where something can be cleaned up and a panic if it can.
|
||||
// This is only intended to catch things in tests.
|
||||
//
|
||||
// All uses of Capability version checks should be listed here.
|
||||
@@ -46,12 +46,12 @@ func capVersSorted() []tailcfg.CapabilityVersion {
|
||||
return capVers
|
||||
}
|
||||
|
||||
// TailscaleVersion returns the Tailscale version for the given CapabilityVersion.
|
||||
// TailscaleVersion returns the Tailscale version for the given [tailcfg.CapabilityVersion].
|
||||
func TailscaleVersion(ver tailcfg.CapabilityVersion) string {
|
||||
return capVerToTailscaleVer[ver]
|
||||
}
|
||||
|
||||
// CapabilityVersion returns the CapabilityVersion for the given Tailscale version.
|
||||
// CapabilityVersion returns the [tailcfg.CapabilityVersion] for the given Tailscale version.
|
||||
// It accepts both full versions (v1.90.1) and minor versions (v1.90).
|
||||
func CapabilityVersion(ver string) tailcfg.CapabilityVersion {
|
||||
if !strings.HasPrefix(ver, "v") {
|
||||
@@ -115,7 +115,7 @@ func TailscaleLatestMajorMinor(n int, stripV bool) []string {
|
||||
return majorSl[len(majorSl)-n:]
|
||||
}
|
||||
|
||||
// CapVerLatest returns the n latest CapabilityVersions.
|
||||
// CapVerLatest returns the n latest [tailcfg.CapabilityVersion] values.
|
||||
func CapVerLatest(n int) []tailcfg.CapabilityVersion {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -28,7 +28,7 @@ var (
|
||||
ErrAPIKeyInvalidGeneration = errors.New("generated API key failed validation")
|
||||
)
|
||||
|
||||
// CreateAPIKey creates a new ApiKey in a user, and returns it.
|
||||
// CreateAPIKey creates a new [types.APIKey] in a user, and returns it.
|
||||
func (hsdb *HSDatabase) CreateAPIKey(
|
||||
expiration *time.Time,
|
||||
) (string, *types.APIKey, error) {
|
||||
@@ -84,7 +84,7 @@ func (hsdb *HSDatabase) CreateAPIKey(
|
||||
return keyStr, &key, nil
|
||||
}
|
||||
|
||||
// ListAPIKeys returns the list of ApiKeys for a user.
|
||||
// ListAPIKeys returns the list of [types.APIKey] values for a user.
|
||||
func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
|
||||
keys := []types.APIKey{}
|
||||
|
||||
@@ -96,7 +96,7 @@ func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetAPIKey returns a ApiKey for a given key.
|
||||
// GetAPIKey returns a [types.APIKey] for a given key.
|
||||
func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) {
|
||||
key := types.APIKey{}
|
||||
if result := hsdb.DB.First(&key, "prefix = ?", prefix); result.Error != nil {
|
||||
@@ -106,7 +106,7 @@ func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) {
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// GetAPIKeyByID returns a ApiKey for a given id.
|
||||
// GetAPIKeyByID returns a [types.APIKey] for a given id.
|
||||
func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
|
||||
key := types.APIKey{}
|
||||
if result := hsdb.DB.Find(&types.APIKey{ID: id}).First(&key); result.Error != nil {
|
||||
@@ -116,7 +116,7 @@ func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// DestroyAPIKey destroys a ApiKey. Returns error if the ApiKey
|
||||
// DestroyAPIKey destroys a [types.APIKey]. Returns error if the [types.APIKey]
|
||||
// does not exist.
|
||||
func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
|
||||
if result := hsdb.DB.Unscoped().Delete(key); result.Error != nil {
|
||||
@@ -126,7 +126,7 @@ func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpireAPIKey marks a ApiKey as expired.
|
||||
// ExpireAPIKey marks a [types.APIKey] as expired.
|
||||
func (hsdb *HSDatabase) ExpireAPIKey(key *types.APIKey) error {
|
||||
err := hsdb.DB.Model(&key).Update("Expiration", time.Now()).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -17,8 +17,8 @@ const (
|
||||
fifty = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// TestEphemeralGarbageCollectorGoRoutineLeak is a test for a goroutine leak in EphemeralGarbageCollector().
|
||||
// It creates a new EphemeralGarbageCollector, schedules several nodes for deletion with a short expiry,
|
||||
// TestEphemeralGarbageCollectorGoRoutineLeak is a test for a goroutine leak in [EphemeralGarbageCollector].
|
||||
// It creates a new [EphemeralGarbageCollector], schedules several nodes for deletion with a short expiry,
|
||||
// and verifies that the nodes are deleted when the expiry time passes, and then
|
||||
// for any leaked goroutines after the garbage collector is closed.
|
||||
func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) {
|
||||
@@ -89,8 +89,8 @@ func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) {
|
||||
t.Logf("Final number of goroutines: %d", runtime.NumGoroutine())
|
||||
}
|
||||
|
||||
// TestEphemeralGarbageCollectorReschedule is a test for the rescheduling of nodes in EphemeralGarbageCollector().
|
||||
// It creates a new EphemeralGarbageCollector, schedules a node for deletion with a longer expiry,
|
||||
// TestEphemeralGarbageCollectorReschedule is a test for the rescheduling of nodes in [EphemeralGarbageCollector].
|
||||
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion with a longer expiry,
|
||||
// and then reschedules it with a shorter expiry, and verifies that the node is deleted only once.
|
||||
func TestEphemeralGarbageCollectorReschedule(t *testing.T) {
|
||||
// Deletion tracking mechanism
|
||||
@@ -145,8 +145,8 @@ func TestEphemeralGarbageCollectorReschedule(t *testing.T) {
|
||||
deleteMutex.Unlock()
|
||||
}
|
||||
|
||||
// TestEphemeralGarbageCollectorCancelAndReschedule is a test for the cancellation and rescheduling of nodes in EphemeralGarbageCollector().
|
||||
// It creates a new EphemeralGarbageCollector, schedules a node for deletion, cancels it, and then reschedules it,
|
||||
// TestEphemeralGarbageCollectorCancelAndReschedule is a test for the cancellation and rescheduling of nodes in [EphemeralGarbageCollector].
|
||||
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion, cancels it, and then reschedules it,
|
||||
// and verifies that the node is deleted only once.
|
||||
func TestEphemeralGarbageCollectorCancelAndReschedule(t *testing.T) {
|
||||
// Deletion tracking mechanism
|
||||
@@ -214,8 +214,8 @@ func TestEphemeralGarbageCollectorCancelAndReschedule(t *testing.T) {
|
||||
deleteMutex.Unlock()
|
||||
}
|
||||
|
||||
// TestEphemeralGarbageCollectorCloseBeforeTimerFires is a test for the closing of the EphemeralGarbageCollector before the timer fires.
|
||||
// It creates a new EphemeralGarbageCollector, schedules a node for deletion, closes the GC, and verifies that the node is not deleted.
|
||||
// TestEphemeralGarbageCollectorCloseBeforeTimerFires is a test for the closing of the [EphemeralGarbageCollector] before the timer fires.
|
||||
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion, closes the GC, and verifies that the node is not deleted.
|
||||
func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) {
|
||||
// Deletion tracking
|
||||
var (
|
||||
@@ -264,7 +264,7 @@ func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) {
|
||||
deleteMutex.Unlock()
|
||||
}
|
||||
|
||||
// TestEphemeralGarbageCollectorScheduleAfterClose verifies that calling Schedule after Close
|
||||
// TestEphemeralGarbageCollectorScheduleAfterClose verifies that calling [EphemeralGarbageCollector.Schedule] after [EphemeralGarbageCollector.Close]
|
||||
// is a no-op and doesn't cause any panics, goroutine leaks, or other issues.
|
||||
func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) {
|
||||
// Count initial goroutines to check for leaks
|
||||
@@ -339,7 +339,7 @@ func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestEphemeralGarbageCollectorConcurrentScheduleAndClose tests the behavior of the garbage collector
|
||||
// when Schedule and Close are called concurrently from multiple goroutines.
|
||||
// when [EphemeralGarbageCollector.Schedule] and [EphemeralGarbageCollector.Close] are called concurrently from multiple goroutines.
|
||||
func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
// Count initial goroutines
|
||||
initialGoroutines := runtime.NumGoroutine()
|
||||
|
||||
@@ -49,7 +49,7 @@ type IPAllocator struct {
|
||||
usedIPs netipx.IPSetBuilder
|
||||
}
|
||||
|
||||
// NewIPAllocator returns a new IPAllocator singleton which
|
||||
// NewIPAllocator returns a new [IPAllocator] singleton which
|
||||
// can be used to hand out unique IP addresses within the
|
||||
// provided IPv4 and IPv6 prefix. It needs to be created
|
||||
// when headscale starts and needs to finish its read
|
||||
@@ -272,7 +272,7 @@ func isTailscaleReservedIP(ip netip.Addr) bool {
|
||||
}
|
||||
|
||||
// BackfillNodeIPs will take a database transaction, and
|
||||
// iterate through all of the current nodes in headscale
|
||||
// iterate through all of the current nodes ([types.Node]) in headscale
|
||||
// and ensure it has IP addresses according to the current
|
||||
// configuration.
|
||||
// This means that if both IPv4 and IPv6 is set in the
|
||||
@@ -346,7 +346,6 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) {
|
||||
// Use Updates() with Select() to only update IP fields, avoiding overwriting
|
||||
// other fields like Expiry. We need Select() because Updates() alone skips
|
||||
// zero values, but we DO want to update IPv4/IPv6 to nil when removing them.
|
||||
// See issue #2862.
|
||||
err := tx.Model(node).Select("ipv4", "ipv6").Updates(node).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("saving node(%d) after adding IPs: %w", node.ID, err)
|
||||
|
||||
@@ -109,7 +109,7 @@ func (hsdb *HSDatabase) getNode(uid types.UserID, name string) (*types.Node, err
|
||||
})
|
||||
}
|
||||
|
||||
// getNode finds a Node by name and user and returns the Node struct.
|
||||
// getNode finds a [types.Node] by name and user and returns the [types.Node] struct.
|
||||
func getNode(tx *gorm.DB, uid types.UserID, name string) (*types.Node, error) {
|
||||
nodes, err := ListNodesByUser(tx, uid)
|
||||
if err != nil {
|
||||
@@ -129,7 +129,7 @@ func (hsdb *HSDatabase) GetNodeByID(id types.NodeID) (*types.Node, error) {
|
||||
return GetNodeByID(hsdb.DB, id)
|
||||
}
|
||||
|
||||
// GetNodeByID finds a Node by ID and returns the Node struct.
|
||||
// GetNodeByID finds a [types.Node] by ID and returns the [types.Node] struct.
|
||||
func GetNodeByID(tx *gorm.DB, id types.NodeID) (*types.Node, error) {
|
||||
mach := types.Node{}
|
||||
if result := tx.
|
||||
@@ -147,7 +147,7 @@ func (hsdb *HSDatabase) GetNodeByMachineKey(machineKey key.MachinePublic) (*type
|
||||
return GetNodeByMachineKey(hsdb.DB, machineKey)
|
||||
}
|
||||
|
||||
// GetNodeByMachineKey finds a Node by its MachineKey and returns the Node struct.
|
||||
// GetNodeByMachineKey finds a [types.Node] by its [key.MachinePublic] and returns the [types.Node] struct.
|
||||
func GetNodeByMachineKey(
|
||||
tx *gorm.DB,
|
||||
machineKey key.MachinePublic,
|
||||
@@ -168,7 +168,7 @@ func (hsdb *HSDatabase) GetNodeByNodeKey(nodeKey key.NodePublic) (*types.Node, e
|
||||
return GetNodeByNodeKey(hsdb.DB, nodeKey)
|
||||
}
|
||||
|
||||
// GetNodeByNodeKey finds a Node by its NodeKey and returns the Node struct.
|
||||
// GetNodeByNodeKey finds a [types.Node] by its [key.NodePublic] and returns the [types.Node] struct.
|
||||
func GetNodeByNodeKey(
|
||||
tx *gorm.DB,
|
||||
nodeKey key.NodePublic,
|
||||
@@ -199,7 +199,7 @@ func SetLastSeen(tx *gorm.DB, nodeID types.NodeID, lastSeen time.Time) error {
|
||||
return tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("last_seen", lastSeen).Error
|
||||
}
|
||||
|
||||
// RenameNode takes a Node struct and a new GivenName for the nodes
|
||||
// RenameNode takes a [types.Node] struct and a new [types.Node.GivenName] for the nodes
|
||||
// and renames it. Validation should be done in the state layer before calling this function.
|
||||
func RenameNode(tx *gorm.DB,
|
||||
nodeID types.NodeID, newName string,
|
||||
@@ -245,7 +245,7 @@ func (hsdb *HSDatabase) DeleteNode(node *types.Node) error {
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteNode deletes a Node from the database.
|
||||
// DeleteNode deletes a [types.Node] from the database.
|
||||
// Caller is responsible for notifying all of change.
|
||||
func DeleteNode(tx *gorm.DB,
|
||||
node *types.Node,
|
||||
@@ -259,7 +259,7 @@ func DeleteNode(tx *gorm.DB,
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEphemeralNode deletes a Node from the database, note that this method
|
||||
// DeleteEphemeralNode deletes a [types.Node] from the database, note that this method
|
||||
// will remove it straight, and not notify any changes or consider any routes.
|
||||
// It is intended for Ephemeral nodes.
|
||||
func (hsdb *HSDatabase) DeleteEphemeralNode(
|
||||
@@ -276,7 +276,7 @@ func (hsdb *HSDatabase) DeleteEphemeralNode(
|
||||
}
|
||||
|
||||
// RegisterNodeForTest is used only for testing purposes to register a node directly in the database.
|
||||
// Production code should use state.HandleNodeFromAuthPath or state.HandleNodeFromPreAuthKey.
|
||||
// Production code should use [state.State.HandleNodeFromAuthPath] or [state.State.HandleNodeFromPreAuthKey].
|
||||
func RegisterNodeForTest(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *netip.Addr) (*types.Node, error) {
|
||||
if !testing.Testing() {
|
||||
panic("RegisterNodeForTest can only be called during tests")
|
||||
@@ -387,7 +387,7 @@ func NodeSetMachineKey(
|
||||
|
||||
// EphemeralGarbageCollector is a garbage collector that will delete nodes after
|
||||
// a certain amount of time.
|
||||
// It is used to delete ephemeral nodes that have disconnected and should be
|
||||
// It is used to delete ephemeral nodes ([types.Node.IsEphemeral]) that have disconnected and should be
|
||||
// cleaned up.
|
||||
type EphemeralGarbageCollector struct {
|
||||
mu sync.Mutex
|
||||
@@ -399,7 +399,7 @@ type EphemeralGarbageCollector struct {
|
||||
cancelCh chan struct{}
|
||||
}
|
||||
|
||||
// NewEphemeralGarbageCollector creates a new EphemeralGarbageCollector, it takes
|
||||
// NewEphemeralGarbageCollector creates a new [EphemeralGarbageCollector], it takes
|
||||
// a deleteFunc that will be called when a node is scheduled for deletion.
|
||||
func NewEphemeralGarbageCollector(deleteFunc func(types.NodeID)) *EphemeralGarbageCollector {
|
||||
return &EphemeralGarbageCollector{
|
||||
|
||||
@@ -31,7 +31,7 @@ func (hsdb *HSDatabase) GetPolicy() (*types.Policy, error) {
|
||||
}
|
||||
|
||||
// GetPolicy returns the latest policy from the database.
|
||||
// This standalone function can be used in contexts where HSDatabase is not available,
|
||||
// This standalone function can be used in contexts where [HSDatabase] is not available,
|
||||
// such as during migrations.
|
||||
func GetPolicy(tx *gorm.DB) (*types.Policy, error) {
|
||||
var p types.Policy
|
||||
@@ -55,7 +55,7 @@ func GetPolicy(tx *gorm.DB) (*types.Policy, error) {
|
||||
|
||||
// PolicyBytes loads policy configuration from file or database based on the configured mode.
|
||||
// Returns nil if no policy is configured, which is valid.
|
||||
// This standalone function can be used in contexts where HSDatabase is not available,
|
||||
// This standalone function can be used in contexts where [HSDatabase] is not available,
|
||||
// such as during migrations.
|
||||
func PolicyBytes(tx *gorm.DB, cfg *types.Config) ([]byte, error) {
|
||||
switch cfg.Policy.Mode {
|
||||
|
||||
@@ -40,7 +40,7 @@ const (
|
||||
authKeyLength = 64
|
||||
)
|
||||
|
||||
// CreatePreAuthKey creates a new PreAuthKey in a user, and returns it.
|
||||
// CreatePreAuthKey creates a new [types.PreAuthKey] in a user, and returns it.
|
||||
// The uid parameter can be nil for system-created tagged keys.
|
||||
// For tagged keys, uid tracks "created by" (who created the key).
|
||||
// For user-owned keys, uid tracks the node owner.
|
||||
@@ -158,7 +158,7 @@ func (hsdb *HSDatabase) ListPreAuthKeys() ([]types.PreAuthKey, error) {
|
||||
return Read(hsdb.DB, ListPreAuthKeys)
|
||||
}
|
||||
|
||||
// ListPreAuthKeys returns all PreAuthKeys in the database.
|
||||
// ListPreAuthKeys returns all [types.PreAuthKey] values in the database.
|
||||
func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
|
||||
var keys []types.PreAuthKey
|
||||
|
||||
@@ -170,7 +170,7 @@ func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// ListPreAuthKeysByUser returns all PreAuthKeys belonging to a specific user.
|
||||
// ListPreAuthKeysByUser returns all [types.PreAuthKey] values belonging to a specific user.
|
||||
func ListPreAuthKeysByUser(tx *gorm.DB, uid types.UserID) ([]types.PreAuthKey, error) {
|
||||
var keys []types.PreAuthKey
|
||||
|
||||
@@ -290,13 +290,13 @@ func (hsdb *HSDatabase) GetPreAuthKey(key string) (*types.PreAuthKey, error) {
|
||||
return GetPreAuthKey(hsdb.DB, key)
|
||||
}
|
||||
|
||||
// GetPreAuthKey returns a PreAuthKey for a given key. The caller is responsible
|
||||
// GetPreAuthKey returns a [types.PreAuthKey] for a given key. The caller is responsible
|
||||
// for checking if the key is usable (expired or used).
|
||||
func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) {
|
||||
return findAuthKey(tx, key)
|
||||
}
|
||||
|
||||
// DestroyPreAuthKey destroys a preauthkey. Returns error if the PreAuthKey
|
||||
// DestroyPreAuthKey destroys a preauthkey. Returns error if the [types.PreAuthKey]
|
||||
// does not exist. This also clears the auth_key_id on any nodes that reference
|
||||
// this key.
|
||||
func DestroyPreAuthKey(tx *gorm.DB, id uint64) error {
|
||||
@@ -331,10 +331,10 @@ func (hsdb *HSDatabase) DeletePreAuthKey(id uint64) error {
|
||||
})
|
||||
}
|
||||
|
||||
// UsePreAuthKey atomically marks a PreAuthKey as used. The UPDATE is
|
||||
// UsePreAuthKey atomically marks a [types.PreAuthKey] as used. The UPDATE is
|
||||
// guarded by `used = false` so two concurrent registrations racing for
|
||||
// the same single-use key cannot both succeed: the first commits and
|
||||
// the second returns PAKError("authkey already used"). Without the
|
||||
// the second returns [types.PAKError]("authkey already used"). Without the
|
||||
// guard the previous code (Update("used", true) with no WHERE) would
|
||||
// silently let both transactions claim the key.
|
||||
func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
|
||||
@@ -354,7 +354,7 @@ func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpirePreAuthKey marks a PreAuthKey as expired.
|
||||
// ExpirePreAuthKey marks a [types.PreAuthKey] as expired.
|
||||
func ExpirePreAuthKey(tx *gorm.DB, id uint64) error {
|
||||
now := time.Now()
|
||||
return tx.Model(&types.PreAuthKey{}).Where("id = ?", id).Update("expiration", now).Error
|
||||
|
||||
@@ -25,7 +25,7 @@ func (hsdb *HSDatabase) CreateUser(user types.User) (*types.User, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// CreateUser creates a new User. Returns error if could not be created
|
||||
// CreateUser creates a new [types.User]. Returns error if could not be created
|
||||
// or another user already exists.
|
||||
func CreateUser(tx *gorm.DB, user types.User) (*types.User, error) {
|
||||
err := util.ValidateUsername(user.Name)
|
||||
@@ -47,7 +47,7 @@ func (hsdb *HSDatabase) DestroyUser(uid types.UserID) error {
|
||||
})
|
||||
}
|
||||
|
||||
// DestroyUser destroys a User. Returns error if the User does
|
||||
// DestroyUser destroys a [types.User]. Returns error if the [types.User] does
|
||||
// not exist or if there are user-owned nodes associated with it.
|
||||
// Tagged nodes have user_id = NULL so they do not block deletion.
|
||||
func DestroyUser(tx *gorm.DB, uid types.UserID) error {
|
||||
@@ -92,8 +92,8 @@ func (hsdb *HSDatabase) RenameUser(uid types.UserID, newName string) error {
|
||||
|
||||
var ErrCannotChangeOIDCUser = errors.New("cannot edit OIDC user")
|
||||
|
||||
// RenameUser renames a User. Returns error if the User does
|
||||
// not exist or if another User exists with the new name.
|
||||
// RenameUser renames a [types.User]. Returns error if the [types.User] does
|
||||
// not exist or if another [types.User] exists with the new name.
|
||||
func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error {
|
||||
var err error
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ func parseVersion(s string) (semver, error) {
|
||||
}
|
||||
|
||||
// ensureDatabaseVersionTable creates the database_versions table if it
|
||||
// does not already exist. Uses GORM AutoMigrate to handle dialect
|
||||
// does not already exist. Uses [gorm.DB.AutoMigrate] to handle dialect
|
||||
// differences between SQLite (datetime) and PostgreSQL (timestamp).
|
||||
// This runs before gormigrate migrations.
|
||||
func ensureDatabaseVersionTable(db *gorm.DB) error {
|
||||
|
||||
@@ -73,11 +73,11 @@ func loadDERPMapFromURL(addr url.URL) (*tailcfg.DERPMap, error) {
|
||||
return &derpMap, err
|
||||
}
|
||||
|
||||
// mergeDERPMaps naively merges a list of DERPMaps into a single
|
||||
// DERPMap, it will _only_ look at the Regions, an integer.
|
||||
// If a region exists in two of the given DERPMaps, the region
|
||||
// form the _last_ DERPMap will be preserved.
|
||||
// An empty DERPMap list will result in a DERPMap with no regions.
|
||||
// mergeDERPMaps naively merges a list of [tailcfg.DERPMap] values into a single
|
||||
// [tailcfg.DERPMap], it will _only_ look at the Regions, an integer.
|
||||
// If a region exists in two of the given [tailcfg.DERPMap] values, the region
|
||||
// form the _last_ [tailcfg.DERPMap] will be preserved.
|
||||
// An empty [tailcfg.DERPMap] list will result in a [tailcfg.DERPMap] with no regions.
|
||||
func mergeDERPMaps(derpMaps []*tailcfg.DERPMap) *tailcfg.DERPMap {
|
||||
result := tailcfg.DERPMap{
|
||||
OmitDefaultRegions: false,
|
||||
|
||||
@@ -30,7 +30,7 @@ type ExtraRecordsMan struct {
|
||||
hashes map[string][32]byte
|
||||
}
|
||||
|
||||
// NewExtraRecordsManager creates a new ExtraRecordsMan and starts watching the file at the given path.
|
||||
// NewExtraRecordsManager creates a new [ExtraRecordsMan] and starts watching the file at the given path.
|
||||
func NewExtraRecordsManager(path string) (*ExtraRecordsMan, error) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
@@ -177,7 +177,7 @@ func (e *ExtraRecordsMan) updateRecords() {
|
||||
e.updateCh <- e.records.Slice()
|
||||
}
|
||||
|
||||
// readExtraRecordsFromPath reads a JSON file of tailcfg.DNSRecord
|
||||
// readExtraRecordsFromPath reads a JSON file of [tailcfg.DNSRecord]
|
||||
// and returns the records and the hash of the file.
|
||||
func readExtraRecordsFromPath(path string) ([]tailcfg.DNSRecord, [32]byte, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
|
||||
@@ -59,7 +59,7 @@ func (api headscaleV1APIServer) CreateUser(
|
||||
return nil, status.Errorf(codes.Internal, "creating user: %s", err)
|
||||
}
|
||||
|
||||
// CreateUser returns a policy change response if the user creation affected policy.
|
||||
// [state.State.CreateUser] returns a policy change response if the user creation affected policy.
|
||||
// This triggers a full policy re-evaluation for all connected nodes.
|
||||
api.h.Change(policyChanged)
|
||||
|
||||
@@ -105,7 +105,7 @@ func (api headscaleV1APIServer) DeleteUser(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use the change returned from DeleteUser which includes proper policy updates
|
||||
// Use the change returned from [state.State.DeleteUser] which includes proper policy updates
|
||||
api.h.Change(policyChanged)
|
||||
|
||||
return &v1.DeleteUserResponse{}, nil
|
||||
@@ -293,7 +293,7 @@ func (api headscaleV1APIServer) RegisterNode(
|
||||
return nil, fmt.Errorf("auto approving routes: %w", err)
|
||||
}
|
||||
|
||||
// Send both changes. Empty changes are ignored by Change().
|
||||
// Send both changes. Empty changes are ignored by [Headscale.Change].
|
||||
api.h.Change(nodeChange, routeChange)
|
||||
|
||||
return &v1.RegisterNodeResponse{Node: node.Proto()}, nil
|
||||
@@ -396,11 +396,11 @@ func (api headscaleV1APIServer) SetApprovedRoutes(
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
// Always propagate node changes from SetApprovedRoutes
|
||||
// Always propagate node changes from [state.State.SetApprovedRoutes]
|
||||
api.h.Change(nodeChange)
|
||||
|
||||
proto := node.Proto()
|
||||
// Populate SubnetRoutes with PrimaryRoutes to ensure it includes only the
|
||||
// Populate [types.Node.SubnetRoutes] with [tailcfg.Node.PrimaryRoutes] to ensure it includes only the
|
||||
// routes that are actively served from the node (per architectural requirement in types/node.go)
|
||||
primaryRoutes := api.h.state.GetNodePrimaryRoutes(node.ID())
|
||||
proto.SubnetRoutes = util.PrefixesToString(primaryRoutes)
|
||||
@@ -554,7 +554,7 @@ func nodesToProto(state *state.State, nodes views.Slice[types.NodeView]) []*v1.N
|
||||
for index, node := range nodes.All() {
|
||||
resp := node.Proto()
|
||||
|
||||
// Tags-as-identity: tagged nodes show as TaggedDevices user in API responses
|
||||
// Tags-as-identity: tagged nodes show as [types.TaggedDevices] user in API responses
|
||||
// (UserID may be set internally for "created by" tracking)
|
||||
if node.IsTagged() {
|
||||
resp.User = types.TaggedDevices.Proto()
|
||||
@@ -852,9 +852,9 @@ func (api headscaleV1APIServer) DebugCreateNode(
|
||||
authRegReq := types.NewRegisterAuthRequest(regData)
|
||||
api.h.state.SetAuthCacheEntry(registrationId, authRegReq)
|
||||
|
||||
// Echo back a synthetic Node so the debug response surface stays
|
||||
// stable. The actual node is created later by AuthApprove via
|
||||
// HandleNodeFromAuthPath using the cached RegistrationData.
|
||||
// Echo back a synthetic [types.Node] so the debug response surface stays
|
||||
// stable. The actual node is created later by [headscaleV1APIServer.AuthApprove] via
|
||||
// [state.State.HandleNodeFromAuthPath] using the cached [types.RegistrationData].
|
||||
echoNode := types.Node{
|
||||
NodeKey: regData.NodeKey,
|
||||
MachineKey: regData.MachineKey,
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestSetTags_Conversion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetTags_TaggedNode tests that SetTags correctly identifies tagged nodes
|
||||
// TestSetTags_TaggedNode tests that [headscaleV1APIServer.SetTags] correctly identifies tagged nodes
|
||||
// and doesn't reject them with the "user-owned nodes" error.
|
||||
// Note: This test doesn't validate ACL tag authorization - that's tested elsewhere.
|
||||
func TestSetTags_TaggedNode(t *testing.T) {
|
||||
@@ -193,7 +193,7 @@ func TestSetTags_TaggedNode(t *testing.T) {
|
||||
// Create API server instance
|
||||
apiServer := newHeadscaleV1APIServer(app)
|
||||
|
||||
// Test: SetTags should work on tagged nodes.
|
||||
// Test: [headscaleV1APIServer.SetTags] should work on tagged nodes.
|
||||
resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
|
||||
NodeId: uint64(taggedNode.ID()),
|
||||
Tags: []string{"tag:initial"}, // Keep existing tag to avoid ACL validation issues
|
||||
@@ -212,7 +212,7 @@ func TestSetTags_TaggedNode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetTags_CannotRemoveAllTags tests that SetTags rejects attempts to remove
|
||||
// TestSetTags_CannotRemoveAllTags tests that [headscaleV1APIServer.SetTags] rejects attempts to remove
|
||||
// all tags from a tagged node, enforcing Tailscale's requirement that tagged
|
||||
// nodes must have at least one tag.
|
||||
func TestSetTags_CannotRemoveAllTags(t *testing.T) {
|
||||
@@ -265,9 +265,8 @@ func TestSetTags_CannotRemoveAllTags(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSetTags_ClearsUserIDInDatabase tests that converting a user-owned node
|
||||
// to a tagged node via SetTags correctly persists user_id = NULL in the
|
||||
// to a tagged node via [headscaleV1APIServer.SetTags] correctly persists user_id = NULL in the
|
||||
// database, not just in-memory.
|
||||
// https://github.com/juanfont/headscale/issues/3161
|
||||
func TestSetTags_ClearsUserIDInDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -309,7 +308,7 @@ func TestSetTags_ClearsUserIDInDatabase(t *testing.T) {
|
||||
|
||||
nodeID := node.ID()
|
||||
|
||||
// Convert to tagged via SetTags API.
|
||||
// Convert to tagged via [headscaleV1APIServer.SetTags] API.
|
||||
apiServer := newHeadscaleV1APIServer(app)
|
||||
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
|
||||
NodeId: uint64(nodeID),
|
||||
@@ -404,9 +403,8 @@ func TestSetTags_NodeDisappearsFromUserListing(t *testing.T) {
|
||||
assert.Contains(t, allResp.GetNodes()[0].GetTags(), "tag:web")
|
||||
}
|
||||
|
||||
// TestSetTags_NodeStoreAndDBConsistency verifies that after SetTags, the
|
||||
// in-memory NodeStore and the database agree on the node's ownership state.
|
||||
// https://github.com/juanfont/headscale/issues/3161
|
||||
// TestSetTags_NodeStoreAndDBConsistency verifies that after [headscaleV1APIServer.SetTags], the
|
||||
// in-memory [state.NodeStore] and the database agree on the node's ownership state.
|
||||
func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -478,9 +476,8 @@ func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) {
|
||||
|
||||
// TestSetTags_UserDeletionDoesNotCascadeToTaggedNode tests that deleting the
|
||||
// original user does not cascade-delete a node that was converted to tagged
|
||||
// via SetTags. This catches the real-world consequence of stale user_id:
|
||||
// via [headscaleV1APIServer.SetTags]. This catches the real-world consequence of stale user_id:
|
||||
// ON DELETE CASCADE would destroy the tagged node.
|
||||
// https://github.com/juanfont/headscale/issues/3161
|
||||
func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -531,7 +528,7 @@ func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) {
|
||||
_, err = app.state.DeleteUser(*user.TypedID())
|
||||
require.NoError(t, err)
|
||||
|
||||
// The tagged node must survive in both NodeStore and database.
|
||||
// The tagged node must survive in both [state.NodeStore] and database.
|
||||
nsNode, found := app.state.GetNodeByID(nodeID)
|
||||
require.True(t, found, "tagged node must survive user deletion in NodeStore")
|
||||
assert.True(t, nsNode.IsTagged())
|
||||
@@ -555,7 +552,7 @@ func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) {
|
||||
require.NotNil(t, user)
|
||||
|
||||
// Delete the user and verify a non-empty change is returned
|
||||
// Issue #2967: Without the fix, DeleteUser returned an empty change,
|
||||
// Without the fix, [state.State.DeleteUser] returned an empty change,
|
||||
// causing stale policy state until another user operation triggered an update.
|
||||
changeSignal, err := app.state.DeleteUser(*user.TypedID())
|
||||
require.NoError(t, err, "DeleteUser should succeed")
|
||||
@@ -564,8 +561,7 @@ func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) {
|
||||
|
||||
// TestDeleteUser_TaggedNodeSurvives tests that deleting a user succeeds when
|
||||
// the user's only nodes are tagged, and that those nodes remain in the
|
||||
// NodeStore with nil UserID.
|
||||
// https://github.com/juanfont/headscale/issues/3077
|
||||
// [state.NodeStore] with nil UserID.
|
||||
func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -605,7 +601,7 @@ func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
|
||||
|
||||
nodeID := node.ID()
|
||||
|
||||
// NodeStore should not list the tagged node under any user.
|
||||
// [state.NodeStore] should not list the tagged node under any user.
|
||||
nodesForUser := app.state.ListNodesByUser(types.UserID(user.ID))
|
||||
assert.Equal(t, 0, nodesForUser.Len(),
|
||||
"tagged nodes should not appear in nodesByUser index")
|
||||
@@ -615,7 +611,7 @@ func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.False(t, changeSignal.IsEmpty())
|
||||
|
||||
// Tagged node survives in the NodeStore.
|
||||
// Tagged node survives in the [state.NodeStore].
|
||||
nodeAfter, found := app.state.GetNodeByID(nodeID)
|
||||
require.True(t, found, "tagged node should survive user deletion")
|
||||
assert.True(t, nodeAfter.IsTagged())
|
||||
|
||||
@@ -129,7 +129,7 @@ func parseCapabilityVersion(req *http.Request) (tailcfg.CapabilityVersion, error
|
||||
}
|
||||
|
||||
// verifyBodyLimit caps the request body for /verify. The DERP verify
|
||||
// protocol payload (tailcfg.DERPAdmitClientRequest) is a few hundred
|
||||
// protocol payload ([tailcfg.DERPAdmitClientRequest]) is a few hundred
|
||||
// bytes; 4 KiB is generous and prevents an unauthenticated client from
|
||||
// OOMing the public router with arbitrarily large POSTs.
|
||||
const verifyBodyLimit int64 = 4 * 1024
|
||||
@@ -358,7 +358,7 @@ func authIDFromRequest(req *http.Request) (types.AuthID, error) {
|
||||
// Listens in /register/:registration_id.
|
||||
//
|
||||
// This is not part of the Tailscale control API, as we could send whatever URL
|
||||
// in the RegisterResponse.AuthURL field.
|
||||
// in the [tailcfg.RegisterResponse.AuthURL] field.
|
||||
func (a *AuthProviderWeb) RegisterHandler(
|
||||
writer http.ResponseWriter,
|
||||
req *http.Request,
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
var errTestUnexpected = errors.New("unexpected failure")
|
||||
|
||||
// TestHandleVerifyRequest_OversizedBodyRejected verifies that the
|
||||
// /verify handler refuses POST bodies larger than verifyBodyLimit.
|
||||
// The MaxBytesReader is applied in VerifyHandler, so we simulate
|
||||
// /verify handler refuses POST bodies larger than [verifyBodyLimit].
|
||||
// The [http.MaxBytesReader] is applied in [Headscale.VerifyHandler], so we simulate
|
||||
// the same wrapping here.
|
||||
func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -47,7 +47,7 @@ func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
|
||||
"oversized body must surface 413")
|
||||
}
|
||||
|
||||
// errorAsHTTPError is a small local helper that unwraps an HTTPError
|
||||
// errorAsHTTPError is a small local helper that unwraps an [HTTPError]
|
||||
// from an error chain.
|
||||
func errorAsHTTPError(err error) (HTTPError, bool) {
|
||||
var h HTTPError
|
||||
|
||||
@@ -27,7 +27,7 @@ var (
|
||||
)
|
||||
|
||||
// offlineNodeCleanupThreshold is how long a node must be disconnected
|
||||
// before cleanupOfflineNodes removes its in-memory state.
|
||||
// before [Batcher.cleanupOfflineNodes] removes its in-memory state.
|
||||
const offlineNodeCleanupThreshold = 15 * time.Minute
|
||||
|
||||
var mapResponseGenerated = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
@@ -49,7 +49,7 @@ func NewBatcher(batchTime time.Duration, workers int, mapper *mapper) *Batcher {
|
||||
}
|
||||
}
|
||||
|
||||
// NewBatcherAndMapper creates a new Batcher with its mapper.
|
||||
// NewBatcherAndMapper creates a new [Batcher] with its [mapper].
|
||||
func NewBatcherAndMapper(cfg *types.Config, state *state.State) *Batcher {
|
||||
m := newMapper(cfg, state)
|
||||
b := NewBatcher(cfg.Tuning.BatchChangeDelay, cfg.Tuning.BatcherWorkers, m)
|
||||
@@ -69,7 +69,7 @@ type nodeConnection interface {
|
||||
updateSentPeers(resp *tailcfg.MapResponse)
|
||||
}
|
||||
|
||||
// generateMapResponse generates a [tailcfg.MapResponse] for the given NodeID based on the provided [change.Change].
|
||||
// generateMapResponse generates a [tailcfg.MapResponse] for the given [types.NodeID] based on the provided [change.Change].
|
||||
func generateMapResponse(nc nodeConnection, mapper *mapper, r change.Change) (*tailcfg.MapResponse, error) {
|
||||
nodeID := nc.nodeID()
|
||||
version := nc.version()
|
||||
@@ -130,17 +130,19 @@ func generateMapResponse(nc nodeConnection, mapper *mapper, r change.Change) (*t
|
||||
|
||||
// When a full update (SendAllPeers=true) produces zero visible peers
|
||||
// (e.g., a restrictive policy isolates this node), the resulting
|
||||
// MapResponse has Peers: []*tailcfg.Node{} (empty non-nil slice).
|
||||
// [tailcfg.MapResponse] has Peers: []*tailcfg.Node{} (empty non-nil slice).
|
||||
//
|
||||
// The Tailscale client only treats Peers as a full authoritative
|
||||
// replacement when len(Peers) > 0 (controlclient/map.go:462).
|
||||
// An empty Peers slice is indistinguishable from a delta response,
|
||||
// so the client silently preserves its existing peer state.
|
||||
//
|
||||
// This matters when a FullUpdate() replaces a pending PolicyChange()
|
||||
// in the batcher (addToBatch short-circuits on HasFull). The
|
||||
// PolicyChange would have computed PeersRemoved via computePeerDiff,
|
||||
// but the FullUpdate path uses WithPeers which sets Peers: [].
|
||||
// This matters when a [change.FullUpdate] replaces a pending
|
||||
// [change.PolicyChange] in the batcher ([Batcher.addToBatch]
|
||||
// short-circuits on [change.HasFull]). The [change.PolicyChange]
|
||||
// would have computed PeersRemoved via
|
||||
// [multiChannelNodeConn.computePeerDiff], but the [change.FullUpdate]
|
||||
// path uses [MapResponseBuilder.WithPeers] which sets Peers: [].
|
||||
//
|
||||
// Fix: when a full update results in zero peers, compute the diff
|
||||
// against lastSentPeers and add explicit PeersRemoved entries so
|
||||
@@ -206,7 +208,7 @@ type workResult struct {
|
||||
// work represents a unit of work to be processed by workers.
|
||||
// All pending changes for a node are bundled into a single work item
|
||||
// so that one worker processes them sequentially. This prevents
|
||||
// out-of-order MapResponse delivery and races on lastSentPeers
|
||||
// out-of-order [tailcfg.MapResponse] delivery and races on lastSentPeers
|
||||
// that occur when multiple workers process changes for the same node.
|
||||
type work struct {
|
||||
changes []change.Change
|
||||
@@ -225,9 +227,9 @@ var (
|
||||
// Batcher batches and distributes map responses to connected nodes.
|
||||
// It uses concurrent maps, per-node mutexes, and a worker pool.
|
||||
//
|
||||
// Lifecycle: Call Start() to spawn workers, then Close() to shut down.
|
||||
// Close() blocks until all workers have exited. A Batcher must not
|
||||
// be reused after Close().
|
||||
// Lifecycle: Call [Batcher.Start] to spawn workers, then [Batcher.Close]
|
||||
// to shut down. [Batcher.Close] blocks until all workers have exited.
|
||||
// A [Batcher] must not be reused after [Batcher.Close].
|
||||
type Batcher struct {
|
||||
tick *time.Ticker
|
||||
mapper *mapper
|
||||
@@ -551,11 +553,11 @@ func (b *Batcher) addToBatch(changes ...change.Change) {
|
||||
// still has it registered. By cleaning up here, we prevent "node not found"
|
||||
// errors when workers try to generate map responses for deleted nodes.
|
||||
//
|
||||
// Safety: change.Change.PeersRemoved is ONLY populated when nodes are actually
|
||||
// deleted from the system (via change.NodeRemoved in state.DeleteNode). Policy
|
||||
// changes that affect peer visibility do NOT use this field - they set
|
||||
// Safety: [change.Change.PeersRemoved] is ONLY populated when nodes are actually
|
||||
// deleted from the system (via [change.NodeRemoved] in [state.State.DeleteNode]).
|
||||
// Policy changes that affect peer visibility do NOT use this field - they set
|
||||
// RequiresRuntimePeerComputation=true and compute removed peers at runtime,
|
||||
// putting them in tailcfg.MapResponse.PeersRemoved (a different struct).
|
||||
// putting them in [tailcfg.MapResponse.PeersRemoved] (a different struct).
|
||||
// Therefore, this cleanup only removes nodes that are truly being deleted,
|
||||
// not nodes that are still connected but have lost visibility of certain peers.
|
||||
//
|
||||
@@ -638,8 +640,8 @@ func (b *Batcher) processBatchedChanges() {
|
||||
}
|
||||
|
||||
// cleanupOfflineNodes removes nodes that have been offline for too long to prevent memory leaks.
|
||||
// Uses Compute() for atomic check-and-delete to prevent TOCTOU races where a node
|
||||
// reconnects between the hasActiveConnections() check and the Delete() call.
|
||||
// Uses xsync.Map.Compute for atomic check-and-delete to prevent TOCTOU races where a node
|
||||
// reconnects between the hasActiveConnections check and the Delete call.
|
||||
func (b *Batcher) cleanupOfflineNodes() {
|
||||
var nodesToCleanup []types.NodeID
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"tailscale.com/util/multierr"
|
||||
)
|
||||
|
||||
// MapResponseBuilder provides a fluent interface for building tailcfg.MapResponse.
|
||||
// MapResponseBuilder provides a fluent interface for building [tailcfg.MapResponse].
|
||||
type MapResponseBuilder struct {
|
||||
resp *tailcfg.MapResponse
|
||||
mapper *mapper
|
||||
@@ -180,6 +180,10 @@ func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView])
|
||||
}
|
||||
|
||||
// WithPacketFilters adds packet filter rules based on policy.
|
||||
//
|
||||
// [State.FilterForNode] returns rules already reduced to only those relevant for this node.
|
||||
// For autogroup:self policies, it returns per-node compiled rules.
|
||||
// For global policies, it returns the global filter reduced for this node.
|
||||
func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder {
|
||||
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
|
||||
if !ok {
|
||||
@@ -187,9 +191,6 @@ func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder {
|
||||
return b
|
||||
}
|
||||
|
||||
// FilterForNode returns rules already reduced to only those relevant for this node.
|
||||
// For autogroup:self policies, it returns per-node compiled rules.
|
||||
// For global policies, it returns the global filter reduced for this node.
|
||||
filter, err := b.mapper.state.FilterForNode(node)
|
||||
if err != nil {
|
||||
b.addError(err)
|
||||
@@ -233,7 +234,8 @@ func (b *MapResponseBuilder) WithPeerChanges(peers views.Slice[types.NodeView])
|
||||
return b
|
||||
}
|
||||
|
||||
// buildTailPeers converts views.Slice[types.NodeView] to []tailcfg.Node with policy filtering and sorting.
|
||||
// buildTailPeers converts [views.Slice] of [types.NodeView] to a slice of [tailcfg.Node]
|
||||
// with policy filtering and sorting.
|
||||
func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ([]*tailcfg.Node, error) {
|
||||
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
|
||||
if !ok {
|
||||
@@ -241,9 +243,10 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
|
||||
}
|
||||
|
||||
// Get unreduced matchers for peer relationship determination.
|
||||
// MatchersForNode returns unreduced matchers that include all rules where the node
|
||||
// could be either source or destination. This is different from FilterForNode which
|
||||
// returns reduced rules for packet filtering (only rules where node is destination).
|
||||
// [State.MatchersForNode] returns unreduced matchers that include all rules where the
|
||||
// node could be either source or destination. This is different from
|
||||
// [State.FilterForNode] which returns reduced rules for packet filtering (only rules
|
||||
// where node is destination).
|
||||
matchers, err := b.mapper.state.MatchersForNode(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -69,7 +69,7 @@ func newMapper(
|
||||
}
|
||||
}
|
||||
|
||||
// generateUserProfiles creates user profiles for MapResponse.
|
||||
// generateUserProfiles creates user profiles for [tailcfg.MapResponse].
|
||||
func generateUserProfiles(
|
||||
node types.NodeView,
|
||||
peers views.Slice[types.NodeView],
|
||||
@@ -267,7 +267,7 @@ func addNextDNSMetadata(resolvers []*dnstype.Resolver, node types.NodeView) {
|
||||
}
|
||||
}
|
||||
|
||||
// fullMapResponse returns a MapResponse for the given node.
|
||||
// fullMapResponse returns a [tailcfg.MapResponse] for the given node.
|
||||
//
|
||||
//nolint:unused
|
||||
func (m *mapper) fullMapResponse(
|
||||
@@ -312,7 +312,7 @@ func (m *mapper) selfMapResponse(
|
||||
return ma, err
|
||||
}
|
||||
|
||||
// policyChangeResponse creates a MapResponse for policy changes.
|
||||
// policyChangeResponse creates a [tailcfg.MapResponse] for policy changes.
|
||||
// It sends:
|
||||
// - PeersRemoved for peers that are no longer visible after the policy change
|
||||
// - PeersChanged for remaining peers (their AllowedIPs may have changed due to policy)
|
||||
@@ -350,7 +350,7 @@ func (m *mapper) policyChangeResponse(
|
||||
}
|
||||
|
||||
if len(removedPeers) > 0 {
|
||||
// Convert tailcfg.NodeID to types.NodeID for WithPeersRemoved
|
||||
// Convert [tailcfg.NodeID] to [types.NodeID] for [MapResponseBuilder.WithPeersRemoved]
|
||||
removedIDs := make([]types.NodeID, len(removedPeers))
|
||||
for i, id := range removedPeers {
|
||||
removedIDs[i] = types.NodeID(id) //nolint:gosec // NodeID types are equivalent
|
||||
@@ -371,7 +371,7 @@ func (m *mapper) policyChangeResponse(
|
||||
return builder.Build()
|
||||
}
|
||||
|
||||
// buildFromChange builds a MapResponse from a change.Change specification.
|
||||
// buildFromChange builds a [tailcfg.MapResponse] from a [change.Change] specification.
|
||||
// This provides fine-grained control over what gets included in the response.
|
||||
func (m *mapper) buildFromChange(
|
||||
nodeID types.NodeID,
|
||||
|
||||
@@ -17,10 +17,10 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// errNoActiveConnections is returned by send when a node has no active
|
||||
// connections (disconnected but kept in the batcher for rapid reconnection).
|
||||
// Callers must not update peer tracking state (lastSentPeers) after this
|
||||
// error because the data was never delivered to any client.
|
||||
// errNoActiveConnections is returned by [multiChannelNodeConn.send] when a node
|
||||
// has no active connections (disconnected but kept in the batcher for rapid
|
||||
// reconnection). Callers must not update peer tracking state (lastSentPeers)
|
||||
// after this error because the data was never delivered to any client.
|
||||
var errNoActiveConnections = errors.New("no active connections")
|
||||
|
||||
// connectionEntry represents a single connection to a node.
|
||||
@@ -51,9 +51,9 @@ type multiChannelNodeConn struct {
|
||||
|
||||
// workMu serializes change processing for this node across batch ticks.
|
||||
// Without this, two workers could process consecutive ticks' bundles
|
||||
// concurrently, causing out-of-order MapResponse delivery and races
|
||||
// on lastSentPeers (Clear+Store in updateSentPeers vs Range in
|
||||
// computePeerDiff).
|
||||
// concurrently, causing out-of-order [tailcfg.MapResponse] delivery and races
|
||||
// on lastSentPeers (Clear+Store in [multiChannelNodeConn.updateSentPeers] vs
|
||||
// Range in [multiChannelNodeConn.computePeerDiff]).
|
||||
workMu sync.Mutex
|
||||
|
||||
closeOnce sync.Once
|
||||
@@ -62,7 +62,7 @@ type multiChannelNodeConn struct {
|
||||
// disconnectedAt records when the last connection was removed.
|
||||
// nil means the node is considered connected (or newly created);
|
||||
// non-nil means the node disconnected at the stored timestamp.
|
||||
// Used by cleanupOfflineNodes to evict stale entries.
|
||||
// Used by [Batcher.cleanupOfflineNodes] to evict stale entries.
|
||||
disconnectedAt atomic.Pointer[time.Time]
|
||||
|
||||
// lastSentPeers tracks which peers were last sent to this node.
|
||||
@@ -182,8 +182,8 @@ func (mc *multiChannelNodeConn) markConnected() {
|
||||
}
|
||||
|
||||
// markDisconnected records the current time as the moment the node
|
||||
// lost its last connection. Used by cleanupOfflineNodes to determine
|
||||
// how long the node has been offline.
|
||||
// lost its last connection. Used by [Batcher.cleanupOfflineNodes] to
|
||||
// determine how long the node has been offline.
|
||||
func (mc *multiChannelNodeConn) markDisconnected() {
|
||||
now := time.Now()
|
||||
mc.disconnectedAt.Store(&now)
|
||||
@@ -235,8 +235,8 @@ func (mc *multiChannelNodeConn) drainPending() []change.Change {
|
||||
// connection can block for up to 50ms), the method snapshots connections under
|
||||
// a read lock, sends without any lock held, then write-locks only to remove
|
||||
// failures. New connections added between the snapshot and cleanup are safe:
|
||||
// they receive a full initial map via AddNode, so missing this update causes
|
||||
// no data loss.
|
||||
// they receive a full initial map via [Batcher.AddNode], so missing this update
|
||||
// causes no data loss.
|
||||
func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error {
|
||||
if data == nil {
|
||||
return nil
|
||||
@@ -389,7 +389,7 @@ func (mc *multiChannelNodeConn) version() tailcfg.CapabilityVersion {
|
||||
return mc.connections[0].version
|
||||
}
|
||||
|
||||
// updateSentPeers updates the tracked peer state based on a sent MapResponse.
|
||||
// updateSentPeers updates the tracked peer state based on a sent [tailcfg.MapResponse].
|
||||
// This must be called after successfully sending a response to keep track of
|
||||
// what the client knows about, enabling accurate diffs for future updates.
|
||||
func (mc *multiChannelNodeConn) updateSentPeers(resp *tailcfg.MapResponse) {
|
||||
|
||||
@@ -65,14 +65,14 @@ const (
|
||||
|
||||
// The first 9 bytes from the server to client over Noise are either an HTTP/2
|
||||
// settings frame (a normal HTTP/2 setup) or, as Tailscale added later, an "early payload"
|
||||
// header that's also 9 bytes long: 5 bytes (earlyPayloadMagic) followed by 4 bytes
|
||||
// of length. Then that many bytes of JSON-encoded tailcfg.EarlyNoise.
|
||||
// header that's also 9 bytes long: 5 bytes ([earlyPayloadMagic]) followed by 4 bytes
|
||||
// of length. Then that many bytes of JSON-encoded [tailcfg.EarlyNoise].
|
||||
// The early payload is optional. Some servers may not send it... But we do!
|
||||
earlyPayloadMagic = "\xff\xff\xffTS"
|
||||
|
||||
// noiseBodyLimit is the maximum allowed request body size for Noise protocol
|
||||
// handlers. This prevents unauthenticated OOM attacks via unbounded io.ReadAll.
|
||||
// No legitimate Noise request (MapRequest, RegisterRequest, etc.) comes close
|
||||
// handlers. This prevents unauthenticated OOM attacks via unbounded [io.ReadAll].
|
||||
// No legitimate Noise request ([tailcfg.MapRequest], [tailcfg.RegisterRequest], etc.) comes close
|
||||
// to this limit; typical payloads are a few KB.
|
||||
noiseBodyLimit int64 = 1048576 // 1 MiB
|
||||
)
|
||||
@@ -86,12 +86,12 @@ type noiseServer struct {
|
||||
machineKey key.MachinePublic
|
||||
nodeKey key.NodePublic
|
||||
|
||||
// EarlyNoise-related stuff
|
||||
// [tailcfg.EarlyNoise]-related stuff
|
||||
challenge key.ChallengePrivate
|
||||
protocolVersion int
|
||||
}
|
||||
|
||||
// NoiseUpgradeHandler is to upgrade the connection and hijack the net.Conn
|
||||
// NoiseUpgradeHandler is to upgrade the connection and hijack the [net.Conn]
|
||||
// in order to use the Noise-based TS2021 protocol. Listens in /ts2021.
|
||||
func (h *Headscale) NoiseUpgradeHandler(
|
||||
writer http.ResponseWriter,
|
||||
@@ -136,7 +136,7 @@ func (h *Headscale) NoiseUpgradeHandler(
|
||||
// This router is served only over the Noise connection, and exposes only the new API.
|
||||
//
|
||||
// The HTTP2 server that exposes this router is created for
|
||||
// a single hijacked connection from /ts2021, using netutil.NewOneConnListener
|
||||
// a single hijacked connection from /ts2021, using [netutil.NewOneConnListener]
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -300,7 +300,7 @@ func (ns *noiseServer) NotImplementedHandler(writer http.ResponseWriter, req *ht
|
||||
}
|
||||
|
||||
// PingResponseHandler handles HEAD requests from clients responding to a
|
||||
// PingRequest. The client calls this endpoint to prove connectivity.
|
||||
// [tailcfg.PingRequest]. The client calls this endpoint to prove connectivity.
|
||||
// The unguessable ping ID serves as authentication.
|
||||
func (h *Headscale) PingResponseHandler(
|
||||
writer http.ResponseWriter,
|
||||
@@ -457,12 +457,12 @@ func (ns *noiseServer) SSHActionHandler(
|
||||
}
|
||||
|
||||
// sshAction resolves the SSH action for the given request parameters.
|
||||
// It returns the action to send to the client, or an HTTPError on failure.
|
||||
// It returns the action to send to the client, or an [HTTPError] on failure.
|
||||
//
|
||||
// Three cases:
|
||||
// 1. Initial request, auto-approved — source recently authenticated
|
||||
// within the check period, accept immediately.
|
||||
// 2. Initial request, needs auth — build a HoldAndDelegate URL and
|
||||
// 2. Initial request, needs auth — build a [tailcfg.SSHAction.HoldAndDelegate] URL and
|
||||
// wait for the user to authenticate.
|
||||
// 3. Follow-up request — an auth_id is present, wait for the auth
|
||||
// verdict and accept or reject.
|
||||
@@ -514,7 +514,7 @@ func (ns *noiseServer) sshAction(
|
||||
}
|
||||
|
||||
// sshActionHoldAndDelegate creates a new auth session bound to the
|
||||
// (src, dst) pair and returns a HoldAndDelegate action that directs the
|
||||
// (src, dst) pair and returns a [tailcfg.SSHAction.HoldAndDelegate] action that directs the
|
||||
// client to authenticate.
|
||||
func (ns *noiseServer) sshActionHoldAndDelegate(
|
||||
reqLog zerolog.Logger,
|
||||
@@ -636,8 +636,8 @@ func (ns *noiseServer) sshActionFollowUp(
|
||||
case <-ctx.Done():
|
||||
// The client disconnected (or its request timed out) before the
|
||||
// auth session resolved. Return an error so the parked goroutine
|
||||
// is freed; without this select sshActionFollowUp would block
|
||||
// until the cache eviction callback signalled FinishAuth, which
|
||||
// is freed; without this select [noiseServer.sshActionFollowUp] would block
|
||||
// until the cache eviction callback signalled [types.AuthRequest.FinishAuth], which
|
||||
// could be up to register_cache_expiration (15 minutes).
|
||||
return nil, NewHTTPError(
|
||||
http.StatusUnauthorized,
|
||||
@@ -674,8 +674,8 @@ func (ns *noiseServer) sshActionFollowUp(
|
||||
// This is the busiest endpoint, as it keeps the HTTP long poll that updates
|
||||
// the clients when something in the network changes.
|
||||
//
|
||||
// The clients POST stuff like HostInfo and their Endpoints here, but
|
||||
// only after their first request (marked with the ReadOnly field).
|
||||
// The clients POST stuff like [tailcfg.Hostinfo] and their Endpoints here, but
|
||||
// only after their first request (marked with the [tailcfg.MapRequest.ReadOnly] field).
|
||||
//
|
||||
// At this moment the updates are sent in a quite horrendous way, but they kinda work.
|
||||
func (ns *noiseServer) PollNetMapHandler(
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
|
||||
// newNoiseRouterWithBodyLimit builds a chi router with the same body-limit
|
||||
// middleware used in the real Noise router but wired to a test handler that
|
||||
// captures the io.ReadAll result. This lets us verify the limit without
|
||||
// needing a full Headscale instance.
|
||||
// captures the [io.ReadAll] result. This lets us verify the limit without
|
||||
// needing a full [Headscale] instance.
|
||||
func newNoiseRouterWithBodyLimit(readBody *[]byte, readErr *error) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
@@ -159,7 +159,7 @@ func TestNoiseBodyLimit_AtExactLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestPollNetMapHandler_OversizedBody calls the real handler with a
|
||||
// MaxBytesReader-wrapped body to verify it fails gracefully (json decode
|
||||
// [http.MaxBytesReader]-wrapped body to verify it fails gracefully (json decode
|
||||
// error on truncated data) rather than consuming unbounded memory.
|
||||
func TestPollNetMapHandler_OversizedBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -173,12 +173,12 @@ func TestPollNetMapHandler_OversizedBody(t *testing.T) {
|
||||
|
||||
ns.PollNetMapHandler(rec, req)
|
||||
|
||||
// Body is truncated → json.Decode fails → httpError returns 500.
|
||||
// Body is truncated → [json.Decoder.Decode] fails → [httpError] returns 500.
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
}
|
||||
|
||||
// TestRegistrationHandler_OversizedBody calls the real handler with a
|
||||
// MaxBytesReader-wrapped body to verify it returns an error response
|
||||
// [http.MaxBytesReader]-wrapped body to verify it returns an error response
|
||||
// rather than consuming unbounded memory.
|
||||
func TestRegistrationHandler_OversizedBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -192,8 +192,8 @@ func TestRegistrationHandler_OversizedBody(t *testing.T) {
|
||||
|
||||
ns.RegistrationHandler(rec, req)
|
||||
|
||||
// json.Decode returns MaxBytesError → regErr wraps it → handler writes
|
||||
// a RegisterResponse with the error and then rejectUnsupported kicks in
|
||||
// [json.Decoder.Decode] returns [http.MaxBytesError] → [regErr] wraps it → handler writes
|
||||
// a [tailcfg.RegisterResponse] with the error and then [rejectUnsupported] kicks in
|
||||
// for version 0 → returns 400.
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
@@ -236,7 +236,7 @@ func TestSSHActionRoute_OldPathReturns404(t *testing.T) {
|
||||
}
|
||||
|
||||
// newSSHActionRequest builds an httptest request with the chi URL params
|
||||
// SSHActionHandler reads (src_node_id and dst_node_id), so the handler
|
||||
// [noiseServer.SSHActionHandler] reads (src_node_id and dst_node_id), so the handler
|
||||
// can be exercised directly without going through the chi router.
|
||||
func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request {
|
||||
t.Helper()
|
||||
@@ -253,8 +253,8 @@ func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request {
|
||||
}
|
||||
|
||||
// putTestNodeInStore creates a node via the database test helper and
|
||||
// also stages it into the in-memory NodeStore so handlers that read
|
||||
// NodeStore-backed APIs (e.g. State.GetNodeByID) can see it.
|
||||
// also stages it into the in-memory [state.NodeStore] so handlers that read
|
||||
// [state.NodeStore]-backed APIs (e.g. [state.State.GetNodeByID]) can see it.
|
||||
func putTestNodeInStore(t *testing.T, app *Headscale, user *types.User, hostname string) *types.Node {
|
||||
t.Helper()
|
||||
|
||||
@@ -276,7 +276,7 @@ func TestSSHActionHandler_RejectsRogueMachineKey(t *testing.T) {
|
||||
src := putTestNodeInStore(t, app, user, "src-node")
|
||||
dst := putTestNodeInStore(t, app, user, "dst-node")
|
||||
|
||||
// noiseServer carries the wrong machine key — a fresh throwaway key,
|
||||
// [noiseServer] carries the wrong machine key — a fresh throwaway key,
|
||||
// not dst.MachineKey.
|
||||
rogue := key.NewMachine().Public()
|
||||
require.NotEqual(t, dst.MachineKey, rogue, "test sanity: rogue key must differ from dst")
|
||||
|
||||
@@ -44,13 +44,13 @@ func MatchesFromFilterRules(rules []tailcfg.FilterRule) []Match {
|
||||
return matches
|
||||
}
|
||||
|
||||
// MatchFromFilterRule derives a Match from a tailcfg.FilterRule. The
|
||||
// destination IP set is the union of DstPorts[].IP and CapGrant[].Dsts:
|
||||
// cap-grant-only rules (e.g. tailscale.com/cap/relay) carry their
|
||||
// destinations in CapGrant.Dsts and would otherwise contribute nothing
|
||||
// to peer-visibility derivation in BuildPeerMap / ReduceNodes, hiding
|
||||
// the cap target from the source unless a companion IP-level rule
|
||||
// also exists.
|
||||
// MatchFromFilterRule derives a [Match] from a [tailcfg.FilterRule]. The
|
||||
// destination IP set is the union of [tailcfg.FilterRule.DstPorts][].IP
|
||||
// and [tailcfg.FilterRule.CapGrant][].Dsts: cap-grant-only rules (e.g.
|
||||
// tailscale.com/cap/relay) carry their destinations in CapGrant.Dsts and
|
||||
// would otherwise contribute nothing to peer-visibility derivation in
|
||||
// [policy.BuildPeerMap] / [policy.ReduceNodes], hiding the cap target
|
||||
// from the source unless a companion IP-level rule also exists.
|
||||
func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
|
||||
srcs := new(netipx.IPSetBuilder)
|
||||
dests := new(netipx.IPSetBuilder)
|
||||
@@ -80,11 +80,11 @@ func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
|
||||
}
|
||||
}
|
||||
|
||||
// MatchFromStrings builds a Match from raw source and destination
|
||||
// MatchFromStrings builds a [Match] from raw source and destination
|
||||
// strings. Unparseable entries are silently dropped (fail-open): the
|
||||
// resulting Match is narrower than the input described, but never
|
||||
// resulting [Match] is narrower than the input described, but never
|
||||
// wider. Callers that need strict validation should pre-validate
|
||||
// their inputs via util.ParseIPSet.
|
||||
// their inputs via [util.ParseIPSet].
|
||||
func MatchFromStrings(sources, destinations []string) Match {
|
||||
srcs := new(netipx.IPSetBuilder)
|
||||
dests := new(netipx.IPSetBuilder)
|
||||
@@ -131,7 +131,7 @@ func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
|
||||
// DestsIsTheInternet reports whether the destination covers "the
|
||||
// internet" — the set represented by autogroup:internet, special-cased
|
||||
// for exit nodes. Returns true if either family's /0 is contained
|
||||
// (0.0.0.0/0 or ::/0), or if dests is a superset of TheInternet(). A
|
||||
// (0.0.0.0/0 or ::/0), or if dests is a superset of [util.TheInternet]. A
|
||||
// single-family /0 counts because operators may write it directly and
|
||||
// it still denotes the whole internet for that family.
|
||||
func (m *Match) DestsIsTheInternet() bool {
|
||||
@@ -140,7 +140,7 @@ func (m *Match) DestsIsTheInternet() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Superset-of-TheInternet check handles merged filter rules
|
||||
// Superset-of-[util.TheInternet] check handles merged filter rules
|
||||
// where the internet prefixes are combined with other dests.
|
||||
theInternet := util.TheInternet()
|
||||
for _, prefix := range theInternet.Prefixes() {
|
||||
|
||||
@@ -68,7 +68,7 @@ type PolicyManager interface {
|
||||
DebugString() string
|
||||
}
|
||||
|
||||
// NewPolicyManager returns a new policy manager.
|
||||
// NewPolicyManager returns a new [PolicyManager].
|
||||
func NewPolicyManager(pol []byte, users []types.User, nodes views.Slice[types.NodeView]) (PolicyManager, error) {
|
||||
var (
|
||||
polMan PolicyManager
|
||||
@@ -83,8 +83,8 @@ func NewPolicyManager(pol []byte, users []types.User, nodes views.Slice[types.No
|
||||
return polMan, err
|
||||
}
|
||||
|
||||
// PolicyManagersForTest returns all available PostureManagers to be used
|
||||
// in tests to validate them in tests that try to determine that they
|
||||
// PolicyManagersForTest returns all available [PolicyManager] implementations to
|
||||
// be used in tests to validate them in tests that try to determine that they
|
||||
// behave the same.
|
||||
func PolicyManagersForTest(pol []byte, users []types.User, nodes views.Slice[types.NodeView]) ([]PolicyManager, error) {
|
||||
var polMans []PolicyManager
|
||||
|
||||
@@ -51,6 +51,11 @@ func ReduceRoutes(
|
||||
}
|
||||
|
||||
// BuildPeerMap builds a map of all peers that can be accessed by each node.
|
||||
//
|
||||
// Compared to [ReduceNodes], which builds the list per node, we end up with
|
||||
// doing the full work for every node (O(n^2)), while this will reduce the
|
||||
// list as we see relationships while building the map, making it O(n^2/2)
|
||||
// in the end, but with less work per node.
|
||||
func BuildPeerMap(
|
||||
nodes views.Slice[types.NodeView],
|
||||
matchers []matcher.Match,
|
||||
@@ -58,9 +63,6 @@ func BuildPeerMap(
|
||||
ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
|
||||
|
||||
// Build the map of all peers according to the matchers.
|
||||
// Compared to ReduceNodes, which builds the list per node, we end up with doing
|
||||
// the full work for every node (On^2), while this will reduce the list as we see
|
||||
// relationships while building the map, making it O(n^2/2) in the end, but with less work per node.
|
||||
for i := range nodes.Len() {
|
||||
for j := i + 1; j < nodes.Len(); j++ {
|
||||
if nodes.At(i).ID() == nodes.At(j).ID() {
|
||||
@@ -78,7 +80,8 @@ func BuildPeerMap(
|
||||
}
|
||||
|
||||
// ApproveRoutesWithPolicy checks if the node can approve the announced routes
|
||||
// and returns the new list of approved routes.
|
||||
// and returns the new list of approved routes. The [PolicyManager] is consulted
|
||||
// via [PolicyManager.NodeCanApproveRoute].
|
||||
// The approved routes will include:
|
||||
// 1. ALL previously approved routes (regardless of whether they're still advertised)
|
||||
// 2. New routes from announcedRoutes that can be auto-approved by policy
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Package policyutil contains pure functions that transform compiled
|
||||
// policy rules for a specific node. The headline function is
|
||||
// ReduceFilterRules, which filters global rules down to those relevant
|
||||
// [ReduceFilterRules], which filters global rules down to those relevant
|
||||
// to one node.
|
||||
//
|
||||
// A node's SubnetRoutes (approved, non-exit) participate in rule
|
||||
// matching so subnet routers receive filter rules for destinations
|
||||
// their subnets cover — the fix for issue #3169.
|
||||
// A node's [types.NodeView.SubnetRoutes] (approved, non-exit) participate
|
||||
// in rule matching so subnet routers receive filter rules for
|
||||
// destinations their subnets cover.
|
||||
package policyutil
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
//
|
||||
// IMPORTANT: This function is designed for global filters only. Per-node filters
|
||||
// (from autogroup:self policies) are already node-specific and should not be passed
|
||||
// to this function. Use PolicyManager.FilterForNode() instead, which handles both cases.
|
||||
// to this function. Use [policy.PolicyManager.FilterForNode] instead, which handles
|
||||
// both cases.
|
||||
func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcfg.FilterRule {
|
||||
ret := []tailcfg.FilterRule{}
|
||||
subnetRoutes := node.SubnetRoutes()
|
||||
@@ -49,13 +50,14 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
|
||||
}
|
||||
|
||||
// If the node has approved subnet routes, preserve
|
||||
// filter rules targeting those routes. SubnetRoutes()
|
||||
// returns only approved, non-exit routes — matching
|
||||
// Tailscale SaaS behavior, which does not generate
|
||||
// filter rules for advertised-but-unapproved routes.
|
||||
// Exit routes (0.0.0.0/0, ::/0) are excluded by
|
||||
// SubnetRoutes() and handled separately via
|
||||
// AllowedIPs/routing.
|
||||
// filter rules targeting those routes.
|
||||
// [types.NodeView.SubnetRoutes] returns only approved,
|
||||
// non-exit routes — matching Tailscale SaaS behavior,
|
||||
// which does not generate filter rules for
|
||||
// advertised-but-unapproved routes. Exit routes
|
||||
// (0.0.0.0/0, ::/0) are excluded by
|
||||
// [types.NodeView.SubnetRoutes] and handled separately
|
||||
// via AllowedIPs/routing.
|
||||
if slices.ContainsFunc(subnetRoutes, expanded.OverlapsPrefix) {
|
||||
dests = append(dests, dest)
|
||||
continue
|
||||
@@ -95,11 +97,11 @@ func ipSetSubsetOf(candidate, container *netipx.IPSet) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// reduceCapGrantRule filters a CapGrant rule to only include CapGrant
|
||||
// entries whose Dsts match the given node's IPs. When a broad prefix
|
||||
// (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is
|
||||
// reduceCapGrantRule filters a [tailcfg.CapGrant] rule to only include
|
||||
// [tailcfg.CapGrant] entries whose Dsts match the given node's IPs. When a
|
||||
// broad prefix (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is
|
||||
// narrowed to the node's specific /32 or /128 prefix. Returns nil if
|
||||
// no CapGrant entries are relevant to this node.
|
||||
// no [tailcfg.CapGrant] entries are relevant to this node.
|
||||
func reduceCapGrantRule(
|
||||
node types.NodeView,
|
||||
rule tailcfg.FilterRule,
|
||||
@@ -136,9 +138,9 @@ func reduceCapGrantRule(
|
||||
// prefixes to node-specific /32 or /128 so peers receive only
|
||||
// the minimum routing surface. The route-match loop below
|
||||
// preserves the original prefix so the subnet-serving node
|
||||
// receives the full CapGrant scope. SubnetRoutes() excludes
|
||||
// both unapproved and exit routes, matching Tailscale SaaS
|
||||
// behavior.
|
||||
// receives the full CapGrant scope. [types.NodeView.SubnetRoutes]
|
||||
// excludes both unapproved and exit routes, matching Tailscale
|
||||
// SaaS behavior.
|
||||
for _, dst := range cg.Dsts {
|
||||
for _, subnetRoute := range subnetRoutes {
|
||||
if dst.Overlaps(subnetRoute) {
|
||||
@@ -151,7 +153,7 @@ func reduceCapGrantRule(
|
||||
if len(matchingDsts) > 0 {
|
||||
// A Dst can be appended twice when a broad prefix both
|
||||
// contains a node IP and overlaps one of its approved
|
||||
// subnet routes. Sort + Compact dedups; netip.Prefix is
|
||||
// subnet routes. Sort + Compact dedups; [netip.Prefix] is
|
||||
// comparable so Compact works with ==.
|
||||
slices.SortFunc(matchingDsts, netip.Prefix.Compare)
|
||||
matchingDsts = slices.Compact(matchingDsts)
|
||||
|
||||
@@ -18,7 +18,7 @@ type grantCategory int
|
||||
|
||||
const (
|
||||
// grantCategoryRegular requires no per-node work. The pre-compiled
|
||||
// rules are complete and only need ReduceFilterRules.
|
||||
// rules are complete and only need [policyutil.ReduceFilterRules].
|
||||
grantCategoryRegular grantCategory = iota
|
||||
|
||||
// grantCategorySelf has autogroup:self destinations that must be
|
||||
@@ -80,7 +80,7 @@ type viaGrantData struct {
|
||||
|
||||
// resolveViaDestinations splits a via grant's destinations into the
|
||||
// flat list of IP prefixes they resolve to plus a flag for
|
||||
// autogroup:internet. Every alias kind goes through Alias.Resolve so
|
||||
// autogroup:internet. Every alias kind goes through [Alias.Resolve] so
|
||||
// adding a new alias type to the policy parser does not silently
|
||||
// disappear from the via path. Non-IP alias kinds (tag, user, group,
|
||||
// wildcard) resolve to /32 host IPs that never overlap with subnet
|
||||
@@ -116,7 +116,7 @@ func resolveViaDestinations(
|
||||
|
||||
// userNodeIndex maps user IDs to their untagged nodes. Built once per
|
||||
// policy or node-set change and read from many goroutines under
|
||||
// PolicyManager.mu; readers must hold the lock (or the snapshot
|
||||
// [PolicyManager.mu]; readers must hold the lock (or the snapshot
|
||||
// returned to them).
|
||||
type userNodeIndex map[uint][]types.NodeView
|
||||
|
||||
@@ -136,7 +136,7 @@ func buildUserNodeIndex(
|
||||
}
|
||||
|
||||
// compileNodeAttrs returns the per-node CapMap derived from policy
|
||||
// nodeAttrs plus the tailnet-wide RandomizeClientPort flag.
|
||||
// nodeAttrs plus the tailnet-wide [Policy.RandomizeClientPort] flag.
|
||||
//
|
||||
// Returns an error when a target alias fails to resolve so the caller
|
||||
// surfaces a corrupt policy instead of silently granting a partial set
|
||||
@@ -163,18 +163,18 @@ func (pol *Policy) compileNodeAttrs(
|
||||
result[id] = capMap
|
||||
}
|
||||
|
||||
// nil RawMessage matches the wire format from a Tailscale-hosted
|
||||
// control plane: capabilities without companion data marshal as
|
||||
// `null` rather than `[]`. Storing nil keeps the merge stable
|
||||
// and lets the compat test diff cleanly against captured
|
||||
// netmaps.
|
||||
// nil [tailcfg.RawMessage] matches the wire format from a
|
||||
// Tailscale-hosted control plane: capabilities without companion
|
||||
// data marshal as null rather than []. Storing nil keeps the
|
||||
// merge stable and lets the compat test diff cleanly against
|
||||
// captured netmaps.
|
||||
if _, exists := capMap[attr]; !exists {
|
||||
capMap[attr] = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cache each node's IPs once per call. Without the cache, the
|
||||
// node-attr inner loop would call NodeView.IPs() once per attr
|
||||
// node-attr inner loop would call [types.NodeView.IPs] once per attr
|
||||
// per node — O(grants × nodes) allocations of a 2-element slice
|
||||
// for what is invariant per node within a single policy compile.
|
||||
type nodeIPs struct {
|
||||
@@ -221,10 +221,11 @@ func (pol *Policy) compileNodeAttrs(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// compileGrants resolves all policy grants into compiledGrant structs.
|
||||
// compileGrants resolves all policy grants into [compiledGrant] structs.
|
||||
// Source resolution and non-self destination resolution happens once
|
||||
// here. This is the single resolution path that replaces the
|
||||
// duplicated work in compileFilterRules and compileGrantWithAutogroupSelf.
|
||||
// duplicated work in [Policy.compileFilterRules] and the autogroup:self
|
||||
// expansion.
|
||||
func (pol *Policy) compileGrants(
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
@@ -256,7 +257,7 @@ func (pol *Policy) compileGrants(
|
||||
return compiled
|
||||
}
|
||||
|
||||
// compileOneGrant resolves a single grant into a compiledGrant.
|
||||
// compileOneGrant resolves a single grant into a [compiledGrant].
|
||||
// All source resolution happens here. Non-self, non-via destination
|
||||
// resolution also happens here. Per-node data (self dests, via
|
||||
// matching) is stored for deferred compilation.
|
||||
@@ -341,7 +342,7 @@ func (pol *Policy) compileOneGrant(
|
||||
|
||||
// compileOneViaGrant resolves sources for a via grant and stores the
|
||||
// deferred per-node data. The actual via-node matching and route
|
||||
// intersection happens in compileViaForNode.
|
||||
// intersection happens in [compileViaForNode].
|
||||
func (pol *Policy) compileOneViaGrant(
|
||||
grant Grant,
|
||||
users types.Users,
|
||||
@@ -404,8 +405,8 @@ func (pol *Policy) compileOneViaGrant(
|
||||
// resolveSources resolves grant sources per-alias, returning the
|
||||
// resolved addresses and a separate slice of non-wildcard sources.
|
||||
// This is the canonical source-resolution path. Its output lands in
|
||||
// compiledGrant.srcIPStrings (among other places) and callers on the
|
||||
// hot path should prefer reading that over calling Resolve again.
|
||||
// [compiledGrant.srcIPStrings] (among other places) and callers on the
|
||||
// hot path should prefer reading that over calling [Alias.Resolve] again.
|
||||
func resolveSources(
|
||||
pol *Policy,
|
||||
sources Aliases,
|
||||
@@ -490,8 +491,9 @@ func buildSrcIPStrings(
|
||||
}
|
||||
|
||||
// compileOtherDests compiles filter rules for non-self, non-via
|
||||
// destinations. This produces both DstPorts rules (from
|
||||
// InternetProtocols) and CapGrant rules (from App).
|
||||
// destinations. This produces both [tailcfg.FilterRule.DstPorts] rules
|
||||
// (from [Grant.InternetProtocols]) and [tailcfg.CapGrant] rules (from
|
||||
// [Grant.App]).
|
||||
func (pol *Policy) compileOtherDests(
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
@@ -580,7 +582,7 @@ func (pol *Policy) compileOtherDests(
|
||||
return rules
|
||||
}
|
||||
|
||||
// hasPerNodeGrants reports whether any compiled grant requires
|
||||
// hasPerNodeGrants reports whether any [compiledGrant] requires
|
||||
// per-node filter compilation (via grants or autogroup:self).
|
||||
func hasPerNodeGrants(grants []compiledGrant) bool {
|
||||
for i := range grants {
|
||||
@@ -592,10 +594,10 @@ func hasPerNodeGrants(grants []compiledGrant) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// globalFilterRules extracts global filter rules from compiled
|
||||
// grants. Via grants produce no global rules (they are per-node
|
||||
// only); regular grants contribute their full pre-compiled ruleset;
|
||||
// self grants contribute their non-self portion.
|
||||
// globalFilterRules extracts global filter rules from [compiledGrant]s.
|
||||
// Via grants produce no global rules (they are per-node only); regular
|
||||
// grants contribute their full pre-compiled ruleset; self grants
|
||||
// contribute their non-self portion.
|
||||
func globalFilterRules(grants []compiledGrant) []tailcfg.FilterRule {
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
@@ -804,10 +806,11 @@ func compileViaForNode(
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubnetRoutes excludes exit routes, so the overlap gate below sees
|
||||
// only subnet advertisements. autogroup:internet on a via-tagged
|
||||
// exit advertiser is handled separately because its eligibility is
|
||||
// per-node (IsExitNode) rather than per-prefix overlap.
|
||||
// [types.NodeView.SubnetRoutes] excludes exit routes, so the overlap
|
||||
// gate below sees only subnet advertisements. autogroup:internet on
|
||||
// a via-tagged exit advertiser is handled separately because its
|
||||
// eligibility is per-node ([types.NodeView.IsExitNode]) rather than
|
||||
// per-prefix overlap.
|
||||
nodeSubnetRoutes := node.SubnetRoutes()
|
||||
|
||||
var viaDstPrefixes []netip.Prefix
|
||||
@@ -826,11 +829,11 @@ func compileViaForNode(
|
||||
}
|
||||
|
||||
// autogroup:internet on a via-tagged exit advertiser becomes a rule
|
||||
// whose DstPorts enumerate util.TheInternet(). The matchers derived
|
||||
// from this rule let Node.CanAccess surface the exit node to the
|
||||
// grant source via DestsIsTheInternet. ReduceFilterRules strips the
|
||||
// rule from the wire format on non-exit advertisers, preserving
|
||||
// SaaS PacketFilter encoding.
|
||||
// whose DstPorts enumerate [util.TheInternet]. The matchers derived
|
||||
// from this rule let [types.NodeView.CanAccess] surface the exit node
|
||||
// to the grant source via [matcher.Match.DestsIsTheInternet].
|
||||
// [policyutil.ReduceFilterRules] strips the rule from the wire format
|
||||
// on non-exit advertisers, preserving SaaS PacketFilter encoding.
|
||||
if cg.via.hasAutoGroupInternet && node.IsExitNode() {
|
||||
viaDstPrefixes = append(
|
||||
viaDstPrefixes,
|
||||
|
||||
@@ -735,10 +735,11 @@ func TestTagPropagationToPeerMap(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, matchersForUser2, "MatchersForNode should return non-empty matchers (at least self-access rule)")
|
||||
|
||||
// Test ReduceNodes logic with the updated nodes and matchers
|
||||
// This is what buildTailPeers does - it takes peers from ListPeers (which might include user1)
|
||||
// and filters them using ReduceNodes with the updated matchers
|
||||
// Inline the ReduceNodes logic to avoid import cycle
|
||||
// Test [policy.ReduceNodes] logic with the updated nodes and matchers
|
||||
// This is what [mapper.MapResponseBuilder.buildTailPeers] does - it takes peers from
|
||||
// [state.State.ListPeers] (which might include user1) and filters them using
|
||||
// [policy.ReduceNodes] with the updated matchers
|
||||
// Inline the [policy.ReduceNodes] logic to avoid import cycle
|
||||
user2View := user2Node.View()
|
||||
user1UpdatedView := user1NodeUpdated.View()
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
// - check: every listed user reaches every dst via a check-action
|
||||
// rule specifically (accept-only matches fail the assertion).
|
||||
|
||||
// SSHPolicyTestResult is the outcome of a single SSHPolicyTest.
|
||||
// SSHPolicyTestResult is the outcome of a single [SSHPolicyTest].
|
||||
type SSHPolicyTestResult struct {
|
||||
Src string `json:"src"`
|
||||
Passed bool `json:"passed"`
|
||||
@@ -122,7 +122,7 @@ func checkFailReason(res SSHPolicyTestResult, user, dst string) string {
|
||||
}
|
||||
|
||||
// RunSSHTests evaluates the live policy's sshTests block and wraps any
|
||||
// failure in errSSHPolicyTestsFailed.
|
||||
// failure in [errSSHPolicyTestsFailed].
|
||||
func (pm *PolicyManager) RunSSHTests() error {
|
||||
if pm == nil || pm.pol == nil || len(pm.pol.SSHTests) == 0 {
|
||||
return nil
|
||||
@@ -162,7 +162,7 @@ func evaluateSSHTests(
|
||||
}
|
||||
|
||||
// runSSHPolicyTests evaluates every sshTests entry. The cache is keyed
|
||||
// by dst NodeID so repeat destinations only compile once per pass.
|
||||
// by dst [types.NodeID] so repeat destinations only compile once per pass.
|
||||
func runSSHPolicyTests(
|
||||
pol *Policy,
|
||||
users []types.User,
|
||||
@@ -389,7 +389,7 @@ func appendUserDst(m map[string][]string, user, dst string) map[string][]string
|
||||
|
||||
// resolveSSHTestSource returns the src's principal addresses and, for
|
||||
// user-shaped sources, the user ID (so autogroup:self can scope to it).
|
||||
// Tag, host, and IP sources return userID 0.
|
||||
// [Tag], [Host], and IP sources return userID 0.
|
||||
func resolveSSHTestSource(
|
||||
src Alias,
|
||||
pol *Policy,
|
||||
@@ -428,9 +428,10 @@ func resolveSSHTestSource(
|
||||
}
|
||||
|
||||
// resolveSSHTestDestNodes maps each dst alias to its destination
|
||||
// NodeViews. autogroup:self needs special handling: it cannot resolve
|
||||
// without per-node context, so it walks the node set keyed on src's
|
||||
// owning user. Other aliases resolve to an IPSet and match via InIPSet.
|
||||
// [types.NodeView]s. autogroup:self needs special handling: it cannot
|
||||
// resolve without per-node context, so it walks the node set keyed on
|
||||
// src's owning user. Other aliases resolve to an [netipx.IPSet] and match
|
||||
// via [types.NodeView.InIPSet].
|
||||
func resolveSSHTestDestNodes(
|
||||
dsts SSHTestDestinations,
|
||||
pol *Policy,
|
||||
@@ -527,8 +528,8 @@ func resolveSSHTestDestNodes(
|
||||
return out, emptyDsts, nil
|
||||
}
|
||||
|
||||
// prefixesToIPSet builds the IPSet that InIPSet expects on the node
|
||||
// side.
|
||||
// prefixesToIPSet builds the [netipx.IPSet] that [types.NodeView.InIPSet]
|
||||
// expects on the node side.
|
||||
func prefixesToIPSet(prefixes []netip.Prefix) (*netipx.IPSet, error) {
|
||||
var b netipx.IPSetBuilder
|
||||
|
||||
@@ -539,9 +540,9 @@ func prefixesToIPSet(prefixes []netip.Prefix) (*netipx.IPSet, error) {
|
||||
return b.IPSet()
|
||||
}
|
||||
|
||||
// compiledSSHPolicy returns the per-node compiled SSH policy, caching
|
||||
// compiledSSHPolicy returns the per-node compiled [tailcfg.SSHPolicy], caching
|
||||
// on miss. baseURL is empty because reachability only checks for the
|
||||
// presence of HoldAndDelegate, not its value.
|
||||
// presence of [tailcfg.SSHAction.HoldAndDelegate], not its value.
|
||||
func compiledSSHPolicy(
|
||||
pol *Policy,
|
||||
users []types.User,
|
||||
@@ -607,8 +608,8 @@ func reachability(
|
||||
return acceptHit, checkHit
|
||||
}
|
||||
|
||||
// principalContainsAddr reports whether any principal's NodeIP matches
|
||||
// srcAddr exactly (the SSH compiler emits one principal per source IP).
|
||||
// principalContainsAddr reports whether any principal's [tailcfg.SSHPrincipal.NodeIP]
|
||||
// matches srcAddr exactly (the SSH compiler emits one principal per source IP).
|
||||
func principalContainsAddr(
|
||||
principals []*tailcfg.SSHPrincipal,
|
||||
srcAddr netip.Addr,
|
||||
@@ -635,7 +636,7 @@ func principalContainsAddr(
|
||||
return false
|
||||
}
|
||||
|
||||
// sshUserMapAllows reports whether SSHUsers permits user. The SSHUsers
|
||||
// sshUserMapAllows reports whether [SSHUsers] permits user. The [SSHUsers]
|
||||
// wire shape (see filter.go compileSSHPolicy):
|
||||
//
|
||||
// - SSHUsers["root"] == "root" allows root; == "" disallows it.
|
||||
|
||||
@@ -4,7 +4,7 @@ package v2
|
||||
// Tailscale-hosted control plane emits where headscale has no
|
||||
// equivalent concept yet. The compat test in
|
||||
// tailscale_nodeattrs_compat_test.go builds the self-view CapMap via
|
||||
// [types.NodeView.TailNode] -- the same call the mapper makes -- and
|
||||
// [types.Node.TailNode] -- the same call the mapper makes -- and
|
||||
// strips these from BOTH sides before [cmp.Diff]; every other cap is
|
||||
// compared in full as it lands on the wire.
|
||||
//
|
||||
@@ -30,8 +30,9 @@ import (
|
||||
// (suggest-exit-node, dns-subdomain-resolve — see
|
||||
// ipn/ipnlocal/local.go:7534 and node_backend.go:745) are emitted only
|
||||
// when the peer satisfies the cap's emission condition. This function
|
||||
// encodes those conditions; the mapper calls it from buildTailPeers and
|
||||
// the compat test calls it to compute the expected per-peer wire shape.
|
||||
// encodes those conditions; the mapper calls it from
|
||||
// [mapper.MapResponseBuilder.buildTailPeers] and the compat test calls
|
||||
// it to compute the expected per-peer wire shape.
|
||||
func PeerCapMap(peer types.NodeView, peerSelfCaps tailcfg.NodeCapMap) tailcfg.NodeCapMap {
|
||||
if len(peerSelfCaps) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
|
||||
// errPolicyTestsFailed and errSSHPolicyTestsFailed share the
|
||||
// "test(s) failed" prefix but stay distinct so callers can use
|
||||
// errors.Is to tell ACL-test and SSH-test failures apart.
|
||||
// [errors.Is] to tell ACL-test and SSH-test failures apart.
|
||||
var (
|
||||
errPolicyTestsFailed = errors.New("test(s) failed")
|
||||
errSSHPolicyTestsFailed = errors.New("test(s) failed")
|
||||
@@ -55,7 +55,7 @@ type PolicyTest struct {
|
||||
|
||||
// SSHPolicyTest is one entry in the policy's `sshTests` block. The
|
||||
// accept/deny/check arrays carry usernames, not destinations — every
|
||||
// listed user is asserted against every entry in Dst.
|
||||
// listed user is asserted against every entry in [SSHPolicyTest.Dst].
|
||||
type SSHPolicyTest struct {
|
||||
// Src is a single source alias (user, group, tag, host, or IP).
|
||||
Src Alias `json:"src"`
|
||||
@@ -78,7 +78,7 @@ type SSHPolicyTest struct {
|
||||
}
|
||||
|
||||
// SSHTestDestinations is the typed list of destination aliases an
|
||||
// sshTests entry targets. validateSSHTestDestination enforces the
|
||||
// sshTests entry targets. [validateSSHTestDestination] enforces the
|
||||
// SSH-specific shape rules (no :port, no CIDR, no autogroup:internet,
|
||||
// known tag).
|
||||
type SSHTestDestinations []Alias
|
||||
@@ -100,7 +100,7 @@ func (d *SSHTestDestinations) UnmarshalJSON(b []byte) error {
|
||||
}
|
||||
|
||||
// UnmarshalJSON parses each typed field. An empty src lands as a nil
|
||||
// Alias so validation surfaces ErrSSHTestEmptySrc rather than a parser
|
||||
// [Alias] so validation surfaces [ErrSSHTestEmptySrc] rather than a parser
|
||||
// failure.
|
||||
func (t *SSHPolicyTest) UnmarshalJSON(b []byte) error {
|
||||
var raw struct {
|
||||
@@ -134,7 +134,7 @@ func (t *SSHPolicyTest) UnmarshalJSON(b []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PolicyTestResult is the outcome of a single PolicyTest.
|
||||
// PolicyTestResult is the outcome of a single [PolicyTest].
|
||||
type PolicyTestResult struct {
|
||||
Src string `json:"src"`
|
||||
Proto Protocol `json:"proto,omitempty"`
|
||||
@@ -216,7 +216,7 @@ func (pm *PolicyManager) RunTests() error {
|
||||
}
|
||||
|
||||
// evaluateTests runs the `tests` block against a fresh compilation of pol.
|
||||
// It is the user-write sandbox: the live PolicyManager state is left
|
||||
// It is the user-write sandbox: the live [PolicyManager] state is left
|
||||
// untouched, so a failing test rejects the write without side effects.
|
||||
func evaluateTests(pol *Policy, users []types.User, nodes views.Slice[types.NodeView]) error {
|
||||
if pol == nil || len(pol.Tests) == 0 {
|
||||
@@ -262,7 +262,7 @@ func runPolicyTests(pol *Policy, filter []tailcfg.FilterRule, users []types.User
|
||||
return results
|
||||
}
|
||||
|
||||
// runPolicyTest evaluates one PolicyTest.
|
||||
// runPolicyTest evaluates one [PolicyTest].
|
||||
func runPolicyTest(test PolicyTest, pol *Policy, filter []tailcfg.FilterRule, users []types.User, nodes views.Slice[types.NodeView]) PolicyTestResult {
|
||||
res := PolicyTestResult{
|
||||
Src: test.Src,
|
||||
@@ -322,8 +322,8 @@ func runPolicyTest(test PolicyTest, pol *Policy, filter []tailcfg.FilterRule, us
|
||||
return res
|
||||
}
|
||||
|
||||
// resolveTestSource resolves the Src alias of a PolicyTest into a slice of
|
||||
// netip.Prefix. parseAlias + Alias.Resolve cover every alias type the rest
|
||||
// resolveTestSource resolves the Src alias of a [PolicyTest] into a slice of
|
||||
// [netip.Prefix]. [parseAlias] + [Alias.Resolve] cover every alias type the rest
|
||||
// of the policy engine supports, so tests inherit alias semantics for free.
|
||||
func resolveTestSource(src string, pol *Policy, users []types.User, nodes views.Slice[types.NodeView]) ([]netip.Prefix, error) {
|
||||
alias, err := parseAlias(src)
|
||||
@@ -377,13 +377,13 @@ func evalReachability(srcPrefixes []netip.Prefix, dst string, proto Protocol, po
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// parseDestinationAlias is a thin wrapper over AliasWithPorts.UnmarshalJSON
|
||||
// parseDestinationAlias is a thin wrapper over [AliasWithPorts.UnmarshalJSON]
|
||||
// so callers can hand it a bare `"host:port"` string without re-implementing
|
||||
// the parse logic.
|
||||
func parseDestinationAlias(dst string) (*AliasWithPorts, error) {
|
||||
var awp AliasWithPorts
|
||||
|
||||
// AliasWithPorts.UnmarshalJSON expects a quoted JSON string, so wrap.
|
||||
// [AliasWithPorts.UnmarshalJSON] expects a quoted JSON string, so wrap.
|
||||
err := awp.UnmarshalJSON([]byte(`"` + dst + `"`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -425,9 +425,9 @@ func srcReachesDst(src netip.Prefix, dstPrefixes []netip.Prefix, ports []tailcfg
|
||||
}
|
||||
|
||||
// ruleMatchesSource reports whether the rule's source list contains src.
|
||||
// SrcIPs may be CIDR, single addresses, IP ranges (`a-b`), or `*`; we use
|
||||
// util.ParseIPSet to cover all of those uniformly. Unparseable entries
|
||||
// are skipped (the rule compiler emits well-formed strings, so this is
|
||||
// [tailcfg.FilterRule.SrcIPs] may be CIDR, single addresses, IP ranges (`a-b`),
|
||||
// or `*`; we use [util.ParseIPSet] to cover all of those uniformly. Unparseable
|
||||
// entries are skipped (the rule compiler emits well-formed strings, so this is
|
||||
// defence-in-depth, not error handling).
|
||||
func ruleMatchesSource(rule tailcfg.FilterRule, src netip.Prefix) bool {
|
||||
for _, raw := range rule.SrcIPs {
|
||||
@@ -445,9 +445,10 @@ func ruleMatchesSource(rule tailcfg.FilterRule, src netip.Prefix) bool {
|
||||
}
|
||||
|
||||
// ruleMatchesProto reports whether the rule permits any of requestedProtos.
|
||||
// An unset rule.IPProto means "any protocol" and matches everything.
|
||||
// requestedProtos is the per-test protocol set: a single proto for an
|
||||
// explicit test.Proto, or the default set when test.Proto is empty.
|
||||
// An unset [tailcfg.FilterRule.IPProto] means "any protocol" and matches
|
||||
// everything. requestedProtos is the per-test protocol set: a single proto
|
||||
// for an explicit [PolicyTest.Proto], or the default set when
|
||||
// [PolicyTest.Proto] is empty.
|
||||
func ruleMatchesProto(rule tailcfg.FilterRule, requestedProtos []int) bool {
|
||||
if len(rule.IPProto) == 0 {
|
||||
return true
|
||||
@@ -479,7 +480,7 @@ func ruleAllowsAnyDest(rule tailcfg.FilterRule, dstPrefixes []netip.Prefix, port
|
||||
return false
|
||||
}
|
||||
|
||||
// destEntryMatchesPrefixes reports whether the rule's NetPortRange.IP
|
||||
// destEntryMatchesPrefixes reports whether the rule's [tailcfg.NetPortRange.IP]
|
||||
// (CIDR, single IP, IP range, or "*") covers any prefix in dstPrefixes.
|
||||
func destEntryMatchesPrefixes(dp tailcfg.NetPortRange, dstPrefixes []netip.Prefix) bool {
|
||||
set, err := util.ParseIPSet(dp.IP, nil)
|
||||
|
||||
@@ -31,8 +31,8 @@ var (
|
||||
//
|
||||
// Brackets are only accepted around IPv6 addresses, not IPv4, hostnames, or other alias types.
|
||||
// Bracket stripping reduces both forms to bare "addr:port" or "addr/prefix:port",
|
||||
// which the normal LastIndex(":") split handles correctly because port strings
|
||||
// never contain colons.
|
||||
// which the normal [strings.LastIndex] of ":" split handles correctly because
|
||||
// port strings never contain colons.
|
||||
func splitDestinationAndPort(input string) (string, string, error) {
|
||||
// Handle RFC 3986 bracketed IPv6 (e.g. "[::1]:80" or "[fd7a::1]/128:80,443").
|
||||
// Strip brackets after validation and fall through to normal parsing.
|
||||
@@ -82,7 +82,7 @@ func splitDestinationAndPort(input string) (string, string, error) {
|
||||
return destination, port, nil
|
||||
}
|
||||
|
||||
// parsePortRange parses a port definition string and returns a slice of PortRange structs.
|
||||
// parsePortRange parses a port definition string and returns a slice of [tailcfg.PortRange] structs.
|
||||
func parsePortRange(portDef string) ([]tailcfg.PortRange, error) {
|
||||
if portDef == "*" {
|
||||
return []tailcfg.PortRange{tailcfg.PortRangeAny}, nil
|
||||
|
||||
@@ -115,7 +115,7 @@ func (m *mapSession) serve() {
|
||||
// This is the mechanism where the node gives us information about its
|
||||
// current configuration.
|
||||
//
|
||||
// Process the MapRequest to update node state (endpoints, hostinfo, etc.)
|
||||
// Process the [tailcfg.MapRequest] to update node state (endpoints, hostinfo, etc.)
|
||||
c, err := m.h.state.UpdateNodeFromMapRequest(m.node.ID, m.req)
|
||||
if err != nil {
|
||||
httpError(m.w, err)
|
||||
@@ -148,9 +148,9 @@ func (m *mapSession) serveLongPoll() {
|
||||
|
||||
m.log.Trace().Caller().Msg("long poll session started")
|
||||
|
||||
// connectGen is set by Connect() below and captured by the deferred cleanup closure.
|
||||
// It allows Disconnect() to reject stale calls from old sessions — if a newer session
|
||||
// has called Connect() (incrementing the generation), the old session's Disconnect()
|
||||
// connectGen is set by [state.State.Connect] below and captured by the deferred cleanup closure.
|
||||
// It allows [state.State.Disconnect] to reject stale calls from old sessions — if a newer session
|
||||
// has called [state.State.Connect] (incrementing the generation), the old session's [state.State.Disconnect]
|
||||
// sees a mismatched generation and becomes a no-op.
|
||||
var connectGen uint64
|
||||
|
||||
@@ -169,7 +169,7 @@ func (m *mapSession) serveLongPoll() {
|
||||
|
||||
// When a node disconnects, it might rapidly reconnect (e.g. mobile clients, network weather).
|
||||
// Instead of immediately marking the node as offline, we wait a few seconds to see if it reconnects.
|
||||
// If it does reconnect, the existing mapSession will be replaced and the node remains online.
|
||||
// If it does reconnect, the existing [mapSession] will be replaced and the node remains online.
|
||||
// If it doesn't reconnect within the timeout, we mark it as offline.
|
||||
//
|
||||
// This avoids flapping nodes in the UI and unnecessary churn in the network.
|
||||
@@ -190,8 +190,8 @@ func (m *mapSession) serveLongPoll() {
|
||||
}
|
||||
|
||||
if disconnected {
|
||||
// Pass the generation from our Connect() call. If a newer session has
|
||||
// connected since (bumping the generation), Disconnect() will detect
|
||||
// Pass the generation from our [state.State.Connect] call. If a newer session has
|
||||
// connected since (bumping the generation), [state.State.Disconnect] will detect
|
||||
// the mismatch and skip the state update, preventing the race where
|
||||
// an old grace period goroutine overwrites a newer session's online status.
|
||||
disconnectChanges, err := m.h.state.Disconnect(m.node.ID, connectGen)
|
||||
@@ -214,12 +214,12 @@ func (m *mapSession) serveLongPoll() {
|
||||
|
||||
m.keepAliveTicker = time.NewTicker(m.keepAlive)
|
||||
|
||||
// Process the initial MapRequest to update node state (endpoints, hostinfo, etc.)
|
||||
// This must be done BEFORE calling Connect() to ensure routes are properly synchronized.
|
||||
// When nodes reconnect, they send their hostinfo with announced routes in the MapRequest.
|
||||
// We need this data in NodeStore before Connect() sets up the primary routes, because
|
||||
// SubnetRoutes() calculates the intersection of announced and approved routes. If we
|
||||
// call Connect() first, SubnetRoutes() returns empty (no announced routes yet), causing
|
||||
// Process the initial [tailcfg.MapRequest] to update node state (endpoints, hostinfo, etc.)
|
||||
// This must be done BEFORE calling [state.State.Connect] to ensure routes are properly synchronized.
|
||||
// When nodes reconnect, they send their hostinfo with announced routes in the [tailcfg.MapRequest].
|
||||
// We need this data in [state.NodeStore] before [state.State.Connect] sets up the primary routes, because
|
||||
// [types.NodeView.SubnetRoutes] calculates the intersection of announced and approved routes. If we
|
||||
// call [state.State.Connect] first, [types.NodeView.SubnetRoutes] returns empty (no announced routes yet), causing
|
||||
// the node to be incorrectly removed from AvailableRoutes.
|
||||
mapReqChange, err := m.h.state.UpdateNodeFromMapRequest(m.node.ID, m.req)
|
||||
if err != nil {
|
||||
@@ -229,8 +229,8 @@ func (m *mapSession) serveLongPoll() {
|
||||
|
||||
// Connect the node after its state has been updated.
|
||||
// We send two separate change notifications because these are distinct operations:
|
||||
// 1. UpdateNodeFromMapRequest: processes the client's reported state (routes, endpoints, hostinfo)
|
||||
// 2. Connect: marks the node online and recalculates primary routes based on the updated state
|
||||
// 1. [state.State.UpdateNodeFromMapRequest]: processes the client's reported state (routes, endpoints, hostinfo)
|
||||
// 2. [state.State.Connect]: marks the node online and recalculates primary routes based on the updated state
|
||||
// While this results in two notifications, it ensures route data is synchronized before
|
||||
// primary route selection occurs, which is critical for proper HA subnet router failover.
|
||||
var connectChanges []change.Change
|
||||
@@ -308,8 +308,8 @@ func (m *mapSession) serveLongPoll() {
|
||||
|
||||
// writeMap writes the map response to the client.
|
||||
// It handles compression if requested and any headers that need to be set.
|
||||
// It also handles flushing the response if the ResponseWriter
|
||||
// implements http.Flusher.
|
||||
// It also handles flushing the response if the [http.ResponseWriter]
|
||||
// implements [http.Flusher].
|
||||
func (m *mapSession) writeMap(msg *tailcfg.MapResponse) error {
|
||||
jsonBody, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
|
||||
@@ -100,7 +100,7 @@ func (w *delayedSuccessResponseWriter) WriteCount() int {
|
||||
// 3. While that write is blocked, queue enough updates to fill the buffered
|
||||
// channel and make the next batcher send hit the stale-send timeout.
|
||||
// 4. That stale-send path removes the session from the batcher, so without an
|
||||
// explicit teardown hook the old serveLongPoll goroutine would stay alive
|
||||
// explicit teardown hook the old [mapSession.serveLongPoll] goroutine would stay alive
|
||||
// but stop receiving future updates.
|
||||
// 5. Release the blocked write and verify the batcher-side stop signal makes
|
||||
// that stale session exit instead of lingering as an orphaned goroutine.
|
||||
|
||||
@@ -20,7 +20,7 @@ var proxyHeaders = [...]string{headerTrueClientIP, headerXRealIP, headerXForward
|
||||
// trustedProxyRealIP rewrites r.RemoteAddr from proxy headers when the
|
||||
// peer is in trusted; for any other peer the headers are stripped so a
|
||||
// downstream handler cannot read a spoofed value. X-Forwarded-For uses
|
||||
// RightmostTrustedRangeStrategy so prepending a value cannot win in a
|
||||
// [realclientip.RightmostTrustedRangeStrategy] so prepending a value cannot win in a
|
||||
// proxy chain.
|
||||
func trustedProxyRealIP(trusted []netip.Prefix) (func(http.Handler) http.Handler, error) {
|
||||
ranges := make([]net.IPNet, 0, len(trusted))
|
||||
|
||||
@@ -96,7 +96,7 @@ func AssertPeerGone(tb testing.TB, observer *TestClient, peerName string) {
|
||||
}
|
||||
|
||||
// AssertPeerHasAllowedIPs checks that a peer has the expected
|
||||
// AllowedIPs prefixes.
|
||||
// [tailcfg.Node.AllowedIPs] prefixes.
|
||||
func AssertPeerHasAllowedIPs(tb testing.TB, observer *TestClient, peerName string, want []netip.Prefix) {
|
||||
tb.Helper()
|
||||
|
||||
@@ -211,7 +211,7 @@ func AssertSelfHasAddresses(tb testing.TB, client *TestClient) {
|
||||
}
|
||||
}
|
||||
|
||||
// EventuallyAssertMeshComplete retries AssertMeshComplete up to
|
||||
// EventuallyAssertMeshComplete retries [AssertMeshComplete] up to
|
||||
// timeout, useful when waiting for state to propagate.
|
||||
func EventuallyAssertMeshComplete(tb testing.TB, clients []*TestClient, timeout time.Duration) {
|
||||
tb.Helper()
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
"tailscale.com/util/eventbus"
|
||||
)
|
||||
|
||||
// TestClient wraps a Tailscale controlclient.Direct connected to a
|
||||
// TestServer. It tracks all received NetworkMap updates, providing
|
||||
// TestClient wraps a Tailscale [controlclient.Direct] connected to a
|
||||
// [TestServer]. It tracks all received [netmap.NetworkMap] updates, providing
|
||||
// helpers to wait for convergence and inspect the client's view of
|
||||
// the network.
|
||||
type TestClient struct {
|
||||
@@ -37,13 +37,13 @@ type TestClient struct {
|
||||
pollCancel context.CancelFunc
|
||||
pollDone chan struct{}
|
||||
|
||||
// Accumulated state from MapResponse callbacks.
|
||||
// Accumulated state from [tailcfg.MapResponse] callbacks.
|
||||
mu sync.RWMutex
|
||||
netmap *netmap.NetworkMap
|
||||
history []*netmap.NetworkMap
|
||||
|
||||
// updates is a buffered channel that receives a signal
|
||||
// each time a new NetworkMap arrives.
|
||||
// each time a new [netmap.NetworkMap] arrives.
|
||||
updates chan *netmap.NetworkMap
|
||||
|
||||
bus *eventbus.Bus
|
||||
@@ -51,7 +51,7 @@ type TestClient struct {
|
||||
tracker *health.Tracker
|
||||
}
|
||||
|
||||
// ClientOption configures a TestClient.
|
||||
// ClientOption configures a [TestClient].
|
||||
type ClientOption func(*clientConfig)
|
||||
|
||||
type clientConfig struct {
|
||||
@@ -66,7 +66,7 @@ func WithEphemeral() ClientOption {
|
||||
return func(c *clientConfig) { c.ephemeral = true }
|
||||
}
|
||||
|
||||
// WithHostname sets the client's hostname in Hostinfo.
|
||||
// WithHostname sets the client's hostname in [tailcfg.Hostinfo].
|
||||
func WithHostname(name string) ClientOption {
|
||||
return func(c *clientConfig) { c.hostname = name }
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func WithUser(user *types.User) ClientOption {
|
||||
return func(c *clientConfig) { c.user = user }
|
||||
}
|
||||
|
||||
// NewClient creates a TestClient, registers it with the TestServer
|
||||
// NewClient creates a [TestClient], registers it with the [TestServer]
|
||||
// using a pre-auth key, and starts long-polling for map updates.
|
||||
func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOption) *TestClient {
|
||||
tb.Helper()
|
||||
@@ -171,7 +171,7 @@ func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOpt
|
||||
return tc
|
||||
}
|
||||
|
||||
// register performs the initial TryLogin to register the client.
|
||||
// register performs the initial [controlclient.Direct.TryLogin] to register the client.
|
||||
func (c *TestClient) register(tb testing.TB) {
|
||||
tb.Helper()
|
||||
|
||||
@@ -188,7 +188,7 @@ func (c *TestClient) register(tb testing.TB) {
|
||||
}
|
||||
}
|
||||
|
||||
// startPoll begins the long-poll MapRequest loop.
|
||||
// startPoll begins the long-poll [tailcfg.MapRequest] loop.
|
||||
func (c *TestClient) startPoll(tb testing.TB) {
|
||||
tb.Helper()
|
||||
|
||||
@@ -197,14 +197,14 @@ func (c *TestClient) startPoll(tb testing.TB) {
|
||||
|
||||
go func() {
|
||||
defer close(c.pollDone)
|
||||
// PollNetMap blocks until ctx is cancelled or the server closes
|
||||
// [controlclient.Direct.PollNetMap] blocks until ctx is cancelled or the server closes
|
||||
// the connection.
|
||||
_ = c.direct.PollNetMap(c.pollCtx, c)
|
||||
}()
|
||||
}
|
||||
|
||||
// UpdateFullNetmap implements controlclient.NetmapUpdater.
|
||||
// Called by controlclient.Direct when a new NetworkMap is received.
|
||||
// UpdateFullNetmap implements [controlclient.NetmapUpdater].
|
||||
// Called by [controlclient.Direct] when a new [netmap.NetworkMap] is received.
|
||||
func (c *TestClient) UpdateFullNetmap(nm *netmap.NetworkMap) {
|
||||
c.mu.Lock()
|
||||
c.netmap = nm
|
||||
@@ -259,7 +259,7 @@ func (c *TestClient) Disconnect(tb testing.TB) {
|
||||
}
|
||||
|
||||
// Reconnect registers and starts a new long-poll session.
|
||||
// Call Disconnect first, or this will disconnect automatically.
|
||||
// Call [TestClient.Disconnect] first, or this will disconnect automatically.
|
||||
func (c *TestClient) Reconnect(tb testing.TB) {
|
||||
tb.Helper()
|
||||
|
||||
@@ -274,7 +274,7 @@ func (c *TestClient) Reconnect(tb testing.TB) {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear stale netmap data so that callers like WaitForPeers
|
||||
// Clear stale netmap data so that callers like [TestClient.WaitForPeers]
|
||||
// actually wait for the new session's map instead of returning
|
||||
// immediately based on the old session's cached state.
|
||||
c.mu.Lock()
|
||||
@@ -282,7 +282,7 @@ func (c *TestClient) Reconnect(tb testing.TB) {
|
||||
c.mu.Unlock()
|
||||
|
||||
// Drain any pending updates from the old session so they
|
||||
// don't satisfy a subsequent WaitForPeers/WaitForUpdate.
|
||||
// don't satisfy a subsequent [TestClient.WaitForPeers]/[TestClient.WaitForUpdate].
|
||||
for {
|
||||
select {
|
||||
case <-c.updates:
|
||||
@@ -315,7 +315,7 @@ func (c *TestClient) ReconnectAfter(tb testing.TB, d time.Duration) {
|
||||
|
||||
// --- State accessors ---
|
||||
|
||||
// Netmap returns the latest NetworkMap, or nil if none received yet.
|
||||
// Netmap returns the latest [netmap.NetworkMap], or nil if none received yet.
|
||||
func (c *TestClient) Netmap() *netmap.NetworkMap {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -425,7 +425,7 @@ func (c *TestClient) UpdateCount() int {
|
||||
return len(c.history)
|
||||
}
|
||||
|
||||
// History returns a copy of all NetworkMap snapshots in order.
|
||||
// History returns a copy of all [netmap.NetworkMap] snapshots in order.
|
||||
func (c *TestClient) History() []*netmap.NetworkMap {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -495,13 +495,13 @@ func (c *TestClient) WaitForCondition(tb testing.TB, desc string, timeout time.D
|
||||
}
|
||||
}
|
||||
|
||||
// Direct returns the underlying controlclient.Direct for
|
||||
// advanced operations like SetHostinfo or SendUpdate.
|
||||
// Direct returns the underlying [controlclient.Direct] for
|
||||
// advanced operations like [controlclient.Direct.SetHostinfo] or SendUpdate.
|
||||
func (c *TestClient) Direct() *controlclient.Direct {
|
||||
return c.direct
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer for debug output.
|
||||
// String implements [fmt.Stringer] for debug output.
|
||||
func (c *TestClient) String() string {
|
||||
nm := c.Netmap()
|
||||
if nm == nil {
|
||||
|
||||
@@ -15,16 +15,16 @@ import (
|
||||
)
|
||||
|
||||
// TestConnectDisconnectRace targets the residual TOCTOU window in
|
||||
// state.Disconnect: the connectGeneration check at state.go:644 is not
|
||||
// atomic with the subsequent NodeStore.UpdateNode and
|
||||
// primaryRoutes.SetRoutes calls. A new Connect that runs between the
|
||||
// [state.State.Disconnect]: the connectGeneration check at state.go:644 is not
|
||||
// atomic with the subsequent [state.NodeStore.UpdateNode] and
|
||||
// primaryRoutes.SetRoutes calls. A new [state.State.Connect] that runs between the
|
||||
// gen check and the mutations can have its effects overwritten by the
|
||||
// stale Disconnect's SetRoutes(empty).
|
||||
// stale [state.State.Disconnect]'s SetRoutes(empty).
|
||||
//
|
||||
// The poll.go grace-period flow protects against the most common case
|
||||
// (RemoveNode + stillConnected). Connect/Disconnect on State directly
|
||||
// ([state.State.RemoveNode] + stillConnected). Connect/Disconnect on [state.State] directly
|
||||
// bypasses that protection and should still leave the state consistent
|
||||
// — if it doesn't, that is the bug behind issue #3203.
|
||||
// — if it doesn't, that is the bug behind the original race issue.
|
||||
//
|
||||
// Run with -race to also catch any data race exposed.
|
||||
func TestConnectDisconnectRace(t *testing.T) {
|
||||
@@ -33,15 +33,15 @@ func TestConnectDisconnectRace(t *testing.T) {
|
||||
|
||||
route := netip.MustParsePrefix("10.0.0.0/24")
|
||||
|
||||
// Use NewClient to get a node fully registered + Connected via the
|
||||
// real noise/poll path. After this, NodeStore + primaryRoutes already
|
||||
// have the node, and Connect has been called once.
|
||||
// Use [servertest.NewClient] to get a node fully registered + Connected via the
|
||||
// real noise/poll path. After this, [state.NodeStore] + primaryRoutes already
|
||||
// have the node, and [state.State.Connect] has been called once.
|
||||
//
|
||||
// Only c2 advertises the route. PrimaryRoutes preserves a current
|
||||
// Only c2 advertises the route. [tailcfg.NodeView.PrimaryRoutes] preserves a current
|
||||
// primary across changes (anti-flap, see primary.go), so if both
|
||||
// nodes were advertising, c1 (lower NodeID) would stay primary and
|
||||
// the test could never observe the route slipping out of c2's
|
||||
// PrimaryRoutes — it would never have been there in the first place.
|
||||
// [tailcfg.NodeView.PrimaryRoutes] — it would never have been there in the first place.
|
||||
c1 := servertest.NewClient(t, srv, "race-r1", servertest.WithUser(user))
|
||||
c2 := servertest.NewClient(t, srv, "race-r2", servertest.WithUser(user))
|
||||
|
||||
@@ -65,19 +65,19 @@ func TestConnectDisconnectRace(t *testing.T) {
|
||||
srv.App.Change(ch)
|
||||
|
||||
// Wait for advertisement + approval to be reflected as a primary
|
||||
// route assignment in PrimaryRoutes; otherwise we'd be racing the
|
||||
// initial steady-state setup, not the Connect/Disconnect window.
|
||||
// route assignment in [tailcfg.NodeView.PrimaryRoutes]; otherwise we'd be racing the
|
||||
// initial steady-state setup, not the [state.State.Connect]/[state.State.Disconnect] window.
|
||||
require.Eventually(t, func() bool {
|
||||
return slices.Contains(srv.State().GetNodePrimaryRoutes(r2ID), route)
|
||||
}, 10*time.Second, 50*time.Millisecond,
|
||||
"primary route should be assigned to r2 before driving the race")
|
||||
|
||||
// Drive the race repeatedly. Each iteration:
|
||||
// 1. Call Connect(id) to obtain a fresh gen — this stands in for
|
||||
// 1. Call [state.State.Connect](id) to obtain a fresh gen — this stands in for
|
||||
// a session that "owns" the node.
|
||||
// 2. Spawn a goroutine that issues Disconnect(id, gen) — the
|
||||
// 2. Spawn a goroutine that issues [state.State.Disconnect](id, gen) — the
|
||||
// stale deferred disconnect.
|
||||
// 3. Concurrently spawn a goroutine that issues Connect(id) —
|
||||
// 3. Concurrently spawn a goroutine that issues [state.State.Connect](id) —
|
||||
// the new session arriving.
|
||||
// 4. After both finish, check the state is consistent: the node
|
||||
// should be online and primaryRoutes should hold the approved
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
// TestContentVerification exercises the correctness of MapResponse
|
||||
// TestContentVerification exercises the correctness of [tailcfg.MapResponse]
|
||||
// content: that the self node, peers, DERP map, and other fields
|
||||
// are populated correctly.
|
||||
func TestContentVerification(t *testing.T) {
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
// channel is never closed (documented as a v3 TODO upstream).
|
||||
// - https://github.com/hashicorp/golang-lru/blob/v2.0.7/expirable/expirable_lru.go#L78-L81
|
||||
//
|
||||
// 2. database/sql internal goroutines: Uses sync.RWMutex which is not
|
||||
// 2. database/sql internal goroutines: Uses [sync.RWMutex] which is not
|
||||
// durably blocking in synctest, causing hangs.
|
||||
// - https://github.com/golang/go/issues/77687 (mutex as durably blocking)
|
||||
//
|
||||
@@ -77,7 +77,7 @@ func TestEphemeralNodes(t *testing.T) {
|
||||
|
||||
// Ensure the ephemeral node's long-poll session is fully
|
||||
// established on the server before disconnecting. Without
|
||||
// this, the Disconnect may cancel a PollNetMap that hasn't
|
||||
// this, the [TestClient.Disconnect] may cancel a [controlclient.Direct.PollNetMap] that hasn't
|
||||
// yet reached serveLongPoll, so no grace period or ephemeral
|
||||
// GC would ever be scheduled.
|
||||
ephemeral.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
// TestGrantPolicies verifies that grant-based policies propagate
|
||||
// correctly through the full control plane (policy -> state -> mapper)
|
||||
// and produce the expected packet filter rules in client netmaps.
|
||||
// and produce the expected packet filter rules in client [netmap.NetworkMap]s.
|
||||
func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
|
||||
t.Parallel()
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
|
||||
return c2.UpdateCount() > countC2
|
||||
})
|
||||
|
||||
// Verify PacketFilter is populated with real rules from the grant.
|
||||
// Verify [netmap.NetworkMap.PacketFilter] is populated with real rules from the grant.
|
||||
nm1 := c1.Netmap()
|
||||
require.NotNil(t, nm1)
|
||||
assert.NotNil(t, nm1.PacketFilter,
|
||||
@@ -132,7 +132,7 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
|
||||
srv.App.Change(changes...)
|
||||
}
|
||||
|
||||
// Wait for PacketFilter with cap match rules to arrive.
|
||||
// Wait for [netmap.NetworkMap.PacketFilter] with cap match rules to arrive.
|
||||
c1.WaitForCondition(t, "packet filter with cap grants",
|
||||
10*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
@@ -143,7 +143,7 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
|
||||
nm1 := c1.Netmap()
|
||||
require.NotNil(t, nm1)
|
||||
|
||||
// Check that the packet filter has CapMatch entries.
|
||||
// Check that the packet filter has [filtertype.CapMatch] entries.
|
||||
// The main grant produces cap/drive and cap/relay.
|
||||
// Companion caps (drive-sharer and relay-target) are
|
||||
// generated with reversed direction.
|
||||
@@ -657,9 +657,9 @@ func TestGrantPolicies(t *testing.T) { //nolint:gocyclo
|
||||
}
|
||||
|
||||
// TestGrantViaSubnetFilterRules verifies that routers with via grants
|
||||
// receive PacketFilter rules that allow the steered subnet traffic.
|
||||
// receive [netmap.NetworkMap.PacketFilter] rules that allow the steered subnet traffic.
|
||||
// This is a regression test: without per-node filter compilation for
|
||||
// via grants, the router's PacketFilter would lack rules for the
|
||||
// via grants, the router's [netmap.NetworkMap.PacketFilter] would lack rules for the
|
||||
// via-steered subnet destinations, causing traffic to be dropped.
|
||||
func TestGrantViaSubnetFilterRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -748,7 +748,7 @@ func TestGrantViaSubnetFilterRules(t *testing.T) {
|
||||
return false
|
||||
})
|
||||
|
||||
// Critical: the router's PacketFilter MUST contain rules with
|
||||
// Critical: the router's [netmap.NetworkMap.PacketFilter] MUST contain rules with
|
||||
// the via-steered subnet (10.0.0.0/24) as a destination.
|
||||
// Without this, the router drops traffic forwarded through it.
|
||||
routerNM := routerA.Netmap()
|
||||
@@ -1101,7 +1101,7 @@ func hasCapMatches(matches []filtertype.Match) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// hasDstRules returns true if any Match in the slice contains a
|
||||
// hasDstRules returns true if any [filtertype.Match] in the slice contains a
|
||||
// non-empty Dsts list.
|
||||
func hasDstRules(matches []filtertype.Match) bool {
|
||||
for _, m := range matches {
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
)
|
||||
|
||||
// Dynamic HA failover scenarios, observed from a viewer client's
|
||||
// perspective. Unlike the static TestViaGrantHACompat golden tests,
|
||||
// perspective. Unlike the static [TestViaGrantHACompat] golden tests,
|
||||
// these exercise runtime transitions: a primary going unhealthy,
|
||||
// revoking its approved route, or losing its tag, and verify that
|
||||
// the viewer's netmap converges to the new primary. These are the
|
||||
// the viewer's [netmap.NetworkMap] converges to the new primary. These are the
|
||||
// end-to-end signals that static captures cannot cover.
|
||||
|
||||
// hasPeerPrimaryRoute reports whether the viewer's current netmap
|
||||
// hasPeerPrimaryRoute reports whether the viewer's current [netmap.NetworkMap]
|
||||
// lists route as a PrimaryRoute on the peer with the given hostname.
|
||||
func hasPeerPrimaryRoute(nm *netmap.NetworkMap, peerHost string, route netip.Prefix) bool {
|
||||
if nm == nil {
|
||||
@@ -43,7 +43,7 @@ func hasPeerPrimaryRoute(nm *netmap.NetworkMap, peerHost string, route netip.Pre
|
||||
}
|
||||
|
||||
// TestHAFailover_ViewerSeesPrimaryFlip verifies that when an HA
|
||||
// primary is marked unhealthy, the viewer's netmap flips the route's
|
||||
// primary is marked unhealthy, the viewer's [netmap.NetworkMap] flips the route's
|
||||
// primary assignment from the old primary to the standby.
|
||||
func TestHAFailover_ViewerSeesPrimaryFlip(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -90,7 +90,7 @@ func TestHAFailover_ViewerSeesPrimaryFlip(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestHAFailover_ViewerSeesRouteRevoke verifies that when the primary
|
||||
// revokes its approved route, the viewer's netmap re-elects the
|
||||
// revokes its approved route, the viewer's [netmap.NetworkMap] re-elects the
|
||||
// standby and the old primary no longer advertises the route.
|
||||
func TestHAFailover_ViewerSeesRouteRevoke(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// advertiseAndApproveRoute sets RoutableIPs on a client and approves
|
||||
// advertiseAndApproveRoute sets [tailcfg.Hostinfo.RoutableIPs] on a client and approves
|
||||
// the route on the server. Returns the node ID.
|
||||
func advertiseAndApproveRoute(
|
||||
t *testing.T,
|
||||
@@ -87,7 +87,7 @@ func TestHAHealthProbe_HealthyNodes(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestHAHealthProbe_UnhealthyFailover verifies that marking a primary
|
||||
// node unhealthy via the PrimaryRoutes API triggers failover to the
|
||||
// node unhealthy via the [state.State.SetNodeUnhealthy] API triggers failover to the
|
||||
// standby.
|
||||
func TestHAHealthProbe_UnhealthyFailover(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -176,7 +176,7 @@ func TestHAHealthProbe_ConnectClearsUnhealthy(t *testing.T) {
|
||||
srv.State().SetNodeHealth(nodeID1, false)
|
||||
assert.False(t, srv.State().IsNodeHealthy(nodeID1))
|
||||
|
||||
// Reconnect clears unhealthy via State.Connect → ClearUnhealthy.
|
||||
// Reconnect clears unhealthy via [state.State.Connect] → [state.State.ClearUnhealthy].
|
||||
c1.Disconnect(t)
|
||||
c1.Reconnect(t)
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestHAHealthProbe_ConnectClearsUnhealthy(t *testing.T) {
|
||||
// that clearing a node's approved routes also clears any stale
|
||||
// Unhealthy bit, mirroring the legacy routes.SetRoutes(empty)
|
||||
// auto-clear. Without this, a probe timeout that lands just before
|
||||
// SetApprovedRoutes would surface as a stale unhealthy node forever.
|
||||
// [state.State.SetApprovedRoutes] would surface as a stale unhealthy node forever.
|
||||
func TestHAHealthProbe_SetApprovedRoutesEmptyClearsUnhealthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -223,7 +223,7 @@ func TestHAHealthProbe_SetApprovedRoutesEmptyClearsUnhealthy(t *testing.T) {
|
||||
// HA candidate; carrying the bit forward leaks into DebugRoutes.
|
||||
//
|
||||
// The poll handler waits a 10s grace period before calling
|
||||
// state.Disconnect, so the assertion is wrapped in Eventually with a
|
||||
// [state.State.Disconnect], so the assertion is wrapped in Eventually with a
|
||||
// generous timeout.
|
||||
func TestHAHealthProbe_DisconnectClearsUnhealthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -255,7 +255,7 @@ func TestHAHealthProbe_DisconnectClearsUnhealthy(t *testing.T) {
|
||||
|
||||
// TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp verifies the
|
||||
// defensive guard for the still-online-but-no-routes case: a probe
|
||||
// that fires after SetApprovedRoutes(empty) should not be allowed
|
||||
// that fires after [state.State.SetApprovedRoutes](empty) should not be allowed
|
||||
// to install a stale Unhealthy bit either.
|
||||
func TestHAHealthProbe_SetUnhealthyNoRoutesIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -69,8 +69,8 @@ func (c *checkTB) runCleanups() {
|
||||
c.cleanups = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
for i := len(cs) - 1; i >= 0; i-- {
|
||||
cs[i]()
|
||||
for _, v := range slices.Backward(cs) {
|
||||
v()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
// TestHarness orchestrates a TestServer with multiple TestClients,
|
||||
// TestHarness orchestrates a [TestServer] with multiple [TestClient] instances,
|
||||
// providing a convenient setup for multi-node control plane tests.
|
||||
type TestHarness struct {
|
||||
Server *TestServer
|
||||
@@ -18,7 +18,7 @@ type TestHarness struct {
|
||||
defaultUser *types.User
|
||||
}
|
||||
|
||||
// HarnessOption configures a TestHarness.
|
||||
// HarnessOption configures a [TestHarness].
|
||||
type HarnessOption func(*harnessConfig)
|
||||
|
||||
type harnessConfig struct {
|
||||
@@ -33,24 +33,24 @@ func defaultHarnessConfig() *harnessConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// WithServerOptions passes ServerOptions through to the underlying
|
||||
// TestServer.
|
||||
// WithServerOptions passes [ServerOption] values through to the underlying
|
||||
// [TestServer].
|
||||
func WithServerOptions(opts ...ServerOption) HarnessOption {
|
||||
return func(c *harnessConfig) { c.serverOpts = append(c.serverOpts, opts...) }
|
||||
}
|
||||
|
||||
// WithDefaultClientOptions applies ClientOptions to every client
|
||||
// created by NewHarness.
|
||||
// WithDefaultClientOptions applies [ClientOption] values to every client
|
||||
// created by [NewHarness].
|
||||
func WithDefaultClientOptions(opts ...ClientOption) HarnessOption {
|
||||
return func(c *harnessConfig) { c.clientOpts = append(c.clientOpts, opts...) }
|
||||
}
|
||||
|
||||
// WithConvergenceTimeout sets how long WaitForMeshComplete waits.
|
||||
// WithConvergenceTimeout sets how long [TestHarness.WaitForMeshComplete] waits.
|
||||
func WithConvergenceTimeout(d time.Duration) HarnessOption {
|
||||
return func(c *harnessConfig) { c.convergenceMax = d }
|
||||
}
|
||||
|
||||
// NewHarness creates a TestServer and numClients connected clients.
|
||||
// NewHarness creates a [TestServer] and numClients connected clients.
|
||||
// All clients share a default user and are registered with reusable
|
||||
// pre-auth keys. The harness waits for all clients to form a
|
||||
// complete mesh before returning.
|
||||
@@ -146,7 +146,7 @@ func (h *TestHarness) WaitForMeshComplete(tb testing.TB, timeout time.Duration)
|
||||
}
|
||||
|
||||
// WaitForConvergence waits until all connected clients have a
|
||||
// non-nil NetworkMap and their peer counts have stabilised.
|
||||
// non-nil [netmap.NetworkMap] and their peer counts have stabilised.
|
||||
func (h *TestHarness) WaitForConvergence(tb testing.TB, timeout time.Duration) {
|
||||
tb.Helper()
|
||||
h.WaitForMeshComplete(tb, timeout)
|
||||
|
||||
@@ -19,14 +19,14 @@ import (
|
||||
// These tests are intentionally strict about expected behavior.
|
||||
// Failures surface real issues in the control plane.
|
||||
|
||||
// TestIssuesMapContent tests issues with MapResponse content correctness.
|
||||
// TestIssuesMapContent tests issues with [tailcfg.MapResponse] content correctness.
|
||||
func TestIssuesMapContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// After mesh formation, all peers should have a known Online status.
|
||||
// The Online field is set when Connect() sends a NodeOnline PeerChange
|
||||
// patch. The initial MapResponse (from auth handler) may have Online=nil
|
||||
// because Connect() hasn't run yet, so we wait for the status to propagate.
|
||||
// The Online field is set when [state.State.Connect] sends a NodeOnline [tailcfg.PeerChange]
|
||||
// patch. The initial [tailcfg.MapResponse] (from auth handler) may have Online=nil
|
||||
// because [state.State.Connect] hasn't run yet, so we wait for the status to propagate.
|
||||
t.Run("initial_map_should_include_peer_online_status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := servertest.NewHarness(t, 3)
|
||||
@@ -55,7 +55,7 @@ func TestIssuesMapContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := servertest.NewHarness(t, 2)
|
||||
|
||||
// The DiscoKey is sent in the first MapRequest (not the RegisterRequest),
|
||||
// The DiscoKey is sent in the first [tailcfg.MapRequest] (not the [tailcfg.RegisterRequest]),
|
||||
// so it may take an extra map update to propagate to peers. Wait for
|
||||
// the condition rather than checking the initial netmap.
|
||||
h.Client(0).WaitForCondition(t, "peer has non-zero DiscoKey",
|
||||
@@ -92,7 +92,7 @@ func TestIssuesMapContent(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Each peer should have a valid user profile in the netmap.
|
||||
// Each peer should have a valid user profile in the [netmap.NetworkMap].
|
||||
t.Run("all_peers_have_user_profiles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -174,7 +174,7 @@ func TestIssuesRoutes(t *testing.T) {
|
||||
// Approving a route via API without the node announcing it must NOT
|
||||
// make the route visible in AllowedIPs. Tailscale uses a strict
|
||||
// advertise-then-approve model: routes are only distributed when the
|
||||
// node advertises them (Hostinfo.RoutableIPs) AND they are approved.
|
||||
// node advertises them ([tailcfg.Hostinfo.RoutableIPs]) AND they are approved.
|
||||
// An approval without advertisement is a dormant pre-approval that
|
||||
// activates once the node starts advertising.
|
||||
t.Run("approved_route_without_announcement_not_distributed", func(t *testing.T) {
|
||||
@@ -253,7 +253,7 @@ func TestIssuesRoutes(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
// Hostinfo route advertisement should be stored on server.
|
||||
// [tailcfg.Hostinfo] route advertisement should be stored on server.
|
||||
t.Run("hostinfo_route_advertisement_stored_on_server", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -491,7 +491,7 @@ func TestIssuesServerMutations(t *testing.T) {
|
||||
assert.Len(t, c3.Peers(), 1)
|
||||
})
|
||||
|
||||
// Hostinfo changes should propagate to peers.
|
||||
// [tailcfg.Hostinfo] changes should propagate to peers.
|
||||
t.Run("hostinfo_changes_propagate_to_peers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -530,11 +530,11 @@ func TestIssuesServerMutations(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestIssuesNodeStoreConsistency tests NodeStore + DB consistency.
|
||||
// TestIssuesNodeStoreConsistency tests [state.NodeStore] + DB consistency.
|
||||
func TestIssuesNodeStoreConsistency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// NodeStore and DB should agree after mutations.
|
||||
// [state.NodeStore] and DB should agree after mutations.
|
||||
t.Run("nodestore_db_consistency_after_operations", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -569,7 +569,7 @@ func TestIssuesNodeStoreConsistency(t *testing.T) {
|
||||
"NodeStore and DB should agree on approved routes")
|
||||
})
|
||||
|
||||
// After rapid reconnect, NodeStore should reflect correct state.
|
||||
// After rapid reconnect, [state.NodeStore] should reflect correct state.
|
||||
t.Run("nodestore_correct_after_rapid_reconnect", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -673,7 +673,7 @@ func TestIssuesGracePeriod(t *testing.T) {
|
||||
|
||||
// Ensure the ephemeral node's long-poll session is fully
|
||||
// established on the server before disconnecting. Without
|
||||
// this, the Disconnect may cancel a PollNetMap that hasn't
|
||||
// this, the [TestClient.Disconnect] may cancel a [controlclient.Direct.PollNetMap] that hasn't
|
||||
// yet reached serveLongPoll, so no grace period or ephemeral
|
||||
// GC would ever be scheduled.
|
||||
ephemeral.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
// TestPingNode verifies the full ping round-trip: the server sends a
|
||||
// PingRequest via MapResponse, the real controlclient.Direct handles it
|
||||
// [tailcfg.PingRequest] via [tailcfg.MapResponse], the real [controlclient.Direct] handles it
|
||||
// by making a HEAD request back over Noise, and the ping tracker records
|
||||
// the latency.
|
||||
func TestPingNode(t *testing.T) {
|
||||
@@ -105,7 +105,7 @@ func TestPingTwoSameNode(t *testing.T) {
|
||||
|
||||
require.NotEqual(t, pingID1, pingID2)
|
||||
|
||||
// Send both PingRequests.
|
||||
// Send both [tailcfg.PingRequest]s.
|
||||
url1 := h.Server.URL + "/machine/ping-response?id=" + pingID1
|
||||
url2 := h.Server.URL + "/machine/ping-response?id=" + pingID2
|
||||
|
||||
@@ -136,7 +136,7 @@ func TestPingTwoSameNode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPingResolveByHostname verifies that ResolveNode can find a node
|
||||
// TestPingResolveByHostname verifies that [state.State.ResolveNode] can find a node
|
||||
// by hostname and that the resolved node can be pinged.
|
||||
func TestPingResolveByHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -156,9 +156,9 @@ func TestPolicyChanges(t *testing.T) {
|
||||
// (Prefix, Host) resolve to exactly the literal prefix and do NOT expand
|
||||
// to include the matching node's other IP addresses.
|
||||
//
|
||||
// PacketFilter rules are INBOUND: they tell the destination node what
|
||||
// [netmap.NetworkMap.PacketFilter] rules are INBOUND: they tell the destination node what
|
||||
// traffic to accept. So the IPv6 destination rule appears in test2's
|
||||
// PacketFilter (the destination), not test1's (the source).
|
||||
// [netmap.NetworkMap.PacketFilter] (the destination), not test1's (the source).
|
||||
func TestIPv6OnlyPrefixACL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestIPv6OnlyPrefixACL(t *testing.T) {
|
||||
c1.WaitForPeers(t, 1, 10*time.Second)
|
||||
c2.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
// PacketFilter is an INBOUND filter: test2 (the destination) should
|
||||
// [netmap.NetworkMap.PacketFilter] is an INBOUND filter: test2 (the destination) should
|
||||
// have the rule allowing traffic FROM test1's IPv6.
|
||||
nm2 := c2.Netmap()
|
||||
require.NotNil(t, nm2)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// TestPollRace targets logical race conditions specifically in the
|
||||
// poll.go session lifecycle and the batcher's handling of concurrent
|
||||
// poll.go session lifecycle and the [mapper.Batcher]'s handling of concurrent
|
||||
// sessions for the same node.
|
||||
|
||||
func TestPollRace(t *testing.T) {
|
||||
@@ -22,9 +22,9 @@ func TestPollRace(t *testing.T) {
|
||||
// The core race: when a node disconnects, poll.go starts a
|
||||
// grace period goroutine (10s ticker loop). If the node
|
||||
// reconnects during this period, the new session calls
|
||||
// Connect() to mark the node online. But the old grace period
|
||||
// goroutine is still running and may call Disconnect() AFTER
|
||||
// the new Connect(), setting IsOnline=false incorrectly.
|
||||
// [state.State.Connect] to mark the node online. But the old grace period
|
||||
// goroutine is still running and may call [state.State.Disconnect] AFTER
|
||||
// the new [state.State.Connect], setting IsOnline=false incorrectly.
|
||||
//
|
||||
// This test verifies the exact symptom: after reconnect within
|
||||
// the grace period, the server-side node state should be online.
|
||||
@@ -99,7 +99,7 @@ func TestPollRace(t *testing.T) {
|
||||
// Wait the full grace period (10s) after reconnect. The old
|
||||
// grace period goroutine should have checked IsConnected
|
||||
// and found the node connected, so should NOT have called
|
||||
// Disconnect().
|
||||
// [state.State.Disconnect].
|
||||
t.Run("server_state_online_12s_after_reconnect", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -195,8 +195,8 @@ func TestPollRace(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// The batcher's IsConnected check: when the grace period
|
||||
// goroutine calls IsConnected(), it should return true if
|
||||
// The [mapper.Batcher]'s IsConnected check: when the grace period
|
||||
// goroutine calls IsConnected, it should return true if
|
||||
// a new session has been added for the same node.
|
||||
t.Run("batcher_knows_reconnected_during_grace", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// TestRace contains tests designed to trigger race conditions in
|
||||
// the control plane. Run with -race to detect data races.
|
||||
// These tests stress concurrent access patterns in poll.go,
|
||||
// the batcher, the NodeStore, and the mapper.
|
||||
// the [mapper.Batcher], the [state.NodeStore], and the [mapper] subsystem.
|
||||
|
||||
// TestRacePollSessionReplacement tests the race between an old
|
||||
// poll session's deferred cleanup and a new session starting.
|
||||
@@ -28,8 +28,8 @@ func TestRacePollSessionReplacement(t *testing.T) {
|
||||
|
||||
// Rapidly replace the poll session by doing immediate
|
||||
// disconnect+reconnect. This races the old session's
|
||||
// deferred cleanup (RemoveNode, Disconnect, grace period
|
||||
// goroutine) with the new session's setup (AddNode, Connect,
|
||||
// deferred cleanup ([state.NodeStore.RemoveNode], [state.State.Disconnect], grace period
|
||||
// goroutine) with the new session's setup ([state.NodeStore.AddNode], [state.State.Connect],
|
||||
// initial map send).
|
||||
t.Run("immediate_session_replace_10x", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -393,13 +393,13 @@ func TestRaceConnectDuringGracePeriod(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestRaceBatcherContention tests race conditions in the batcher
|
||||
// TestRaceBatcherContention tests race conditions in the [mapper.Batcher]
|
||||
// when many changes arrive simultaneously.
|
||||
func TestRaceBatcherContention(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Many nodes connecting at the same time generates many
|
||||
// concurrent Change() calls. The batcher must handle this
|
||||
// concurrent [hscontrol.Headscale.Change] calls. The [mapper.Batcher] must handle this
|
||||
// without dropping updates or panicking.
|
||||
t.Run("many_simultaneous_connects", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -427,8 +427,8 @@ func TestRaceBatcherContention(t *testing.T) {
|
||||
})
|
||||
|
||||
// Rapid connect + disconnect + connect of different nodes
|
||||
// generates interleaved AddNode/RemoveNode/AddNode in the
|
||||
// batcher.
|
||||
// generates interleaved [state.NodeStore.AddNode]/[state.NodeStore.RemoveNode]/[state.NodeStore.AddNode] in the
|
||||
// [mapper.Batcher].
|
||||
t.Run("interleaved_add_remove_add", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -514,7 +514,7 @@ func TestRaceBatcherContention(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRaceMapResponseDuringDisconnect tests what happens when a
|
||||
// map response is being written while the session is being torn down.
|
||||
// [tailcfg.MapResponse] is being written while the session is being torn down.
|
||||
func TestRaceMapResponseDuringDisconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -587,12 +587,12 @@ func TestRaceMapResponseDuringDisconnect(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestRaceNodeStoreContention tests concurrent access to the NodeStore.
|
||||
// TestRaceNodeStoreContention tests concurrent access to the [state.NodeStore].
|
||||
func TestRaceNodeStoreContention(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Many GetNodeByID calls while nodes are connecting and
|
||||
// disconnecting. This tests the NodeStore's read/write locking.
|
||||
// Many [state.State.GetNodeByID] calls while nodes are connecting and
|
||||
// disconnecting. This tests the [state.NodeStore]'s read/write locking.
|
||||
t.Run("concurrent_reads_during_mutations", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -655,7 +655,7 @@ func TestRaceNodeStoreContention(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// ListNodes while nodes are being added and removed.
|
||||
// [state.State.ListNodes] while nodes are being added and removed.
|
||||
t.Run("list_nodes_during_churn", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package servertest provides an in-process test harness for Headscale's
|
||||
// control plane. It wires a real Headscale server to real Tailscale
|
||||
// controlclient.Direct instances, enabling fast, deterministic tests
|
||||
// [controlclient.Direct] instances, enabling fast, deterministic tests
|
||||
// of the full control protocol without Docker or separate processes.
|
||||
package servertest
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
// TestServer is an in-process Headscale control server suitable for
|
||||
// use with Tailscale's controlclient.Direct.
|
||||
// use with Tailscale's [controlclient.Direct].
|
||||
//
|
||||
// Networking uses tailscale.com/net/memnet so that all TCP
|
||||
// connections stay in-process — no real sockets are opened.
|
||||
@@ -33,7 +33,7 @@ type TestServer struct {
|
||||
st *state.State
|
||||
}
|
||||
|
||||
// ServerOption configures a TestServer.
|
||||
// ServerOption configures a [TestServer].
|
||||
type ServerOption func(*serverConfig)
|
||||
|
||||
type serverConfig struct {
|
||||
@@ -201,15 +201,15 @@ func (s *TestServer) State() *state.State {
|
||||
|
||||
// Close shuts down the in-memory HTTP server and listener.
|
||||
// Subsystem cleanup (batcher, ephemeral GC) is handled by
|
||||
// tb.Cleanup callbacks registered in StartBatcherForTest and
|
||||
// StartEphemeralGCForTest.
|
||||
// [testing.TB.Cleanup] callbacks registered in [hscontrol.Headscale.StartBatcherForTest] and
|
||||
// [hscontrol.Headscale.StartEphemeralGCForTest].
|
||||
func (s *TestServer) Close() {
|
||||
s.httpServer.Close()
|
||||
s.ln.Close()
|
||||
}
|
||||
|
||||
// MemNet returns the in-memory network used by this server,
|
||||
// so that TestClient dialers can be wired to it.
|
||||
// so that [TestClient] dialers can be wired to it.
|
||||
func (s *TestServer) MemNet() *memnet.Network {
|
||||
return s.memNet
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// consistency bugs.
|
||||
|
||||
// TestStressConnectDisconnect exercises rapid connect/disconnect
|
||||
// patterns that stress the grace period, batcher, and NodeStore.
|
||||
// patterns that stress the grace period, batcher, and [state.NodeStore].
|
||||
func TestStressConnectDisconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -536,7 +536,7 @@ func TestStressDataIntegrity(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// MachineKey should be consistent: the server should track
|
||||
// [netmap.NetworkMap.MachineKey] should be consistent: the server should track
|
||||
// the same machine key the client registered with.
|
||||
t.Run("machine_key_consistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -551,7 +551,7 @@ func TestStressDataIntegrity(t *testing.T) {
|
||||
nm := c1.Netmap()
|
||||
require.NotNil(t, nm)
|
||||
|
||||
// The client's MachineKey in the netmap should be non-zero.
|
||||
// The client's [netmap.NetworkMap.MachineKey] should be non-zero.
|
||||
assert.False(t, nm.MachineKey.IsZero(),
|
||||
"client's MachineKey should be non-zero")
|
||||
|
||||
@@ -564,7 +564,7 @@ func TestStressDataIntegrity(t *testing.T) {
|
||||
"client and server should agree on MachineKey")
|
||||
})
|
||||
|
||||
// NodeKey should be consistent between client and server.
|
||||
// [netmap.NetworkMap.NodeKey] should be consistent between client and server.
|
||||
t.Run("node_key_consistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ var viaCompatTests = []struct {
|
||||
}
|
||||
|
||||
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and
|
||||
// compares headscale's MapResponse structure against the captured netmap.
|
||||
// compares headscale's [tailcfg.MapResponse] structure against the captured [netmap.NetworkMap].
|
||||
//
|
||||
// The comparison is IP-independent: it validates peer visibility, route
|
||||
// prefixes in AllowedIPs, and PrimaryRoutes — not literal Tailscale IP
|
||||
@@ -128,7 +128,7 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
|
||||
// Determine which routes each node should advertise. If the golden
|
||||
// topology has explicit routable_ips, use those. Otherwise infer
|
||||
// from the netmap peer AllowedIPs and packet filter dst prefixes.
|
||||
// from the [netmap.NetworkMap] peer AllowedIPs and packet filter dst prefixes.
|
||||
nodeRoutes := inferNodeRoutes(t, c)
|
||||
|
||||
// Build approved routes from topology. The topology's approved_routes
|
||||
@@ -181,7 +181,7 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
srv.App.Change(routeChange)
|
||||
}
|
||||
|
||||
// Wait for peers based on golden netmap expected counts.
|
||||
// Wait for peers based on golden [netmap.NetworkMap] expected counts.
|
||||
for viewerName, cl := range clients {
|
||||
capture := c.Captures[viewerName]
|
||||
if capture.Netmap == nil {
|
||||
@@ -202,8 +202,8 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all nodes have received at least one MapResponse,
|
||||
// including nodes with 0 expected peers that skipped WaitForPeers.
|
||||
// Ensure all nodes have received at least one [tailcfg.MapResponse],
|
||||
// including nodes with 0 expected peers that skipped [TestClient.WaitForPeers].
|
||||
for name, cl := range clients {
|
||||
cl.WaitForCondition(t, name+" initial netmap", 15*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
@@ -211,7 +211,7 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
})
|
||||
}
|
||||
|
||||
// Compare each viewer's MapResponse against the golden netmap.
|
||||
// Compare each viewer's [tailcfg.MapResponse] against the golden [netmap.NetworkMap].
|
||||
for viewerName, cl := range clients {
|
||||
capture := c.Captures[viewerName]
|
||||
if capture.Netmap == nil {
|
||||
@@ -227,8 +227,8 @@ func runViaMapCompat(t *testing.T, c *testcapture.Capture) {
|
||||
}
|
||||
}
|
||||
|
||||
// compareNetmap compares the headscale MapResponse against the
|
||||
// captured netmap data in an IP-independent way. It validates:
|
||||
// compareNetmap compares the headscale [tailcfg.MapResponse] against the
|
||||
// captured [netmap.NetworkMap] data in an IP-independent way. It validates:
|
||||
// - Peer visibility (which peers are present, by hostname)
|
||||
// - Route prefixes in AllowedIPs (non-Tailscale-IP entries like 10.44.0.0/16)
|
||||
// - Number of Tailscale IPs per peer (should be 2: one v4 + one v6)
|
||||
@@ -430,7 +430,7 @@ func compareNetmap(
|
||||
}
|
||||
|
||||
// saasAddrsByPeer builds a map from SaaS Tailscale address to peer
|
||||
// hostname using each capture's SelfNode.Addresses. Peers not in
|
||||
// hostname using each capture's [tailcfg.NodeView.Addresses]. Peers not in
|
||||
// clients are skipped.
|
||||
func saasAddrsByPeer(
|
||||
want testcapture.Node,
|
||||
@@ -442,7 +442,7 @@ func saasAddrsByPeer(
|
||||
return out
|
||||
}
|
||||
|
||||
// Walk peers listed in this netmap.
|
||||
// Walk peers listed in this [netmap.NetworkMap].
|
||||
for _, peer := range want.Netmap.Peers {
|
||||
name := extractHostname(peer.Name())
|
||||
if _, isOurs := clients[name]; !isOurs {
|
||||
@@ -457,7 +457,7 @@ func saasAddrsByPeer(
|
||||
}
|
||||
}
|
||||
|
||||
// The viewer's own SelfNode addresses also appear as possible src.
|
||||
// The viewer's own [tailcfg.NodeView] addresses also appear as possible src.
|
||||
if want.Netmap.SelfNode.Valid() {
|
||||
name := extractHostname(want.Netmap.SelfNode.Name())
|
||||
|
||||
@@ -533,8 +533,8 @@ func canonicaliseSrcStrings(
|
||||
}
|
||||
|
||||
// canonicaliseSrcPrefixes is the headscale-side counterpart of
|
||||
// canonicaliseSrcStrings, reading already-parsed netip.Prefix values
|
||||
// from tailcfg.Match.Srcs.
|
||||
// [canonicaliseSrcStrings], reading already-parsed [netip.Prefix] values
|
||||
// from [tailcfg.Match.Srcs].
|
||||
func canonicaliseSrcPrefixes(
|
||||
t *testing.T,
|
||||
srcs []netip.Prefix,
|
||||
@@ -627,7 +627,7 @@ type peerSummary struct {
|
||||
PrimaryRoutes []string // sorted
|
||||
}
|
||||
|
||||
// parsePrefixOrAddr parses a string as a netip.Prefix. If the string
|
||||
// parsePrefixOrAddr parses a string as a [netip.Prefix]. If the string
|
||||
// is a bare IP address (no slash), it is converted to a single-host
|
||||
// prefix (/32 for IPv4, /128 for IPv6). Golden data DstPorts.IP can
|
||||
// contain either form.
|
||||
@@ -704,7 +704,7 @@ func countTailscaleIPsView(allowedIPs interface {
|
||||
|
||||
// inferNodeRoutes determines which routes each node should advertise.
|
||||
// If the topology has explicit routable_ips, those are used. Otherwise
|
||||
// routes are inferred from the netmap peer AllowedIPs and packet
|
||||
// routes are inferred from the [netmap.NetworkMap] peer AllowedIPs and packet
|
||||
// filter destination prefixes.
|
||||
func inferNodeRoutes(t *testing.T, c *testcapture.Capture) map[string][]netip.Prefix {
|
||||
t.Helper()
|
||||
@@ -725,7 +725,7 @@ func inferNodeRoutes(t *testing.T, c *testcapture.Capture) map[string][]netip.Pr
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: infer from each capture's netmap — scan peers with
|
||||
// Tier 2: infer from each capture's [netmap.NetworkMap] — scan peers with
|
||||
// route prefixes in AllowedIPs. If node X appears as a peer with
|
||||
// route prefix 10.44.0.0/16, then X should advertise that route.
|
||||
for _, node := range c.Captures {
|
||||
|
||||
@@ -46,7 +46,7 @@ var viaHACompatTests = []struct {
|
||||
// TestViaGrantHACompat loads golden captures from Tailscale SaaS that
|
||||
// test via grant steering combined with HA primary route election.
|
||||
// Each capture uses an inline topology with 4-6 nodes (instead of the
|
||||
// shared 15-node grant topology used by TestViaGrantMapCompat).
|
||||
// shared 15-node grant topology used by [TestViaGrantMapCompat]).
|
||||
func TestViaGrantHACompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -175,7 +175,7 @@ func runViaHACompat(t *testing.T, c *testcapture.Capture) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all nodes have an initial netmap.
|
||||
// Ensure all nodes have an initial [netmap.NetworkMap].
|
||||
for name, cl := range clients {
|
||||
cl.WaitForCondition(t, name+" initial netmap", 15*time.Second,
|
||||
func(nm *netmap.NetworkMap) bool {
|
||||
@@ -183,7 +183,7 @@ func runViaHACompat(t *testing.T, c *testcapture.Capture) {
|
||||
})
|
||||
}
|
||||
|
||||
// Compare each viewer's MapResponse against golden netmap.
|
||||
// Compare each viewer's [tailcfg.MapResponse] against golden [netmap.NetworkMap].
|
||||
for viewerName, cl := range clients {
|
||||
capture := c.Captures[viewerName]
|
||||
if capture.Netmap == nil {
|
||||
@@ -196,9 +196,9 @@ func runViaHACompat(t *testing.T, c *testcapture.Capture) {
|
||||
}
|
||||
}
|
||||
|
||||
// compareCaptureNetmap compares headscale's MapResponse against a
|
||||
// testcapture.Node's netmap data. Same logic as compareNetmap but
|
||||
// reads from typed testcapture fields instead of goldenFile strings.
|
||||
// compareCaptureNetmap compares headscale's [tailcfg.MapResponse] against a
|
||||
// [testcapture.Node]'s [netmap.NetworkMap] data. Same logic as [compareNetmap] but
|
||||
// reads from typed [testcapture] fields instead of goldenFile strings.
|
||||
func compareCaptureNetmap(
|
||||
t *testing.T,
|
||||
viewer *servertest.TestClient,
|
||||
@@ -250,7 +250,7 @@ func compareCaptureNetmap(
|
||||
}
|
||||
}
|
||||
|
||||
// Build peer summaries from headscale MapResponse.
|
||||
// Build peer summaries from headscale [tailcfg.MapResponse].
|
||||
gotPeers := map[string]capturePeerSummary{}
|
||||
|
||||
for _, peer := range nm.Peers {
|
||||
@@ -335,7 +335,7 @@ type capturePeerSummary struct {
|
||||
PrimaryRoutes []string
|
||||
}
|
||||
|
||||
// captureNodeOrder returns node names from a testcapture.Capture
|
||||
// captureNodeOrder returns node names from a [testcapture.Capture]
|
||||
// sorted by SaaS node creation time, for deterministic DB ID assignment.
|
||||
// SaaS elects HA primaries by registration order (first registered wins),
|
||||
// which correlates with Created timestamp, not with the random snowflake
|
||||
@@ -377,7 +377,7 @@ func captureNodeOrder(t *testing.T, c *testcapture.Capture) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// convertCapturePolicy converts a testcapture's policy for headscale,
|
||||
// convertCapturePolicy converts a [testcapture.Capture]'s policy for headscale,
|
||||
// replacing SaaS emails with headscale user format. Fails the test if
|
||||
// none of the known SaaS emails are present: that would mean the
|
||||
// capture was regenerated with a new tag-owner identity and this
|
||||
|
||||
@@ -150,7 +150,7 @@ func (s *State) DebugOverview() string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// DebugNodeStore returns debug information about the NodeStore.
|
||||
// DebugNodeStore returns debug information about the [NodeStore].
|
||||
func (s *State) DebugNodeStore() string {
|
||||
return s.nodeStore.DebugString()
|
||||
}
|
||||
@@ -257,7 +257,7 @@ func (s *State) DebugFilter() ([]tailcfg.FilterRule, error) {
|
||||
}
|
||||
|
||||
// DebugRoutes returns the current primary routes information as a
|
||||
// structured object built from the NodeStore snapshot.
|
||||
// structured object built from the [NodeStore] snapshot.
|
||||
func (s *State) DebugRoutes() types.DebugRoutes {
|
||||
debug := types.DebugRoutes{
|
||||
AvailableRoutes: make(map[types.NodeID][]netip.Prefix),
|
||||
@@ -421,7 +421,7 @@ func (s *State) DebugDERPJSON() DebugDERPInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// DebugNodeStoreJSON returns the actual nodes map from the current NodeStore snapshot.
|
||||
// DebugNodeStoreJSON returns the actual nodes map from the current [NodeStore] snapshot.
|
||||
func (s *State) DebugNodeStoreJSON() map[types.NodeID]types.Node {
|
||||
snapshot := s.nodeStore.data.Load()
|
||||
return snapshot.nodesByID
|
||||
|
||||
@@ -29,7 +29,7 @@ type HAHealthProber struct {
|
||||
lastStableSession *xsync.Map[types.NodeID, uint64]
|
||||
}
|
||||
|
||||
// NewHAHealthProber creates a prober that uses the given State for
|
||||
// NewHAHealthProber creates a prober that uses the given [State] for
|
||||
// ping tracking and primary route management.
|
||||
// isConnected should return true if a node has an active map session.
|
||||
func NewHAHealthProber(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package state provides pure functions for processing MapRequest data.
|
||||
// These functions are extracted from UpdateNodeFromMapRequest to improve
|
||||
// Package state provides pure functions for processing [tailcfg.MapRequest] data.
|
||||
// These functions are extracted from [State.UpdateNodeFromMapRequest] to improve
|
||||
// testability and maintainability.
|
||||
|
||||
package state
|
||||
@@ -10,19 +10,19 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// netInfoFromMapRequest determines the correct NetInfo to use.
|
||||
// Returns the NetInfo that should be used for this request.
|
||||
// netInfoFromMapRequest determines the correct [tailcfg.NetInfo] to use.
|
||||
// Returns the [tailcfg.NetInfo] that should be used for this request.
|
||||
func netInfoFromMapRequest(
|
||||
nodeID types.NodeID,
|
||||
currentHostinfo *tailcfg.Hostinfo,
|
||||
reqHostinfo *tailcfg.Hostinfo,
|
||||
) *tailcfg.NetInfo {
|
||||
// If request has NetInfo, use it
|
||||
// If request has [tailcfg.NetInfo], use it
|
||||
if reqHostinfo != nil && reqHostinfo.NetInfo != nil {
|
||||
return reqHostinfo.NetInfo
|
||||
}
|
||||
|
||||
// Otherwise, use current NetInfo if available
|
||||
// Otherwise, use current [tailcfg.NetInfo] if available
|
||||
if currentHostinfo != nil && currentHostinfo.NetInfo != nil {
|
||||
log.Debug().
|
||||
Caller().
|
||||
@@ -33,7 +33,7 @@ func netInfoFromMapRequest(
|
||||
return currentHostinfo.NetInfo
|
||||
}
|
||||
|
||||
// No NetInfo available anywhere - log for debugging
|
||||
// No [tailcfg.NetInfo] available anywhere - log for debugging
|
||||
var hostname string
|
||||
if reqHostinfo != nil {
|
||||
hostname = reqHostinfo.Hostname
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestNetInfoPreservationInRegistrationFlow(t *testing.T) {
|
||||
nodeID := types.NodeID(1)
|
||||
|
||||
// This test reproduces the bug in registration flows where NetInfo was lost
|
||||
// because we used the wrong hostinfo reference when calling NetInfoFromMapRequest
|
||||
// because we used the wrong hostinfo reference when calling [netInfoFromMapRequest]
|
||||
t.Run("registration_flow_bug_reproduction", func(t *testing.T) {
|
||||
// Simulate existing node with NetInfo (before re-registration)
|
||||
existingNodeHostinfo := &tailcfg.Hostinfo{
|
||||
|
||||
@@ -21,12 +21,12 @@ import (
|
||||
)
|
||||
|
||||
// fallbackGivenName is the DNS label used when a node is written with
|
||||
// an empty GivenName. Matches Tailscale SaaS behaviour for empty
|
||||
// sanitised labels.
|
||||
// an empty [types.Node.GivenName]. Matches Tailscale SaaS behaviour
|
||||
// for empty sanitised labels.
|
||||
const fallbackGivenName = "node"
|
||||
|
||||
// Errors returned by SetGivenName. ErrNodeNotFound is defined in
|
||||
// state.go and reused here.
|
||||
// Errors returned by [NodeStore.SetGivenName]. [ErrNodeNotFound] is defined
|
||||
// in state.go and reused here.
|
||||
var (
|
||||
ErrGivenNameTaken = errors.New("given name already in use by another node")
|
||||
ErrGivenNameInvalid = errors.New("given name is not a valid DNS label")
|
||||
@@ -132,10 +132,10 @@ func NewNodeStore(allNodes types.Nodes, peersFunc PeersFunc, batchSize int, batc
|
||||
return store
|
||||
}
|
||||
|
||||
// Snapshot is the representation of the current state of the NodeStore.
|
||||
// Snapshot is the representation of the current state of the [NodeStore].
|
||||
// It contains all nodes and their relationships.
|
||||
// It is a copy-on-write structure, meaning that when a write occurs,
|
||||
// a new Snapshot is created with the updated state,
|
||||
// a new [Snapshot] is created with the updated state,
|
||||
// and replaces the old one atomically.
|
||||
type Snapshot struct {
|
||||
// nodesByID is the main source of truth for nodes.
|
||||
@@ -161,7 +161,7 @@ type Snapshot struct {
|
||||
// based on the current policy.
|
||||
type PeersFunc func(nodes []types.NodeView) map[types.NodeID][]types.NodeView
|
||||
|
||||
// work represents a single operation to be performed on the NodeStore.
|
||||
// work represents a single operation to be performed on the [NodeStore].
|
||||
type work struct {
|
||||
op int
|
||||
nodeID types.NodeID
|
||||
@@ -211,18 +211,19 @@ func (s *NodeStore) PutNode(n types.Node) types.NodeView {
|
||||
return resultNode
|
||||
}
|
||||
|
||||
// UpdateNodeFunc is a function type that takes a pointer to a Node and modifies it.
|
||||
// UpdateNodeFunc is a function type that takes a pointer to a [types.Node] and modifies it.
|
||||
type UpdateNodeFunc func(n *types.Node)
|
||||
|
||||
// UpdateNode applies a function to modify a specific node in the
|
||||
// store. Single-node convenience wrapper around [NodeStore.UpdateNodes]
|
||||
// — the writer goroutine signals completion only after the post-batch
|
||||
// snapshot has been stored, so the follow-up GetNode read sees the
|
||||
// applied update. Returns the resulting node and whether it exists.
|
||||
// snapshot has been stored, so the follow-up [NodeStore.GetNode] read
|
||||
// sees the applied update. Returns the resulting node and whether it
|
||||
// exists.
|
||||
//
|
||||
// Callers that need to change several nodes atomically should call
|
||||
// UpdateNodes directly; collecting changes into one batch keeps the
|
||||
// election from running on a half-applied snapshot.
|
||||
// [NodeStore.UpdateNodes] directly; collecting changes into one batch
|
||||
// keeps the election from running on a half-applied snapshot.
|
||||
func (s *NodeStore) UpdateNode(nodeID types.NodeID, updateFn UpdateNodeFunc) (types.NodeView, bool) {
|
||||
timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("update"))
|
||||
defer timer.ObserveDuration()
|
||||
@@ -287,19 +288,20 @@ func (s *NodeStore) DeleteNode(id types.NodeID) {
|
||||
nodeStoreOperations.WithLabelValues("delete").Inc()
|
||||
}
|
||||
|
||||
// SetGivenName sets node.GivenName on the node identified by id,
|
||||
// SetGivenName sets [types.Node.GivenName] on the node identified by id,
|
||||
// rejecting the write if the name is already held by another node.
|
||||
// Intended for the admin rename path, where auto-bumping a
|
||||
// user-supplied name would be surprising.
|
||||
//
|
||||
// Returns:
|
||||
// - the stored NodeView and nil on success
|
||||
// - ErrGivenNameInvalid if name is not a valid DNS label
|
||||
// - ErrGivenNameTaken if another node already holds name
|
||||
// - ErrNodeNotFound if no node with id exists
|
||||
// - the stored [types.NodeView] and nil on success
|
||||
// - [ErrGivenNameInvalid] if name is not a valid DNS label
|
||||
// - [ErrGivenNameTaken] if another node already holds name
|
||||
// - [ErrNodeNotFound] if no node with id exists
|
||||
//
|
||||
// Runs as a single writer-goroutine op, so the uniqueness check and
|
||||
// the write are atomic with respect to concurrent PutNode/UpdateNode.
|
||||
// Runs as a single writer-goroutine op, so the uniqueness check and the
|
||||
// write are atomic with respect to concurrent
|
||||
// [NodeStore.PutNode]/[NodeStore.UpdateNode].
|
||||
func (s *NodeStore) SetGivenName(id types.NodeID, name string) (types.NodeView, error) {
|
||||
timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("set_name"))
|
||||
defer timer.ObserveDuration()
|
||||
@@ -330,13 +332,13 @@ func (s *NodeStore) SetGivenName(id types.NodeID, name string) (types.NodeView,
|
||||
return <-w.nodeResult, nil
|
||||
}
|
||||
|
||||
// Start initializes the NodeStore and starts processing the write queue.
|
||||
// Start initializes the [NodeStore] and starts processing the write queue.
|
||||
func (s *NodeStore) Start() {
|
||||
s.writeQueue = make(chan work)
|
||||
go s.processWrite()
|
||||
}
|
||||
|
||||
// Stop stops the NodeStore.
|
||||
// Stop stops the [NodeStore].
|
||||
func (s *NodeStore) Stop() {
|
||||
close(s.writeQueue)
|
||||
}
|
||||
@@ -536,14 +538,14 @@ func (s *NodeStore) applyBatch(batch []work) {
|
||||
|
||||
// resolveGivenName returns a unique DNS label for the node identified
|
||||
// by self, based on the caller-supplied base label. If base is empty
|
||||
// it falls back to fallbackGivenName ("node"). The label's own holder
|
||||
// it falls back to [fallbackGivenName] ("node"). The label's own holder
|
||||
// (self) is excluded from the collision scan so an idempotent write
|
||||
// keeps the current label.
|
||||
//
|
||||
// On collision the label is bumped as base, base-1, base-2, …, first
|
||||
// unused wins. Must be called from the NodeStore writer goroutine
|
||||
// (inside applyBatch) so the nodes map reflects all earlier ops in
|
||||
// the batch and no other writer can interleave.
|
||||
// unused wins. Must be called from the [NodeStore] writer goroutine
|
||||
// (inside [NodeStore.applyBatch]) so the nodes map reflects all earlier
|
||||
// ops in the batch and no other writer can interleave.
|
||||
func resolveGivenName(nodes map[types.NodeID]types.Node, self types.NodeID, base string) string {
|
||||
if base == "" {
|
||||
base = fallbackGivenName
|
||||
@@ -569,7 +571,7 @@ func resolveGivenName(nodes map[types.NodeID]types.Node, self types.NodeID, base
|
||||
}
|
||||
|
||||
// snapshotFromNodes builds the index maps and primary-route table for
|
||||
// a new Snapshot. prevRoutes carries forward the previous primary
|
||||
// a new [Snapshot]. prevRoutes carries forward the previous primary
|
||||
// assignment so a still-valid choice survives unrelated batches.
|
||||
func snapshotFromNodes(
|
||||
nodes map[types.NodeID]types.Node,
|
||||
@@ -719,8 +721,8 @@ func electPrimaryRoutes(
|
||||
|
||||
// GetNode retrieves a node by its ID.
|
||||
// The bool indicates if the node exists or is available (like "err not found").
|
||||
// The NodeView might be invalid, so it must be checked with .Valid(), which must be used to ensure
|
||||
// it isn't an invalid node (this is more of a node error or node is broken).
|
||||
// The [types.NodeView] might be invalid, so it must be checked with .Valid(), which must
|
||||
// be used to ensure it isn't an invalid node (this is more of a node error or node is broken).
|
||||
func (s *NodeStore) GetNode(id types.NodeID) (types.NodeView, bool) {
|
||||
timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("get"))
|
||||
defer timer.ObserveDuration()
|
||||
@@ -735,10 +737,10 @@ func (s *NodeStore) GetNode(id types.NodeID) (types.NodeView, bool) {
|
||||
return n.View(), true
|
||||
}
|
||||
|
||||
// GetNodeByNodeKey retrieves a node by its NodeKey.
|
||||
// GetNodeByNodeKey retrieves a node by its [key.NodePublic].
|
||||
// The bool indicates if the node exists or is available (like "err not found").
|
||||
// The NodeView might be invalid, so it must be checked with .Valid(), which must be used to ensure
|
||||
// it isn't an invalid node (this is more of a node error or node is broken).
|
||||
// The [types.NodeView] might be invalid, so it must be checked with .Valid(), which must
|
||||
// be used to ensure it isn't an invalid node (this is more of a node error or node is broken).
|
||||
func (s *NodeStore) GetNodeByNodeKey(nodeKey key.NodePublic) (types.NodeView, bool) {
|
||||
timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("get_by_key"))
|
||||
defer timer.ObserveDuration()
|
||||
@@ -790,7 +792,7 @@ func (s *NodeStore) GetNodeByMachineKeyAnyUser(machineKey key.MachinePublic) (ty
|
||||
return types.NodeView{}, false
|
||||
}
|
||||
|
||||
// DebugString returns debug information about the NodeStore.
|
||||
// DebugString returns debug information about the [NodeStore].
|
||||
func (s *NodeStore) DebugString() string {
|
||||
snapshot := s.data.Load()
|
||||
|
||||
@@ -971,8 +973,8 @@ func (s *NodeStore) PrimaryRoutesString() string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RebuildPeerMaps rebuilds the peer relationship map using the current peersFunc.
|
||||
// This must be called after policy changes because peersFunc uses PolicyManager's
|
||||
// RebuildPeerMaps rebuilds the peer relationship map using the current [PeersFunc].
|
||||
// This must be called after policy changes because [PeersFunc] uses [policy.PolicyManager]'s
|
||||
// filters to determine which nodes can see each other. Without rebuilding, the
|
||||
// peer map would use stale filter data until the next node add/delete.
|
||||
func (s *NodeStore) RebuildPeerMaps() {
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
|
||||
const pingIDLength = 16
|
||||
|
||||
// pingTracker correlates outgoing PingRequests with incoming HEAD
|
||||
// pingTracker correlates outgoing [tailcfg.PingRequest]s with incoming HEAD
|
||||
// callbacks. Entries have no server-side TTL: callers are responsible
|
||||
// for cleaning up via CancelPing or by reading from the response channel
|
||||
// within their own timeout.
|
||||
// for cleaning up via [pingTracker.cancel] or by reading from the response
|
||||
// channel within their own timeout.
|
||||
type pingTracker struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]*pendingPing
|
||||
@@ -80,7 +80,7 @@ func (pt *pingTracker) cancel(pingID string) {
|
||||
}
|
||||
|
||||
// drain closes every outstanding response channel and clears the map.
|
||||
// Called from State.Close to unblock any caller still waiting on a
|
||||
// Called from [State.Close] to unblock any caller still waiting on a
|
||||
// channel that will never receive.
|
||||
func (pt *pingTracker) drain() {
|
||||
pt.mu.Lock()
|
||||
@@ -93,7 +93,7 @@ func (pt *pingTracker) drain() {
|
||||
}
|
||||
|
||||
// RegisterPing tracks a pending ping and returns its ID and a channel
|
||||
// for the latency. Callers must defer CancelPing or read the channel
|
||||
// for the latency. Callers must defer [State.CancelPing] or read the channel
|
||||
// within their own timeout; there is no server-side TTL.
|
||||
func (s *State) RegisterPing(nodeID types.NodeID) (string, <-chan time.Duration) {
|
||||
return s.pings.register(nodeID)
|
||||
|
||||
@@ -20,12 +20,13 @@ var (
|
||||
ErrRequestedTagsInvalidOrNotPermitted = errors.New("requested tags")
|
||||
)
|
||||
|
||||
// ErrTaggedNodeHasUser is returned when a tagged node has a UserID set.
|
||||
// ErrTaggedNodeHasUser is returned when a tagged node has a [types.Node.UserID] set.
|
||||
var ErrTaggedNodeHasUser = errors.New("tagged node must not have user_id set")
|
||||
|
||||
// validateNodeOwnership ensures proper node ownership model.
|
||||
// A node must be either user-owned or tagged, and these are mutually exclusive:
|
||||
// tagged nodes must not have a UserID, and user-owned nodes must not have tags.
|
||||
// tagged nodes must not have a [types.Node.UserID], and user-owned nodes must
|
||||
// not have tags.
|
||||
func validateNodeOwnership(node *types.Node) error {
|
||||
if node.IsTagged() {
|
||||
if len(node.Tags) == 0 {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Test configuration for NodeStore batching.
|
||||
// Test configuration for [NodeStore] batching.
|
||||
// These values are optimized for test speed rather than production use.
|
||||
const (
|
||||
TestBatchSize = 5
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// mdTypesetBody creates a body element with md-typeset styling
|
||||
// that matches the official Headscale documentation design.
|
||||
// Uses CSS classes with styles defined in assets.CSS.
|
||||
// Uses CSS classes with styles defined in [assets.CSS].
|
||||
func mdTypesetBody(children ...elem.Node) *elem.Element {
|
||||
return elem.Body(attrs.Props{
|
||||
attrs.Style: styles.Props{
|
||||
@@ -34,7 +34,7 @@ func mdTypesetBody(children ...elem.Node) *elem.Element {
|
||||
|
||||
// Styled Element Wrappers
|
||||
// These functions wrap elem-go elements using CSS classes.
|
||||
// Styling is handled by the CSS in assets.CSS.
|
||||
// Styling is handled by the CSS in [assets.CSS].
|
||||
|
||||
// H1 creates a H1 element styled by .md-typeset h1
|
||||
func H1(children ...elem.Node) *elem.Element {
|
||||
@@ -86,17 +86,17 @@ func PreCode(code string) *elem.Element {
|
||||
return elem.Code(nil, elem.Text(code))
|
||||
}
|
||||
|
||||
// Deprecated: use H1, H2, H3 instead
|
||||
// Deprecated: use [H1], [H2], [H3] instead
|
||||
func headerOne(text string) *elem.Element {
|
||||
return H1(elem.Text(text))
|
||||
}
|
||||
|
||||
// Deprecated: use H1, H2, H3 instead
|
||||
// Deprecated: use [H1], [H2], [H3] instead
|
||||
func headerTwo(text string) *elem.Element {
|
||||
return H2(elem.Text(text))
|
||||
}
|
||||
|
||||
// Deprecated: use H1, H2, H3 instead
|
||||
// Deprecated: use [H1], [H2], [H3] instead
|
||||
func headerThree(text string) *elem.Element {
|
||||
return H3(elem.Text(text))
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func (k *APIKey) maskedPrefix() string {
|
||||
return k.Prefix + "***"
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging.
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
|
||||
// SECURITY: This method intentionally does NOT log the full key or hash.
|
||||
// Only the masked prefix is logged for identification purposes.
|
||||
func (k *APIKey) MarshalZerologObject(e *zerolog.Event) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package change declares the Change type: a compact description of
|
||||
// what must land in a MapResponse. The mapper reads Change values to
|
||||
// build responses without inspecting state, and Merge combines
|
||||
// Package change declares the [Change] type: a compact description of
|
||||
// what must land in a [tailcfg.MapResponse]. The mapper reads [Change] values to
|
||||
// build responses without inspecting state, and [Change.Merge] combines
|
||||
// multiple pending changes for a single tick.
|
||||
package change
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// Change declares what should be included in a MapResponse.
|
||||
// Change declares what should be included in a [tailcfg.MapResponse].
|
||||
// The mapper uses this to build the response without guessing.
|
||||
type Change struct {
|
||||
// Reason is a human-readable description for logging/debugging.
|
||||
@@ -26,12 +26,12 @@ type Change struct {
|
||||
// Used for self-update detection and filtering.
|
||||
OriginNode types.NodeID
|
||||
|
||||
// Content flags - what to include in the MapResponse.
|
||||
// Content flags - what to include in the [tailcfg.MapResponse].
|
||||
IncludeSelf bool
|
||||
IncludeDERPMap bool
|
||||
IncludeDNS bool
|
||||
IncludeDomain bool
|
||||
IncludePolicy bool // PacketFilters and SSHPolicy - always sent together
|
||||
IncludePolicy bool // [tailcfg.MapResponse.PacketFilters] and [tailcfg.MapResponse.SSHPolicy] - always sent together
|
||||
|
||||
// Peer changes.
|
||||
PeersChanged []types.NodeID
|
||||
@@ -46,12 +46,12 @@ type Change struct {
|
||||
|
||||
// PingRequest, if non-nil, is a ping request to send to the node.
|
||||
// Used by the debug ping endpoint to verify node connectivity.
|
||||
// PingRequest is always targeted to a specific node via TargetNode.
|
||||
// [Change.PingRequest] is always targeted to a specific node via [Change.TargetNode].
|
||||
PingRequest *tailcfg.PingRequest
|
||||
}
|
||||
|
||||
// boolFieldNames returns all boolean field names for exhaustive testing.
|
||||
// When adding a new boolean field to Change, add it here.
|
||||
// When adding a new boolean field to [Change], add it here.
|
||||
// Tests use reflection to verify this matches the struct.
|
||||
func (r Change) boolFieldNames() []string {
|
||||
return []string{
|
||||
@@ -80,16 +80,16 @@ func (r Change) Merge(other Change) Change {
|
||||
merged.PeersRemoved = uniqueNodeIDs(slices.Concat(r.PeersRemoved, other.PeersRemoved))
|
||||
merged.PeerPatches = slices.Concat(r.PeerPatches, other.PeerPatches)
|
||||
|
||||
// Preserve OriginNode for self-update detection.
|
||||
// If either change has OriginNode set, keep it so the mapper
|
||||
// Preserve [Change.OriginNode] for self-update detection.
|
||||
// If either change has [Change.OriginNode] set, keep it so the mapper
|
||||
// can detect self-updates and send the node its own changes.
|
||||
if merged.OriginNode == 0 {
|
||||
merged.OriginNode = other.OriginNode
|
||||
}
|
||||
|
||||
// Preserve TargetNode for targeted responses.
|
||||
// Preserve [Change.TargetNode] for targeted responses.
|
||||
// Merging two changes targeted at different nodes is not supported
|
||||
// because the merged result can only have one TargetNode, which
|
||||
// because the merged result can only have one [Change.TargetNode], which
|
||||
// would cause the other target's content to be misrouted.
|
||||
if merged.TargetNode != 0 && other.TargetNode != 0 && merged.TargetNode != other.TargetNode {
|
||||
panic(fmt.Sprintf(
|
||||
@@ -102,9 +102,9 @@ func (r Change) Merge(other Change) Change {
|
||||
merged.TargetNode = other.TargetNode
|
||||
}
|
||||
|
||||
// Preserve PingRequest (first wins).
|
||||
// Preserve [Change.PingRequest] (first wins).
|
||||
//
|
||||
// Foot-gun: if two PingRequests to the same target merge in the
|
||||
// Foot-gun: if two [tailcfg.PingRequest] values to the same target merge in the
|
||||
// same tick, only the first is emitted. The client-side
|
||||
// isUniquePingRequest check then suppresses the second when it
|
||||
// eventually arrives, and the caller waits out the full
|
||||
@@ -154,7 +154,7 @@ func (r Change) IsSelfOnly() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// IsTargetedToNode returns true if this response should only be sent to TargetNode.
|
||||
// IsTargetedToNode returns true if this response should only be sent to [Change.TargetNode].
|
||||
func (r Change) IsTargetedToNode() bool {
|
||||
return r.TargetNode != 0
|
||||
}
|
||||
@@ -167,7 +167,7 @@ func (r Change) IsFull() bool {
|
||||
|
||||
// Type returns a categorized type string for metrics.
|
||||
// This provides a bounded set of values suitable for Prometheus labels,
|
||||
// unlike Reason which is free-form text for logging.
|
||||
// unlike [Change.Reason] which is free-form text for logging.
|
||||
func (r Change) Type() string {
|
||||
if r.IsFull() {
|
||||
return "full"
|
||||
@@ -211,7 +211,7 @@ func (r Change) ShouldSendToNode(nodeID types.NodeID) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// HasFull returns true if any response in the slice is a full update.
|
||||
// HasFull returns true if any response in the slice is a full update ([Change.IsFull]).
|
||||
func HasFull(rs []Change) bool {
|
||||
for _, r := range rs {
|
||||
if r.IsFull() {
|
||||
@@ -349,7 +349,7 @@ func DERPMap() Change {
|
||||
}
|
||||
|
||||
// PolicyChange creates a response for policy changes.
|
||||
// Policy changes require runtime peer visibility computation.
|
||||
// Policy changes require runtime peer visibility computation ([Change.RequiresRuntimePeerComputation]).
|
||||
func PolicyChange() Change {
|
||||
return Change{
|
||||
Reason: "policy change",
|
||||
@@ -407,8 +407,8 @@ func KeyExpiry(nodeID types.NodeID, expiry *time.Time) Change {
|
||||
|
||||
// High-level change constructors
|
||||
|
||||
// NodeAdded returns a Change for when a node is added or updated.
|
||||
// The OriginNode field enables self-update detection by the mapper.
|
||||
// NodeAdded returns a [Change] for when a node is added or updated.
|
||||
// The [Change.OriginNode] field enables self-update detection by the mapper.
|
||||
func NodeAdded(id types.NodeID) Change {
|
||||
c := PeersChanged("node added", id)
|
||||
c.OriginNode = id
|
||||
@@ -416,12 +416,12 @@ func NodeAdded(id types.NodeID) Change {
|
||||
return c
|
||||
}
|
||||
|
||||
// NodeRemoved returns a Change for when a node is removed.
|
||||
// NodeRemoved returns a [Change] for when a node is removed.
|
||||
func NodeRemoved(id types.NodeID) Change {
|
||||
return PeersRemoved(id)
|
||||
}
|
||||
|
||||
// NodeOnlineFor returns a Change for when a node comes online.
|
||||
// NodeOnlineFor returns a [Change] for when a node comes online.
|
||||
// If the node is a subnet router, a full update is sent instead of a patch.
|
||||
func NodeOnlineFor(node types.NodeView) Change {
|
||||
if node.IsSubnetRouter() {
|
||||
@@ -434,7 +434,7 @@ func NodeOnlineFor(node types.NodeView) Change {
|
||||
return NodeOnline(node.ID())
|
||||
}
|
||||
|
||||
// NodeOfflineFor returns a Change for when a node goes offline.
|
||||
// NodeOfflineFor returns a [Change] for when a node goes offline.
|
||||
// If the node is a subnet router, a full update is sent instead of a patch.
|
||||
func NodeOfflineFor(node types.NodeView) Change {
|
||||
if node.IsSubnetRouter() {
|
||||
@@ -447,8 +447,8 @@ func NodeOfflineFor(node types.NodeView) Change {
|
||||
return NodeOffline(node.ID())
|
||||
}
|
||||
|
||||
// KeyExpiryFor returns a Change for when a node's key expiry changes.
|
||||
// The OriginNode field enables self-update detection by the mapper.
|
||||
// KeyExpiryFor returns a [Change] for when a node's key expiry changes.
|
||||
// The [Change.OriginNode] field enables self-update detection by the mapper.
|
||||
func KeyExpiryFor(id types.NodeID, expiry time.Time) Change {
|
||||
c := KeyExpiry(id, &expiry)
|
||||
c.OriginNode = id
|
||||
@@ -456,8 +456,8 @@ func KeyExpiryFor(id types.NodeID, expiry time.Time) Change {
|
||||
return c
|
||||
}
|
||||
|
||||
// EndpointOrDERPUpdate returns a Change for when a node's endpoints or DERP region changes.
|
||||
// The OriginNode field enables self-update detection by the mapper.
|
||||
// EndpointOrDERPUpdate returns a [Change] for when a node's endpoints or DERP region changes.
|
||||
// The [Change.OriginNode] field enables self-update detection by the mapper.
|
||||
func EndpointOrDERPUpdate(id types.NodeID, patch *tailcfg.PeerChange) Change {
|
||||
c := PeerPatched("endpoint/DERP update", patch)
|
||||
c.OriginNode = id
|
||||
@@ -465,7 +465,7 @@ func EndpointOrDERPUpdate(id types.NodeID, patch *tailcfg.PeerChange) Change {
|
||||
return c
|
||||
}
|
||||
|
||||
// UserAdded returns a Change for when a user is added or updated.
|
||||
// UserAdded returns a [Change] for when a user is added or updated.
|
||||
// A full update is sent to refresh user profiles on all nodes.
|
||||
func UserAdded() Change {
|
||||
c := FullUpdate()
|
||||
@@ -474,7 +474,7 @@ func UserAdded() Change {
|
||||
return c
|
||||
}
|
||||
|
||||
// UserRemoved returns a Change for when a user is removed.
|
||||
// UserRemoved returns a [Change] for when a user is removed.
|
||||
// A full update is sent to refresh user profiles on all nodes.
|
||||
func UserRemoved() Change {
|
||||
c := FullUpdate()
|
||||
@@ -483,9 +483,9 @@ func UserRemoved() Change {
|
||||
return c
|
||||
}
|
||||
|
||||
// PingNode creates a Change that sends a PingRequest to a specific
|
||||
// PingNode creates a [Change] that sends a [tailcfg.PingRequest] to a specific
|
||||
// node. pr must be non-nil and nodeID must be non-zero; the node
|
||||
// responds to the PingRequest URL to prove connectivity.
|
||||
// responds to the [tailcfg.PingRequest] URL to prove connectivity.
|
||||
func PingNode(nodeID types.NodeID, pr *tailcfg.PingRequest) Change {
|
||||
return Change{
|
||||
Reason: "ping node",
|
||||
@@ -494,7 +494,7 @@ func PingNode(nodeID types.NodeID, pr *tailcfg.PingRequest) Change {
|
||||
}
|
||||
}
|
||||
|
||||
// ExtraRecords returns a Change for when DNS extra records change.
|
||||
// ExtraRecords returns a [Change] for when DNS extra records change.
|
||||
func ExtraRecords() Change {
|
||||
c := DNSConfig()
|
||||
c.Reason = "extra records update"
|
||||
|
||||
@@ -69,35 +69,35 @@ const (
|
||||
|
||||
// StateUpdate is an internal message containing information about
|
||||
// a state change that has happened to the network.
|
||||
// If type is StateFullUpdate, all fields are ignored.
|
||||
// If type is [StateFullUpdate], all fields are ignored.
|
||||
type StateUpdate struct {
|
||||
// The type of update
|
||||
Type StateUpdateType
|
||||
|
||||
// ChangeNodes must be set when Type is StatePeerAdded
|
||||
// and StatePeerChanged and contains the full node
|
||||
// and [StatePeerChanged] and contains the full node
|
||||
// object for added nodes.
|
||||
ChangeNodes []NodeID
|
||||
|
||||
// ChangePatches must be set when Type is StatePeerChangedPatch
|
||||
// and contains a populated PeerChange object.
|
||||
// ChangePatches must be set when Type is [StatePeerChangedPatch]
|
||||
// and contains a populated [tailcfg.PeerChange] object.
|
||||
ChangePatches []*tailcfg.PeerChange
|
||||
|
||||
// Removed must be set when Type is StatePeerRemoved and
|
||||
// Removed must be set when Type is [StatePeerRemoved] and
|
||||
// contain a list of the nodes that has been removed from
|
||||
// the network.
|
||||
Removed []NodeID
|
||||
|
||||
// DERPMap must be set when Type is StateDERPUpdated and
|
||||
// DERPMap must be set when Type is [StateDERPUpdated] and
|
||||
// contain the new DERP Map.
|
||||
DERPMap *tailcfg.DERPMap
|
||||
|
||||
// Additional message for tracking origin or what being
|
||||
// updated, useful for ambiguous updates like StatePeerChanged.
|
||||
// updated, useful for ambiguous updates like [StatePeerChanged].
|
||||
Message string
|
||||
}
|
||||
|
||||
// Empty reports if there are any updates in the StateUpdate.
|
||||
// Empty reports if there are any updates in the [StateUpdate].
|
||||
func (su *StateUpdate) Empty() bool {
|
||||
switch su.Type {
|
||||
case StatePeerChanged:
|
||||
@@ -233,7 +233,7 @@ type SSHCheckBinding struct {
|
||||
// PendingRegistrationConfirmation captures the server-side state needed
|
||||
// to finalise a node registration after the user has confirmed it on
|
||||
// the OIDC interstitial. The OIDC callback resolves the user identity
|
||||
// and node expiry, stores them on the cached AuthRequest, and renders
|
||||
// and node expiry, stores them on the cached [AuthRequest], and renders
|
||||
// a confirmation page; only when the user POSTs the confirmation form
|
||||
// does the actual node registration run.
|
||||
//
|
||||
@@ -250,9 +250,9 @@ type PendingRegistrationConfirmation struct {
|
||||
// node. It carries the minimum data needed to either complete a node
|
||||
// registration (regData populated) or an SSH check-mode auth (sshBinding
|
||||
// populated), and signals the verdict via the finished channel. The closed
|
||||
// flag guards FinishAuth against double-close.
|
||||
// flag guards [AuthRequest.FinishAuth] against double-close.
|
||||
//
|
||||
// AuthRequest is always handled by pointer so the channel and atomic flag
|
||||
// [AuthRequest] is always handled by pointer so the channel and atomic flag
|
||||
// have a single canonical instance even when stored in caches that
|
||||
// internally copy values.
|
||||
type AuthRequest struct {
|
||||
@@ -260,7 +260,7 @@ type AuthRequest struct {
|
||||
// or OIDC). It carries the cached registration payload that the
|
||||
// auth callback uses to promote this request into a real node.
|
||||
//
|
||||
// nil for non-registration flows. Use RegistrationData() to read it
|
||||
// nil for non-registration flows. Use [AuthRequest.RegistrationData] to read it
|
||||
// safely.
|
||||
regData *RegistrationData
|
||||
|
||||
@@ -269,7 +269,7 @@ type AuthRequest struct {
|
||||
// and OIDC callback can refuse to record a verdict for any other
|
||||
// pair.
|
||||
//
|
||||
// nil for non-SSH-check flows. Use SSHCheckBinding() to read it
|
||||
// nil for non-SSH-check flows. Use [AuthRequest.SSHCheckBinding] to read it
|
||||
// safely.
|
||||
sshBinding *SSHCheckBinding
|
||||
|
||||
@@ -294,7 +294,7 @@ func NewAuthRequest() *AuthRequest {
|
||||
}
|
||||
|
||||
// NewRegisterAuthRequest creates a pending auth request carrying the
|
||||
// minimal RegistrationData for a node-registration flow. The data is
|
||||
// minimal [RegistrationData] for a node-registration flow. The data is
|
||||
// stored by pointer; callers must not mutate it after handing it off.
|
||||
func NewRegisterAuthRequest(data *RegistrationData) *AuthRequest {
|
||||
return &AuthRequest{
|
||||
@@ -320,8 +320,8 @@ func NewSSHCheckAuthRequest(src, dst NodeID) *AuthRequest {
|
||||
}
|
||||
|
||||
// RegistrationData returns the cached registration payload. It panics if
|
||||
// called on an AuthRequest that was not created via
|
||||
// NewRegisterAuthRequest.
|
||||
// called on an [AuthRequest] that was not created via
|
||||
// [NewRegisterAuthRequest].
|
||||
func (rn *AuthRequest) RegistrationData() *RegistrationData {
|
||||
if rn.regData == nil {
|
||||
panic("RegistrationData can only be used in registration requests")
|
||||
@@ -331,8 +331,8 @@ func (rn *AuthRequest) RegistrationData() *RegistrationData {
|
||||
}
|
||||
|
||||
// SSHCheckBinding returns the (src, dst) node pair an SSH check-mode
|
||||
// auth request is bound to. It panics if called on an AuthRequest that
|
||||
// was not created via NewSSHCheckAuthRequest.
|
||||
// auth request is bound to. It panics if called on an [AuthRequest] that
|
||||
// was not created via [NewSSHCheckAuthRequest].
|
||||
func (rn *AuthRequest) SSHCheckBinding() *SSHCheckBinding {
|
||||
if rn.sshBinding == nil {
|
||||
panic("SSHCheckBinding can only be used in SSH check-mode requests")
|
||||
@@ -342,19 +342,19 @@ func (rn *AuthRequest) SSHCheckBinding() *SSHCheckBinding {
|
||||
}
|
||||
|
||||
// IsRegistration reports whether this auth request carries registration
|
||||
// data (i.e. it was created via NewRegisterAuthRequest).
|
||||
// data (i.e. it was created via [NewRegisterAuthRequest]).
|
||||
func (rn *AuthRequest) IsRegistration() bool {
|
||||
return rn.regData != nil
|
||||
}
|
||||
|
||||
// IsSSHCheck reports whether this auth request is bound to an SSH
|
||||
// check-mode (src, dst) pair (i.e. it was created via
|
||||
// NewSSHCheckAuthRequest).
|
||||
// [NewSSHCheckAuthRequest]).
|
||||
func (rn *AuthRequest) IsSSHCheck() bool {
|
||||
return rn.sshBinding != nil
|
||||
}
|
||||
|
||||
// SetPendingConfirmation marks this AuthRequest as having an
|
||||
// SetPendingConfirmation marks this [AuthRequest] as having an
|
||||
// OIDC-resolved user that is waiting to confirm the registration on
|
||||
// the interstitial. The OIDC callback should call this and then render
|
||||
// the confirmation page; the /register/confirm POST handler reads the
|
||||
@@ -364,8 +364,8 @@ func (rn *AuthRequest) SetPendingConfirmation(p *PendingRegistrationConfirmation
|
||||
}
|
||||
|
||||
// PendingConfirmation returns the pending OIDC-resolved registration
|
||||
// state captured by SetPendingConfirmation, or nil if no OIDC callback
|
||||
// has yet resolved an identity for this AuthRequest.
|
||||
// state captured by [AuthRequest.SetPendingConfirmation], or nil if no OIDC callback
|
||||
// has yet resolved an identity for this [AuthRequest].
|
||||
func (rn *AuthRequest) PendingConfirmation() *PendingRegistrationConfirmation {
|
||||
return rn.pendingConfirmation
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestNewSSHCheckAuthRequestBinding verifies that an SSH-check AuthRequest
|
||||
// TestNewSSHCheckAuthRequestBinding verifies that an SSH-check [AuthRequest]
|
||||
// captures the (src, dst) node pair at construction time and rejects
|
||||
// callers that try to read RegistrationData from it.
|
||||
// callers that try to read [AuthRequest.RegistrationData] from it.
|
||||
func TestNewSSHCheckAuthRequestBinding(t *testing.T) {
|
||||
const src, dst NodeID = 7, 11
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestNewSSHCheckAuthRequestBinding(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestNewRegisterAuthRequestPayload verifies that a registration
|
||||
// AuthRequest carries the supplied RegistrationData and rejects callers
|
||||
// [AuthRequest] carries the supplied [RegistrationData] and rejects callers
|
||||
// that try to read SSH-check binding from it.
|
||||
func TestNewRegisterAuthRequestPayload(t *testing.T) {
|
||||
data := &RegistrationData{Hostname: "node-a"}
|
||||
@@ -45,7 +45,7 @@ func TestNewRegisterAuthRequestPayload(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestNewAuthRequestEmptyPayload verifies that a payload-less
|
||||
// AuthRequest reports both Is* helpers as false and panics on either
|
||||
// [AuthRequest] reports both Is* helpers as false and panics on either
|
||||
// payload accessor.
|
||||
func TestNewAuthRequestEmptyPayload(t *testing.T) {
|
||||
req := NewAuthRequest()
|
||||
@@ -58,7 +58,7 @@ func TestNewAuthRequestEmptyPayload(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestPendingRegistrationConfirmation verifies that the OIDC callback
|
||||
// can stash a pending confirmation onto an AuthRequest and that the
|
||||
// can stash a pending confirmation onto an [AuthRequest] and that the
|
||||
// /register/confirm POST handler can read it back unchanged.
|
||||
func TestPendingRegistrationConfirmation(t *testing.T) {
|
||||
req := NewRegisterAuthRequest(&RegistrationData{Hostname: "phish-test"})
|
||||
|
||||
@@ -68,7 +68,7 @@ type HARouteConfig struct {
|
||||
ProbeInterval time.Duration
|
||||
|
||||
// ProbeTimeout is the maximum time to wait for a probe response
|
||||
// before declaring a node unhealthy. Must be less than ProbeInterval.
|
||||
// before declaring a node unhealthy. Must be less than [HARouteConfig.ProbeInterval].
|
||||
ProbeTimeout time.Duration
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ type Config struct {
|
||||
|
||||
// DNSConfig is the headscale representation of the DNS configuration.
|
||||
// It is kept in the config update for some settings that are
|
||||
// not directly converted into a tailcfg.DNSConfig.
|
||||
// not directly converted into a [tailcfg.DNSConfig].
|
||||
DNSConfig DNSConfig
|
||||
|
||||
// TailcfgDNSConfig is the tailcfg representation of the DNS configuration,
|
||||
@@ -337,7 +337,7 @@ type Tuning struct {
|
||||
// NodeStoreBatchTimeout is the maximum time to wait before processing a
|
||||
// partial batch of node operations.
|
||||
//
|
||||
// When NodeStoreBatchSize operations haven't accumulated, this timeout ensures
|
||||
// When [Tuning.NodeStoreBatchSize] operations haven't accumulated, this timeout ensures
|
||||
// writes don't wait indefinitely. The batch processes when either the size
|
||||
// threshold is reached OR this timeout expires, whichever comes first.
|
||||
//
|
||||
@@ -355,8 +355,8 @@ func validatePKCEMethod(method string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Domain returns the hostname/domain part of the ServerURL.
|
||||
// If the ServerURL is not a valid URL, it returns the BaseDomain.
|
||||
// Domain returns the hostname/domain part of the [Config.ServerURL].
|
||||
// If the [Config.ServerURL] is not a valid URL, it returns the [Config.BaseDomain].
|
||||
func (c *Config) Domain() string {
|
||||
u, err := url.Parse(c.ServerURL)
|
||||
if err != nil {
|
||||
@@ -369,7 +369,7 @@ func (c *Config) Domain() string {
|
||||
// LoadConfig prepares and loads the Headscale configuration into Viper.
|
||||
// This means it sets the default values, reads the configuration file and
|
||||
// environment variables, and handles deprecated configuration options.
|
||||
// It has to be called before LoadServerConfig and LoadCLIConfig.
|
||||
// It has to be called before [LoadServerConfig] and [LoadCLIConfig].
|
||||
// The configuration is not validated and the caller should check for errors
|
||||
// using a validation function.
|
||||
func LoadConfig(path string, isFile bool) error {
|
||||
|
||||
@@ -475,8 +475,8 @@ func TestSafeServerURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigJSONOmitsSecrets verifies that marshalling a Config to JSON
|
||||
// (as /debug/config does via state.DebugConfig) does not leak the
|
||||
// TestConfigJSONOmitsSecrets verifies that marshalling a [Config] to JSON
|
||||
// (as /debug/config does via [state.State.DebugConfig]) does not leak the
|
||||
// Postgres password, the OIDC client secret, or the headscale admin
|
||||
// API key. Operators who widen metrics_listen_addr to 0.0.0.0 should
|
||||
// not be able to read these back via debug endpoints reachable over
|
||||
|
||||
@@ -33,7 +33,7 @@ var (
|
||||
)
|
||||
|
||||
// RouteFunc is a function that takes a node ID and returns a list of
|
||||
// netip.Prefixes representing the routes for that node.
|
||||
// [netip.Prefix] values representing the routes for that node.
|
||||
type RouteFunc func(id NodeID) []netip.Prefix
|
||||
|
||||
// nodeAttrDisableIPv4 is the policy nodeAttr key that suppresses the
|
||||
@@ -58,17 +58,17 @@ func filterIPv4(ps []netip.Prefix) []netip.Prefix {
|
||||
}
|
||||
|
||||
// ViaRouteResult describes via grant effects for a viewer-peer pair.
|
||||
// UsePrimary is always a subset of Include: it marks which included
|
||||
// [ViaRouteResult.UsePrimary] is always a subset of [ViaRouteResult.Include]: it marks which included
|
||||
// prefixes must additionally defer to HA primary election.
|
||||
type ViaRouteResult struct {
|
||||
// Include contains prefixes this peer should serve to this viewer (via-designated).
|
||||
Include []netip.Prefix
|
||||
// Exclude contains prefixes steered to OTHER peers (suppress from global primary).
|
||||
Exclude []netip.Prefix
|
||||
// UsePrimary contains prefixes from Include where a regular
|
||||
// UsePrimary contains prefixes from [ViaRouteResult.Include] where a regular
|
||||
// (non-via) grant also covers the prefix. In these cases HA
|
||||
// primary election wins — only the primary router should get
|
||||
// the route in AllowedIPs. When a prefix is NOT in UsePrimary,
|
||||
// the route in [tailcfg.Node.AllowedIPs]. When a prefix is NOT in [ViaRouteResult.UsePrimary],
|
||||
// per-viewer via steering applies.
|
||||
UsePrimary []netip.Prefix
|
||||
}
|
||||
@@ -132,8 +132,8 @@ type Node struct {
|
||||
Hostname string
|
||||
|
||||
// Givenname represents either:
|
||||
// a DNS normalized version of Hostname
|
||||
// a valid name set by the User
|
||||
// a DNS normalized version of [Node.Hostname]
|
||||
// a valid name set by the [User]
|
||||
//
|
||||
// GivenName is the name used in all DNS related
|
||||
// parts of headscale.
|
||||
@@ -152,7 +152,7 @@ type Node struct {
|
||||
// Tags cannot be removed once set (one-way transition).
|
||||
Tags Strings `gorm:"column:tags;serializer:json"`
|
||||
|
||||
// When a node has been created with a PreAuthKey, we need to
|
||||
// When a node has been created with a [PreAuthKey], we need to
|
||||
// prevent the preauthkey from being deleted before the node.
|
||||
// The preauthkey can define "tags" of the node so we need it
|
||||
// around.
|
||||
@@ -263,8 +263,8 @@ func (node *Node) HasTag(tag string) bool {
|
||||
return slices.Contains(node.Tags, tag)
|
||||
}
|
||||
|
||||
// TypedUserID returns the UserID as a typed UserID type.
|
||||
// Returns 0 if UserID is nil.
|
||||
// TypedUserID returns the [Node.UserID] as a typed [UserID] type.
|
||||
// Returns 0 if [Node.UserID] is nil.
|
||||
func (node *Node) TypedUserID() UserID {
|
||||
if node.UserID == nil {
|
||||
return 0
|
||||
@@ -299,7 +299,7 @@ func (node *Node) Prefixes() []netip.Prefix {
|
||||
|
||||
// ExitRoutes returns the node's approved exit routes (0.0.0.0/0
|
||||
// and/or ::/0). Consumed unconditionally by RoutesForPeer when the
|
||||
// viewer uses an exit node; excluded from CanAccessRoute which only
|
||||
// viewer uses an exit node; excluded from [Node.CanAccessRoute] which only
|
||||
// handles non-exit routing.
|
||||
func (node *Node) ExitRoutes() []netip.Prefix {
|
||||
var routes []netip.Prefix
|
||||
@@ -315,7 +315,7 @@ func (node *Node) ExitRoutes() []netip.Prefix {
|
||||
|
||||
// IsExitNode reports whether the node has any approved exit routes.
|
||||
// Approval is required: an advertised-but-unapproved exit route does
|
||||
// not make the node an exit node (fix for #3169).
|
||||
// not make the node an exit node.
|
||||
func (node *Node) IsExitNode() bool {
|
||||
return len(node.ExitRoutes()) > 0
|
||||
}
|
||||
@@ -340,9 +340,9 @@ func (node *Node) InIPSet(set *netipx.IPSet) bool {
|
||||
}
|
||||
|
||||
// AppendToIPSet adds all IP addresses of the node to the given
|
||||
// netipx.IPSetBuilder. For identity-based aliases (tags, users,
|
||||
// [netipx.IPSetBuilder]. For identity-based aliases (tags, users,
|
||||
// groups, autogroups), both IPv4 and IPv6 must be included to
|
||||
// match Tailscale's behavior in the FilterRule wire format.
|
||||
// match Tailscale's behavior in the [tailcfg.FilterRule] wire format.
|
||||
func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
|
||||
if node.IPv4 != nil {
|
||||
build.Add(*node.IPv4)
|
||||
@@ -357,7 +357,7 @@ func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
|
||||
// matchers. A node owns two source identities for ACL purposes:
|
||||
// - its own IPs (regular peer membership)
|
||||
// - any approved subnet routes it advertises (subnet-router-as-src,
|
||||
// used for subnet-to-subnet ACLs — issue #3157)
|
||||
// used for subnet-to-subnet ACLs)
|
||||
//
|
||||
// Either identity matching a rule's src — combined with the dst
|
||||
// matching node2's IPs, node2's approved subnet routes, or "the
|
||||
@@ -396,18 +396,18 @@ func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
|
||||
// CanAccessRoute determines whether a specific route prefix should be
|
||||
// visible to this node based on the given matchers.
|
||||
//
|
||||
// Unlike CanAccess, this function intentionally does NOT check
|
||||
// DestsIsTheInternet(). Exit routes (0.0.0.0/0, ::/0) are handled by
|
||||
// Unlike [Node.CanAccess], this function intentionally does NOT check
|
||||
// [matcher.Match.DestsIsTheInternet]. Exit routes (0.0.0.0/0, ::/0) are handled by
|
||||
// RoutesForPeer (state.go) which adds them unconditionally from
|
||||
// ExitRoutes(), not through ACL-based route filtering. The
|
||||
// DestsIsTheInternet check in CanAccess exists solely for peer
|
||||
// [Node.ExitRoutes], not through ACL-based route filtering. The
|
||||
// [matcher.Match.DestsIsTheInternet] check in [Node.CanAccess] exists solely for peer
|
||||
// visibility determination (should two nodes see each other), which
|
||||
// is a separate concern from route prefix authorization.
|
||||
//
|
||||
// Additionally, autogroup:internet is explicitly skipped during filter
|
||||
// rule compilation (filter.go), so no matchers ever contain "the
|
||||
// internet" from internet-targeted ACLs. Wildcard "*" dests produce
|
||||
// matchers where DestsOverlapsPrefixes(0.0.0.0/0) already returns
|
||||
// matchers where [matcher.Match.DestsOverlapsPrefixes](0.0.0.0/0) already returns
|
||||
// true, so the check would be redundant for that case.
|
||||
func (node *Node) CanAccessRoute(matchers []matcher.Match, route netip.Prefix) bool {
|
||||
src := node.IPs()
|
||||
@@ -500,8 +500,8 @@ func (node *Node) Proto() *v1.Node {
|
||||
}
|
||||
|
||||
// Set User field based on node ownership
|
||||
// Note: User will be set to TaggedDevices in the gRPC layer (grpcv1.go)
|
||||
// for proper MapResponse formatting
|
||||
// Note: User will be set to [TaggedDevices] in the gRPC layer (grpcv1.go)
|
||||
// for proper [tailcfg.MapResponse] formatting
|
||||
if node.User != nil {
|
||||
nodeProto.User = node.User.Proto()
|
||||
}
|
||||
@@ -548,8 +548,8 @@ func (node *Node) GetFQDN(baseDomain string) (string, error) {
|
||||
}
|
||||
|
||||
// AnnouncedRoutes returns the list of routes the node announces, as
|
||||
// reported by the client in Hostinfo.RoutableIPs. Announcement alone
|
||||
// does not grant visibility — see SubnetRoutes for approval-gated
|
||||
// reported by the client in [tailcfg.Hostinfo.RoutableIPs]. Announcement alone
|
||||
// does not grant visibility — see [Node.SubnetRoutes] for approval-gated
|
||||
// access.
|
||||
func (node *Node) AnnouncedRoutes() []netip.Prefix {
|
||||
if node.Hostinfo == nil {
|
||||
@@ -560,13 +560,13 @@ func (node *Node) AnnouncedRoutes() []netip.Prefix {
|
||||
}
|
||||
|
||||
// SubnetRoutes returns the list of routes (excluding exit routes) that the node
|
||||
// announces and are approved. Also used by CanAccess and CanAccessRoute as part
|
||||
// of the subnet-router-as-source identity (issue #3157).
|
||||
// announces and are approved. Also used by [Node.CanAccess] and [Node.CanAccessRoute] as part
|
||||
// of the subnet-router-as-source identity.
|
||||
//
|
||||
// IMPORTANT: This method is used for internal data structures and should NOT be
|
||||
// used for the gRPC Proto conversion. For Proto, SubnetRoutes must be populated
|
||||
// manually with PrimaryRoutes to ensure it includes only routes actively served
|
||||
// by the node. See the comment in Proto() method and the implementation in
|
||||
// by the node. See the comment in [Node.Proto] method and the implementation in
|
||||
// grpcv1.go/nodesToProto.
|
||||
func (node *Node) SubnetRoutes() []netip.Prefix {
|
||||
var routes []netip.Prefix
|
||||
@@ -589,7 +589,7 @@ func (node *Node) IsSubnetRouter() bool {
|
||||
return len(node.SubnetRoutes()) > 0
|
||||
}
|
||||
|
||||
// AllApprovedRoutes returns the combination of SubnetRoutes and ExitRoutes.
|
||||
// AllApprovedRoutes returns the combination of [Node.SubnetRoutes] and [Node.ExitRoutes].
|
||||
func (node *Node) AllApprovedRoutes() []netip.Prefix {
|
||||
return append(node.SubnetRoutes(), node.ExitRoutes()...)
|
||||
}
|
||||
@@ -598,9 +598,9 @@ func (node *Node) String() string {
|
||||
return node.Hostname
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging.
|
||||
// This method is used with zerolog's EmbedObject() for flat field embedding
|
||||
// or Object() for nested logging when multiple nodes are logged.
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
|
||||
// This method is used with [zerolog.Event.EmbedObject] for flat field embedding
|
||||
// or [zerolog.Event.Object] for nested logging when multiple nodes are logged.
|
||||
func (node *Node) MarshalZerologObject(e *zerolog.Event) {
|
||||
if node == nil {
|
||||
return
|
||||
@@ -628,11 +628,11 @@ func (node *Node) MarshalZerologObject(e *zerolog.Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// PeerChangeFromMapRequest takes a MapRequest and compares it to the node
|
||||
// to produce a PeerChange struct that can be used to updated the node and
|
||||
// PeerChangeFromMapRequest takes a [tailcfg.MapRequest] and compares it to the node
|
||||
// to produce a [tailcfg.PeerChange] struct that can be used to updated the node and
|
||||
// inform peers about smaller changes to the node.
|
||||
// When a field is added to this function, remember to also add it to:
|
||||
// - node.ApplyPeerChange
|
||||
// - [Node.ApplyPeerChange]
|
||||
// - logTracePeerChange in poll.go.
|
||||
func (node *Node) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
|
||||
ret := tailcfg.PeerChange{
|
||||
@@ -714,7 +714,7 @@ func (node *Node) RegisterMethodToV1Enum() v1.RegisterMethod {
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyPeerChange takes a PeerChange struct and updates the node.
|
||||
// ApplyPeerChange takes a [tailcfg.PeerChange] struct and updates the node.
|
||||
func (node *Node) ApplyPeerChange(change *tailcfg.PeerChange) {
|
||||
if change.Key != nil {
|
||||
node.NodeKey = *change.Key
|
||||
@@ -812,8 +812,8 @@ func (node *Node) DebugString() string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for NodeView.
|
||||
// This delegates to the underlying Node's implementation.
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for [NodeView].
|
||||
// This delegates to the underlying [Node]'s implementation.
|
||||
func (nv NodeView) MarshalZerologObject(e *zerolog.Event) {
|
||||
if !nv.Valid() {
|
||||
return
|
||||
@@ -823,8 +823,8 @@ func (nv NodeView) MarshalZerologObject(e *zerolog.Event) {
|
||||
}
|
||||
|
||||
// Owner returns the owner for display purposes.
|
||||
// For tagged nodes, returns TaggedDevices. For user-owned nodes, returns the user.
|
||||
// Returns an invalid UserView if the node is in an orphaned state (no tags, no user).
|
||||
// For tagged nodes, returns [TaggedDevices]. For user-owned nodes, returns the user.
|
||||
// Returns an invalid [UserView] if the node is in an orphaned state (no tags, no user).
|
||||
// Callers should check .Valid() on the result before accessing fields.
|
||||
func (nv NodeView) Owner() UserView {
|
||||
if nv.IsTagged() {
|
||||
@@ -950,8 +950,8 @@ func (nv NodeView) IsEphemeral() bool {
|
||||
return nv.ж.IsEphemeral()
|
||||
}
|
||||
|
||||
// PeerChangeFromMapRequest takes a MapRequest and compares it to the node
|
||||
// to produce a PeerChange struct that can be used to updated the node and
|
||||
// PeerChangeFromMapRequest takes a [tailcfg.MapRequest] and compares it to the node
|
||||
// to produce a [tailcfg.PeerChange] struct that can be used to updated the node and
|
||||
// inform peers about smaller changes to the node.
|
||||
func (nv NodeView) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
|
||||
if !nv.Valid() {
|
||||
@@ -998,7 +998,7 @@ func (nv NodeView) RequestTags() []string {
|
||||
return nv.Hostinfo().RequestTags().AsSlice()
|
||||
}
|
||||
|
||||
// Proto converts the NodeView to a protobuf representation.
|
||||
// Proto converts the [NodeView] to a protobuf representation.
|
||||
func (nv NodeView) Proto() *v1.Node {
|
||||
if !nv.Valid() {
|
||||
return nil
|
||||
@@ -1025,8 +1025,8 @@ func (nv NodeView) HasTag(tag string) bool {
|
||||
return nv.ж.HasTag(tag)
|
||||
}
|
||||
|
||||
// TypedUserID returns the UserID as a typed UserID type.
|
||||
// Returns 0 if UserID is nil or node is invalid.
|
||||
// TypedUserID returns the [Node.UserID] as a typed [UserID] type.
|
||||
// Returns 0 if [Node.UserID] is nil or node is invalid.
|
||||
func (nv NodeView) TypedUserID() UserID {
|
||||
if !nv.Valid() {
|
||||
return 0
|
||||
@@ -1036,8 +1036,8 @@ func (nv NodeView) TypedUserID() UserID {
|
||||
}
|
||||
|
||||
// TailscaleUserID returns the user ID to use in Tailscale protocol.
|
||||
// Tagged nodes always return TaggedDevices.ID, user-owned nodes return their actual UserID.
|
||||
// Returns 0 for nodes in an orphaned state (no tags, no UserID).
|
||||
// Tagged nodes always return [TaggedDevices].ID, user-owned nodes return their actual [Node.UserID].
|
||||
// Returns 0 for nodes in an orphaned state (no tags, no [Node.UserID]).
|
||||
func (nv NodeView) TailscaleUserID() tailcfg.UserID {
|
||||
if !nv.Valid() {
|
||||
return 0
|
||||
@@ -1056,7 +1056,7 @@ func (nv NodeView) TailscaleUserID() tailcfg.UserID {
|
||||
return tailcfg.UserID(int64(nv.UserID().Get()))
|
||||
}
|
||||
|
||||
// Prefixes returns the node IPs as netip.Prefix.
|
||||
// Prefixes returns the node IPs as [netip.Prefix].
|
||||
func (nv NodeView) Prefixes() []netip.Prefix {
|
||||
if !nv.Valid() {
|
||||
return nil
|
||||
@@ -1118,7 +1118,7 @@ func equalPrefixesUnordered(a, b []netip.Prefix) bool {
|
||||
|
||||
// HasPolicyChange reports whether the node has changes that affect
|
||||
// policy evaluation. Includes approved subnet routes because they act
|
||||
// as source identity in CanAccess for subnet-to-subnet ACLs (#3157).
|
||||
// as source identity in [Node.CanAccess] for subnet-to-subnet ACLs.
|
||||
func (nv NodeView) HasPolicyChange(other NodeView) bool {
|
||||
if nv.UserID() != other.UserID() {
|
||||
return true
|
||||
@@ -1139,7 +1139,7 @@ func (nv NodeView) HasPolicyChange(other NodeView) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// TailNodes converts a slice of NodeViews into Tailscale tailcfg.Nodes.
|
||||
// TailNodes converts a slice of [NodeView] values into Tailscale [tailcfg.Node] values.
|
||||
func TailNodes(
|
||||
nodes views.Slice[NodeView],
|
||||
capVer tailcfg.CapabilityVersion,
|
||||
@@ -1162,7 +1162,7 @@ func TailNodes(
|
||||
return tNodes, nil
|
||||
}
|
||||
|
||||
// TailNode converts a NodeView into a Tailscale tailcfg.Node.
|
||||
// TailNode converts a [NodeView] into a Tailscale [tailcfg.Node].
|
||||
//
|
||||
// selfPolicyCaps is the per-node CapMap from [policy.PolicyManager.NodeCapMap]
|
||||
// and is merged into the baseline. Pass it when building the self view of the
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestNodeIsTagged tests the IsTagged() method for determining if a node is tagged.
|
||||
// TestNodeIsTagged tests the [Node.IsTagged] method for determining if a node is tagged.
|
||||
func TestNodeIsTagged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -44,9 +44,9 @@ func TestNodeIsTagged(t *testing.T) {
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Tags should be copied from AuthKey during registration, so a node
|
||||
// with only AuthKey.Tags and no Tags would be invalid in practice.
|
||||
// IsTagged() only checks node.Tags, not AuthKey.Tags.
|
||||
// Tags should be copied from [Node.AuthKey] during registration, so a node
|
||||
// with only [PreAuthKey.Tags] and no [Node.Tags] would be invalid in practice.
|
||||
// [Node.IsTagged] only checks [Node.Tags], not [PreAuthKey.Tags].
|
||||
name: "node registered with tagged authkey only - not tagged (tags should be copied)",
|
||||
node: Node{
|
||||
AuthKey: &PreAuthKey{
|
||||
@@ -83,7 +83,7 @@ func TestNodeIsTagged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeViewIsTagged tests the IsTagged() method on NodeView.
|
||||
// TestNodeViewIsTagged tests the [NodeView.IsTagged] method on [NodeView].
|
||||
func TestNodeViewIsTagged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -98,15 +98,15 @@ func TestNodeViewIsTagged(t *testing.T) {
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// Tags should be copied from AuthKey during registration, so a node
|
||||
// with only AuthKey.Tags and no Tags would be invalid in practice.
|
||||
// Tags should be copied from [Node.AuthKey] during registration, so a node
|
||||
// with only [PreAuthKey.Tags] and no [Node.Tags] would be invalid in practice.
|
||||
name: "node with only AuthKey tags - not tagged (tags should be copied)",
|
||||
node: Node{
|
||||
AuthKey: &PreAuthKey{
|
||||
Tags: []string{"tag:web"},
|
||||
},
|
||||
},
|
||||
want: false, // IsTagged() only checks node.Tags
|
||||
want: false, // [Node.IsTagged] only checks [Node.Tags]
|
||||
},
|
||||
{
|
||||
name: "user-owned node",
|
||||
@@ -126,7 +126,7 @@ func TestNodeViewIsTagged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeHasTag tests the HasTag() method for checking specific tag membership.
|
||||
// TestNodeHasTag tests the [Node.HasTag] method for checking specific tag membership.
|
||||
func TestNodeHasTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -151,8 +151,8 @@ func TestNodeHasTag(t *testing.T) {
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Tags should be copied from AuthKey during registration
|
||||
// HasTag() only checks node.Tags, not AuthKey.Tags
|
||||
// Tags should be copied from [Node.AuthKey] during registration
|
||||
// [Node.HasTag] only checks [Node.Tags], not [PreAuthKey.Tags]
|
||||
name: "node has tag only in authkey - returns false",
|
||||
node: Node{
|
||||
AuthKey: &PreAuthKey{
|
||||
@@ -163,7 +163,7 @@ func TestNodeHasTag(t *testing.T) {
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// node.Tags is what matters, not AuthKey.Tags
|
||||
// [Node.Tags] is what matters, not [PreAuthKey.Tags]
|
||||
name: "node has tag in Tags but not in AuthKey",
|
||||
node: Node{
|
||||
Tags: []string{"tag:server"},
|
||||
@@ -259,8 +259,8 @@ func TestNodeOwnershipModel(t *testing.T) {
|
||||
description: "User-owned nodes are owned by the user, not by tags",
|
||||
},
|
||||
{
|
||||
// Tags should be copied from AuthKey to Node during registration
|
||||
// IsTagged() only checks node.Tags, not AuthKey.Tags
|
||||
// Tags should be copied from [Node.AuthKey] to [Node] during registration
|
||||
// [Node.IsTagged] only checks [Node.Tags], not [PreAuthKey.Tags]
|
||||
name: "node with only authkey tags - not tagged (tags should be copied)",
|
||||
node: Node{
|
||||
ID: 3,
|
||||
|
||||
@@ -113,7 +113,7 @@ func Test_NodeCanAccess(t *testing.T) {
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
// Subnet-to-subnet tests for issue #3157.
|
||||
// Subnet-to-subnet tests.
|
||||
// When ACL src and dst are both subnet CIDRs, subnet
|
||||
// routers advertising those subnets must see each other.
|
||||
{
|
||||
@@ -153,7 +153,7 @@ func Test_NodeCanAccess(t *testing.T) {
|
||||
{
|
||||
// With a unidirectional ACL (src=A→dst=B), the dst
|
||||
// router cannot access the src router. Bidirectional
|
||||
// peer visibility comes from ReduceNodes checking
|
||||
// peer visibility comes from [policy.ReduceNodes] checking
|
||||
// both A.CanAccess(B) || B.CanAccess(A).
|
||||
name: "subnet-to-subnet-unidirectional-dst-cannot-access-src-3157",
|
||||
node1: Node{
|
||||
@@ -550,7 +550,6 @@ func TestPeerChangeFromMapRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestApplyPeerChange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -708,7 +707,7 @@ func TestNodeRegisterMethodToV1Enum(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasNetworkChanges tests the NodeView method for detecting
|
||||
// TestHasNetworkChanges tests the [NodeView] method for detecting
|
||||
// when a node's network properties have changed.
|
||||
func TestHasNetworkChanges(t *testing.T) {
|
||||
mustIPPtr := func(s string) *netip.Addr {
|
||||
|
||||
@@ -25,8 +25,8 @@ type PreAuthKey struct {
|
||||
Prefix string
|
||||
Hash []byte // bcrypt
|
||||
|
||||
// For tagged keys: UserID tracks who created the key (informational)
|
||||
// For user-owned keys: UserID tracks the node owner
|
||||
// For tagged keys: [PreAuthKey.UserID] tracks who created the key (informational)
|
||||
// For user-owned keys: [PreAuthKey.UserID] tracks the node owner
|
||||
// Can be nil for system-created tagged keys
|
||||
UserID *uint
|
||||
User *User `gorm:"constraint:OnDelete:SET NULL;"`
|
||||
@@ -122,7 +122,7 @@ func (pak *PreAuthKey) Validate() error {
|
||||
return PAKError("invalid authkey")
|
||||
}
|
||||
|
||||
// Use EmbedObject for safe logging - never log full key
|
||||
// Use [zerolog.Event.EmbedObject] for safe logging - never log full key
|
||||
log.Debug().
|
||||
Caller().
|
||||
EmbedObject(pak).
|
||||
@@ -144,8 +144,8 @@ func (pak *PreAuthKey) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsTagged returns true if this PreAuthKey creates tagged nodes.
|
||||
// When a PreAuthKey has tags, nodes registered with it will be tagged nodes.
|
||||
// IsTagged returns true if this [PreAuthKey] creates tagged nodes.
|
||||
// When a [PreAuthKey] has tags, nodes registered with it will be tagged nodes.
|
||||
func (pak *PreAuthKey) IsTagged() bool {
|
||||
return len(pak.Tags) > 0
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func (pak *PreAuthKey) maskedPrefix() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging.
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
|
||||
// SECURITY: This method intentionally does NOT log the full key or hash.
|
||||
// Only the masked prefix is logged for identification purposes.
|
||||
func (pak *PreAuthKey) MarshalZerologObject(e *zerolog.Event) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// RegistrationData is the payload cached for a pending node registration.
|
||||
// It replaces the previous practice of caching a full *Node and carries
|
||||
// It replaces the previous practice of caching a full *[Node] and carries
|
||||
// only the fields the registration callback path actually consumes when
|
||||
// promoting a pending registration to a real node.
|
||||
//
|
||||
@@ -34,18 +34,18 @@ type RegistrationData struct {
|
||||
// Already validated/normalised by EnsureHostname at producer time.
|
||||
Hostname string
|
||||
|
||||
// Hostinfo is the original Hostinfo from the RegisterRequest,
|
||||
// Hostinfo is the original [tailcfg.Hostinfo] from the [tailcfg.RegisterRequest],
|
||||
// stored so that the auth callback can populate the new node's
|
||||
// initial Hostinfo (and so that observability/CLI consumers see
|
||||
// initial [tailcfg.Hostinfo] (and so that observability/CLI consumers see
|
||||
// fields like OS, OSVersion, and IPNVersion before the first
|
||||
// MapRequest restores the live set).
|
||||
// [tailcfg.MapRequest] restores the live set).
|
||||
//
|
||||
// May be nil if the client did not send Hostinfo in the original
|
||||
// RegisterRequest.
|
||||
// May be nil if the client did not send [tailcfg.Hostinfo] in the original
|
||||
// [tailcfg.RegisterRequest].
|
||||
Hostinfo *tailcfg.Hostinfo
|
||||
|
||||
// Endpoints is the initial set of WireGuard endpoints the node
|
||||
// reported. The first MapRequest after registration overwrites
|
||||
// reported. The first [tailcfg.MapRequest] after registration overwrites
|
||||
// this with the live set.
|
||||
Endpoints []netip.AddrPort
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ type Route struct {
|
||||
// Advertised is now only stored as part of [Node.Hostinfo].
|
||||
Advertised bool
|
||||
|
||||
// Enabled is stored directly on the node as ApprovedRoutes.
|
||||
// Enabled is stored directly on the node as [Node.ApprovedRoutes].
|
||||
Enabled bool
|
||||
|
||||
// IsPrimary is only determined in memory as it is only relevant
|
||||
|
||||
@@ -2,20 +2,20 @@ package types
|
||||
|
||||
import "net/netip"
|
||||
|
||||
// The named slice types below are used for GORM-persisted Node columns
|
||||
// The named slice types below are used for GORM-persisted [Node] columns
|
||||
// that serialise as JSON. GORM v2's struct-based Updates skips fields
|
||||
// it considers zero — for unnamed slice types that is nil — and the
|
||||
// default reflect.Value.IsZero treats a nil slice as zero. By giving
|
||||
// default [reflect.Value.IsZero] treats a nil slice as zero. By giving
|
||||
// each slice an IsZero() that always returns false, the column is
|
||||
// always included in UPDATE statements regardless of whether the
|
||||
// caller is clearing the field. JSON marshalling is unchanged: a nil
|
||||
// value serialises to null and an empty value serialises to [].
|
||||
//
|
||||
// The .List() helpers return the underlying unnamed slice for the
|
||||
// places (mainly testify assertions over reflect.DeepEqual) where the
|
||||
// places (mainly testify assertions over [reflect.DeepEqual]) where the
|
||||
// distinction between the named and unnamed type matters.
|
||||
|
||||
// Strings is a []string with a GORM-friendly IsZero.
|
||||
// Strings is a []string with a GORM-friendly [Strings.IsZero].
|
||||
type Strings []string
|
||||
|
||||
// IsZero implements GORM's zeroer interface to keep the column in the
|
||||
@@ -25,7 +25,7 @@ func (Strings) IsZero() bool { return false }
|
||||
// List returns the underlying []string.
|
||||
func (s Strings) List() []string { return []string(s) }
|
||||
|
||||
// Prefixes is a []netip.Prefix with a GORM-friendly IsZero.
|
||||
// Prefixes is a []netip.Prefix with a GORM-friendly [Prefixes.IsZero].
|
||||
type Prefixes []netip.Prefix
|
||||
|
||||
// IsZero implements GORM's zeroer interface to keep the column in the
|
||||
@@ -35,7 +35,7 @@ func (Prefixes) IsZero() bool { return false }
|
||||
// List returns the underlying []netip.Prefix.
|
||||
func (s Prefixes) List() []netip.Prefix { return []netip.Prefix(s) }
|
||||
|
||||
// AddrPorts is a []netip.AddrPort with a GORM-friendly IsZero.
|
||||
// AddrPorts is a []netip.AddrPort with a GORM-friendly [AddrPorts.IsZero].
|
||||
type AddrPorts []netip.AddrPort
|
||||
|
||||
// IsZero implements GORM's zeroer interface to keep the column in the
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
// CommentHeader returns the // comment header that gets prepended to
|
||||
// a Capture file when it is written. The header is purely
|
||||
// a [Capture] file when it is written. The header is purely
|
||||
// informational; consumers ignore it. Format:
|
||||
//
|
||||
// <TestID>
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// schema version: <SchemaVersion>
|
||||
//
|
||||
// Both `tool_version` and `schema_version` are also stored as
|
||||
// first-class JSON fields on the Capture struct; the comment lines
|
||||
// first-class JSON fields on the [Capture] struct; the comment lines
|
||||
// exist purely so the values are visible at a glance without
|
||||
// parsing the file.
|
||||
//
|
||||
@@ -68,8 +68,8 @@ func CommentHeader(c *Capture) string {
|
||||
// captures at all.
|
||||
//
|
||||
// The phrasing depends on which fields the capture uses:
|
||||
// - SSH captures populate SSHRules
|
||||
// - other captures populate PacketFilterRules
|
||||
// - SSH captures populate [NodeCapture.SSHRules]
|
||||
// - other captures populate [NodeCapture.PacketFilterRules]
|
||||
//
|
||||
// If both fields appear (mixed/unusual), filter rules wins.
|
||||
func captureStats(c *Capture) string {
|
||||
|
||||
@@ -9,17 +9,17 @@ import (
|
||||
"github.com/tailscale/hujson"
|
||||
)
|
||||
|
||||
// ErrUnsupportedSchemaVersion is returned by Read when a capture
|
||||
// advertises a SchemaVersion newer than the current binary supports.
|
||||
// ErrUnsupportedSchemaVersion is returned by [Read] when a capture
|
||||
// advertises a [Capture.SchemaVersion] newer than the current binary supports.
|
||||
var ErrUnsupportedSchemaVersion = errors.New("testcapture: unsupported schema version")
|
||||
|
||||
// Read parses a HuJSON capture file from disk into a Capture.
|
||||
// Read parses a HuJSON capture file from disk into a [Capture].
|
||||
//
|
||||
// Comments and trailing commas in the file are stripped before
|
||||
// unmarshaling. Files advertising a SchemaVersion newer than the
|
||||
// current binary's are rejected with ErrUnsupportedSchemaVersion;
|
||||
// SchemaVersion == 0 (pre-versioning) is accepted for backwards compat.
|
||||
// The returned Capture's CapturedAt is the value recorded in the file
|
||||
// unmarshaling. Files advertising a [Capture.SchemaVersion] newer than the
|
||||
// current binary's are rejected with [ErrUnsupportedSchemaVersion];
|
||||
// [Capture.SchemaVersion] == 0 (pre-versioning) is accepted for backwards compat.
|
||||
// The returned [Capture]'s [Capture.CapturedAt] is the value recorded in the file
|
||||
// (not "now").
|
||||
func Read(path string) (*Capture, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -43,8 +43,8 @@ func Read(path string) (*Capture, error) {
|
||||
}
|
||||
|
||||
// unmarshalHuJSON parses HuJSON bytes (JSON with comments / trailing
|
||||
// commas) into v. Comments are stripped via hujson.Standardize before
|
||||
// json.Unmarshal is called.
|
||||
// commas) into v. Comments are stripped via [hujson.Value.Standardize] before
|
||||
// [json.Unmarshal] is called.
|
||||
func unmarshalHuJSON(data []byte, v any) error {
|
||||
ast, err := hujson.Parse(data)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
//
|
||||
// Files are HuJSON. Wire-format Tailscale data (filter rules, netmap,
|
||||
// whois, SSH rules) is stored as proper tailcfg/netmap/filtertype/
|
||||
// apitype values rather than json.RawMessage so that schema drift
|
||||
// apitype values rather than [json.RawMessage] so that schema drift
|
||||
// between the capture tool and headscale becomes a compile error
|
||||
// rather than a silent test failure, and so that consumers don't
|
||||
// have to repeat json.Unmarshal at every read site. Storing data as
|
||||
// json.RawMessage previously hid a serious capture-pipeline bug (the
|
||||
// have to repeat [json.Unmarshal] at every read site. Storing data as
|
||||
// [json.RawMessage] previously hid a serious capture-pipeline bug (the
|
||||
// IPN bus initial notification returns a stale Peers slice — see the
|
||||
// comment on Node.Netmap below) for months.
|
||||
// comment on [Node.Netmap] below) for months.
|
||||
//
|
||||
// All four capture types (acl, routes, grant, ssh) use the same Capture
|
||||
// All four capture types (acl, routes, grant, ssh) use the same [Capture]
|
||||
// shape. SSH scenarios populate Captures[name].SSHRules; the others
|
||||
// populate Captures[name].PacketFilterRules + Captures[name].Netmap.
|
||||
package testcapture
|
||||
@@ -37,11 +37,11 @@ const SchemaVersion = 1
|
||||
// Capture is one captured run of one scenario.
|
||||
//
|
||||
// All four capture types (acl, routes, grant, ssh) use this same shape.
|
||||
// SSH scenarios populate Captures[name].SSHRules; the others populate
|
||||
// Captures[name].PacketFilterRules + Captures[name].Netmap.
|
||||
// SSH scenarios populate [Capture.Captures][name].SSHRules; the others populate
|
||||
// [Capture.Captures][name].PacketFilterRules + [Capture.Captures][name].Netmap.
|
||||
type Capture struct {
|
||||
// SchemaVersion identifies the on-disk format version. Always set
|
||||
// to testcapture.SchemaVersion when written.
|
||||
// to [SchemaVersion] when written.
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
|
||||
// TestID is the stable identifier of the scenario, derived from
|
||||
@@ -67,15 +67,15 @@ type Capture struct {
|
||||
Tailnet string `json:"tailnet"`
|
||||
|
||||
// Error is true when the SaaS API rejected the policy or when
|
||||
// capture itself failed. In the rejection case, Captures reflects
|
||||
// the pre-push baseline (deny-all default) and Input.APIResponseBody
|
||||
// capture itself failed. In the rejection case, [Capture.Captures] reflects
|
||||
// the pre-push baseline (deny-all default) and [Input.APIResponseBody]
|
||||
// is populated.
|
||||
Error bool `json:"error,omitempty"`
|
||||
|
||||
// CaptureError is set when the capture itself failed (timeout,
|
||||
// missing data, etc.). The partially-captured Captures map is
|
||||
// missing data, etc.). The partially-captured [Capture.Captures] map is
|
||||
// still included for post-mortem. Distinct from
|
||||
// Input.APIResponseBody which describes a SaaS API rejection.
|
||||
// [Input.APIResponseBody] which describes a SaaS API rejection.
|
||||
CaptureError string `json:"capture_error,omitempty"`
|
||||
|
||||
// Input is everything that was sent to the tailnet to produce
|
||||
@@ -94,7 +94,7 @@ type Capture struct {
|
||||
// Input describes everything that was sent to the tailnet to produce
|
||||
// the captured state.
|
||||
//
|
||||
// Input has a custom UnmarshalJSON to accept both the new on-disk
|
||||
// [Input] has a custom [Input.UnmarshalJSON] to accept both the new on-disk
|
||||
// shape (where full_policy is a JSON-encoded string) and the legacy
|
||||
// shape (where full_policy is a JSON object). The legacy shape is
|
||||
// re-marshaled to a string at load time so consumers see the typed
|
||||
@@ -109,7 +109,7 @@ type Input struct {
|
||||
// APIResponseCode is the HTTP status code of the policy POST.
|
||||
APIResponseCode int `json:"api_response_code"`
|
||||
|
||||
// APIResponseBody is only populated when APIResponseCode != 200.
|
||||
// APIResponseBody is only populated when [Input.APIResponseCode] != 200.
|
||||
APIResponseBody *APIResponseBody `json:"api_response_body,omitempty"`
|
||||
|
||||
// Tailnet describes the tailnet-wide settings the capture tool applied
|
||||
@@ -126,10 +126,10 @@ type Input struct {
|
||||
ScenarioPath string `json:"scenario_path,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON writes FullPolicy as a raw JSON object rather than a
|
||||
// MarshalJSON writes [Input.FullPolicy] as a raw JSON object rather than a
|
||||
// double-quoted string. Consumers (including via_compat_test.go which
|
||||
// uses its own local types) expect to parse full_policy as a JSON
|
||||
// object, not a JSON string. The UnmarshalJSON below accepts both
|
||||
// object, not a JSON string. The [Input.UnmarshalJSON] below accepts both
|
||||
// forms on read so old and new captures are interchangeable.
|
||||
func (i Input) MarshalJSON() ([]byte, error) {
|
||||
type alias Input
|
||||
@@ -153,7 +153,7 @@ func (i Input) MarshalJSON() ([]byte, error) {
|
||||
// as a JSON-encoded string) and the legacy shape (full_policy as a
|
||||
// JSON object). Legacy objects are re-marshaled into a string at
|
||||
// load time so consumers see the typed field uniformly. New captures
|
||||
// always write the object form via the custom MarshalJSON above.
|
||||
// always write the object form via the custom [Input.MarshalJSON] above.
|
||||
func (i *Input) UnmarshalJSON(data []byte) error {
|
||||
type alias Input
|
||||
|
||||
@@ -243,7 +243,7 @@ type SettingsInput struct {
|
||||
|
||||
// Topology describes the users and nodes present in the tailnet at
|
||||
// capture time. Headscale's compat tests use this to construct
|
||||
// equivalent types.User and types.Node objects.
|
||||
// equivalent [types.User] and [types.Node] objects.
|
||||
type Topology struct {
|
||||
// Users in the tailnet. Always populated by the capture tool.
|
||||
Users []TopologyUser `json:"users"`
|
||||
@@ -266,22 +266,22 @@ type TopologyNode struct {
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
|
||||
// User is the TopologyUser.Name for user-owned nodes. Empty for
|
||||
// User is the [TopologyUser.Name] for user-owned nodes. Empty for
|
||||
// tagged nodes.
|
||||
User string `json:"user,omitempty"`
|
||||
|
||||
// RoutableIPs is what the node advertised
|
||||
// (Hostinfo.RoutableIPs in its own netmap.SelfNode).
|
||||
// ([tailcfg.Hostinfo.RoutableIPs] in its own [netmap.NetworkMap.SelfNode]).
|
||||
// May include 0.0.0.0/0 + ::/0 for exit nodes.
|
||||
RoutableIPs []string `json:"routable_ips"`
|
||||
|
||||
// ApprovedRoutes is the subset of RoutableIPs the tailnet has
|
||||
// ApprovedRoutes is the subset of [TopologyNode.RoutableIPs] the tailnet has
|
||||
// approved. Used by Headscale's NodeCanApproveRoute test.
|
||||
ApprovedRoutes []string `json:"approved_routes"`
|
||||
}
|
||||
|
||||
// Node is the captured state for one node, keyed by GivenName in
|
||||
// Capture.Captures.
|
||||
// [Capture.Captures].
|
||||
//
|
||||
// All four capture types populate the same struct. Different fields are
|
||||
// used by different test types:
|
||||
@@ -300,7 +300,7 @@ type Node struct {
|
||||
// PacketFilterMatches is the compiled filter matches (with
|
||||
// CapMatch) returned by tailscaled localapi
|
||||
// /debug-packet-filter-matches. Captured alongside
|
||||
// PacketFilterRules; useful for grant tests that want the
|
||||
// [Node.PacketFilterRules]; useful for grant tests that want the
|
||||
// compiled form.
|
||||
PacketFilterMatches []filtertype.Match `json:"packet_filter_matches,omitempty"`
|
||||
|
||||
@@ -311,7 +311,7 @@ type Node struct {
|
||||
// settle on a fresh delta-triggered notification, NOT by reading
|
||||
// the WatchIPNBus(NotifyInitialNetMap) initial notification.
|
||||
// The initial notification carries cn.NetMap() which returns
|
||||
// nb.netMap as-is — the netmap.NetworkMap whose Peers slice was
|
||||
// nb.netMap as-is — the [netmap.NetworkMap] whose Peers slice was
|
||||
// set at full-sync time and never re-synchronized from the
|
||||
// authoritative nb.peers map. The capture tool previously used the initial
|
||||
// notification and silently captured netmaps with mostly-empty
|
||||
@@ -327,6 +327,6 @@ type Node struct {
|
||||
Whois map[string]*apitype.WhoIsResponse `json:"whois,omitempty"`
|
||||
|
||||
// SSHRules is the SSH rules slice extracted from
|
||||
// netmap.SSHPolicy.Rules. Populated only for SSH scenarios.
|
||||
// [netmap.NetworkMap.SSHPolicy].Rules. Populated only for SSH scenarios.
|
||||
SSHRules []*tailcfg.SSHRule `json:"ssh_rules,omitempty"`
|
||||
}
|
||||
|
||||
@@ -138,9 +138,9 @@ func sampleSSHCapture() *testcapture.Capture {
|
||||
}
|
||||
|
||||
// equalViaJSON compares two captures by JSON-marshaling them and
|
||||
// comparing the bytes. The Capture struct embeds tailcfg view types
|
||||
// comparing the bytes. The [testcapture.Capture] struct embeds tailcfg view types
|
||||
// with unexported pointer fields that go-cmp can't traverse, so a
|
||||
// JSON round-trip is the simplest way to verify Write+Read produced
|
||||
// JSON round-trip is the simplest way to verify [testcapture.Write]+[testcapture.Read] produced
|
||||
// equivalent values.
|
||||
func equalViaJSON(t *testing.T, want, got *testcapture.Capture) {
|
||||
t.Helper()
|
||||
@@ -416,7 +416,7 @@ func TestCommentHeader_EmptyFilterRulesCountAsEmpty(t *testing.T) {
|
||||
|
||||
// TestInputUnmarshal_LegacyObjectForm asserts that a legacy capture
|
||||
// file written with full_policy as a raw JSON object (not a
|
||||
// JSON-encoded string) still deserialises into a valid Input, with
|
||||
// JSON-encoded string) still deserialises into a valid [testcapture.Input], with
|
||||
// the policy re-marshaled to a compact string so downstream consumers
|
||||
// see a uniform typed field.
|
||||
func TestInputUnmarshal_LegacyObjectForm(t *testing.T) {
|
||||
@@ -444,8 +444,8 @@ func TestInputUnmarshal_LegacyObjectForm(t *testing.T) {
|
||||
t.Errorf("FullPolicy:\n got %q\nwant %q", got.FullPolicy, want)
|
||||
}
|
||||
|
||||
// Round-trip: the new MarshalJSON must emit the object form so
|
||||
// UnmarshalJSON re-reads it identically.
|
||||
// Round-trip: the new [testcapture.Input.MarshalJSON] must emit the object form so
|
||||
// [testcapture.Input.UnmarshalJSON] re-reads it identically.
|
||||
out, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("re-marshal: %v", err)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/tailscale/hujson"
|
||||
)
|
||||
|
||||
// ErrNilCapture is returned by Write when called with a nil Capture.
|
||||
// ErrNilCapture is returned by [Write] when called with a nil [Capture].
|
||||
var ErrNilCapture = errors.New("testcapture: nil capture")
|
||||
|
||||
// Write serializes c as a HuJSON file with a comment header. The
|
||||
@@ -19,9 +19,9 @@ var ErrNilCapture = errors.New("testcapture: nil capture")
|
||||
// and is then renamed into place, so concurrent regeneration cannot
|
||||
// leave a half-written file behind.
|
||||
//
|
||||
// The comment header is built by CommentHeader from c's TestID,
|
||||
// Description, and Captures. The file's parent directory must
|
||||
// already exist; callers should MkdirAll first.
|
||||
// The comment header is built by [CommentHeader] from c's [Capture.TestID],
|
||||
// [Capture.Description], and [Capture.Captures]. The file's parent directory must
|
||||
// already exist; callers should [os.MkdirAll] first.
|
||||
func Write(path string, c *Capture) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("testcapture: Write %s: %w", path, ErrNilCapture)
|
||||
@@ -86,7 +86,7 @@ func Write(path string, c *Capture) error {
|
||||
}
|
||||
|
||||
// marshalHuJSON serializes v as HuJSON-formatted bytes. It is
|
||||
// standard JSON encoding followed by hujson.Format which produces
|
||||
// standard JSON encoding followed by [hujson.Format] which produces
|
||||
// consistent indentation/whitespace.
|
||||
func marshalHuJSON(v any) ([]byte, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
|
||||
@@ -17,18 +17,18 @@ const EnvTestLogLevel = "HEADSCALE_TEST_LOG_LEVEL"
|
||||
// emits zerolog output, so this init() runs once per test binary and is
|
||||
// the only place that needs to know about test logging configuration.
|
||||
//
|
||||
// Default: ErrorLevel (silent in green-path runs, real errors still surface).
|
||||
// Default: [zerolog.ErrorLevel] (silent in green-path runs, real errors still surface).
|
||||
// Override: HEADSCALE_TEST_LOG_LEVEL=debug (or trace, info, warn, disabled).
|
||||
//
|
||||
// Production binaries are unaffected because testing.Testing() returns false
|
||||
// outside of test execution. The same testing.Testing() pattern is already
|
||||
// Production binaries are unaffected because [testing.Testing] returns false
|
||||
// outside of test execution. The same [testing.Testing] pattern is already
|
||||
// used in hscontrol/db/users.go and hscontrol/db/node.go, so importing the
|
||||
// testing package here is consistent with existing project conventions.
|
||||
//
|
||||
// Pitfalls:
|
||||
// - log.Fatal still calls os.Exit and log.Panic still panics regardless of
|
||||
// - log.Fatal still calls [os.Exit] and log.Panic still panics regardless of
|
||||
// level — only the rendered message is suppressed.
|
||||
// - Local buffer loggers (zerolog.New(&buf)) are also gated by the global
|
||||
// - Local buffer loggers ([zerolog.New] with a buffer) are also gated by the global
|
||||
// level. Tests that assert on log output (currently only
|
||||
// hscontrol/util/zlog) re-enable trace level via their own init_test.go.
|
||||
func init() {
|
||||
|
||||
@@ -92,22 +92,22 @@ func (v *UserView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
|
||||
func (v UserView) Model() gorm.Model { return v.ж.Model }
|
||||
|
||||
// Name (username) for the user, is used if email is empty
|
||||
// Should not be used, please use Username().
|
||||
// It is unique if ProviderIdentifier is not set.
|
||||
// Should not be used, please use [User.Username].
|
||||
// It is unique if [User.ProviderIdentifier] is not set.
|
||||
func (v UserView) Name() string { return v.ж.Name }
|
||||
|
||||
// Typically the full name of the user
|
||||
func (v UserView) DisplayName() string { return v.ж.DisplayName }
|
||||
|
||||
// Email of the user
|
||||
// Should not be used, please use Username().
|
||||
// Should not be used, please use [User.Username].
|
||||
func (v UserView) Email() string { return v.ж.Email }
|
||||
|
||||
// ProviderIdentifier is a unique or not set identifier of the
|
||||
// user from OIDC. It is the combination of `iss`
|
||||
// and `sub` claim in the OIDC token.
|
||||
// It is unique if set.
|
||||
// It is unique together with Name.
|
||||
// It is unique together with [User.Name].
|
||||
func (v UserView) ProviderIdentifier() sql.NullString { return v.ж.ProviderIdentifier }
|
||||
|
||||
// Provider is the origin of the user account,
|
||||
@@ -208,8 +208,8 @@ func (v NodeView) IPv6() views.ValuePointer[netip.Addr] { return views.ValuePoin
|
||||
func (v NodeView) Hostname() string { return v.ж.Hostname }
|
||||
|
||||
// Givenname represents either:
|
||||
// a DNS normalized version of Hostname
|
||||
// a valid name set by the User
|
||||
// a DNS normalized version of [Node.Hostname]
|
||||
// a valid name set by the [User]
|
||||
//
|
||||
// GivenName is the name used in all DNS related
|
||||
// parts of headscale.
|
||||
@@ -228,7 +228,7 @@ func (v NodeView) RegisterMethod() string { return v.ж.RegisterMethod }
|
||||
// Tags cannot be removed once set (one-way transition).
|
||||
func (v NodeView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) }
|
||||
|
||||
// When a node has been created with a PreAuthKey, we need to
|
||||
// When a node has been created with a [PreAuthKey], we need to
|
||||
// prevent the preauthkey from being deleted before the node.
|
||||
// The preauthkey can define "tags" of the node so we need it
|
||||
// around.
|
||||
@@ -376,8 +376,8 @@ func (v PreAuthKeyView) Prefix() string { return v.ж.Prefix }
|
||||
// bcrypt
|
||||
func (v PreAuthKeyView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Hash) }
|
||||
|
||||
// For tagged keys: UserID tracks who created the key (informational)
|
||||
// For user-owned keys: UserID tracks the node owner
|
||||
// For tagged keys: [PreAuthKey.UserID] tracks who created the key (informational)
|
||||
// For user-owned keys: [PreAuthKey.UserID] tracks the node owner
|
||||
// Can be nil for system-created tagged keys
|
||||
func (v PreAuthKeyView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) }
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const (
|
||||
TaggedDevicesUserID = 2147455555
|
||||
)
|
||||
|
||||
// TaggedDevices is a special user used in MapResponse for tagged nodes.
|
||||
// TaggedDevices is a special user used in [tailcfg.MapResponse] for tagged nodes.
|
||||
// Tagged nodes don't belong to a real user - the tag is their identity.
|
||||
// This special user ID is used when rendering tagged nodes in the Tailscale protocol.
|
||||
var TaggedDevices = User{
|
||||
@@ -72,22 +72,22 @@ type User struct {
|
||||
// but not if you only run with CLI users.
|
||||
|
||||
// Name (username) for the user, is used if email is empty
|
||||
// Should not be used, please use Username().
|
||||
// It is unique if ProviderIdentifier is not set.
|
||||
// Should not be used, please use [User.Username].
|
||||
// It is unique if [User.ProviderIdentifier] is not set.
|
||||
Name string
|
||||
|
||||
// Typically the full name of the user
|
||||
DisplayName string
|
||||
|
||||
// Email of the user
|
||||
// Should not be used, please use Username().
|
||||
// Should not be used, please use [User.Username].
|
||||
Email string
|
||||
|
||||
// ProviderIdentifier is a unique or not set identifier of the
|
||||
// user from OIDC. It is the combination of `iss`
|
||||
// and `sub` claim in the OIDC token.
|
||||
// It is unique if set.
|
||||
// It is unique together with Name.
|
||||
// It is unique together with [User.Name].
|
||||
ProviderIdentifier sql.NullString
|
||||
|
||||
// Provider is the origin of the user account,
|
||||
@@ -105,7 +105,7 @@ func (u *User) StringID() string {
|
||||
return strconv.FormatUint(uint64(u.ID), 10)
|
||||
}
|
||||
|
||||
// TypedID returns a pointer to the user's ID as a UserID type.
|
||||
// TypedID returns a pointer to the user's ID as a [UserID] type.
|
||||
// This is a convenience method to avoid ugly casting like ptr.To(types.UserID(user.ID)).
|
||||
func (u *User) TypedID() *UserID {
|
||||
uid := UserID(u.ID)
|
||||
@@ -128,8 +128,8 @@ func (u *User) Username() string {
|
||||
)
|
||||
}
|
||||
|
||||
// Display returns the DisplayName if it exists, otherwise
|
||||
// it will return the Username.
|
||||
// Display returns the [User.DisplayName] if it exists, otherwise
|
||||
// it will return the [User.Username].
|
||||
func (u *User) Display() string {
|
||||
return cmp.Or(u.DisplayName, u.Username())
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func (u UserView) TailscaleUser() tailcfg.User {
|
||||
}
|
||||
|
||||
// ID returns the user's ID.
|
||||
// This is a custom accessor because gorm.Model.ID is embedded
|
||||
// This is a custom accessor because [gorm.Model].ID is embedded
|
||||
// and the viewer generator doesn't always produce it.
|
||||
func (u UserView) ID() uint {
|
||||
return u.ж.ID
|
||||
@@ -208,7 +208,7 @@ func (u *User) Proto() *v1.User {
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging.
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
|
||||
func (u *User) MarshalZerologObject(e *zerolog.Event) {
|
||||
if u == nil {
|
||||
return
|
||||
@@ -223,7 +223,7 @@ func (u *User) MarshalZerologObject(e *zerolog.Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements zerolog.LogObjectMarshaler for UserView.
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for [UserView].
|
||||
func (u UserView) MarshalZerologObject(e *zerolog.Event) {
|
||||
if !u.Valid() {
|
||||
return
|
||||
@@ -239,6 +239,7 @@ type FlexibleStringSlice []string
|
||||
|
||||
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||
var arr []string
|
||||
|
||||
err := json.Unmarshal(data, &arr)
|
||||
if err == nil {
|
||||
*f = arr
|
||||
@@ -246,6 +247,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
var single string
|
||||
|
||||
err = json.Unmarshal(data, &single)
|
||||
if err == nil {
|
||||
*f = []string{single}
|
||||
@@ -259,7 +261,6 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||
// string "true" or "false" instead of a boolean.
|
||||
// This maps bool to a specific type with a custom unmarshaler to
|
||||
// ensure we can decode it from a string.
|
||||
// https://github.com/juanfont/headscale/issues/2293
|
||||
type FlexibleBoolean bool
|
||||
|
||||
func (bit *FlexibleBoolean) UnmarshalJSON(data []byte) error {
|
||||
@@ -294,23 +295,23 @@ type OIDCClaims struct {
|
||||
Iss string `json:"iss"`
|
||||
|
||||
// Name is the user's full name.
|
||||
Name string `json:"name,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Groups FlexibleStringSlice `json:"groups,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
|
||||
ProfilePictureURL string `json:"picture,omitempty"`
|
||||
Username string `json:"preferred_username,omitempty"`
|
||||
ProfilePictureURL string `json:"picture,omitempty"`
|
||||
Username string `json:"preferred_username,omitempty"`
|
||||
}
|
||||
|
||||
// Identifier returns a unique identifier string combining the Iss and Sub claims.
|
||||
// The format depends on whether Iss is a URL or not:
|
||||
// Identifier returns a unique identifier string combining the [OIDCClaims.Iss] and [OIDCClaims.Sub] claims.
|
||||
// The format depends on whether [OIDCClaims.Iss] is a URL or not:
|
||||
// - For URLs: Joins the URL and sub path (e.g., "https://example.com/sub")
|
||||
// - For non-URLs: Joins with a slash (e.g., "oidc/sub")
|
||||
// - For empty Iss: Returns just "sub"
|
||||
// - For empty Sub: Returns just the Issuer
|
||||
// - For empty [OIDCClaims.Iss]: Returns just "sub"
|
||||
// - For empty [OIDCClaims.Sub]: Returns just the Issuer
|
||||
// - For both empty: Returns empty string
|
||||
//
|
||||
// The result is cleaned using CleanIdentifier() to ensure consistent formatting.
|
||||
// The result is cleaned using [CleanIdentifier] to ensure consistent formatting.
|
||||
func (c *OIDCClaims) Identifier() string {
|
||||
// Handle empty components special cases
|
||||
if c.Iss == "" && c.Sub == "" {
|
||||
@@ -407,18 +408,18 @@ func CleanIdentifier(identifier string) string {
|
||||
}
|
||||
|
||||
type OIDCUserInfo struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email"`
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
|
||||
Groups FlexibleStringSlice `json:"groups"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
// FromClaim overrides a User from OIDC claims.
|
||||
// FromClaim overrides a [User] from OIDC claims.
|
||||
// All fields will be updated, except for the ID.
|
||||
func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) {
|
||||
err := util.ValidateUsername(claims.Username)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"go4.org/netipx"
|
||||
)
|
||||
|
||||
// This is borrowed from, and updated to use IPSet
|
||||
// This is borrowed from, and updated to use [netipx.IPSet]
|
||||
// https://github.com/tailscale/tailscale/blob/71029cea2ddf82007b80f465b256d027eab0f02d/wgengine/filter/tailcfg.go#L97-L162
|
||||
// TODO(kradalby): contribute upstream and make public.
|
||||
var (
|
||||
@@ -24,7 +24,7 @@ var (
|
||||
// - a CIDR (e.g. "192.168.0.0/16")
|
||||
// - a range of two IPs, inclusive, separated by hyphen ("2eff::1-2eff::0800")
|
||||
//
|
||||
// bits, if non-nil, is the legacy SrcBits CIDR length to make a IP
|
||||
// bits, if non-nil, is the legacy [tailcfg.FilterRule.SrcBits] CIDR length to make a IP
|
||||
// address (without a slash) treated as a CIDR of *bits length.
|
||||
// nolint
|
||||
func ParseIPSet(arg string, bits *int) (*netipx.IPSet, error) {
|
||||
@@ -114,7 +114,7 @@ func StringToIPPrefix(prefixes []string) ([]netip.Prefix, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IPSetAddrIter returns a function that iterates over all the IPs in the IPSet.
|
||||
// IPSetAddrIter returns a function that iterates over all the IPs in the [netipx.IPSet].
|
||||
func IPSetAddrIter(ipSet *netipx.IPSet) iter.Seq[netip.Addr] {
|
||||
return func(yield func(netip.Addr) bool) {
|
||||
for _, rng := range ipSet.Ranges() {
|
||||
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
)
|
||||
|
||||
// DNS validation errors. Hostname-side validation lives on
|
||||
// `tailscale.com/util/dnsname` and NodeStore collision handling; only
|
||||
// `tailscale.com/util/dnsname` and [state.NodeStore] collision handling; only
|
||||
// the username-side errors stay in this package.
|
||||
var (
|
||||
ErrUsernameTooShort = errors.New("username must be at least 2 characters long")
|
||||
@@ -71,12 +71,12 @@ func ValidateUsername(username string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateMagicDNSRootDomains generates a list of DNS entries to be included in `Routes` in `MapResponse`.
|
||||
// generateMagicDNSRootDomains generates a list of DNS entries to be included in [tailcfg.DNSConfig.Routes] in [tailcfg.MapResponse].
|
||||
// This list of reverse DNS entries instructs the OS on what subnets and domains the Tailscale embedded DNS
|
||||
// server (listening in 100.100.100.100 udp/53) should be used for.
|
||||
//
|
||||
// Tailscale.com includes in the list:
|
||||
// - the `BaseDomain` of the user
|
||||
// - the [types.DNSConfig.BaseDomain] of the user
|
||||
// - the reverse DNS entry for IPv6 (0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa., see below more on IPv6)
|
||||
// - the reverse DNS entries for the IPv4 subnets covered by the user's `IPPrefix`.
|
||||
// In the public SaaS this is [64-127].100.in-addr.arpa.
|
||||
@@ -93,7 +93,7 @@ func ValidateUsername(username string) error {
|
||||
// From the netmask we can find out the wildcard bits (the bits that are not set in the netmask).
|
||||
// This allows us to then calculate the subnets included in the subsequent class block and generate the entries.
|
||||
func GenerateIPv4DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN {
|
||||
// Conversion to the std lib net.IPnet, a bit easier to operate
|
||||
// Conversion to the std lib [net.IPNet], a bit easier to operate
|
||||
netRange := netipx.PrefixIPNet(ipPrefix)
|
||||
maskBits, _ := netRange.Mask.Size()
|
||||
|
||||
@@ -130,12 +130,12 @@ func GenerateIPv4DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN {
|
||||
return fqdns
|
||||
}
|
||||
|
||||
// generateMagicDNSRootDomains generates a list of DNS entries to be included in `Routes` in `MapResponse`.
|
||||
// generateMagicDNSRootDomains generates a list of DNS entries to be included in [tailcfg.DNSConfig.Routes] in [tailcfg.MapResponse].
|
||||
// This list of reverse DNS entries instructs the OS on what subnets and domains the Tailscale embedded DNS
|
||||
// server (listening in 100.100.100.100 udp/53) should be used for.
|
||||
//
|
||||
// Tailscale.com includes in the list:
|
||||
// - the `BaseDomain` of the user
|
||||
// - the [types.DNSConfig.BaseDomain] of the user
|
||||
// - the reverse DNS entry for IPv6 (0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa., see below more on IPv6)
|
||||
// - the reverse DNS entries for the IPv4 subnets covered by the user's `IPPrefix`.
|
||||
// In the public SaaS this is [64-127].100.in-addr.arpa.
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
|
||||
func TestMagicDNSRootDomains100(t *testing.T) {
|
||||
domains := GenerateIPv4DNSRootDomain(netip.MustParsePrefix("100.64.0.0/10"))
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ func MustStringsToPrefixes(strings []string) []netip.Prefix {
|
||||
return ret
|
||||
}
|
||||
|
||||
// TheInternet returns the IPSet for the Internet.
|
||||
// TheInternet returns the [netipx.IPSet] for the Internet.
|
||||
// https://www.youtube.com/watch?v=iDbyYGrswtg
|
||||
var TheInternet = sync.OnceValue(func() *netipx.IPSet {
|
||||
var internetBuilder netipx.IPSetBuilder
|
||||
|
||||
@@ -94,7 +94,7 @@ type Traceroute struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// ParseTraceroute parses the output of the traceroute command and returns a Traceroute struct.
|
||||
// ParseTraceroute parses the output of the traceroute command and returns a [Traceroute] struct.
|
||||
func ParseTraceroute(output string) (Traceroute, error) {
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
if len(lines) < 1 {
|
||||
|
||||
@@ -796,7 +796,6 @@ over a maximum of 30 hops:
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestGenerateRegistrationKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Package zlog provides zerolog utilities for safe and consistent logging.
|
||||
//
|
||||
// This package contains:
|
||||
// - Safe wrapper types for external types (tailcfg.Hostinfo, tailcfg.MapRequest)
|
||||
// that implement LogObjectMarshaler with security-conscious field redaction
|
||||
// - Safe wrapper types for external types ([tailcfg.Hostinfo], [tailcfg.MapRequest])
|
||||
// that implement [zerolog.LogObjectMarshaler] with security-conscious field redaction
|
||||
//
|
||||
// For field name constants, use the zf subpackage:
|
||||
//
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user