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{
Use: "list",
Use: cmdList,
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 {
response, err := client.ListApiKeys(ctx, &v1.ListApiKeysRequest{})
if err != nil {
@@ -51,9 +51,8 @@ var listAPIKeys = &cobra.Command{
}
return printListOutput(cmd, response.GetApiKeys(), func() error {
tableData := pterm.TableData{
{"ID", "Prefix", "Expiration", "Created"},
}
tableData := make(pterm.TableData, 1, 1+len(response.GetApiKeys()))
tableData[0] = []string{"ID", "Prefix", colExpiration, colCreated}
for _, key := range response.GetApiKeys() {
expiration := "-"
@@ -82,7 +81,7 @@ var createAPIKeyCmd = &cobra.Command{
Creates a new Api key, the Api key is only visible on creation
and cannot be retrieved again.
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 {
expiration, err := expirationFromFlag(cmd)
if err != nil {
@@ -117,9 +116,9 @@ func apiKeyIDOrPrefix(cmd *cobra.Command) (uint64, string, error) {
}
var expireAPIKeyCmd = &cobra.Command{
Use: "expire",
Use: cmdExpire,
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 {
id, prefix, err := apiKeyIDOrPrefix(cmd)
if err != nil {
@@ -139,9 +138,9 @@ var expireAPIKeyCmd = &cobra.Command{
}
var deleteAPIKeyCmd = &cobra.Command{
Use: "delete",
Use: cmdDelete,
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 {
id, prefix, err := apiKeyIDOrPrefix(cmd)
if err != nil {

View File

@@ -89,9 +89,9 @@ var registerNodeCmd = &cobra.Command{
}
var listNodesCmd = &cobra.Command{
Use: "list",
Use: cmdList,
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 {
user, _ := cmd.Flags().GetString("user")
@@ -101,7 +101,7 @@ var listNodesCmd = &cobra.Command{
}
return printListOutput(cmd, response.GetNodes(), func() error {
tableData, err := nodesToPtables(user, response.GetNodes())
tableData, err := nodesToPtables(response.GetNodes())
if err != nil {
return fmt.Errorf("converting to table: %w", err)
}
@@ -145,12 +145,12 @@ var listNodeRoutesCmd = &cobra.Command{
}
var expireNodeCmd = &cobra.Command{
Use: "expire",
Use: cmdExpire,
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.
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 {
identifier, _ := cmd.Flags().GetUint64("identifier")
disableExpiry, _ := cmd.Flags().GetBool("disable")
@@ -229,9 +229,9 @@ var renameNodeCmd = &cobra.Command{
}
var deleteNodeCmd = &cobra.Command{
Use: "delete",
Use: cmdDelete,
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 {
identifier, _ := cmd.Flags().GetUint64("identifier")
@@ -252,7 +252,7 @@ var deleteNodeCmd = &cobra.Command{
"Do you want to remove the node %s?",
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)
@@ -262,7 +262,7 @@ var deleteNodeCmd = &cobra.Command{
return printOutput(
cmd,
map[string]string{"Result": "Node deleted"},
map[string]string{colResult: "Node deleted"},
"Node deleted",
)
}),
@@ -304,10 +304,7 @@ be assigned to nodes.`,
},
}
func nodesToPtables(
currentUser string,
nodes []*v1.Node,
) (pterm.TableData, error) {
func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) {
tableHeader := []string{
"ID",
"Hostname",
@@ -319,11 +316,12 @@ func nodesToPtables(
"IP addresses",
"Ephemeral",
"Last seen",
"Expiration",
colExpiration,
"Connected",
"Expired",
}
tableData := pterm.TableData{tableHeader}
tableData := make(pterm.TableData, 1, 1+len(nodes))
tableData[0] = tableHeader
for _, node := range nodes {
var ephemeral bool
@@ -447,7 +445,8 @@ func nodeRoutesToPtables(
"Available",
"Serving (Primary)",
}
tableData := pterm.TableData{tableHeader}
tableData := make(pterm.TableData, 1, 1+len(nodes))
tableData[0] = tableHeader
for _, node := range nodes {
nodeData := []string{

View File

@@ -61,7 +61,7 @@ var policyCmd = &cobra.Command{
var getPolicy = &cobra.Command{
Use: "get",
Short: "Print the current ACL Policy",
Aliases: []string{"show", "view", "fetch"},
Aliases: []string{cmdShow, "view", "fetch"},
RunE: func(cmd *cobra.Command, args []string) error {
var policyData string
@@ -205,9 +205,9 @@ var checkPolicy = &cobra.Command{
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).
// 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.
pm, err := policy.NewPolicyManager(policyBytes, users, nodes.ViewSlice())
if err != nil {

View File

@@ -42,9 +42,9 @@ var preauthkeysCmd = &cobra.Command{
}
var listPreAuthKeys = &cobra.Command{
Use: "list",
Use: cmdList,
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 {
response, err := client.ListPreAuthKeys(ctx, &v1.ListPreAuthKeysRequest{})
if err != nil {
@@ -52,17 +52,16 @@ var listPreAuthKeys = &cobra.Command{
}
return printListOutput(cmd, response.GetPreAuthKeys(), func() error {
tableData := pterm.TableData{
{
"ID",
"Key/Prefix",
"Reusable",
"Ephemeral",
"Used",
"Expiration",
"Created",
"Owner",
},
tableData := make(pterm.TableData, 1, 1+len(response.GetPreAuthKeys()))
tableData[0] = []string{
"ID",
"Key/Prefix",
"Reusable",
"Ephemeral",
"Used",
colExpiration,
colCreated,
"Owner",
}
for _, key := range response.GetPreAuthKeys() {
@@ -100,7 +99,7 @@ var listPreAuthKeys = &cobra.Command{
var createPreAuthKeyCmd = &cobra.Command{
Use: "create",
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 {
user, _ := cmd.Flags().GetUint64("user")
reusable, _ := cmd.Flags().GetBool("reusable")
@@ -130,9 +129,9 @@ var createPreAuthKeyCmd = &cobra.Command{
}
var expirePreAuthKeyCmd = &cobra.Command{
Use: "expire",
Use: cmdExpire,
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 {
id, _ := cmd.Flags().GetUint64("id")
@@ -154,9 +153,9 @@ var expirePreAuthKeyCmd = &cobra.Command{
}
var deletePreAuthKeyCmd = &cobra.Command{
Use: "delete",
Use: cmdDelete,
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 {
id, _ := cmd.Flags().GetUint64("id")

View File

@@ -30,7 +30,7 @@ func init() {
Bool("force", false, "Disable prompts and forces the execution")
// 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 {
cmd.SilenceUsage = false

View File

@@ -4,6 +4,19 @@ import (
"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) {
tests := []struct {
name string
@@ -14,64 +27,64 @@ func TestFilterPreReleasesIfStable(t *testing.T) {
}{
{
name: "stable version filters alpha tag",
currentVersion: "0.23.0",
tag: "v0.24.0-alpha.1",
currentVersion: v23,
tag: v24Alpha1Tag,
expectedFilter: true,
description: "When on stable release, alpha tags should be filtered",
},
{
name: "stable version filters beta tag",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "v0.24.0-beta.2",
expectedFilter: true,
description: "When on stable release, beta tags should be filtered",
},
{
name: "stable version filters rc tag",
currentVersion: "0.23.0",
tag: "v0.24.0-rc.1",
currentVersion: v23,
tag: v24RCTag,
expectedFilter: true,
description: "When on stable release, rc tags should be filtered",
},
{
name: "stable version allows stable tag",
currentVersion: "0.23.0",
tag: "v0.24.0",
currentVersion: v23,
tag: v24Tag,
expectedFilter: false,
description: "When on stable release, stable tags should not be filtered",
},
{
name: "alpha version allows alpha tag",
currentVersion: "0.23.0-alpha.1",
currentVersion: v23Alpha1,
tag: "v0.24.0-alpha.2",
expectedFilter: false,
description: "When on alpha release, alpha tags should not be filtered",
},
{
name: "alpha version allows beta tag",
currentVersion: "0.23.0-alpha.1",
currentVersion: v23Alpha1,
tag: "v0.24.0-beta.1",
expectedFilter: false,
description: "When on alpha release, beta tags should not be filtered",
},
{
name: "alpha version allows rc tag",
currentVersion: "0.23.0-alpha.1",
tag: "v0.24.0-rc.1",
currentVersion: v23Alpha1,
tag: v24RCTag,
expectedFilter: false,
description: "When on alpha release, rc tags should not be filtered",
},
{
name: "alpha version allows stable tag",
currentVersion: "0.23.0-alpha.1",
tag: "v0.24.0",
currentVersion: v23Alpha1,
tag: v24Tag,
expectedFilter: false,
description: "When on alpha release, stable tags should not be filtered",
},
{
name: "beta version allows alpha tag",
currentVersion: "0.23.0-beta.1",
tag: "v0.24.0-alpha.1",
currentVersion: v23Beta1,
tag: v24Alpha1Tag,
expectedFilter: false,
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",
currentVersion: "0.23.0-beta.1",
tag: "v0.24.0-rc.1",
currentVersion: v23Beta1,
tag: v24RCTag,
expectedFilter: false,
description: "When on beta release, rc tags should not be filtered",
},
{
name: "beta version allows stable tag",
currentVersion: "0.23.0-beta.1",
tag: "v0.24.0",
currentVersion: v23Beta1,
tag: v24Tag,
expectedFilter: false,
description: "When on beta release, stable tags should not be filtered",
},
{
name: "rc version allows alpha tag",
currentVersion: "0.23.0-rc.1",
tag: "v0.24.0-alpha.1",
currentVersion: v23RC1,
tag: v24Alpha1Tag,
expectedFilter: false,
description: "When on rc release, alpha tags should not be filtered",
},
{
name: "rc version allows beta tag",
currentVersion: "0.23.0-rc.1",
currentVersion: v23RC1,
tag: "v0.24.0-beta.1",
expectedFilter: false,
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",
currentVersion: "0.23.0-rc.1",
tag: "v0.24.0",
currentVersion: v23RC1,
tag: v24Tag,
expectedFilter: false,
description: "When on rc release, stable tags should not be filtered",
},
{
name: "stable version with patch filters alpha",
currentVersion: "0.23.1",
tag: "v0.24.0-alpha.1",
currentVersion: v231,
tag: v24Alpha1Tag,
expectedFilter: true,
description: "Stable version with patch number should filter alpha tags",
},
{
name: "stable version with patch allows stable",
currentVersion: "0.23.1",
tag: "v0.24.0",
currentVersion: v231,
tag: v24Tag,
expectedFilter: false,
description: "Stable version with patch number should allow stable tags",
},
{
name: "tag with alpha substring in version number",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "v1.0.0-alpha.1",
expectedFilter: true,
description: "Tags with alpha in version string should be filtered on stable",
},
{
name: "tag with beta substring in version number",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "v1.0.0-beta.1",
expectedFilter: true,
description: "Tags with beta in version string should be filtered on stable",
},
{
name: "tag with rc substring in version number",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "v1.0.0-rc.1",
expectedFilter: true,
description: "Tags with rc in version string should be filtered on stable",
},
{
name: "empty tag on stable version",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "",
expectedFilter: false,
description: "Empty tags should not be filtered",
},
{
name: "dev version allows all tags",
currentVersion: "0.23.0-dev",
tag: "v0.24.0-alpha.1",
currentVersion: v23Dev,
tag: v24Alpha1Tag,
expectedFilter: false,
description: "Dev versions should not filter any tags (pre-release allows all)",
},
{
name: "stable version filters dev tag",
currentVersion: "0.23.0",
currentVersion: v23,
tag: "v0.24.0-dev",
expectedFilter: true,
description: "When on stable release, dev tags should be filtered",
},
{
name: "dev version allows dev tag",
currentVersion: "0.23.0-dev",
currentVersion: v23Dev,
tag: "v0.24.0-dev.1",
expectedFilter: false,
description: "When on dev release, dev tags should not be filtered",
},
{
name: "dev version allows stable tag",
currentVersion: "0.23.0-dev",
tag: "v0.24.0",
currentVersion: v23Dev,
tag: v24Tag,
expectedFilter: false,
description: "When on dev release, stable tags should not be filtered",
},
@@ -222,25 +235,25 @@ func TestIsPreReleaseVersion(t *testing.T) {
}{
{
name: "stable version",
version: "0.23.0",
version: v23,
expected: false,
description: "Stable version should not be pre-release",
},
{
name: "alpha version",
version: "0.23.0-alpha.1",
version: v23Alpha1,
expected: true,
description: "Alpha version should be pre-release",
},
{
name: "beta version",
version: "0.23.0-beta.1",
version: v23Beta1,
expected: true,
description: "Beta version should be pre-release",
},
{
name: "rc version",
version: "0.23.0-rc.1",
version: v23RC1,
expected: true,
description: "RC version should be pre-release",
},
@@ -258,7 +271,7 @@ func TestIsPreReleaseVersion(t *testing.T) {
},
{
name: "dev version",
version: "0.23.0-dev",
version: v23Dev,
expected: true,
description: "Dev version should be pre-release",
},
@@ -270,7 +283,7 @@ func TestIsPreReleaseVersion(t *testing.T) {
},
{
name: "version with patch number",
version: "0.23.1",
version: v231,
expected: false,
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{
Use: "create NAME",
Short: "Creates a new user",
Aliases: []string{"c", "new"},
Aliases: []string{"c", cmdNew},
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return errMissingParameter
@@ -115,7 +115,7 @@ var createUserCmd = &cobra.Command{
var destroyUserCmd = &cobra.Command{
Use: "destroy --identifier ID or --name NAME",
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 {
id, username, err := usernameAndIDFromFlag(cmd)
if err != nil {
@@ -142,7 +142,7 @@ var destroyUserCmd = &cobra.Command{
"Do you want to remove the user %q (%d) and any associated preauthkeys?",
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()}
@@ -157,9 +157,9 @@ var destroyUserCmd = &cobra.Command{
}
var listUsersCmd = &cobra.Command{
Use: "list",
Use: cmdList,
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 {
request := &v1.ListUsersRequest{}
@@ -183,7 +183,9 @@ var listUsersCmd = &cobra.Command{
}
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() {
tableData = append(
tableData,

View File

@@ -67,9 +67,9 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
return app, nil
}
// grpcRunE wraps a cobra RunE func, injecting a ready gRPC client and
// context. Connection lifecycle is managed by the wrapper — callers
// never see the underlying conn or cancel func.
// grpcRunE wraps a cobra [cobra.Command.RunE] func, injecting a ready
// gRPC client and context. Connection lifecycle is managed by the
// wrapper — callers never see the underlying conn or cancel func.
func grpcRunE(
fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error,
) func(*cobra.Command, []string) error {
@@ -103,7 +103,7 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
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 == "" {
log.Debug().
Str("socket", cfg.UnixSocket).
@@ -112,9 +112,9 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
address = cfg.UnixSocket
// 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.
// 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
if err != nil {
if os.IsPermission(err) {
@@ -269,7 +269,7 @@ func printListOutput(
// printError writes err to stderr, formatting it as JSON/YAML when the
// --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) {
type errOutput struct {
Error string `json:"error"`

View File

@@ -58,6 +58,14 @@ const (
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
// EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.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{
attrs.Style: styles.Props{
styles.Background: "var(--hs-bg)",
styles.Border: "1px solid var(--hs-border)",
styles.BorderRadius: "0.5rem",
styles.Border: cssBorderHS,
styles.BorderRadius: spaceS,
styles.Padding: "clamp(1rem, 3vw, 1.5rem)",
styles.MarginBottom: spaceL,
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.LineHeight: lineHeightCode, // 1.4
styles.OverflowX: "auto", // Horizontal scroll
"overflow-wrap": "break-word", // Word wrapping
"word-wrap": "break-word", // Legacy support
cssOverflowWrap: cssBreakWord, // Word wrapping
"word-wrap": cssBreakWord, // Legacy support
styles.WhiteSpace: "pre-wrap", // Preserve whitespace
styles.MarginTop: spaceM, // 1em
styles.MarginBottom: spaceM, // 1em
@@ -171,7 +179,7 @@ func baseTypesetStyles() styles.Props {
styles.LineHeight: lineHeightBase, // 1.6
styles.Color: colorTextPrimary,
styles.FontFamily: fontFamilySystem,
"overflow-wrap": "break-word",
cssOverflowWrap: cssBreakWord,
styles.TextAlign: "left",
}
}
@@ -190,7 +198,7 @@ func h1Styles() styles.Props {
styles.FontWeight: "300",
"letter-spacing": "-0.01em",
styles.FontFamily: fontFamilySystem, // Roboto
"overflow-wrap": "break-word",
cssOverflowWrap: cssBreakWord,
}
}
@@ -208,7 +216,7 @@ func h2Styles() styles.Props {
"letter-spacing": "-0.01em",
styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54)
styles.FontFamily: fontFamilySystem, // Roboto
"overflow-wrap": "break-word",
cssOverflowWrap: cssBreakWord,
}
}
@@ -226,7 +234,7 @@ func h3Styles() styles.Props {
"letter-spacing": "-0.01em",
styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54)
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.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset
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.LineHeight: lineHeightBase, // 1.6 - 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.LineHeight: lineHeightBase, // 1.6 - 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{
styles.Color: colorPrimaryAccent, // #4051b5 - var(--md-primary-fg-color)
styles.TextDecoration: "none",
"word-break": "break-word",
"word-break": cssBreakWord,
styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset
}
}
@@ -310,7 +318,7 @@ func inlineCodeStyles() styles.Props {
styles.FontSize: fontSizeCode, // 0.85em
styles.FontFamily: fontFamilyCode, // Roboto Mono
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{
attrs.Style: styles.Props{
styles.Display: "flex",
styles.AlignItems: "center",
styles.AlignItems: cssCenter,
styles.Gap: spaceM,
styles.MarginTop: space2XL,
styles.MarginBottom: space2XL,
@@ -369,12 +377,12 @@ func successBox(heading string, children ...elem.Node) *elem.Element {
attrs.Props{
attrs.Style: styles.Props{
styles.Display: "flex",
styles.AlignItems: "center",
styles.AlignItems: cssCenter,
styles.Gap: spaceM,
styles.Padding: spaceL,
styles.BackgroundColor: "var(--hs-success-bg)",
styles.Border: "1px solid var(--hs-success)",
styles.BorderRadius: "0.5rem",
styles.BorderRadius: spaceS,
styles.MarginBottom: spaceXL,
}.ToInline(),
attrs.Role: "status",
@@ -414,12 +422,12 @@ func errorBox(heading string, children ...elem.Node) *elem.Element {
attrs.Props{
attrs.Style: styles.Props{
styles.Display: "flex",
styles.AlignItems: "center",
styles.AlignItems: cssCenter,
styles.Gap: spaceM,
styles.Padding: spaceL,
styles.BackgroundColor: "var(--hs-error-bg)",
styles.Border: "1px solid var(--hs-error)",
styles.BorderRadius: "0.5rem",
styles.BorderRadius: spaceS,
styles.MarginBottom: spaceXL,
}.ToInline(),
attrs.Role: "alert",
@@ -462,7 +470,7 @@ func warningBox(title, message string) *elem.Element {
styles.Padding: spaceL,
styles.BackgroundColor: "var(--hs-warning-bg)",
styles.Border: "1px solid var(--hs-warning-border)",
styles.BorderRadius: "0.5rem",
styles.BorderRadius: spaceS,
styles.MarginTop: spaceL,
styles.MarginBottom: spaceL,
}.ToInline(),
@@ -492,7 +500,7 @@ func downloadButton(href, text string) *elem.Element {
attrs.Download: "headscale_macos.mobileconfig",
attrs.Style: styles.Props{
styles.Display: "inline-flex",
styles.AlignItems: "center",
styles.AlignItems: cssCenter,
styles.Padding: "0.75rem 1.5rem",
styles.BackgroundColor: "var(--md-primary-fg-color)",
styles.Color: "#ffffff",
@@ -542,8 +550,8 @@ func detailsBox(summary string, children ...elem.Node) *elem.Element {
return elem.Details(attrs.Props{
attrs.Style: styles.Props{
styles.Background: "var(--hs-bg)",
styles.Border: "1px solid var(--hs-border)",
styles.BorderRadius: "0.5rem",
styles.Border: cssBorderHS,
styles.BorderRadius: spaceS,
styles.Padding: spaceS + " " + spaceM,
styles.MarginTop: spaceL,
styles.MarginBottom: spaceL,
@@ -584,7 +592,7 @@ func statusMessage(message string, isSuccess bool) *elem.Element {
styles.Padding: spaceM,
styles.BackgroundColor: bgColor,
styles.Color: textColor,
styles.BorderRadius: "0.5rem",
styles.BorderRadius: spaceS,
styles.Border: "1px solid " + textColor,
styles.MarginBottom: spaceL,
styles.FontSize: fontSizeBase,

View File

@@ -17,7 +17,7 @@ type PingResult struct {
// Status is "ok", "timeout", or "error".
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
// NodeID is the ID of the pinged node.
@@ -27,7 +27,7 @@ type PingResult struct {
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.
type ConnectedNode struct {
ID types.NodeID
@@ -36,7 +36,7 @@ type ConnectedNode struct {
}
// 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 {
children := []elem.Node{
headscaleLogo(),
@@ -94,7 +94,7 @@ func pingForm(query string) *elem.Element {
attrs.Style: styles.Props{
styles.Display: "flex",
styles.Gap: spaceS,
styles.AlignItems: "center",
styles.AlignItems: cssCenter,
styles.FlexWrap: "wrap",
styles.MarginTop: spaceM,
}.ToInline(),
@@ -107,7 +107,7 @@ func pingForm(query string) *elem.Element {
attrs.Autofocus: "true",
attrs.Style: styles.Props{
styles.Padding: "0.75rem " + spaceM,
styles.Border: "1px solid var(--hs-border)",
styles.Border: cssBorderHS,
styles.BorderRadius: "0.375rem",
styles.Width: "280px",
styles.MaxWidth: "100%",

View File

@@ -31,11 +31,11 @@ type RegisterConfirmInfo struct {
User string
// Hostname is the hostname the registering tailscaled instance
// reported in its RegisterRequest.
// reported in its [tailcfg.RegisterRequest].
Hostname string
// 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
// 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.WhiteSpace: "nowrap",
styles.Color: "var(--md-default-fg-color--light)",
styles.BorderBottom: "1px solid var(--hs-border)",
styles.BorderBottom: cssBorderHS,
}.ToInline(),
}, elem.Text(row[0])),
elem.Td(attrs.Props{
attrs.Style: styles.Props{
styles.Padding: "0.5rem 0",
styles.BorderBottom: "1px solid var(--hs-border)",
styles.BorderBottom: cssBorderHS,
}.ToInline(),
}, val),
))

View File

@@ -32,14 +32,14 @@ func DefaultConfigEnv() map[string]string {
// Embedded DERP is the default for test isolation.
// 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.
"HEADSCALE_DERP_URLS": "",
"HEADSCALE_DERP_AUTO_UPDATE_ENABLED": "false",
"HEADSCALE_DERP_UPDATE_FREQUENCY": "1m",
"HEADSCALE_DERP_SERVER_ENABLED": "true",
"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_STUN_LISTEN_ADDR": "0.0.0.0:3478",
"HEADSCALE_DERP_SERVER_PRIVATE_KEY_PATH": "/tmp/derp.key",

View File

@@ -48,6 +48,9 @@ const (
headscaleDefaultPort = 8080
IntegrationTestDockerFileName = "Dockerfile.integration"
defaultDirPerm = 0o755
binHeadscale = "headscale"
flagOutput = "--output"
acceptJSON = "Accept: application/json"
)
var (
@@ -94,8 +97,8 @@ type HeadscaleInContainer struct {
// Headscale instance.
type Option = func(c *HeadscaleInContainer)
// WithACLPolicy adds a hscontrol.ACLPolicy policy to the
// HeadscaleInContainer instance.
// WithACLPolicy adds a [policyv2.Policy] to the
// [HeadscaleInContainer] instance.
func WithACLPolicy(acl *policyv2.Policy) Option {
return func(hsic *HeadscaleInContainer) {
if acl == nil {
@@ -127,7 +130,7 @@ func WithoutTLS() Option {
// WithCustomTLS uses the given certificates for the Headscale instance.
// 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 {
return func(hsic *HeadscaleInContainer) {
hsic.tlsCACert = caCert
@@ -320,7 +323,7 @@ func (hsic *HeadscaleInContainer) buildEntrypoint() []string {
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
func New(
@@ -364,8 +367,8 @@ func New(
// TLS is enabled by default for all integration tests.
// Generate a self-signed certificate if TLS was not explicitly
// disabled via WithoutTLS() and no custom cert was provided
// via WithCustomTLS().
// disabled via [WithoutTLS] and no custom cert was provided
// via [WithCustomTLS].
if !hsic.noTLS && len(hsic.tlsCert) == 0 {
caCert, cert, key, err := integrationutil.CreateCertificate(hsic.hostname)
if err != nil {
@@ -394,9 +397,9 @@ func New(
if hsic.postgres {
hsic.env["HEADSCALE_DATABASE_TYPE"] = "postgres"
hsic.env["HEADSCALE_DATABASE_POSTGRES_HOST"] = "postgres-" + hash
hsic.env["HEADSCALE_DATABASE_POSTGRES_USER"] = "headscale"
hsic.env["HEADSCALE_DATABASE_POSTGRES_PASS"] = "headscale"
hsic.env["HEADSCALE_DATABASE_POSTGRES_NAME"] = "headscale"
hsic.env["HEADSCALE_DATABASE_POSTGRES_USER"] = binHeadscale
hsic.env["HEADSCALE_DATABASE_POSTGRES_PASS"] = binHeadscale
hsic.env["HEADSCALE_DATABASE_POSTGRES_NAME"] = binHeadscale
delete(hsic.env, "HEADSCALE_DATABASE_SQLITE_PATH")
// 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
dockertestutil.DockerAddIntegrationLabels(runOptions, "headscale")
dockertestutil.DockerAddIntegrationLabels(runOptions, binHeadscale)
var container *dockertest.Resource
@@ -509,7 +512,7 @@ func New(
prebuiltImage := os.Getenv("HEADSCALE_INTEGRATION_HEADSCALE_IMAGE")
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
repo, tag, ok := strings.Cut(prebuiltImage, ":")
if !ok {
@@ -709,7 +712,7 @@ func (t *HeadscaleInContainer) Shutdown() (string, string, error) {
}
// WriteLogs writes the current stdout/stderr log of the container to
// the given io.Writers.
// the given [io.Writer]s.
func (t *HeadscaleInContainer) WriteLogs(stdout, stderr io.Writer) error {
return dockertestutil.WriteLog(t.pool, t.container, stdout, stderr)
}
@@ -1010,13 +1013,13 @@ func (t *HeadscaleInContainer) GetHostMetricsPort() string {
return t.hostMetricsPort
}
// GetHealthEndpoint returns a health endpoint for the HeadscaleInContainer
// GetHealthEndpoint returns a health endpoint for the [HeadscaleInContainer]
// instance.
func (t *HeadscaleInContainer) GetHealthEndpoint() string {
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 {
return t.getEndpoint(false)
}
@@ -1051,12 +1054,12 @@ func (t *HeadscaleInContainer) GetCert() []byte {
return t.tlsCACert
}
// GetHostname returns the hostname of the HeadscaleInContainer.
// GetHostname returns the hostname of the [HeadscaleInContainer].
func (t *HeadscaleInContainer) GetHostname() string {
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 {
return t.container.GetIPInNetwork(network)
}
@@ -1095,12 +1098,12 @@ func (t *HeadscaleInContainer) CreateUser(
user string,
) (*v1.User, error) {
command := []string{
"headscale",
binHeadscale,
"users",
"create",
user,
fmt.Sprintf("--email=%s@test.no", user),
"--output",
flagOutput,
"json",
}
@@ -1140,7 +1143,7 @@ type AuthKeyOptions struct {
// This supports both user-owned and tags-only auth keys.
func (t *HeadscaleInContainer) CreateAuthKeyWithOptions(opts AuthKeyOptions) (*v1.PreAuthKey, error) {
command := []string{
"headscale",
binHeadscale,
}
// Only add --user flag if User is specified
@@ -1153,7 +1156,7 @@ func (t *HeadscaleInContainer) CreateAuthKeyWithOptions(opts AuthKeyOptions) (*v
"create",
"--expiration",
"24h",
"--output",
flagOutput,
"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
// to authorise a TailscaleClient with the Headscale instance.
// to authorise a TailscaleClient with the [HeadscaleInContainer] instance.
func (t *HeadscaleInContainer) CreateAuthKey(
user uint64,
reusable bool,
@@ -1223,12 +1226,12 @@ func (t *HeadscaleInContainer) DeleteAuthKey(
id uint64,
) error {
command := []string{
"headscale",
binHeadscale,
"preauthkeys",
"delete",
"--id",
strconv.FormatUint(id, 10),
"--output",
flagOutput,
"json",
}
@@ -1275,13 +1278,13 @@ func (t *HeadscaleInContainer) ListNodes(
}
if len(users) == 0 {
err := execUnmarshal([]string{"headscale", "nodes", "list", "--output", "json"})
err := execUnmarshal([]string{binHeadscale, "nodes", "list", flagOutput, "json"})
if err != nil {
return nil, err
}
} else {
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)
if err != nil {
@@ -1299,12 +1302,12 @@ func (t *HeadscaleInContainer) ListNodes(
func (t *HeadscaleInContainer) DeleteNode(nodeID uint64) error {
command := []string{
"headscale",
binHeadscale,
"nodes",
"delete",
"--identifier",
strconv.FormatUint(nodeID, 10),
"--output",
flagOutput,
"json",
"--force",
}
@@ -1355,7 +1358,7 @@ func (t *HeadscaleInContainer) NodesByName() (map[string]*v1.Node, error) {
// ListUsers returns a list of users from Headscale.
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(
t.container,
@@ -1395,13 +1398,13 @@ func (t *HeadscaleInContainer) MapUsers() (map[string]*v1.User, error) {
// DeleteUser deletes a user from the Headscale instance.
func (t *HeadscaleInContainer) DeleteUser(userID uint64) error {
command := []string{
"headscale",
binHeadscale,
"users",
"delete",
"--identifier",
strconv.FormatUint(userID, 10),
"--force",
"--output",
flagOutput,
"json",
}
@@ -1444,7 +1447,7 @@ func (h *HeadscaleInContainer) SetPolicy(pol *policyv2.Policy) error {
func (h *HeadscaleInContainer) reloadDatabasePolicy() error {
_, err := h.Execute(
[]string{
"headscale",
binHeadscale,
"policy",
"set",
"-f",
@@ -1476,7 +1479,7 @@ func (h *HeadscaleInContainer) PID() (int, error) {
// 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
// "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 {
// pidof returns exit code 1 when no process is found
return 0, os.ErrNotExist
@@ -1533,8 +1536,8 @@ func (h *HeadscaleInContainer) Reload() error {
// ApproveRoutes approves routes for a node.
func (t *HeadscaleInContainer) ApproveRoutes(id uint64, routes []netip.Prefix) (*v1.Node, error) {
command := []string{
"headscale", "nodes", "approve-routes",
"--output", "json",
binHeadscale, "nodes", "approve-routes",
flagOutput, "json",
"--identifier", strconv.FormatUint(id, 10),
"--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>.
func (t *HeadscaleInContainer) SetNodeTags(nodeID uint64, tags []string) error {
command := []string{
"headscale", "nodes", "tag",
binHeadscale, "nodes", "tag",
"--identifier", strconv.FormatUint(nodeID, 10),
"--output", "json",
flagOutput, "json",
}
// 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 {
pid, err := t.Execute([]string{"pidof", "headscale"})
pid, err := t.Execute([]string{"pidof", binHeadscale})
if err != nil {
return err
}
@@ -1621,7 +1624,7 @@ func (t *HeadscaleInContainer) SendInterrupt() error {
func (t *HeadscaleInContainer) GetAllMapReponses() (map[types.NodeID][]tailcfg.MapResponse, error) {
// Execute curl inside the container to access the debug endpoint locally
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)
@@ -1641,7 +1644,7 @@ func (t *HeadscaleInContainer) GetAllMapReponses() (map[types.NodeID][]tailcfg.M
func (t *HeadscaleInContainer) PrimaryRoutes() (*types.DebugRoutes, error) {
// Execute curl inside the container to access the debug endpoint locally
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)
@@ -1661,7 +1664,7 @@ func (t *HeadscaleInContainer) PrimaryRoutes() (*types.DebugRoutes, error) {
func (t *HeadscaleInContainer) DebugBatcher() (*hscontrol.DebugBatcherInfo, error) {
// Execute curl inside the container to access the debug endpoint locally
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)
@@ -1677,11 +1680,11 @@ func (t *HeadscaleInContainer) DebugBatcher() (*hscontrol.DebugBatcherInfo, erro
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) {
// Execute curl inside the container to access the debug endpoint locally
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)
@@ -1701,7 +1704,7 @@ func (t *HeadscaleInContainer) DebugNodeStore() (map[types.NodeID]types.Node, er
func (t *HeadscaleInContainer) DebugFilter() ([]tailcfg.FilterRule, error) {
// Execute curl inside the container to access the debug endpoint locally
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)

View File

@@ -43,6 +43,7 @@ const (
dockerContextPath = "../."
caCertRoot = "/usr/local/share/ca-certificates"
dockerExecuteTimeout = 60 * time.Second
tailscaleBin = "tailscale"
)
// 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.
func WithNetwork(network *dockertest.Network) Option {
return func(tsic *TailscaleInContainer) {
@@ -215,7 +216,7 @@ func WithBuildTag(tag string) Option {
}
// 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 {
return func(tsic *TailscaleInContainer) {
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")
// 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 {
commands = append(commands,
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
dockertestutil.DockerAddIntegrationLabels(tailscaleOptions, "tailscale")
dockertestutil.DockerAddIntegrationLabels(tailscaleOptions, tailscaleBin)
var container *dockertest.Resource
@@ -413,13 +414,13 @@ func New(
// the pre-built image as it won't have the necessary code compiled in.
hasBuildTags := len(tsic.buildConfig.tags) > 0
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 = ""
}
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
repo, tag, ok := strings.Cut(prebuiltImage, ":")
@@ -609,7 +610,7 @@ func (t *TailscaleInContainer) Version() string {
return t.version
}
// ContainerID returns the Docker container ID of the TailscaleInContainer
// ContainerID returns the Docker container ID of the [TailscaleInContainer]
// instance.
func (t *TailscaleInContainer) ContainerID() string {
return t.container.Container.ID
@@ -657,7 +658,7 @@ func (t *TailscaleInContainer) buildLoginCommand(
loginServer, authKey string,
) []string {
command := []string{
"tailscale",
tailscaleBin,
"up",
"--login-server=" + loginServer,
"--hostname=" + t.hostname,
@@ -736,12 +737,12 @@ func (t *TailscaleInContainer) LoginWithURL(
// Logout runs the logout routine on the given Tailscale instance.
func (t *TailscaleInContainer) Logout() error {
_, _, err := t.Execute([]string{"tailscale", "logout"})
_, _, err := t.Execute([]string{tailscaleBin, "logout"})
if err != nil {
return err
}
stdout, stderr, _ := t.Execute([]string{"tailscale", "status"})
stdout, stderr, _ := t.Execute([]string{tailscaleBin, "status"})
if !strings.Contains(stdout+stderr, "Logged out.") {
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
_, err = backoff.Retry(context.Background(), func() (struct{}, error) {
// 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 {
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.
func (t *TailscaleInContainer) Up() error {
command := []string{
"tailscale",
tailscaleBin,
"up",
}
@@ -804,7 +805,7 @@ func (t *TailscaleInContainer) Up() error {
// Down runs `tailscale down` with no arguments.
func (t *TailscaleInContainer) Down() error {
command := []string{
"tailscale",
tailscaleBin,
"down",
}
@@ -835,7 +836,7 @@ func (t *TailscaleInContainer) ReconnectToNetwork(network *dockertest.Network) e
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) {
if len(t.ips) != 0 {
return t.ips, nil
@@ -844,7 +845,7 @@ func (t *TailscaleInContainer) IPs() ([]netip.Addr, error) {
// Retry with exponential backoff to handle eventual consistency
ips, err := backoff.Retry(context.Background(), func() ([]netip.Addr, error) {
command := []string{
"tailscale",
tailscaleBin,
"ip",
}
@@ -926,10 +927,10 @@ func (t *TailscaleInContainer) MustIPv6() netip.Addr {
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) {
command := []string{
"tailscale",
tailscaleBin,
"status",
"--json",
}
@@ -954,7 +955,7 @@ func (t *TailscaleInContainer) Status(save ...bool) (*ipnstate.Status, error) {
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 {
status, err := t.Status()
if err != nil {
@@ -979,7 +980,7 @@ func (t *TailscaleInContainer) MustID() types.NodeID {
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.
// Panics if version is lower then minimum.
func (t *TailscaleInContainer) Netmap() (*netmap.NetworkMap, error) {
@@ -988,7 +989,7 @@ func (t *TailscaleInContainer) Netmap() (*netmap.NetworkMap, error) {
}
command := []string{
"tailscale",
tailscaleBin,
"debug",
"netmap",
}
@@ -1014,7 +1015,7 @@ func (t *TailscaleInContainer) Netmap() (*netmap.NetworkMap, error) {
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`
// as there seem to be some weirdness omitting endpoint and DERP info if we use
// Patch updates.
@@ -1037,8 +1038,8 @@ func (t *TailscaleInContainer) Netmap() (*netmap.NetworkMap, error) {
// return notify.NetMap, nil
// }
// watchIPN watches `tailscale debug watch-ipn` for a ipn.Notify object until
// it gets one that has a netmap.NetworkMap.
// watchIPN watches `tailscale debug watch-ipn` for a [ipn.Notify] object until
// it gets one that has a [netmap.NetworkMap].
//
//nolint:unused
func (t *TailscaleInContainer) watchIPN(ctx context.Context) (*ipn.Notify, error) {
@@ -1115,7 +1116,7 @@ func (t *TailscaleInContainer) DebugDERPRegion(region string) (*ipnstate.DebugDE
}
command := []string{
"tailscale",
tailscaleBin,
"debug",
"derp",
region,
@@ -1138,10 +1139,10 @@ func (t *TailscaleInContainer) DebugDERPRegion(region string) (*ipnstate.DebugDE
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) {
command := []string{
"tailscale",
tailscaleBin,
"netcheck",
"--format=json",
}
@@ -1258,7 +1259,7 @@ func (t *TailscaleInContainer) waitForBackendState(state string, timeout time.Du
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
//
// 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
// or IP. It accepts a series of PingOption.
// or IP. It accepts a series of [PingOption].
// TODO(kradalby): Make multiping, go routine magic.
func (t *TailscaleInContainer) Ping(hostnameOrIP string, opts ...PingOption) error {
args := pingArgs{
@@ -1397,7 +1398,7 @@ func (t *TailscaleInContainer) Ping(hostnameOrIP string, opts ...PingOption) err
command := make([]string, 0, 6)
command = append(command,
"tailscale", "ping",
tailscaleBin, "ping",
fmt.Sprintf("--timeout=%s", args.timeout),
fmt.Sprintf("--c=%d", args.count),
"--until-direct="+strconv.FormatBool(args.direct),
@@ -1494,7 +1495,7 @@ const (
)
// 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) {
args := curlArgs{
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
// after headers (HTTP 200 with the connection torn down before the
// 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.
if result == "" {
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) {
// -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.
// -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
@@ -1603,7 +1604,7 @@ func (t *TailscaleInContainer) SaveLog(path string) (string, string, error) {
}
// WriteLogs writes the current stdout/stderr log of the container to
// the given io.Writers.
// the given [io.Writer]s.
func (t *TailscaleInContainer) WriteLogs(stdout, stderr io.Writer) error {
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
}
// 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 {
return t.container.ConnectToNetwork(network)
}