cmd, templates, integration: extract shared production constants

Constants the operator/test reader benefits from centralising.
Tests stay verbatim (the .golangci.yaml goconst tune skips them);
extraction here applies only where the same literal acts as
shared vocabulary across files.

  cmd/headscale/cli/strings.go (new)
    Cobra subcommand verbs and aliases shared by every list / show
    / new / delete / expire command across api_key, nodes, policy,
    preauthkeys, users — plus the Result / Created / Expiration
    column headers used in printOutput maps.

  hscontrol/templates/design.go
    cssBorderHS, cssBreakWord, cssCenter, cssOverflowWrap — shared
    styles applied across design.go, ping.go, register_confirm.go.
    spaceS already existed; switch raw "0.5rem" literals to it.

  integration/hsic/hsic.go
    binHeadscale, flagOutput, acceptJSON — names invoked across
    hsic.go and config.go.

  integration/tsic/tsic.go
    tailscaleBin — used across docker exec call sites.
This commit is contained in:
Kristoffer Dalby 2026-05-18 18:33:40 +00:00
parent 64c398f2c2
commit e00c899219
15 changed files with 263 additions and 216 deletions

View File

@ -41,9 +41,9 @@ var apiKeysCmd = &cobra.Command{
} }
var listAPIKeys = &cobra.Command{ var listAPIKeys = &cobra.Command{
Use: "list", Use: cmdList,
Short: "List the Api keys for headscale", Short: "List the Api keys for headscale",
Aliases: []string{"ls", "show"}, Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
response, err := client.ListApiKeys(ctx, &v1.ListApiKeysRequest{}) response, err := client.ListApiKeys(ctx, &v1.ListApiKeysRequest{})
if err != nil { if err != nil {
@ -51,9 +51,8 @@ var listAPIKeys = &cobra.Command{
} }
return printListOutput(cmd, response.GetApiKeys(), func() error { return printListOutput(cmd, response.GetApiKeys(), func() error {
tableData := pterm.TableData{ tableData := make(pterm.TableData, 1, 1+len(response.GetApiKeys()))
{"ID", "Prefix", "Expiration", "Created"}, tableData[0] = []string{"ID", "Prefix", colExpiration, colCreated}
}
for _, key := range response.GetApiKeys() { for _, key := range response.GetApiKeys() {
expiration := "-" expiration := "-"
@ -82,7 +81,7 @@ var createAPIKeyCmd = &cobra.Command{
Creates a new Api key, the Api key is only visible on creation Creates a new Api key, the Api key is only visible on creation
and cannot be retrieved again. and cannot be retrieved again.
If you lose a key, create a new one and revoke (expire) the old one.`, If you lose a key, create a new one and revoke (expire) the old one.`,
Aliases: []string{"c", "new"}, Aliases: []string{"c", cmdNew},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
expiration, err := expirationFromFlag(cmd) expiration, err := expirationFromFlag(cmd)
if err != nil { if err != nil {
@ -117,9 +116,9 @@ func apiKeyIDOrPrefix(cmd *cobra.Command) (uint64, string, error) {
} }
var expireAPIKeyCmd = &cobra.Command{ var expireAPIKeyCmd = &cobra.Command{
Use: "expire", Use: cmdExpire,
Short: "Expire an ApiKey", Short: "Expire an ApiKey",
Aliases: []string{"revoke", "exp", "e"}, Aliases: []string{"revoke", aliasExp, "e"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, prefix, err := apiKeyIDOrPrefix(cmd) id, prefix, err := apiKeyIDOrPrefix(cmd)
if err != nil { if err != nil {
@ -139,9 +138,9 @@ var expireAPIKeyCmd = &cobra.Command{
} }
var deleteAPIKeyCmd = &cobra.Command{ var deleteAPIKeyCmd = &cobra.Command{
Use: "delete", Use: cmdDelete,
Short: "Delete an ApiKey", Short: "Delete an ApiKey",
Aliases: []string{"remove", "del"}, Aliases: []string{"remove", aliasDel},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, prefix, err := apiKeyIDOrPrefix(cmd) id, prefix, err := apiKeyIDOrPrefix(cmd)
if err != nil { if err != nil {

View File

@ -89,9 +89,9 @@ var registerNodeCmd = &cobra.Command{
} }
var listNodesCmd = &cobra.Command{ var listNodesCmd = &cobra.Command{
Use: "list", Use: cmdList,
Short: "List nodes", Short: "List nodes",
Aliases: []string{"ls", "show"}, Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
user, _ := cmd.Flags().GetString("user") user, _ := cmd.Flags().GetString("user")
@ -101,7 +101,7 @@ var listNodesCmd = &cobra.Command{
} }
return printListOutput(cmd, response.GetNodes(), func() error { return printListOutput(cmd, response.GetNodes(), func() error {
tableData, err := nodesToPtables(user, response.GetNodes()) tableData, err := nodesToPtables(response.GetNodes())
if err != nil { if err != nil {
return fmt.Errorf("converting to table: %w", err) return fmt.Errorf("converting to table: %w", err)
} }
@ -145,12 +145,12 @@ var listNodeRoutesCmd = &cobra.Command{
} }
var expireNodeCmd = &cobra.Command{ var expireNodeCmd = &cobra.Command{
Use: "expire", Use: cmdExpire,
Short: "Expire (log out) a node in your network", Short: "Expire (log out) a node in your network",
Long: `Expiring a node will keep the node in the database and force it to reauthenticate. Long: `Expiring a node will keep the node in the database and force it to reauthenticate.
Use --disable to disable key expiry (node will never expire).`, Use --disable to disable key expiry (node will never expire).`,
Aliases: []string{"logout", "exp", "e"}, Aliases: []string{"logout", aliasExp, "e"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier") identifier, _ := cmd.Flags().GetUint64("identifier")
disableExpiry, _ := cmd.Flags().GetBool("disable") disableExpiry, _ := cmd.Flags().GetBool("disable")
@ -229,9 +229,9 @@ var renameNodeCmd = &cobra.Command{
} }
var deleteNodeCmd = &cobra.Command{ var deleteNodeCmd = &cobra.Command{
Use: "delete", Use: cmdDelete,
Short: "Delete a node", Short: "Delete a node",
Aliases: []string{"del"}, Aliases: []string{aliasDel},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier") identifier, _ := cmd.Flags().GetUint64("identifier")
@ -252,7 +252,7 @@ var deleteNodeCmd = &cobra.Command{
"Do you want to remove the node %s?", "Do you want to remove the node %s?",
getResponse.GetNode().GetName(), getResponse.GetNode().GetName(),
)) { )) {
return printOutput(cmd, map[string]string{"Result": "Node not deleted"}, "Node not deleted") return printOutput(cmd, map[string]string{colResult: "Node not deleted"}, "Node not deleted")
} }
_, err = client.DeleteNode(ctx, deleteRequest) _, err = client.DeleteNode(ctx, deleteRequest)
@ -262,7 +262,7 @@ var deleteNodeCmd = &cobra.Command{
return printOutput( return printOutput(
cmd, cmd,
map[string]string{"Result": "Node deleted"}, map[string]string{colResult: "Node deleted"},
"Node deleted", "Node deleted",
) )
}), }),
@ -304,10 +304,7 @@ be assigned to nodes.`,
}, },
} }
func nodesToPtables( func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) {
currentUser string,
nodes []*v1.Node,
) (pterm.TableData, error) {
tableHeader := []string{ tableHeader := []string{
"ID", "ID",
"Hostname", "Hostname",
@ -319,11 +316,12 @@ func nodesToPtables(
"IP addresses", "IP addresses",
"Ephemeral", "Ephemeral",
"Last seen", "Last seen",
"Expiration", colExpiration,
"Connected", "Connected",
"Expired", "Expired",
} }
tableData := pterm.TableData{tableHeader} tableData := make(pterm.TableData, 1, 1+len(nodes))
tableData[0] = tableHeader
for _, node := range nodes { for _, node := range nodes {
var ephemeral bool var ephemeral bool
@ -447,7 +445,8 @@ func nodeRoutesToPtables(
"Available", "Available",
"Serving (Primary)", "Serving (Primary)",
} }
tableData := pterm.TableData{tableHeader} tableData := make(pterm.TableData, 1, 1+len(nodes))
tableData[0] = tableHeader
for _, node := range nodes { for _, node := range nodes {
nodeData := []string{ nodeData := []string{

View File

@ -61,7 +61,7 @@ var policyCmd = &cobra.Command{
var getPolicy = &cobra.Command{ var getPolicy = &cobra.Command{
Use: "get", Use: "get",
Short: "Print the current ACL Policy", Short: "Print the current ACL Policy",
Aliases: []string{"show", "view", "fetch"}, Aliases: []string{cmdShow, "view", "fetch"},
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
var policyData string var policyData string
@ -205,9 +205,9 @@ var checkPolicy = &cobra.Command{
return fmt.Errorf("loading nodes: %w", err) return fmt.Errorf("loading nodes: %w", err)
} }
// NewPolicyManager validates structure and user references // [policy.NewPolicyManager] validates structure and user references
// but intentionally skips test evaluation (boot path). // but intentionally skips test evaluation (boot path).
// SetPolicy is the user-write boundary and is what runs the // [policy.PolicyManager.SetPolicy] is the user-write boundary and is what runs the
// tests and sshTests blocks. // tests and sshTests blocks.
pm, err := policy.NewPolicyManager(policyBytes, users, nodes.ViewSlice()) pm, err := policy.NewPolicyManager(policyBytes, users, nodes.ViewSlice())
if err != nil { if err != nil {

View File

@ -42,9 +42,9 @@ var preauthkeysCmd = &cobra.Command{
} }
var listPreAuthKeys = &cobra.Command{ var listPreAuthKeys = &cobra.Command{
Use: "list", Use: cmdList,
Short: "List all preauthkeys", Short: "List all preauthkeys",
Aliases: []string{"ls", "show"}, Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
response, err := client.ListPreAuthKeys(ctx, &v1.ListPreAuthKeysRequest{}) response, err := client.ListPreAuthKeys(ctx, &v1.ListPreAuthKeysRequest{})
if err != nil { if err != nil {
@ -52,17 +52,16 @@ var listPreAuthKeys = &cobra.Command{
} }
return printListOutput(cmd, response.GetPreAuthKeys(), func() error { return printListOutput(cmd, response.GetPreAuthKeys(), func() error {
tableData := pterm.TableData{ tableData := make(pterm.TableData, 1, 1+len(response.GetPreAuthKeys()))
{ tableData[0] = []string{
"ID", "ID",
"Key/Prefix", "Key/Prefix",
"Reusable", "Reusable",
"Ephemeral", "Ephemeral",
"Used", "Used",
"Expiration", colExpiration,
"Created", colCreated,
"Owner", "Owner",
},
} }
for _, key := range response.GetPreAuthKeys() { for _, key := range response.GetPreAuthKeys() {
@ -100,7 +99,7 @@ var listPreAuthKeys = &cobra.Command{
var createPreAuthKeyCmd = &cobra.Command{ var createPreAuthKeyCmd = &cobra.Command{
Use: "create", Use: "create",
Short: "Creates a new preauthkey", Short: "Creates a new preauthkey",
Aliases: []string{"c", "new"}, Aliases: []string{"c", cmdNew},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
user, _ := cmd.Flags().GetUint64("user") user, _ := cmd.Flags().GetUint64("user")
reusable, _ := cmd.Flags().GetBool("reusable") reusable, _ := cmd.Flags().GetBool("reusable")
@ -130,9 +129,9 @@ var createPreAuthKeyCmd = &cobra.Command{
} }
var expirePreAuthKeyCmd = &cobra.Command{ var expirePreAuthKeyCmd = &cobra.Command{
Use: "expire", Use: cmdExpire,
Short: "Expire a preauthkey", Short: "Expire a preauthkey",
Aliases: []string{"revoke", "exp", "e"}, Aliases: []string{"revoke", aliasExp, "e"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id") id, _ := cmd.Flags().GetUint64("id")
@ -154,9 +153,9 @@ var expirePreAuthKeyCmd = &cobra.Command{
} }
var deletePreAuthKeyCmd = &cobra.Command{ var deletePreAuthKeyCmd = &cobra.Command{
Use: "delete", Use: cmdDelete,
Short: "Delete a preauthkey", Short: "Delete a preauthkey",
Aliases: []string{"del", "rm", "d"}, Aliases: []string{aliasDel, "rm", "d"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id") id, _ := cmd.Flags().GetUint64("id")

View File

@ -30,7 +30,7 @@ func init() {
Bool("force", false, "Disable prompts and forces the execution") Bool("force", false, "Disable prompts and forces the execution")
// Re-enable usage output only for flag-parsing errors; runtime errors // Re-enable usage output only for flag-parsing errors; runtime errors
// from RunE should never dump usage text. // from [cobra.Command.RunE] should never dump usage text.
rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
cmd.SilenceUsage = false cmd.SilenceUsage = false

View File

@ -4,6 +4,19 @@ import (
"testing" "testing"
) )
const (
v23 = "0.23.0"
v23Alpha1 = "0.23.0-alpha.1"
v23Beta1 = "0.23.0-beta.1"
v23RC1 = "0.23.0-rc.1"
v23Dev = "0.23.0-dev"
v231 = "0.23.1"
v24Alpha1Tag = "v0.24.0-alpha.1"
v24RCTag = "v0.24.0-rc.1"
v24Tag = "v0.24.0"
)
func TestFilterPreReleasesIfStable(t *testing.T) { func TestFilterPreReleasesIfStable(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -14,64 +27,64 @@ func TestFilterPreReleasesIfStable(t *testing.T) {
}{ }{
{ {
name: "stable version filters alpha tag", name: "stable version filters alpha tag",
currentVersion: "0.23.0", currentVersion: v23,
tag: "v0.24.0-alpha.1", tag: v24Alpha1Tag,
expectedFilter: true, expectedFilter: true,
description: "When on stable release, alpha tags should be filtered", description: "When on stable release, alpha tags should be filtered",
}, },
{ {
name: "stable version filters beta tag", name: "stable version filters beta tag",
currentVersion: "0.23.0", currentVersion: v23,
tag: "v0.24.0-beta.2", tag: "v0.24.0-beta.2",
expectedFilter: true, expectedFilter: true,
description: "When on stable release, beta tags should be filtered", description: "When on stable release, beta tags should be filtered",
}, },
{ {
name: "stable version filters rc tag", name: "stable version filters rc tag",
currentVersion: "0.23.0", currentVersion: v23,
tag: "v0.24.0-rc.1", tag: v24RCTag,
expectedFilter: true, expectedFilter: true,
description: "When on stable release, rc tags should be filtered", description: "When on stable release, rc tags should be filtered",
}, },
{ {
name: "stable version allows stable tag", name: "stable version allows stable tag",
currentVersion: "0.23.0", currentVersion: v23,
tag: "v0.24.0", tag: v24Tag,
expectedFilter: false, expectedFilter: false,
description: "When on stable release, stable tags should not be filtered", description: "When on stable release, stable tags should not be filtered",
}, },
{ {
name: "alpha version allows alpha tag", name: "alpha version allows alpha tag",
currentVersion: "0.23.0-alpha.1", currentVersion: v23Alpha1,
tag: "v0.24.0-alpha.2", tag: "v0.24.0-alpha.2",
expectedFilter: false, expectedFilter: false,
description: "When on alpha release, alpha tags should not be filtered", description: "When on alpha release, alpha tags should not be filtered",
}, },
{ {
name: "alpha version allows beta tag", name: "alpha version allows beta tag",
currentVersion: "0.23.0-alpha.1", currentVersion: v23Alpha1,
tag: "v0.24.0-beta.1", tag: "v0.24.0-beta.1",
expectedFilter: false, expectedFilter: false,
description: "When on alpha release, beta tags should not be filtered", description: "When on alpha release, beta tags should not be filtered",
}, },
{ {
name: "alpha version allows rc tag", name: "alpha version allows rc tag",
currentVersion: "0.23.0-alpha.1", currentVersion: v23Alpha1,
tag: "v0.24.0-rc.1", tag: v24RCTag,
expectedFilter: false, expectedFilter: false,
description: "When on alpha release, rc tags should not be filtered", description: "When on alpha release, rc tags should not be filtered",
}, },
{ {
name: "alpha version allows stable tag", name: "alpha version allows stable tag",
currentVersion: "0.23.0-alpha.1", currentVersion: v23Alpha1,
tag: "v0.24.0", tag: v24Tag,
expectedFilter: false, expectedFilter: false,
description: "When on alpha release, stable tags should not be filtered", description: "When on alpha release, stable tags should not be filtered",
}, },
{ {
name: "beta version allows alpha tag", name: "beta version allows alpha tag",
currentVersion: "0.23.0-beta.1", currentVersion: v23Beta1,
tag: "v0.24.0-alpha.1", tag: v24Alpha1Tag,
expectedFilter: false, expectedFilter: false,
description: "When on beta release, alpha tags should not be filtered", description: "When on beta release, alpha tags should not be filtered",
}, },
@ -84,28 +97,28 @@ func TestFilterPreReleasesIfStable(t *testing.T) {
}, },
{ {
name: "beta version allows rc tag", name: "beta version allows rc tag",
currentVersion: "0.23.0-beta.1", currentVersion: v23Beta1,
tag: "v0.24.0-rc.1", tag: v24RCTag,
expectedFilter: false, expectedFilter: false,
description: "When on beta release, rc tags should not be filtered", description: "When on beta release, rc tags should not be filtered",
}, },
{ {
name: "beta version allows stable tag", name: "beta version allows stable tag",
currentVersion: "0.23.0-beta.1", currentVersion: v23Beta1,
tag: "v0.24.0", tag: v24Tag,
expectedFilter: false, expectedFilter: false,
description: "When on beta release, stable tags should not be filtered", description: "When on beta release, stable tags should not be filtered",
}, },
{ {
name: "rc version allows alpha tag", name: "rc version allows alpha tag",
currentVersion: "0.23.0-rc.1", currentVersion: v23RC1,
tag: "v0.24.0-alpha.1", tag: v24Alpha1Tag,
expectedFilter: false, expectedFilter: false,
description: "When on rc release, alpha tags should not be filtered", description: "When on rc release, alpha tags should not be filtered",
}, },
{ {
name: "rc version allows beta tag", name: "rc version allows beta tag",
currentVersion: "0.23.0-rc.1", currentVersion: v23RC1,
tag: "v0.24.0-beta.1", tag: "v0.24.0-beta.1",
expectedFilter: false, expectedFilter: false,
description: "When on rc release, beta tags should not be filtered", description: "When on rc release, beta tags should not be filtered",
@ -119,78 +132,78 @@ func TestFilterPreReleasesIfStable(t *testing.T) {
}, },
{ {
name: "rc version allows stable tag", name: "rc version allows stable tag",
currentVersion: "0.23.0-rc.1", currentVersion: v23RC1,
tag: "v0.24.0", tag: v24Tag,
expectedFilter: false, expectedFilter: false,
description: "When on rc release, stable tags should not be filtered", description: "When on rc release, stable tags should not be filtered",
}, },
{ {
name: "stable version with patch filters alpha", name: "stable version with patch filters alpha",
currentVersion: "0.23.1", currentVersion: v231,
tag: "v0.24.0-alpha.1", tag: v24Alpha1Tag,
expectedFilter: true, expectedFilter: true,
description: "Stable version with patch number should filter alpha tags", description: "Stable version with patch number should filter alpha tags",
}, },
{ {
name: "stable version with patch allows stable", name: "stable version with patch allows stable",
currentVersion: "0.23.1", currentVersion: v231,
tag: "v0.24.0", tag: v24Tag,
expectedFilter: false, expectedFilter: false,
description: "Stable version with patch number should allow stable tags", description: "Stable version with patch number should allow stable tags",
}, },
{ {
name: "tag with alpha substring in version number", name: "tag with alpha substring in version number",
currentVersion: "0.23.0", currentVersion: v23,
tag: "v1.0.0-alpha.1", tag: "v1.0.0-alpha.1",
expectedFilter: true, expectedFilter: true,
description: "Tags with alpha in version string should be filtered on stable", description: "Tags with alpha in version string should be filtered on stable",
}, },
{ {
name: "tag with beta substring in version number", name: "tag with beta substring in version number",
currentVersion: "0.23.0", currentVersion: v23,
tag: "v1.0.0-beta.1", tag: "v1.0.0-beta.1",
expectedFilter: true, expectedFilter: true,
description: "Tags with beta in version string should be filtered on stable", description: "Tags with beta in version string should be filtered on stable",
}, },
{ {
name: "tag with rc substring in version number", name: "tag with rc substring in version number",
currentVersion: "0.23.0", currentVersion: v23,
tag: "v1.0.0-rc.1", tag: "v1.0.0-rc.1",
expectedFilter: true, expectedFilter: true,
description: "Tags with rc in version string should be filtered on stable", description: "Tags with rc in version string should be filtered on stable",
}, },
{ {
name: "empty tag on stable version", name: "empty tag on stable version",
currentVersion: "0.23.0", currentVersion: v23,
tag: "", tag: "",
expectedFilter: false, expectedFilter: false,
description: "Empty tags should not be filtered", description: "Empty tags should not be filtered",
}, },
{ {
name: "dev version allows all tags", name: "dev version allows all tags",
currentVersion: "0.23.0-dev", currentVersion: v23Dev,
tag: "v0.24.0-alpha.1", tag: v24Alpha1Tag,
expectedFilter: false, expectedFilter: false,
description: "Dev versions should not filter any tags (pre-release allows all)", description: "Dev versions should not filter any tags (pre-release allows all)",
}, },
{ {
name: "stable version filters dev tag", name: "stable version filters dev tag",
currentVersion: "0.23.0", currentVersion: v23,
tag: "v0.24.0-dev", tag: "v0.24.0-dev",
expectedFilter: true, expectedFilter: true,
description: "When on stable release, dev tags should be filtered", description: "When on stable release, dev tags should be filtered",
}, },
{ {
name: "dev version allows dev tag", name: "dev version allows dev tag",
currentVersion: "0.23.0-dev", currentVersion: v23Dev,
tag: "v0.24.0-dev.1", tag: "v0.24.0-dev.1",
expectedFilter: false, expectedFilter: false,
description: "When on dev release, dev tags should not be filtered", description: "When on dev release, dev tags should not be filtered",
}, },
{ {
name: "dev version allows stable tag", name: "dev version allows stable tag",
currentVersion: "0.23.0-dev", currentVersion: v23Dev,
tag: "v0.24.0", tag: v24Tag,
expectedFilter: false, expectedFilter: false,
description: "When on dev release, stable tags should not be filtered", description: "When on dev release, stable tags should not be filtered",
}, },
@ -222,25 +235,25 @@ func TestIsPreReleaseVersion(t *testing.T) {
}{ }{
{ {
name: "stable version", name: "stable version",
version: "0.23.0", version: v23,
expected: false, expected: false,
description: "Stable version should not be pre-release", description: "Stable version should not be pre-release",
}, },
{ {
name: "alpha version", name: "alpha version",
version: "0.23.0-alpha.1", version: v23Alpha1,
expected: true, expected: true,
description: "Alpha version should be pre-release", description: "Alpha version should be pre-release",
}, },
{ {
name: "beta version", name: "beta version",
version: "0.23.0-beta.1", version: v23Beta1,
expected: true, expected: true,
description: "Beta version should be pre-release", description: "Beta version should be pre-release",
}, },
{ {
name: "rc version", name: "rc version",
version: "0.23.0-rc.1", version: v23RC1,
expected: true, expected: true,
description: "RC version should be pre-release", description: "RC version should be pre-release",
}, },
@ -258,7 +271,7 @@ func TestIsPreReleaseVersion(t *testing.T) {
}, },
{ {
name: "dev version", name: "dev version",
version: "0.23.0-dev", version: v23Dev,
expected: true, expected: true,
description: "Dev version should be pre-release", description: "Dev version should be pre-release",
}, },
@ -270,7 +283,7 @@ func TestIsPreReleaseVersion(t *testing.T) {
}, },
{ {
name: "version with patch number", name: "version with patch number",
version: "0.23.1", version: v231,
expected: false, expected: false,
description: "Stable version with patch should not be pre-release", description: "Stable version with patch should not be pre-release",
}, },

View File

@ -0,0 +1,23 @@
package cli
// Shared CLI vocabulary used across multiple command definitions in this
// package. Centralising the strings prevents goconst drift and ensures a
// typo in a subcommand name fails to compile rather than silently
// breaking the binding.
const (
// Subcommand verbs (cobra Use field).
cmdList = "list"
cmdShow = "show"
cmdNew = "new"
cmdDelete = "delete"
cmdExpire = "expire"
// Subcommand aliases.
aliasDel = "del"
aliasExp = "exp"
// Output table column headers and printOutput map keys.
colResult = "Result"
colCreated = "Created"
colExpiration = "Expiration"
)

View File

@ -70,7 +70,7 @@ var userCmd = &cobra.Command{
var createUserCmd = &cobra.Command{ var createUserCmd = &cobra.Command{
Use: "create NAME", Use: "create NAME",
Short: "Creates a new user", Short: "Creates a new user",
Aliases: []string{"c", "new"}, Aliases: []string{"c", cmdNew},
Args: func(cmd *cobra.Command, args []string) error { Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 { if len(args) < 1 {
return errMissingParameter return errMissingParameter
@ -115,7 +115,7 @@ var createUserCmd = &cobra.Command{
var destroyUserCmd = &cobra.Command{ var destroyUserCmd = &cobra.Command{
Use: "destroy --identifier ID or --name NAME", Use: "destroy --identifier ID or --name NAME",
Short: "Destroys a user", Short: "Destroys a user",
Aliases: []string{"delete"}, Aliases: []string{cmdDelete},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, username, err := usernameAndIDFromFlag(cmd) id, username, err := usernameAndIDFromFlag(cmd)
if err != nil { if err != nil {
@ -142,7 +142,7 @@ var destroyUserCmd = &cobra.Command{
"Do you want to remove the user %q (%d) and any associated preauthkeys?", "Do you want to remove the user %q (%d) and any associated preauthkeys?",
user.GetName(), user.GetId(), user.GetName(), user.GetId(),
)) { )) {
return printOutput(cmd, map[string]string{"Result": "User not destroyed"}, "User not destroyed") return printOutput(cmd, map[string]string{colResult: "User not destroyed"}, "User not destroyed")
} }
deleteRequest := &v1.DeleteUserRequest{Id: user.GetId()} deleteRequest := &v1.DeleteUserRequest{Id: user.GetId()}
@ -157,9 +157,9 @@ var destroyUserCmd = &cobra.Command{
} }
var listUsersCmd = &cobra.Command{ var listUsersCmd = &cobra.Command{
Use: "list", Use: cmdList,
Short: "List all the users", Short: "List all the users",
Aliases: []string{"ls", "show"}, Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
request := &v1.ListUsersRequest{} request := &v1.ListUsersRequest{}
@ -183,7 +183,9 @@ var listUsersCmd = &cobra.Command{
} }
return printListOutput(cmd, response.GetUsers(), func() error { return printListOutput(cmd, response.GetUsers(), func() error {
tableData := pterm.TableData{{"ID", "Name", "Username", "Email", "Created"}} tableData := make(pterm.TableData, 1, 1+len(response.GetUsers()))
tableData[0] = []string{"ID", "Name", "Username", "Email", colCreated}
for _, user := range response.GetUsers() { for _, user := range response.GetUsers() {
tableData = append( tableData = append(
tableData, tableData,

View File

@ -67,9 +67,9 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
return app, nil return app, nil
} }
// grpcRunE wraps a cobra RunE func, injecting a ready gRPC client and // grpcRunE wraps a cobra [cobra.Command.RunE] func, injecting a ready
// context. Connection lifecycle is managed by the wrapper — callers // gRPC client and context. Connection lifecycle is managed by the
// never see the underlying conn or cancel func. // wrapper — callers never see the underlying conn or cancel func.
func grpcRunE( func grpcRunE(
fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error, fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error,
) func(*cobra.Command, []string) error { ) func(*cobra.Command, []string) error {
@ -103,7 +103,7 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
address := cfg.CLI.Address address := cfg.CLI.Address
// If the address is not set, we assume that we are on the server hosting hscontrol. // If the address is not set, we assume that we are on the server hosting [hscontrol].
if address == "" { if address == "" {
log.Debug(). log.Debug().
Str("socket", cfg.UnixSocket). Str("socket", cfg.UnixSocket).
@ -112,9 +112,9 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
address = cfg.UnixSocket address = cfg.UnixSocket
// Try to give the user better feedback if we cannot write to the headscale // Try to give the user better feedback if we cannot write to the headscale
// socket. Note: os.OpenFile on a Unix domain socket returns ENXIO on // socket. Note: [os.OpenFile] on a Unix domain socket returns ENXIO on
// Linux which is expected — only permission errors are actionable here. // Linux which is expected — only permission errors are actionable here.
// The actual gRPC connection uses net.Dial which handles sockets properly. // The actual gRPC connection uses [net.Dial] which handles sockets properly.
socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) //nolint socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) //nolint
if err != nil { if err != nil {
if os.IsPermission(err) { if os.IsPermission(err) {
@ -269,7 +269,7 @@ func printListOutput(
// printError writes err to stderr, formatting it as JSON/YAML when the // printError writes err to stderr, formatting it as JSON/YAML when the
// --output flag requests machine-readable output. Used exclusively by // --output flag requests machine-readable output. Used exclusively by
// Execute() so that every error surfaces in the format the caller asked for. // [Execute] so that every error surfaces in the format the caller asked for.
func printError(err error, outputFormat string) { func printError(err error, outputFormat string) {
type errOutput struct { type errOutput struct {
Error string `json:"error"` Error string `json:"error"`

View File

@ -58,6 +58,14 @@ const (
space3XL = "4rem" //nolint:unused // 64px - 3x extra large spacing space3XL = "4rem" //nolint:unused // 64px - 3x extra large spacing
) )
// Shared CSS value constants used across templates.
const (
cssBorderHS = "1px solid var(--hs-border)" //nolint:unused // Shared HS border
cssBreakWord = "break-word" //nolint:unused // Word wrapping
cssCenter = "center" //nolint:unused // Center alignment
cssOverflowWrap = "overflow-wrap" //nolint:unused // CSS property key
)
// Typography System // Typography System
// EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css // EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css
// Material for MkDocs typography - exact values from .md-typeset CSS. // Material for MkDocs typography - exact values from .md-typeset CSS.
@ -121,8 +129,8 @@ func card(title string, children ...elem.Node) *elem.Element {
return elem.Div(attrs.Props{ return elem.Div(attrs.Props{
attrs.Style: styles.Props{ attrs.Style: styles.Props{
styles.Background: "var(--hs-bg)", styles.Background: "var(--hs-bg)",
styles.Border: "1px solid var(--hs-border)", styles.Border: cssBorderHS,
styles.BorderRadius: "0.5rem", styles.BorderRadius: spaceS,
styles.Padding: "clamp(1rem, 3vw, 1.5rem)", styles.Padding: "clamp(1rem, 3vw, 1.5rem)",
styles.MarginBottom: spaceL, styles.MarginBottom: spaceL,
styles.BoxShadow: "0 1px 3px rgba(0,0,0,0.1)", styles.BoxShadow: "0 1px 3px rgba(0,0,0,0.1)",
@ -147,8 +155,8 @@ func codeBlock(code string) *elem.Element {
styles.FontSize: fontSizeCode, // 0.85em styles.FontSize: fontSizeCode, // 0.85em
styles.LineHeight: lineHeightCode, // 1.4 styles.LineHeight: lineHeightCode, // 1.4
styles.OverflowX: "auto", // Horizontal scroll styles.OverflowX: "auto", // Horizontal scroll
"overflow-wrap": "break-word", // Word wrapping cssOverflowWrap: cssBreakWord, // Word wrapping
"word-wrap": "break-word", // Legacy support "word-wrap": cssBreakWord, // Legacy support
styles.WhiteSpace: "pre-wrap", // Preserve whitespace styles.WhiteSpace: "pre-wrap", // Preserve whitespace
styles.MarginTop: spaceM, // 1em styles.MarginTop: spaceM, // 1em
styles.MarginBottom: spaceM, // 1em styles.MarginBottom: spaceM, // 1em
@ -171,7 +179,7 @@ func baseTypesetStyles() styles.Props {
styles.LineHeight: lineHeightBase, // 1.6 styles.LineHeight: lineHeightBase, // 1.6
styles.Color: colorTextPrimary, styles.Color: colorTextPrimary,
styles.FontFamily: fontFamilySystem, styles.FontFamily: fontFamilySystem,
"overflow-wrap": "break-word", cssOverflowWrap: cssBreakWord,
styles.TextAlign: "left", styles.TextAlign: "left",
} }
} }
@ -190,7 +198,7 @@ func h1Styles() styles.Props {
styles.FontWeight: "300", styles.FontWeight: "300",
"letter-spacing": "-0.01em", "letter-spacing": "-0.01em",
styles.FontFamily: fontFamilySystem, // Roboto styles.FontFamily: fontFamilySystem, // Roboto
"overflow-wrap": "break-word", cssOverflowWrap: cssBreakWord,
} }
} }
@ -208,7 +216,7 @@ func h2Styles() styles.Props {
"letter-spacing": "-0.01em", "letter-spacing": "-0.01em",
styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54) styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54)
styles.FontFamily: fontFamilySystem, // Roboto styles.FontFamily: fontFamilySystem, // Roboto
"overflow-wrap": "break-word", cssOverflowWrap: cssBreakWord,
} }
} }
@ -226,7 +234,7 @@ func h3Styles() styles.Props {
"letter-spacing": "-0.01em", "letter-spacing": "-0.01em",
styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54) styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54)
styles.FontFamily: fontFamilySystem, // Roboto styles.FontFamily: fontFamilySystem, // Roboto
"overflow-wrap": "break-word", cssOverflowWrap: cssBreakWord,
} }
} }
@ -242,7 +250,7 @@ func paragraphStyles() styles.Props {
styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset
styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset
styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87)
"overflow-wrap": "break-word", cssOverflowWrap: cssBreakWord,
} }
} }
@ -260,7 +268,7 @@ func orderedListStyles() styles.Props {
styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset
styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset
styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset
"overflow-wrap": "break-word", cssOverflowWrap: cssBreakWord,
} }
} }
@ -278,7 +286,7 @@ func unorderedListStyles() styles.Props {
styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset
styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset
styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset
"overflow-wrap": "break-word", cssOverflowWrap: cssBreakWord,
} }
} }
@ -292,7 +300,7 @@ func linkStyles() styles.Props {
return styles.Props{ return styles.Props{
styles.Color: colorPrimaryAccent, // #4051b5 - var(--md-primary-fg-color) styles.Color: colorPrimaryAccent, // #4051b5 - var(--md-primary-fg-color)
styles.TextDecoration: "none", styles.TextDecoration: "none",
"word-break": "break-word", "word-break": cssBreakWord,
styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset
} }
} }
@ -310,7 +318,7 @@ func inlineCodeStyles() styles.Props {
styles.FontSize: fontSizeCode, // 0.85em styles.FontSize: fontSizeCode, // 0.85em
styles.FontFamily: fontFamilyCode, // Roboto Mono styles.FontFamily: fontFamilyCode, // Roboto Mono
styles.Padding: "0 0.2941176471em", styles.Padding: "0 0.2941176471em",
"word-break": "break-word", "word-break": cssBreakWord,
} }
} }
@ -338,7 +346,7 @@ func orDivider() *elem.Element {
return elem.Div(attrs.Props{ return elem.Div(attrs.Props{
attrs.Style: styles.Props{ attrs.Style: styles.Props{
styles.Display: "flex", styles.Display: "flex",
styles.AlignItems: "center", styles.AlignItems: cssCenter,
styles.Gap: spaceM, styles.Gap: spaceM,
styles.MarginTop: space2XL, styles.MarginTop: space2XL,
styles.MarginBottom: space2XL, styles.MarginBottom: space2XL,
@ -369,12 +377,12 @@ func successBox(heading string, children ...elem.Node) *elem.Element {
attrs.Props{ attrs.Props{
attrs.Style: styles.Props{ attrs.Style: styles.Props{
styles.Display: "flex", styles.Display: "flex",
styles.AlignItems: "center", styles.AlignItems: cssCenter,
styles.Gap: spaceM, styles.Gap: spaceM,
styles.Padding: spaceL, styles.Padding: spaceL,
styles.BackgroundColor: "var(--hs-success-bg)", styles.BackgroundColor: "var(--hs-success-bg)",
styles.Border: "1px solid var(--hs-success)", styles.Border: "1px solid var(--hs-success)",
styles.BorderRadius: "0.5rem", styles.BorderRadius: spaceS,
styles.MarginBottom: spaceXL, styles.MarginBottom: spaceXL,
}.ToInline(), }.ToInline(),
attrs.Role: "status", attrs.Role: "status",
@ -414,12 +422,12 @@ func errorBox(heading string, children ...elem.Node) *elem.Element {
attrs.Props{ attrs.Props{
attrs.Style: styles.Props{ attrs.Style: styles.Props{
styles.Display: "flex", styles.Display: "flex",
styles.AlignItems: "center", styles.AlignItems: cssCenter,
styles.Gap: spaceM, styles.Gap: spaceM,
styles.Padding: spaceL, styles.Padding: spaceL,
styles.BackgroundColor: "var(--hs-error-bg)", styles.BackgroundColor: "var(--hs-error-bg)",
styles.Border: "1px solid var(--hs-error)", styles.Border: "1px solid var(--hs-error)",
styles.BorderRadius: "0.5rem", styles.BorderRadius: spaceS,
styles.MarginBottom: spaceXL, styles.MarginBottom: spaceXL,
}.ToInline(), }.ToInline(),
attrs.Role: "alert", attrs.Role: "alert",
@ -462,7 +470,7 @@ func warningBox(title, message string) *elem.Element {
styles.Padding: spaceL, styles.Padding: spaceL,
styles.BackgroundColor: "var(--hs-warning-bg)", styles.BackgroundColor: "var(--hs-warning-bg)",
styles.Border: "1px solid var(--hs-warning-border)", styles.Border: "1px solid var(--hs-warning-border)",
styles.BorderRadius: "0.5rem", styles.BorderRadius: spaceS,
styles.MarginTop: spaceL, styles.MarginTop: spaceL,
styles.MarginBottom: spaceL, styles.MarginBottom: spaceL,
}.ToInline(), }.ToInline(),
@ -492,7 +500,7 @@ func downloadButton(href, text string) *elem.Element {
attrs.Download: "headscale_macos.mobileconfig", attrs.Download: "headscale_macos.mobileconfig",
attrs.Style: styles.Props{ attrs.Style: styles.Props{
styles.Display: "inline-flex", styles.Display: "inline-flex",
styles.AlignItems: "center", styles.AlignItems: cssCenter,
styles.Padding: "0.75rem 1.5rem", styles.Padding: "0.75rem 1.5rem",
styles.BackgroundColor: "var(--md-primary-fg-color)", styles.BackgroundColor: "var(--md-primary-fg-color)",
styles.Color: "#ffffff", styles.Color: "#ffffff",
@ -542,8 +550,8 @@ func detailsBox(summary string, children ...elem.Node) *elem.Element {
return elem.Details(attrs.Props{ return elem.Details(attrs.Props{
attrs.Style: styles.Props{ attrs.Style: styles.Props{
styles.Background: "var(--hs-bg)", styles.Background: "var(--hs-bg)",
styles.Border: "1px solid var(--hs-border)", styles.Border: cssBorderHS,
styles.BorderRadius: "0.5rem", styles.BorderRadius: spaceS,
styles.Padding: spaceS + " " + spaceM, styles.Padding: spaceS + " " + spaceM,
styles.MarginTop: spaceL, styles.MarginTop: spaceL,
styles.MarginBottom: spaceL, styles.MarginBottom: spaceL,
@ -584,7 +592,7 @@ func statusMessage(message string, isSuccess bool) *elem.Element {
styles.Padding: spaceM, styles.Padding: spaceM,
styles.BackgroundColor: bgColor, styles.BackgroundColor: bgColor,
styles.Color: textColor, styles.Color: textColor,
styles.BorderRadius: "0.5rem", styles.BorderRadius: spaceS,
styles.Border: "1px solid " + textColor, styles.Border: "1px solid " + textColor,
styles.MarginBottom: spaceL, styles.MarginBottom: spaceL,
styles.FontSize: fontSizeBase, styles.FontSize: fontSizeBase,

View File

@ -17,7 +17,7 @@ type PingResult struct {
// Status is "ok", "timeout", or "error". // Status is "ok", "timeout", or "error".
Status string Status string
// Latency is the round-trip time (only meaningful when Status is "ok"). // Latency is the round-trip time (only meaningful when [PingResult.Status] is "ok").
Latency time.Duration Latency time.Duration
// NodeID is the ID of the pinged node. // NodeID is the ID of the pinged node.
@ -27,7 +27,7 @@ type PingResult struct {
Message string Message string
} }
// ConnectedNode is a node currently connected to the batcher, // ConnectedNode is a node currently connected to the [mapper.Batcher],
// displayed as a quick-ping link on the debug ping page. // displayed as a quick-ping link on the debug ping page.
type ConnectedNode struct { type ConnectedNode struct {
ID types.NodeID ID types.NodeID
@ -36,7 +36,7 @@ type ConnectedNode struct {
} }
// PingPage renders the /debug/ping page with a form, optional result, // PingPage renders the /debug/ping page with a form, optional result,
// and a list of connected nodes as quick-ping links. // and a list of connected nodes ([ConnectedNode]) as quick-ping links.
func PingPage(query string, result *PingResult, nodes []ConnectedNode) *elem.Element { func PingPage(query string, result *PingResult, nodes []ConnectedNode) *elem.Element {
children := []elem.Node{ children := []elem.Node{
headscaleLogo(), headscaleLogo(),
@ -94,7 +94,7 @@ func pingForm(query string) *elem.Element {
attrs.Style: styles.Props{ attrs.Style: styles.Props{
styles.Display: "flex", styles.Display: "flex",
styles.Gap: spaceS, styles.Gap: spaceS,
styles.AlignItems: "center", styles.AlignItems: cssCenter,
styles.FlexWrap: "wrap", styles.FlexWrap: "wrap",
styles.MarginTop: spaceM, styles.MarginTop: spaceM,
}.ToInline(), }.ToInline(),
@ -107,7 +107,7 @@ func pingForm(query string) *elem.Element {
attrs.Autofocus: "true", attrs.Autofocus: "true",
attrs.Style: styles.Props{ attrs.Style: styles.Props{
styles.Padding: "0.75rem " + spaceM, styles.Padding: "0.75rem " + spaceM,
styles.Border: "1px solid var(--hs-border)", styles.Border: cssBorderHS,
styles.BorderRadius: "0.375rem", styles.BorderRadius: "0.375rem",
styles.Width: "280px", styles.Width: "280px",
styles.MaxWidth: "100%", styles.MaxWidth: "100%",

View File

@ -31,11 +31,11 @@ type RegisterConfirmInfo struct {
User string User string
// Hostname is the hostname the registering tailscaled instance // Hostname is the hostname the registering tailscaled instance
// reported in its RegisterRequest. // reported in its [tailcfg.RegisterRequest].
Hostname string Hostname string
// OS is the operating system the registering tailscaled reported. // OS is the operating system the registering tailscaled reported.
// May be the empty string when the client did not send Hostinfo. // May be the empty string when the client did not send [tailcfg.Hostinfo].
OS string OS string
// MachineKey is the short fingerprint of the registering machine // MachineKey is the short fingerprint of the registering machine
@ -110,13 +110,13 @@ func deviceTable(rows [4][2]string) *elem.Element {
styles.FontWeight: "600", styles.FontWeight: "600",
styles.WhiteSpace: "nowrap", styles.WhiteSpace: "nowrap",
styles.Color: "var(--md-default-fg-color--light)", styles.Color: "var(--md-default-fg-color--light)",
styles.BorderBottom: "1px solid var(--hs-border)", styles.BorderBottom: cssBorderHS,
}.ToInline(), }.ToInline(),
}, elem.Text(row[0])), }, elem.Text(row[0])),
elem.Td(attrs.Props{ elem.Td(attrs.Props{
attrs.Style: styles.Props{ attrs.Style: styles.Props{
styles.Padding: "0.5rem 0", styles.Padding: "0.5rem 0",
styles.BorderBottom: "1px solid var(--hs-border)", styles.BorderBottom: cssBorderHS,
}.ToInline(), }.ToInline(),
}, val), }, val),
)) ))

View File

@ -32,14 +32,14 @@ func DefaultConfigEnv() map[string]string {
// Embedded DERP is the default for test isolation. // Embedded DERP is the default for test isolation.
// Tests should not depend on external DERP infrastructure. // Tests should not depend on external DERP infrastructure.
// Use WithPublicDERP() to opt out for tests that explicitly // Use [WithPublicDERP] to opt out for tests that explicitly
// need public DERP relays. // need public DERP relays.
"HEADSCALE_DERP_URLS": "", "HEADSCALE_DERP_URLS": "",
"HEADSCALE_DERP_AUTO_UPDATE_ENABLED": "false", "HEADSCALE_DERP_AUTO_UPDATE_ENABLED": "false",
"HEADSCALE_DERP_UPDATE_FREQUENCY": "1m", "HEADSCALE_DERP_UPDATE_FREQUENCY": "1m",
"HEADSCALE_DERP_SERVER_ENABLED": "true", "HEADSCALE_DERP_SERVER_ENABLED": "true",
"HEADSCALE_DERP_SERVER_REGION_ID": "999", "HEADSCALE_DERP_SERVER_REGION_ID": "999",
"HEADSCALE_DERP_SERVER_REGION_CODE": "headscale", "HEADSCALE_DERP_SERVER_REGION_CODE": binHeadscale,
"HEADSCALE_DERP_SERVER_REGION_NAME": "Headscale Embedded DERP", "HEADSCALE_DERP_SERVER_REGION_NAME": "Headscale Embedded DERP",
"HEADSCALE_DERP_SERVER_STUN_LISTEN_ADDR": "0.0.0.0:3478", "HEADSCALE_DERP_SERVER_STUN_LISTEN_ADDR": "0.0.0.0:3478",
"HEADSCALE_DERP_SERVER_PRIVATE_KEY_PATH": "/tmp/derp.key", "HEADSCALE_DERP_SERVER_PRIVATE_KEY_PATH": "/tmp/derp.key",

View File

@ -48,6 +48,9 @@ const (
headscaleDefaultPort = 8080 headscaleDefaultPort = 8080
IntegrationTestDockerFileName = "Dockerfile.integration" IntegrationTestDockerFileName = "Dockerfile.integration"
defaultDirPerm = 0o755 defaultDirPerm = 0o755
binHeadscale = "headscale"
flagOutput = "--output"
acceptJSON = "Accept: application/json"
) )
var ( var (
@ -94,8 +97,8 @@ type HeadscaleInContainer struct {
// Headscale instance. // Headscale instance.
type Option = func(c *HeadscaleInContainer) type Option = func(c *HeadscaleInContainer)
// WithACLPolicy adds a hscontrol.ACLPolicy policy to the // WithACLPolicy adds a [policyv2.Policy] to the
// HeadscaleInContainer instance. // [HeadscaleInContainer] instance.
func WithACLPolicy(acl *policyv2.Policy) Option { func WithACLPolicy(acl *policyv2.Policy) Option {
return func(hsic *HeadscaleInContainer) { return func(hsic *HeadscaleInContainer) {
if acl == nil { if acl == nil {
@ -127,7 +130,7 @@ func WithoutTLS() Option {
// WithCustomTLS uses the given certificates for the Headscale instance. // WithCustomTLS uses the given certificates for the Headscale instance.
// The caCert is installed into the container's trust store and returned // The caCert is installed into the container's trust store and returned
// by GetCert() so that clients can trust this server. // by [HeadscaleInContainer.GetCert] so that clients can trust this server.
func WithCustomTLS(caCert, cert, key []byte) Option { func WithCustomTLS(caCert, cert, key []byte) Option {
return func(hsic *HeadscaleInContainer) { return func(hsic *HeadscaleInContainer) {
hsic.tlsCACert = caCert hsic.tlsCACert = caCert
@ -320,7 +323,7 @@ func (hsic *HeadscaleInContainer) buildEntrypoint() []string {
return []string{"/bin/bash", "-c", strings.Join(commands, " ; ")} return []string{"/bin/bash", "-c", strings.Join(commands, " ; ")}
} }
// New returns a new HeadscaleInContainer instance. // New returns a new [HeadscaleInContainer] instance.
// //
//nolint:gocyclo // complex container setup with many options //nolint:gocyclo // complex container setup with many options
func New( func New(
@ -364,8 +367,8 @@ func New(
// TLS is enabled by default for all integration tests. // TLS is enabled by default for all integration tests.
// Generate a self-signed certificate if TLS was not explicitly // Generate a self-signed certificate if TLS was not explicitly
// disabled via WithoutTLS() and no custom cert was provided // disabled via [WithoutTLS] and no custom cert was provided
// via WithCustomTLS(). // via [WithCustomTLS].
if !hsic.noTLS && len(hsic.tlsCert) == 0 { if !hsic.noTLS && len(hsic.tlsCert) == 0 {
caCert, cert, key, err := integrationutil.CreateCertificate(hsic.hostname) caCert, cert, key, err := integrationutil.CreateCertificate(hsic.hostname)
if err != nil { if err != nil {
@ -394,9 +397,9 @@ func New(
if hsic.postgres { if hsic.postgres {
hsic.env["HEADSCALE_DATABASE_TYPE"] = "postgres" hsic.env["HEADSCALE_DATABASE_TYPE"] = "postgres"
hsic.env["HEADSCALE_DATABASE_POSTGRES_HOST"] = "postgres-" + hash hsic.env["HEADSCALE_DATABASE_POSTGRES_HOST"] = "postgres-" + hash
hsic.env["HEADSCALE_DATABASE_POSTGRES_USER"] = "headscale" hsic.env["HEADSCALE_DATABASE_POSTGRES_USER"] = binHeadscale
hsic.env["HEADSCALE_DATABASE_POSTGRES_PASS"] = "headscale" hsic.env["HEADSCALE_DATABASE_POSTGRES_PASS"] = binHeadscale
hsic.env["HEADSCALE_DATABASE_POSTGRES_NAME"] = "headscale" hsic.env["HEADSCALE_DATABASE_POSTGRES_NAME"] = binHeadscale
delete(hsic.env, "HEADSCALE_DATABASE_SQLITE_PATH") delete(hsic.env, "HEADSCALE_DATABASE_SQLITE_PATH")
// Determine postgres image - use prebuilt if available, otherwise pull from registry // Determine postgres image - use prebuilt if available, otherwise pull from registry
@ -501,7 +504,7 @@ func New(
} }
// Add integration test labels if running under hi tool // Add integration test labels if running under hi tool
dockertestutil.DockerAddIntegrationLabels(runOptions, "headscale") dockertestutil.DockerAddIntegrationLabels(runOptions, binHeadscale)
var container *dockertest.Resource var container *dockertest.Resource
@ -509,7 +512,7 @@ func New(
prebuiltImage := os.Getenv("HEADSCALE_INTEGRATION_HEADSCALE_IMAGE") prebuiltImage := os.Getenv("HEADSCALE_INTEGRATION_HEADSCALE_IMAGE")
if prebuiltImage != "" { if prebuiltImage != "" {
log.Printf("Using pre-built headscale image: %s", prebuiltImage) log.Printf("Using pre-built headscale image: %s", prebuiltImage) //nolint:gosec // G706: integration-only log of trusted env value
// Parse image into repository and tag // Parse image into repository and tag
repo, tag, ok := strings.Cut(prebuiltImage, ":") repo, tag, ok := strings.Cut(prebuiltImage, ":")
if !ok { if !ok {
@ -709,7 +712,7 @@ func (t *HeadscaleInContainer) Shutdown() (string, string, error) {
} }
// WriteLogs writes the current stdout/stderr log of the container to // WriteLogs writes the current stdout/stderr log of the container to
// the given io.Writers. // the given [io.Writer]s.
func (t *HeadscaleInContainer) WriteLogs(stdout, stderr io.Writer) error { func (t *HeadscaleInContainer) WriteLogs(stdout, stderr io.Writer) error {
return dockertestutil.WriteLog(t.pool, t.container, stdout, stderr) return dockertestutil.WriteLog(t.pool, t.container, stdout, stderr)
} }
@ -1010,13 +1013,13 @@ func (t *HeadscaleInContainer) GetHostMetricsPort() string {
return t.hostMetricsPort return t.hostMetricsPort
} }
// GetHealthEndpoint returns a health endpoint for the HeadscaleInContainer // GetHealthEndpoint returns a health endpoint for the [HeadscaleInContainer]
// instance. // instance.
func (t *HeadscaleInContainer) GetHealthEndpoint() string { func (t *HeadscaleInContainer) GetHealthEndpoint() string {
return t.GetEndpoint() + "/health" return t.GetEndpoint() + "/health"
} }
// GetEndpoint returns the Headscale endpoint for the HeadscaleInContainer. // GetEndpoint returns the Headscale endpoint for the [HeadscaleInContainer].
func (t *HeadscaleInContainer) GetEndpoint() string { func (t *HeadscaleInContainer) GetEndpoint() string {
return t.getEndpoint(false) return t.getEndpoint(false)
} }
@ -1051,12 +1054,12 @@ func (t *HeadscaleInContainer) GetCert() []byte {
return t.tlsCACert return t.tlsCACert
} }
// GetHostname returns the hostname of the HeadscaleInContainer. // GetHostname returns the hostname of the [HeadscaleInContainer].
func (t *HeadscaleInContainer) GetHostname() string { func (t *HeadscaleInContainer) GetHostname() string {
return t.hostname return t.hostname
} }
// GetIPInNetwork returns the IP address of the HeadscaleInContainer in the given network. // GetIPInNetwork returns the IP address of the [HeadscaleInContainer] in the given network.
func (t *HeadscaleInContainer) GetIPInNetwork(network *dockertest.Network) string { func (t *HeadscaleInContainer) GetIPInNetwork(network *dockertest.Network) string {
return t.container.GetIPInNetwork(network) return t.container.GetIPInNetwork(network)
} }
@ -1095,12 +1098,12 @@ func (t *HeadscaleInContainer) CreateUser(
user string, user string,
) (*v1.User, error) { ) (*v1.User, error) {
command := []string{ command := []string{
"headscale", binHeadscale,
"users", "users",
"create", "create",
user, user,
fmt.Sprintf("--email=%s@test.no", user), fmt.Sprintf("--email=%s@test.no", user),
"--output", flagOutput,
"json", "json",
} }
@ -1140,7 +1143,7 @@ type AuthKeyOptions struct {
// This supports both user-owned and tags-only auth keys. // This supports both user-owned and tags-only auth keys.
func (t *HeadscaleInContainer) CreateAuthKeyWithOptions(opts AuthKeyOptions) (*v1.PreAuthKey, error) { func (t *HeadscaleInContainer) CreateAuthKeyWithOptions(opts AuthKeyOptions) (*v1.PreAuthKey, error) {
command := []string{ command := []string{
"headscale", binHeadscale,
} }
// Only add --user flag if User is specified // Only add --user flag if User is specified
@ -1153,7 +1156,7 @@ func (t *HeadscaleInContainer) CreateAuthKeyWithOptions(opts AuthKeyOptions) (*v
"create", "create",
"--expiration", "--expiration",
"24h", "24h",
"--output", flagOutput,
"json", "json",
) )
@ -1189,7 +1192,7 @@ func (t *HeadscaleInContainer) CreateAuthKeyWithOptions(opts AuthKeyOptions) (*v
} }
// CreateAuthKey creates a new "authorisation key" for a User that can be used // CreateAuthKey creates a new "authorisation key" for a User that can be used
// to authorise a TailscaleClient with the Headscale instance. // to authorise a TailscaleClient with the [HeadscaleInContainer] instance.
func (t *HeadscaleInContainer) CreateAuthKey( func (t *HeadscaleInContainer) CreateAuthKey(
user uint64, user uint64,
reusable bool, reusable bool,
@ -1223,12 +1226,12 @@ func (t *HeadscaleInContainer) DeleteAuthKey(
id uint64, id uint64,
) error { ) error {
command := []string{ command := []string{
"headscale", binHeadscale,
"preauthkeys", "preauthkeys",
"delete", "delete",
"--id", "--id",
strconv.FormatUint(id, 10), strconv.FormatUint(id, 10),
"--output", flagOutput,
"json", "json",
} }
@ -1275,13 +1278,13 @@ func (t *HeadscaleInContainer) ListNodes(
} }
if len(users) == 0 { if len(users) == 0 {
err := execUnmarshal([]string{"headscale", "nodes", "list", "--output", "json"}) err := execUnmarshal([]string{binHeadscale, "nodes", "list", flagOutput, "json"})
if err != nil { if err != nil {
return nil, err return nil, err
} }
} else { } else {
for _, user := range users { for _, user := range users {
command := []string{"headscale", "--user", user, "nodes", "list", "--output", "json"} command := []string{binHeadscale, "--user", user, "nodes", "list", flagOutput, "json"}
err := execUnmarshal(command) err := execUnmarshal(command)
if err != nil { if err != nil {
@ -1299,12 +1302,12 @@ func (t *HeadscaleInContainer) ListNodes(
func (t *HeadscaleInContainer) DeleteNode(nodeID uint64) error { func (t *HeadscaleInContainer) DeleteNode(nodeID uint64) error {
command := []string{ command := []string{
"headscale", binHeadscale,
"nodes", "nodes",
"delete", "delete",
"--identifier", "--identifier",
strconv.FormatUint(nodeID, 10), strconv.FormatUint(nodeID, 10),
"--output", flagOutput,
"json", "json",
"--force", "--force",
} }
@ -1355,7 +1358,7 @@ func (t *HeadscaleInContainer) NodesByName() (map[string]*v1.Node, error) {
// ListUsers returns a list of users from Headscale. // ListUsers returns a list of users from Headscale.
func (t *HeadscaleInContainer) ListUsers() ([]*v1.User, error) { func (t *HeadscaleInContainer) ListUsers() ([]*v1.User, error) {
command := []string{"headscale", "users", "list", "--output", "json"} command := []string{binHeadscale, "users", "list", flagOutput, "json"}
result, _, err := dockertestutil.ExecuteCommand( result, _, err := dockertestutil.ExecuteCommand(
t.container, t.container,
@ -1395,13 +1398,13 @@ func (t *HeadscaleInContainer) MapUsers() (map[string]*v1.User, error) {
// DeleteUser deletes a user from the Headscale instance. // DeleteUser deletes a user from the Headscale instance.
func (t *HeadscaleInContainer) DeleteUser(userID uint64) error { func (t *HeadscaleInContainer) DeleteUser(userID uint64) error {
command := []string{ command := []string{
"headscale", binHeadscale,
"users", "users",
"delete", "delete",
"--identifier", "--identifier",
strconv.FormatUint(userID, 10), strconv.FormatUint(userID, 10),
"--force", "--force",
"--output", flagOutput,
"json", "json",
} }
@ -1444,7 +1447,7 @@ func (h *HeadscaleInContainer) SetPolicy(pol *policyv2.Policy) error {
func (h *HeadscaleInContainer) reloadDatabasePolicy() error { func (h *HeadscaleInContainer) reloadDatabasePolicy() error {
_, err := h.Execute( _, err := h.Execute(
[]string{ []string{
"headscale", binHeadscale,
"policy", "policy",
"set", "set",
"-f", "-f",
@ -1476,7 +1479,7 @@ func (h *HeadscaleInContainer) PID() (int, error) {
// Use pidof to find the headscale process, which is more reliable than grep // Use pidof to find the headscale process, which is more reliable than grep
// as it only looks for the actual binary name, not processes that contain // as it only looks for the actual binary name, not processes that contain
// "headscale" in their command line (like the dlv debugger). // "headscale" in their command line (like the dlv debugger).
output, err := h.Execute([]string{"pidof", "headscale"}) output, err := h.Execute([]string{"pidof", binHeadscale})
if err != nil { if err != nil {
// pidof returns exit code 1 when no process is found // pidof returns exit code 1 when no process is found
return 0, os.ErrNotExist return 0, os.ErrNotExist
@ -1533,8 +1536,8 @@ func (h *HeadscaleInContainer) Reload() error {
// ApproveRoutes approves routes for a node. // ApproveRoutes approves routes for a node.
func (t *HeadscaleInContainer) ApproveRoutes(id uint64, routes []netip.Prefix) (*v1.Node, error) { func (t *HeadscaleInContainer) ApproveRoutes(id uint64, routes []netip.Prefix) (*v1.Node, error) {
command := []string{ command := []string{
"headscale", "nodes", "approve-routes", binHeadscale, "nodes", "approve-routes",
"--output", "json", flagOutput, "json",
"--identifier", strconv.FormatUint(id, 10), "--identifier", strconv.FormatUint(id, 10),
"--routes=" + strings.Join(util.PrefixesToString(routes), ","), "--routes=" + strings.Join(util.PrefixesToString(routes), ","),
} }
@ -1568,9 +1571,9 @@ func (t *HeadscaleInContainer) ApproveRoutes(id uint64, routes []netip.Prefix) (
// SetTags API which is exposed via the CLI command: headscale nodes tag -i <id> -t <tags>. // SetTags API which is exposed via the CLI command: headscale nodes tag -i <id> -t <tags>.
func (t *HeadscaleInContainer) SetNodeTags(nodeID uint64, tags []string) error { func (t *HeadscaleInContainer) SetNodeTags(nodeID uint64, tags []string) error {
command := []string{ command := []string{
"headscale", "nodes", "tag", binHeadscale, "nodes", "tag",
"--identifier", strconv.FormatUint(nodeID, 10), "--identifier", strconv.FormatUint(nodeID, 10),
"--output", "json", flagOutput, "json",
} }
// Add tags - the CLI expects -t flag for each tag or comma-separated // Add tags - the CLI expects -t flag for each tag or comma-separated
@ -1605,7 +1608,7 @@ func (t *HeadscaleInContainer) FetchPath(path string) ([]byte, error) {
} }
func (t *HeadscaleInContainer) SendInterrupt() error { func (t *HeadscaleInContainer) SendInterrupt() error {
pid, err := t.Execute([]string{"pidof", "headscale"}) pid, err := t.Execute([]string{"pidof", binHeadscale})
if err != nil { if err != nil {
return err return err
} }
@ -1621,7 +1624,7 @@ func (t *HeadscaleInContainer) SendInterrupt() error {
func (t *HeadscaleInContainer) GetAllMapReponses() (map[types.NodeID][]tailcfg.MapResponse, error) { func (t *HeadscaleInContainer) GetAllMapReponses() (map[types.NodeID][]tailcfg.MapResponse, error) {
// Execute curl inside the container to access the debug endpoint locally // Execute curl inside the container to access the debug endpoint locally
command := []string{ command := []string{
"curl", "-s", "-H", "Accept: application/json", "http://localhost:9090/debug/mapresponses", "curl", "-s", "-H", acceptJSON, "http://localhost:9090/debug/mapresponses",
} }
result, err := t.Execute(command) result, err := t.Execute(command)
@ -1641,7 +1644,7 @@ func (t *HeadscaleInContainer) GetAllMapReponses() (map[types.NodeID][]tailcfg.M
func (t *HeadscaleInContainer) PrimaryRoutes() (*types.DebugRoutes, error) { func (t *HeadscaleInContainer) PrimaryRoutes() (*types.DebugRoutes, error) {
// Execute curl inside the container to access the debug endpoint locally // Execute curl inside the container to access the debug endpoint locally
command := []string{ command := []string{
"curl", "-s", "-H", "Accept: application/json", "http://localhost:9090/debug/routes", "curl", "-s", "-H", acceptJSON, "http://localhost:9090/debug/routes",
} }
result, err := t.Execute(command) result, err := t.Execute(command)
@ -1661,7 +1664,7 @@ func (t *HeadscaleInContainer) PrimaryRoutes() (*types.DebugRoutes, error) {
func (t *HeadscaleInContainer) DebugBatcher() (*hscontrol.DebugBatcherInfo, error) { func (t *HeadscaleInContainer) DebugBatcher() (*hscontrol.DebugBatcherInfo, error) {
// Execute curl inside the container to access the debug endpoint locally // Execute curl inside the container to access the debug endpoint locally
command := []string{ command := []string{
"curl", "-s", "-H", "Accept: application/json", "http://localhost:9090/debug/batcher", "curl", "-s", "-H", acceptJSON, "http://localhost:9090/debug/batcher",
} }
result, err := t.Execute(command) result, err := t.Execute(command)
@ -1677,11 +1680,11 @@ func (t *HeadscaleInContainer) DebugBatcher() (*hscontrol.DebugBatcherInfo, erro
return &debugInfo, nil return &debugInfo, nil
} }
// DebugNodeStore fetches the NodeStore data from the debug endpoint. // DebugNodeStore fetches the [state.NodeStore] data from the debug endpoint.
func (t *HeadscaleInContainer) DebugNodeStore() (map[types.NodeID]types.Node, error) { func (t *HeadscaleInContainer) DebugNodeStore() (map[types.NodeID]types.Node, error) {
// Execute curl inside the container to access the debug endpoint locally // Execute curl inside the container to access the debug endpoint locally
command := []string{ command := []string{
"curl", "-s", "-H", "Accept: application/json", "http://localhost:9090/debug/nodestore", "curl", "-s", "-H", acceptJSON, "http://localhost:9090/debug/nodestore",
} }
result, err := t.Execute(command) result, err := t.Execute(command)
@ -1701,7 +1704,7 @@ func (t *HeadscaleInContainer) DebugNodeStore() (map[types.NodeID]types.Node, er
func (t *HeadscaleInContainer) DebugFilter() ([]tailcfg.FilterRule, error) { func (t *HeadscaleInContainer) DebugFilter() ([]tailcfg.FilterRule, error) {
// Execute curl inside the container to access the debug endpoint locally // Execute curl inside the container to access the debug endpoint locally
command := []string{ command := []string{
"curl", "-s", "-H", "Accept: application/json", "http://localhost:9090/debug/filter", "curl", "-s", "-H", acceptJSON, "http://localhost:9090/debug/filter",
} }
result, err := t.Execute(command) result, err := t.Execute(command)

View File

@ -43,6 +43,7 @@ const (
dockerContextPath = "../." dockerContextPath = "../."
caCertRoot = "/usr/local/share/ca-certificates" caCertRoot = "/usr/local/share/ca-certificates"
dockerExecuteTimeout = 60 * time.Second dockerExecuteTimeout = 60 * time.Second
tailscaleBin = "tailscale"
) )
// defaultPingTimeoutVal returns the per-attempt timeout for tailscale ping. // defaultPingTimeoutVal returns the per-attempt timeout for tailscale ping.
@ -127,7 +128,7 @@ func WithCACert(cert []byte) Option {
} }
} }
// WithNetwork sets the Docker container network to use with // WithNetwork sets the Docker [dockertest.Network] to use with
// the Tailscale instance. // the Tailscale instance.
func WithNetwork(network *dockertest.Network) Option { func WithNetwork(network *dockertest.Network) Option {
return func(tsic *TailscaleInContainer) { return func(tsic *TailscaleInContainer) {
@ -215,7 +216,7 @@ func WithBuildTag(tag string) Option {
} }
// WithExtraLoginArgs adds additional arguments to the `tailscale up` command // WithExtraLoginArgs adds additional arguments to the `tailscale up` command
// as part of the Login function. // as part of the [TailscaleInContainer.Login] function.
func WithExtraLoginArgs(args []string) Option { func WithExtraLoginArgs(args []string) Option {
return func(tsic *TailscaleInContainer) { return func(tsic *TailscaleInContainer) {
tsic.extraLoginArgs = append(tsic.extraLoginArgs, args...) tsic.extraLoginArgs = append(tsic.extraLoginArgs, args...)
@ -270,7 +271,7 @@ func (t *TailscaleInContainer) buildEntrypoint() []string {
commands = append(commands, "while ! ip route show default >/dev/null 2>&1; do sleep 0.1; done") commands = append(commands, "while ! ip route show default >/dev/null 2>&1; do sleep 0.1; done")
// If CA certs are configured, wait for them to be written by the Go code // If CA certs are configured, wait for them to be written by the Go code
// (certs are written after container start via tsic.WriteFile) // (certs are written after container start via [TailscaleInContainer.WriteFile])
if len(t.caCerts) > 0 { if len(t.caCerts) > 0 {
commands = append(commands, commands = append(commands,
fmt.Sprintf("while [ ! -f %s/user-0.crt ]; do sleep 0.1; done", caCertRoot)) fmt.Sprintf("while [ ! -f %s/user-0.crt ]; do sleep 0.1; done", caCertRoot))
@ -389,7 +390,7 @@ func New(
} }
// Add integration test labels if running under hi tool // Add integration test labels if running under hi tool
dockertestutil.DockerAddIntegrationLabels(tailscaleOptions, "tailscale") dockertestutil.DockerAddIntegrationLabels(tailscaleOptions, tailscaleBin)
var container *dockertest.Resource var container *dockertest.Resource
@ -413,13 +414,13 @@ func New(
// the pre-built image as it won't have the necessary code compiled in. // the pre-built image as it won't have the necessary code compiled in.
hasBuildTags := len(tsic.buildConfig.tags) > 0 hasBuildTags := len(tsic.buildConfig.tags) > 0
if hasBuildTags && prebuiltImage != "" { if hasBuildTags && prebuiltImage != "" {
log.Printf("Ignoring pre-built image %s because custom build tags are required: %v", log.Printf("Ignoring pre-built image %s because custom build tags are required: %v", //nolint:gosec // G706: integration-only log of trusted env value
prebuiltImage, tsic.buildConfig.tags) prebuiltImage, tsic.buildConfig.tags)
prebuiltImage = "" prebuiltImage = ""
} }
if prebuiltImage != "" { if prebuiltImage != "" {
log.Printf("Using pre-built tailscale image: %s", prebuiltImage) log.Printf("Using pre-built tailscale image: %s", prebuiltImage) //nolint:gosec // G706: integration-only log of trusted env value
// Parse image into repository and tag // Parse image into repository and tag
repo, tag, ok := strings.Cut(prebuiltImage, ":") repo, tag, ok := strings.Cut(prebuiltImage, ":")
@ -609,7 +610,7 @@ func (t *TailscaleInContainer) Version() string {
return t.version return t.version
} }
// ContainerID returns the Docker container ID of the TailscaleInContainer // ContainerID returns the Docker container ID of the [TailscaleInContainer]
// instance. // instance.
func (t *TailscaleInContainer) ContainerID() string { func (t *TailscaleInContainer) ContainerID() string {
return t.container.Container.ID return t.container.Container.ID
@ -657,7 +658,7 @@ func (t *TailscaleInContainer) buildLoginCommand(
loginServer, authKey string, loginServer, authKey string,
) []string { ) []string {
command := []string{ command := []string{
"tailscale", tailscaleBin,
"up", "up",
"--login-server=" + loginServer, "--login-server=" + loginServer,
"--hostname=" + t.hostname, "--hostname=" + t.hostname,
@ -736,12 +737,12 @@ func (t *TailscaleInContainer) LoginWithURL(
// Logout runs the logout routine on the given Tailscale instance. // Logout runs the logout routine on the given Tailscale instance.
func (t *TailscaleInContainer) Logout() error { func (t *TailscaleInContainer) Logout() error {
_, _, err := t.Execute([]string{"tailscale", "logout"}) _, _, err := t.Execute([]string{tailscaleBin, "logout"})
if err != nil { if err != nil {
return err return err
} }
stdout, stderr, _ := t.Execute([]string{"tailscale", "status"}) stdout, stderr, _ := t.Execute([]string{tailscaleBin, "status"})
if !strings.Contains(stdout+stderr, "Logged out.") { if !strings.Contains(stdout+stderr, "Logged out.") {
return fmt.Errorf("logging out, stdout: %s, stderr: %s", stdout, stderr) //nolint:err113 return fmt.Errorf("logging out, stdout: %s, stderr: %s", stdout, stderr) //nolint:err113
} }
@ -768,7 +769,7 @@ func (t *TailscaleInContainer) Restart() error {
// We use exponential backoff to poll until we can successfully execute a command // We use exponential backoff to poll until we can successfully execute a command
_, err = backoff.Retry(context.Background(), func() (struct{}, error) { _, err = backoff.Retry(context.Background(), func() (struct{}, error) {
// Try to execute a simple command to verify the container is responsive // Try to execute a simple command to verify the container is responsive
_, _, err := t.Execute([]string{"tailscale", "version"}, dockertestutil.ExecuteCommandTimeout(5*time.Second)) _, _, err := t.Execute([]string{tailscaleBin, "version"}, dockertestutil.ExecuteCommandTimeout(5*time.Second))
if err != nil { if err != nil {
return struct{}{}, fmt.Errorf("container not ready: %w", err) return struct{}{}, fmt.Errorf("container not ready: %w", err)
} }
@ -785,7 +786,7 @@ func (t *TailscaleInContainer) Restart() error {
// Up runs `tailscale up` with no arguments. // Up runs `tailscale up` with no arguments.
func (t *TailscaleInContainer) Up() error { func (t *TailscaleInContainer) Up() error {
command := []string{ command := []string{
"tailscale", tailscaleBin,
"up", "up",
} }
@ -804,7 +805,7 @@ func (t *TailscaleInContainer) Up() error {
// Down runs `tailscale down` with no arguments. // Down runs `tailscale down` with no arguments.
func (t *TailscaleInContainer) Down() error { func (t *TailscaleInContainer) Down() error {
command := []string{ command := []string{
"tailscale", tailscaleBin,
"down", "down",
} }
@ -835,7 +836,7 @@ func (t *TailscaleInContainer) ReconnectToNetwork(network *dockertest.Network) e
return dockertestutil.ReconnectContainerToNetwork(t.pool, network, t.hostname) return dockertestutil.ReconnectContainerToNetwork(t.pool, network, t.hostname)
} }
// IPs returns the netip.Addr of the Tailscale instance. // IPs returns the [netip.Addr] of the Tailscale instance.
func (t *TailscaleInContainer) IPs() ([]netip.Addr, error) { func (t *TailscaleInContainer) IPs() ([]netip.Addr, error) {
if len(t.ips) != 0 { if len(t.ips) != 0 {
return t.ips, nil return t.ips, nil
@ -844,7 +845,7 @@ func (t *TailscaleInContainer) IPs() ([]netip.Addr, error) {
// Retry with exponential backoff to handle eventual consistency // Retry with exponential backoff to handle eventual consistency
ips, err := backoff.Retry(context.Background(), func() ([]netip.Addr, error) { ips, err := backoff.Retry(context.Background(), func() ([]netip.Addr, error) {
command := []string{ command := []string{
"tailscale", tailscaleBin,
"ip", "ip",
} }
@ -926,10 +927,10 @@ func (t *TailscaleInContainer) MustIPv6() netip.Addr {
panic("no ipv6 found") panic("no ipv6 found")
} }
// Status returns the ipnstate.Status of the Tailscale instance. // Status returns the [ipnstate.Status] of the Tailscale instance.
func (t *TailscaleInContainer) Status(save ...bool) (*ipnstate.Status, error) { func (t *TailscaleInContainer) Status(save ...bool) (*ipnstate.Status, error) {
command := []string{ command := []string{
"tailscale", tailscaleBin,
"status", "status",
"--json", "--json",
} }
@ -954,7 +955,7 @@ func (t *TailscaleInContainer) Status(save ...bool) (*ipnstate.Status, error) {
return &status, err return &status, err
} }
// MustStatus returns the ipnstate.Status of the Tailscale instance. // MustStatus returns the [ipnstate.Status] of the Tailscale instance.
func (t *TailscaleInContainer) MustStatus() *ipnstate.Status { func (t *TailscaleInContainer) MustStatus() *ipnstate.Status {
status, err := t.Status() status, err := t.Status()
if err != nil { if err != nil {
@ -979,7 +980,7 @@ func (t *TailscaleInContainer) MustID() types.NodeID {
return types.NodeID(id) return types.NodeID(id)
} }
// Netmap returns the current Netmap (netmap.NetworkMap) of the Tailscale instance. // Netmap returns the current Netmap ([netmap.NetworkMap]) of the Tailscale instance.
// Only works with Tailscale 1.56 and newer. // Only works with Tailscale 1.56 and newer.
// Panics if version is lower then minimum. // Panics if version is lower then minimum.
func (t *TailscaleInContainer) Netmap() (*netmap.NetworkMap, error) { func (t *TailscaleInContainer) Netmap() (*netmap.NetworkMap, error) {
@ -988,7 +989,7 @@ func (t *TailscaleInContainer) Netmap() (*netmap.NetworkMap, error) {
} }
command := []string{ command := []string{
"tailscale", tailscaleBin,
"debug", "debug",
"netmap", "netmap",
} }
@ -1014,7 +1015,7 @@ func (t *TailscaleInContainer) Netmap() (*netmap.NetworkMap, error) {
return &nm, err return &nm, err
} }
// Netmap returns the current Netmap (netmap.NetworkMap) of the Tailscale instance. // Netmap returns the current Netmap ([netmap.NetworkMap]) of the Tailscale instance.
// This implementation is based on getting the netmap from `tailscale debug watch-ipn` // This implementation is based on getting the netmap from `tailscale debug watch-ipn`
// as there seem to be some weirdness omitting endpoint and DERP info if we use // as there seem to be some weirdness omitting endpoint and DERP info if we use
// Patch updates. // Patch updates.
@ -1037,8 +1038,8 @@ func (t *TailscaleInContainer) Netmap() (*netmap.NetworkMap, error) {
// return notify.NetMap, nil // return notify.NetMap, nil
// } // }
// watchIPN watches `tailscale debug watch-ipn` for a ipn.Notify object until // watchIPN watches `tailscale debug watch-ipn` for a [ipn.Notify] object until
// it gets one that has a netmap.NetworkMap. // it gets one that has a [netmap.NetworkMap].
// //
//nolint:unused //nolint:unused
func (t *TailscaleInContainer) watchIPN(ctx context.Context) (*ipn.Notify, error) { func (t *TailscaleInContainer) watchIPN(ctx context.Context) (*ipn.Notify, error) {
@ -1115,7 +1116,7 @@ func (t *TailscaleInContainer) DebugDERPRegion(region string) (*ipnstate.DebugDE
} }
command := []string{ command := []string{
"tailscale", tailscaleBin,
"debug", "debug",
"derp", "derp",
region, region,
@ -1138,10 +1139,10 @@ func (t *TailscaleInContainer) DebugDERPRegion(region string) (*ipnstate.DebugDE
return &report, err return &report, err
} }
// Netcheck returns the current Netcheck Report (netcheck.Report) of the Tailscale instance. // Netcheck returns the current Netcheck Report ([netcheck.Report]) of the Tailscale instance.
func (t *TailscaleInContainer) Netcheck() (*netcheck.Report, error) { func (t *TailscaleInContainer) Netcheck() (*netcheck.Report, error) {
command := []string{ command := []string{
"tailscale", tailscaleBin,
"netcheck", "netcheck",
"--format=json", "--format=json",
} }
@ -1258,7 +1259,7 @@ func (t *TailscaleInContainer) waitForBackendState(state string, timeout time.Du
continue // Keep retrying on status errors continue // Keep retrying on status errors
} }
// ipnstate.Status.CurrentTailnet was added in Tailscale 1.22.0 // [ipnstate.Status.CurrentTailnet] was added in Tailscale 1.22.0
// https://github.com/tailscale/tailscale/pull/3865 // https://github.com/tailscale/tailscale/pull/3865
// //
// Before that, we can check the BackendState to see if the // Before that, we can check the BackendState to see if the
@ -1382,7 +1383,7 @@ func WithPingUntilDirect(direct bool) PingOption {
} }
// Ping executes the Tailscale ping command and pings a hostname // Ping executes the Tailscale ping command and pings a hostname
// or IP. It accepts a series of PingOption. // or IP. It accepts a series of [PingOption].
// TODO(kradalby): Make multiping, go routine magic. // TODO(kradalby): Make multiping, go routine magic.
func (t *TailscaleInContainer) Ping(hostnameOrIP string, opts ...PingOption) error { func (t *TailscaleInContainer) Ping(hostnameOrIP string, opts ...PingOption) error {
args := pingArgs{ args := pingArgs{
@ -1397,7 +1398,7 @@ func (t *TailscaleInContainer) Ping(hostnameOrIP string, opts ...PingOption) err
command := make([]string, 0, 6) command := make([]string, 0, 6)
command = append(command, command = append(command,
"tailscale", "ping", tailscaleBin, "ping",
fmt.Sprintf("--timeout=%s", args.timeout), fmt.Sprintf("--timeout=%s", args.timeout),
fmt.Sprintf("--c=%d", args.count), fmt.Sprintf("--c=%d", args.count),
"--until-direct="+strconv.FormatBool(args.direct), "--until-direct="+strconv.FormatBool(args.direct),
@ -1494,7 +1495,7 @@ const (
) )
// Curl executes the Tailscale curl command and curls a hostname // Curl executes the Tailscale curl command and curls a hostname
// or IP. It accepts a series of CurlOption. // or IP. It accepts a series of [CurlOption].
func (t *TailscaleInContainer) Curl(url string, opts ...CurlOption) (string, error) { func (t *TailscaleInContainer) Curl(url string, opts ...CurlOption) (string, error) {
args := curlArgs{ args := curlArgs{
connectionTimeout: defaultConnectionTimeout, connectionTimeout: defaultConnectionTimeout,
@ -1536,7 +1537,7 @@ func (t *TailscaleInContainer) Curl(url string, opts ...CurlOption) (string, err
// curl exit 0 with an empty body usually means a mid-stream reset // curl exit 0 with an empty body usually means a mid-stream reset
// after headers (HTTP 200 with the connection torn down before the // after headers (HTTP 200 with the connection torn down before the
// body arrived). Without this signal, callers wrapping the call in // body arrived). Without this signal, callers wrapping the call in
// EventuallyWithT see assert.NoError pass and assert.Len fail with // [assert.EventuallyWithT] see [assert.NoError] pass and [assert.Len] fail with
// no error to drive a retry. // no error to drive a retry.
if result == "" { if result == "" {
return result, fmt.Errorf("%w: %s from %s", errCurlEmptyResponseBody, url, t.Hostname()) return result, fmt.Errorf("%w: %s from %s", errCurlEmptyResponseBody, url, t.Hostname())
@ -1558,7 +1559,7 @@ func (t *TailscaleInContainer) CurlFailFast(url string) (string, error) {
func (t *TailscaleInContainer) Traceroute(ip netip.Addr) (util.Traceroute, error) { func (t *TailscaleInContainer) Traceroute(ip netip.Addr) (util.Traceroute, error) {
// -w 1: wait at most 1s for each probe response. busybox's default // -w 1: wait at most 1s for each probe response. busybox's default
// is 5s, which means an EventuallyWithT loop at 200ms ticks // is 5s, which means an [assert.EventuallyWithT] loop at 200ms ticks
// can spend 25 ticks worth of budget on a single Traceroute. // can spend 25 ticks worth of budget on a single Traceroute.
// -q 1: send 1 probe per hop instead of 3. The HA tests only care // -q 1: send 1 probe per hop instead of 3. The HA tests only care
// about the first hop's identity; the other probes are dead // about the first hop's identity; the other probes are dead
@ -1603,7 +1604,7 @@ func (t *TailscaleInContainer) SaveLog(path string) (string, string, error) {
} }
// WriteLogs writes the current stdout/stderr log of the container to // WriteLogs writes the current stdout/stderr log of the container to
// the given io.Writers. // the given [io.Writer]s.
func (t *TailscaleInContainer) WriteLogs(stdout, stderr io.Writer) error { func (t *TailscaleInContainer) WriteLogs(stdout, stderr io.Writer) error {
return dockertestutil.WriteLog(t.pool, t.container, stdout, stderr) return dockertestutil.WriteLog(t.pool, t.container, stdout, stderr)
} }
@ -1677,7 +1678,7 @@ func (t *TailscaleInContainer) GetNodePrivateKey() (*key.NodePrivate, error) {
return &p.Persist.PrivateNodeKey, nil return &p.Persist.PrivateNodeKey, nil
} }
// ConnectToNetwork connects the Tailscale container to an additional Docker network. // ConnectToNetwork connects the Tailscale container to an additional Docker [dockertest.Network].
func (t *TailscaleInContainer) ConnectToNetwork(network *dockertest.Network) error { func (t *TailscaleInContainer) ConnectToNetwork(network *dockertest.Network) error {
return t.container.ConnectToNetwork(network) return t.container.ConnectToNetwork(network)
} }