This commit is contained in:
Kristoffer Dalby 2025-07-15 14:51:23 +00:00
parent 024ed59ea9
commit 8253d588c6
31 changed files with 300 additions and 364 deletions

View File

@ -41,6 +41,47 @@ systemctl start headscale
### BREAKING
- **CLI: Remove deprecated flags**
- `--identifier` flag removed - use `--node` or `--user` instead
- `--namespace` flag removed - use `--user` instead
**Command changes:**
```bash
# Before
headscale nodes expire --identifier 123
headscale nodes rename --identifier 123 new-name
headscale nodes delete --identifier 123
headscale nodes move --identifier 123 --user 456
headscale nodes list-routes --identifier 123
# After
headscale nodes expire --node 123
headscale nodes rename --node 123 new-name
headscale nodes delete --node 123
headscale nodes move --node 123 --user 456
headscale nodes list-routes --node 123
# Before
headscale users destroy --identifier 123
headscale users rename --identifier 123 --new-name john
headscale users list --identifier 123
# After
headscale users destroy --user 123
headscale users rename --user 123 --new-name john
headscale users list --user 123
# Before
headscale nodes register --namespace myuser nodekey
headscale nodes list --namespace myuser
headscale preauthkeys create --namespace myuser
# After
headscale nodes register --user myuser nodekey
headscale nodes list --user myuser
headscale preauthkeys create --user myuser
```
- Policy: Zero or empty destination port is no longer allowed
[#2606](https://github.com/juanfont/headscale/pull/2606)

View File

@ -15,7 +15,6 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)
func init() {
rootCmd.AddCommand(apiKeysCmd)
apiKeysCmd.AddCommand(listAPIKeys)
@ -98,7 +97,6 @@ var listAPIKeys = &cobra.Command{
}
return nil
})
if err != nil {
return
}
@ -148,7 +146,6 @@ If you loose a key, create a new one and revoke (expire) the old one.`,
SuccessOutput(response.GetApiKey(), response.GetApiKey(), output)
return nil
})
if err != nil {
return
}
@ -185,7 +182,6 @@ var expireAPIKeyCmd = &cobra.Command{
SuccessOutput(response, "Key expired", output)
return nil
})
if err != nil {
return
}
@ -222,7 +218,6 @@ var deleteAPIKeyCmd = &cobra.Command{
SuccessOutput(response, "Key deleted", output)
return nil
})
if err != nil {
return
}

View File

@ -2,7 +2,7 @@ package cli
import (
"context"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
)
@ -11,6 +11,6 @@ func WithClient(fn func(context.Context, v1.HeadscaleServiceClient) error) error
ctx, client, conn, cancel := newHeadscaleCLIWithConfig()
defer cancel()
defer conn.Close()
return fn(ctx, client)
}
}

View File

@ -37,10 +37,10 @@ func TestConfigTestCommandHelp(t *testing.T) {
// 1. It depends on configuration files being present
// 2. It calls log.Fatal() which would exit the test process
// 3. It tries to initialize a full Headscale server
//
//
// In a real refactor, we would:
// 1. Extract the configuration validation logic to a testable function
// 2. Return errors instead of calling log.Fatal()
// 3. Accept configuration as a parameter instead of loading from global state
//
// For now, we test the command structure and that it's properly wired up.
// For now, we test the command structure and that it's properly wired up.

View File

@ -15,11 +15,6 @@ const (
errPreAuthKeyMalformed = Error("key is malformed. expected 64 hex characters with `nodekey` prefix")
)
// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors
type Error string
func (e Error) Error() string { return string(e) }
func init() {
rootCmd.AddCommand(debugCmd)
@ -30,11 +25,6 @@ func init() {
}
createNodeCmd.Flags().StringP("user", "u", "", "User")
createNodeCmd.Flags().StringP("namespace", "n", "", "User")
createNodeNamespaceFlag := createNodeCmd.Flags().Lookup("namespace")
createNodeNamespaceFlag.Deprecated = deprecateNamespaceMessage
createNodeNamespaceFlag.Hidden = true
err = createNodeCmd.MarkFlagRequired("user")
if err != nil {
log.Fatal().Err(err).Msg("")
@ -60,7 +50,7 @@ var createNodeCmd = &cobra.Command{
Use: "create-node",
Short: "Create a node that can be registered with `nodes register <>` command",
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
user, err := cmd.Flags().GetString("user")
if err != nil {
@ -129,7 +119,6 @@ var createNodeCmd = &cobra.Command{
SuccessOutput(response.GetNode(), "Node created", output)
return nil
})
if err != nil {
return
}

View File

@ -41,7 +41,7 @@ func TestCreateNodeCommandInDebugCommand(t *testing.T) {
func TestCreateNodeCommandFlags(t *testing.T) {
// Test that create-node has the required flags
// Test name flag
nameFlag := createNodeCmd.Flags().Lookup("name")
assert.NotNil(t, nameFlag)
@ -63,22 +63,16 @@ func TestCreateNodeCommandFlags(t *testing.T) {
assert.NotNil(t, routeFlag)
assert.Equal(t, "r", routeFlag.Shorthand)
// Test deprecated namespace flag
namespaceFlag := createNodeCmd.Flags().Lookup("namespace")
assert.NotNil(t, namespaceFlag)
assert.Equal(t, "n", namespaceFlag.Shorthand)
assert.True(t, namespaceFlag.Hidden, "Namespace flag should be hidden")
assert.Equal(t, deprecateNamespaceMessage, namespaceFlag.Deprecated)
}
func TestCreateNodeCommandRequiredFlags(t *testing.T) {
// Test that required flags are marked as required
// We can't easily test the actual requirement enforcement without executing the command
// But we can test that the flags exist and have the expected properties
// These flags should be required based on the init() function
requiredFlags := []string{"name", "user", "key"}
for _, flagName := range requiredFlags {
flag := createNodeCmd.Flags().Lookup(flagName)
assert.NotNil(t, flag, "Required flag %s should exist", flagName)
@ -134,8 +128,6 @@ func TestCreateNodeCommandFlagDescriptions(t *testing.T) {
routeFlag := createNodeCmd.Flags().Lookup("route")
assert.Contains(t, routeFlag.Usage, "routes to advertise")
namespaceFlag := createNodeCmd.Flags().Lookup("namespace")
assert.Equal(t, "User", namespaceFlag.Usage) // Same as user flag
}
// Note: We can't easily test the actual execution of create-node because:
@ -149,4 +141,4 @@ func TestCreateNodeCommandFlagDescriptions(t *testing.T) {
// 3. Return errors instead of calling ErrorOutput/SuccessOutput
// 4. Add validation functions that can be tested independently
//
// For now, we test the command structure and flag configuration.
// For now, we test the command structure and flag configuration.

View File

@ -22,7 +22,7 @@ var generatePrivateKeyCmd = &cobra.Command{
Use: "private-key",
Short: "Generate a private key for the headscale server",
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
machineKey := key.NewMachine()
machineKeyStr, err := machineKey.MarshalText()

View File

@ -18,17 +18,17 @@ func TestGenerateCommand(t *testing.T) {
Use: "headscale",
Short: "headscale - a Tailscale control server",
}
cmd.AddCommand(generateCmd)
out := new(bytes.Buffer)
cmd.SetOut(out)
cmd.SetErr(out)
cmd.SetArgs([]string{"generate", "--help"})
err := cmd.Execute()
require.NoError(t, err)
outStr := out.String()
assert.Contains(t, outStr, "Generate commands")
assert.Contains(t, outStr, "private-key")
@ -42,17 +42,17 @@ func TestGenerateCommandAlias(t *testing.T) {
Use: "headscale",
Short: "headscale - a Tailscale control server",
}
cmd.AddCommand(generateCmd)
out := new(bytes.Buffer)
cmd.SetOut(out)
cmd.SetErr(out)
cmd.SetArgs([]string{"gen", "--help"})
err := cmd.Execute()
require.NoError(t, err)
outStr := out.String()
assert.Contains(t, outStr, "Generate commands")
}
@ -77,7 +77,7 @@ func TestGeneratePrivateKeyCommand(t *testing.T) {
expectYAML: false,
},
{
name: "yaml output",
name: "yaml output",
args: []string{"generate", "private-key", "--output", "yaml"},
expectJSON: false,
expectYAML: true,
@ -89,15 +89,15 @@ func TestGeneratePrivateKeyCommand(t *testing.T) {
// Note: This command calls SuccessOutput which exits the process
// We can't test the actual execution easily without mocking
// Instead, we test the command structure and that it exists
cmd := &cobra.Command{
Use: "headscale",
Short: "headscale - a Tailscale control server",
}
cmd.AddCommand(generateCmd)
cmd.PersistentFlags().StringP("output", "o", "", "Output format")
// Test that the command exists and can be found
privateKeyCmd, _, err := cmd.Find([]string{"generate", "private-key"})
require.NoError(t, err)
@ -112,17 +112,17 @@ func TestGeneratePrivateKeyHelp(t *testing.T) {
Use: "headscale",
Short: "headscale - a Tailscale control server",
}
cmd.AddCommand(generateCmd)
out := new(bytes.Buffer)
cmd.SetOut(out)
cmd.SetErr(out)
cmd.SetArgs([]string{"generate", "private-key", "--help"})
err := cmd.Execute()
require.NoError(t, err)
outStr := out.String()
assert.Contains(t, outStr, "Generate a private key for the headscale server")
assert.Contains(t, outStr, "Usage:")
@ -132,10 +132,10 @@ func TestGeneratePrivateKeyHelp(t *testing.T) {
func TestPrivateKeyGeneration(t *testing.T) {
// We can't easily test the full command because it calls SuccessOutput which exits
// But we can test that the key generation produces valid output format
// This is testing the core logic that would be in the command
// In a real refactor, we'd extract this to a testable function
// For now, we can test that the command structure is correct
assert.NotNil(t, generatePrivateKeyCmd)
assert.Equal(t, "private-key", generatePrivateKeyCmd.Use)
@ -148,7 +148,7 @@ func TestGenerateCommandStructure(t *testing.T) {
assert.Equal(t, "generate", generateCmd.Use)
assert.Equal(t, "Generate commands", generateCmd.Short)
assert.Contains(t, generateCmd.Aliases, "gen")
// Test that private-key is a subcommand
found := false
for _, subcmd := range generateCmd.Commands() {
@ -167,31 +167,31 @@ func validatePrivateKeyOutput(t *testing.T, output string, format string) {
var result map[string]interface{}
err := json.Unmarshal([]byte(output), &result)
require.NoError(t, err, "Output should be valid JSON")
privateKey, exists := result["private_key"]
require.True(t, exists, "JSON should contain private_key field")
keyStr, ok := privateKey.(string)
require.True(t, ok, "private_key should be a string")
require.NotEmpty(t, keyStr, "private_key should not be empty")
// Basic validation that it looks like a machine key
assert.True(t, strings.HasPrefix(keyStr, "mkey:"), "Machine key should start with mkey:")
case "yaml":
var result map[string]interface{}
err := yaml.Unmarshal([]byte(output), &result)
require.NoError(t, err, "Output should be valid YAML")
privateKey, exists := result["private_key"]
require.True(t, exists, "YAML should contain private_key field")
keyStr, ok := privateKey.(string)
require.True(t, ok, "private_key should be a string")
require.NotEmpty(t, keyStr, "private_key should not be empty")
assert.True(t, strings.HasPrefix(keyStr, "mkey:"), "Machine key should start with mkey:")
default:
// Default format should just be the key itself
assert.True(t, strings.HasPrefix(output, "mkey:"), "Default output should be the machine key")
@ -203,7 +203,7 @@ func validatePrivateKeyOutput(t *testing.T, output string, format string) {
func TestPrivateKeyOutputFormats(t *testing.T) {
// Test cases for different output formats
// These test the validation logic we would use after refactoring
tests := []struct {
format string
sample string
@ -213,7 +213,7 @@ func TestPrivateKeyOutputFormats(t *testing.T) {
sample: `{"private_key": "mkey:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234"}`,
},
{
format: "yaml",
format: "yaml",
sample: "private_key: mkey:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234\n",
},
{
@ -221,10 +221,10 @@ func TestPrivateKeyOutputFormats(t *testing.T) {
sample: "mkey:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234",
},
}
for _, tt := range tests {
t.Run("format_"+tt.format, func(t *testing.T) {
validatePrivateKeyOutput(t, tt.sample, tt.format)
})
}
}
}

View File

@ -15,6 +15,11 @@ import (
"github.com/spf13/cobra"
)
// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors
type Error string
func (e Error) Error() string { return string(e) }
const (
errMockOidcClientIDNotDefined = Error("MOCKOIDC_CLIENT_ID not defined")
errMockOidcClientSecretNotDefined = Error("MOCKOIDC_CLIENT_SECRET not defined")

View File

@ -32,27 +32,13 @@ func init() {
// Display options
listNodesCmd.Flags().BoolP("tags", "t", false, "Show tags")
listNodesCmd.Flags().String("columns", "", "Comma-separated list of columns to display")
// Backward compatibility
listNodesCmd.Flags().StringP("namespace", "n", "", "User")
listNodesNamespaceFlag := listNodesCmd.Flags().Lookup("namespace")
listNodesNamespaceFlag.Deprecated = deprecateNamespaceMessage
listNodesNamespaceFlag.Hidden = true
nodeCmd.AddCommand(listNodesCmd)
listNodeRoutesCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
listNodeRoutesCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID) - deprecated, use --node")
identifierFlag := listNodeRoutesCmd.Flags().Lookup("identifier")
identifierFlag.Deprecated = "use --node"
identifierFlag.Hidden = true
nodeCmd.AddCommand(listNodeRoutesCmd)
registerNodeCmd.Flags().StringP("user", "u", "", "User")
registerNodeCmd.Flags().StringP("namespace", "n", "", "User")
registerNodeNamespaceFlag := registerNodeCmd.Flags().Lookup("namespace")
registerNodeNamespaceFlag.Deprecated = deprecateNamespaceMessage
registerNodeNamespaceFlag.Hidden = true
err := registerNodeCmd.MarkFlagRequired("user")
if err != nil {
log.Fatal(err.Error())
@ -65,40 +51,24 @@ func init() {
nodeCmd.AddCommand(registerNodeCmd)
expireNodeCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
expireNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID) - deprecated, use --node")
identifierFlag = expireNodeCmd.Flags().Lookup("identifier")
identifierFlag.Deprecated = "use --node"
identifierFlag.Hidden = true
if err != nil {
log.Fatal(err.Error())
}
nodeCmd.AddCommand(expireNodeCmd)
renameNodeCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
renameNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID) - deprecated, use --node")
identifierFlag = renameNodeCmd.Flags().Lookup("identifier")
identifierFlag.Deprecated = "use --node"
identifierFlag.Hidden = true
if err != nil {
log.Fatal(err.Error())
}
nodeCmd.AddCommand(renameNodeCmd)
deleteNodeCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
deleteNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID) - deprecated, use --node")
identifierFlag = deleteNodeCmd.Flags().Lookup("identifier")
identifierFlag.Deprecated = "use --node"
identifierFlag.Hidden = true
if err != nil {
log.Fatal(err.Error())
}
nodeCmd.AddCommand(deleteNodeCmd)
moveNodeCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
moveNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID) - deprecated, use --node")
identifierFlag = moveNodeCmd.Flags().Lookup("identifier")
identifierFlag.Deprecated = "use --node"
identifierFlag.Hidden = true
if err != nil {
log.Fatal(err.Error())
@ -106,24 +76,19 @@ func init() {
moveNodeCmd.Flags().Uint64P("user", "u", 0, "New user")
moveNodeCmd.Flags().StringP("namespace", "n", "", "User")
moveNodeNamespaceFlag := moveNodeCmd.Flags().Lookup("namespace")
moveNodeNamespaceFlag.Deprecated = deprecateNamespaceMessage
moveNodeNamespaceFlag.Hidden = true
err = moveNodeCmd.MarkFlagRequired("user")
if err != nil {
log.Fatal(err.Error())
}
nodeCmd.AddCommand(moveNodeCmd)
tagCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
tagCmd.MarkFlagRequired("identifier")
tagCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
tagCmd.MarkFlagRequired("node")
tagCmd.Flags().StringSliceP("tags", "t", []string{}, "List of tags to add to the node")
nodeCmd.AddCommand(tagCmd)
approveRoutesCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
approveRoutesCmd.MarkFlagRequired("identifier")
approveRoutesCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
approveRoutesCmd.MarkFlagRequired("node")
approveRoutesCmd.Flags().StringSliceP("routes", "r", []string{}, `List of routes that will be approved (comma-separated, e.g. "10.0.0.0/8,192.168.0.0/24" or empty string to remove all approved routes)`)
nodeCmd.AddCommand(approveRoutesCmd)
@ -140,7 +105,7 @@ var registerNodeCmd = &cobra.Command{
Use: "register",
Short: "Registers a node to your network",
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
user, err := cmd.Flags().GetString("user")
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
@ -181,7 +146,6 @@ var registerNodeCmd = &cobra.Command{
fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()), output)
return nil
})
if err != nil {
return
}
@ -202,15 +166,12 @@ var listNodesCmd = &cobra.Command{
err = WithClient(func(ctx context.Context, client v1.HeadscaleServiceClient) error {
request := &v1.ListNodesRequest{}
// Handle user filtering (existing functionality)
if user, _ := cmd.Flags().GetString("user"); user != "" {
request.User = user
}
if namespace, _ := cmd.Flags().GetString("namespace"); namespace != "" {
request.User = namespace // backward compatibility
}
// Handle node filtering (new functionality)
if nodeFlag, _ := cmd.Flags().GetString("node"); nodeFlag != "" {
// Use smart lookup to determine filter type
@ -267,7 +228,6 @@ var listNodesCmd = &cobra.Command{
}
return nil
})
if err != nil {
return
}
@ -279,7 +239,7 @@ var listNodeRoutesCmd = &cobra.Command{
Short: "List routes available on nodes",
Aliases: []string{"lsr", "routes"},
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
identifier, err := GetNodeIdentifier(cmd)
if err != nil {
ErrorOutput(
@ -339,7 +299,6 @@ var listNodeRoutesCmd = &cobra.Command{
}
return nil
})
if err != nil {
return
}
@ -352,7 +311,7 @@ var expireNodeCmd = &cobra.Command{
Long: "Expiring a node will keep the node in the database and force it to reauthenticate.",
Aliases: []string{"logout", "exp", "e"},
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
identifier, err := GetNodeIdentifier(cmd)
if err != nil {
@ -385,7 +344,6 @@ var expireNodeCmd = &cobra.Command{
SuccessOutput(response.GetNode(), "Node expired", output)
return nil
})
if err != nil {
return
}
@ -396,7 +354,7 @@ var renameNodeCmd = &cobra.Command{
Use: "rename NEW_NAME",
Short: "Renames a node in your network",
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
identifier, err := GetNodeIdentifier(cmd)
if err != nil {
@ -412,7 +370,7 @@ var renameNodeCmd = &cobra.Command{
if len(args) > 0 {
newName = args[0]
}
err = WithClient(func(ctx context.Context, client v1.HeadscaleServiceClient) error {
request := &v1.RenameNodeRequest{
NodeId: identifier,
@ -435,7 +393,6 @@ var renameNodeCmd = &cobra.Command{
SuccessOutput(response.GetNode(), "Node renamed", output)
return nil
})
if err != nil {
return
}
@ -447,7 +404,7 @@ var deleteNodeCmd = &cobra.Command{
Short: "Delete a node",
Aliases: []string{"del"},
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
identifier, err := GetNodeIdentifier(cmd)
if err != nil {
@ -477,7 +434,6 @@ var deleteNodeCmd = &cobra.Command{
nodeName = getResponse.GetNode().GetName()
return nil
})
if err != nil {
return
}
@ -502,7 +458,7 @@ var deleteNodeCmd = &cobra.Command{
deleteRequest := &v1.DeleteNodeRequest{
NodeId: identifier,
}
response, err := client.DeleteNode(ctx, deleteRequest)
if output != "" {
SuccessOutput(response, "", output)
@ -523,7 +479,6 @@ var deleteNodeCmd = &cobra.Command{
)
return nil
})
if err != nil {
return
}
@ -538,7 +493,7 @@ var moveNodeCmd = &cobra.Command{
Short: "Move node to another user",
Aliases: []string{"mv"},
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
identifier, err := GetNodeIdentifier(cmd)
if err != nil {
@ -593,7 +548,6 @@ var moveNodeCmd = &cobra.Command{
SuccessOutput(moveResponse.GetNode(), "Node moved to another user", output)
return nil
})
if err != nil {
return
}
@ -618,7 +572,7 @@ it can be run to remove the IPs that should no longer
be assigned to nodes.`,
Run: func(cmd *cobra.Command, args []string) {
var err error
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
confirm := false
prompt := &survey.Confirm{
@ -643,7 +597,6 @@ be assigned to nodes.`,
SuccessOutput(changes, "Node IPs backfilled successfully", output)
return nil
})
if err != nil {
return
}
@ -829,8 +782,8 @@ var tagCmd = &cobra.Command{
Short: "Manage the tags of a node",
Aliases: []string{"tags", "t"},
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
// retrieve flags from CLI
identifier, err := GetNodeIdentifier(cmd)
if err != nil {
@ -876,7 +829,6 @@ var tagCmd = &cobra.Command{
}
return nil
})
if err != nil {
return
}
@ -887,8 +839,8 @@ var approveRoutesCmd = &cobra.Command{
Use: "approve-routes",
Short: "Manage the approved routes of a node",
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
// retrieve flags from CLI
identifier, err := GetNodeIdentifier(cmd)
if err != nil {
@ -934,7 +886,6 @@ var approveRoutesCmd = &cobra.Command{
}
return nil
})
if err != nil {
return
}

View File

@ -41,8 +41,8 @@ var getPolicy = &cobra.Command{
Short: "Print the current ACL Policy",
Aliases: []string{"show", "view", "fetch"},
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
err := WithClient(func(ctx context.Context, client v1.HeadscaleServiceClient) error {
request := &v1.GetPolicyRequest{}
@ -58,7 +58,6 @@ var getPolicy = &cobra.Command{
SuccessOutput("", response.GetPolicy(), "")
return nil
})
if err != nil {
return
}
@ -73,7 +72,7 @@ var setPolicy = &cobra.Command{
This command only works when the acl.policy_mode is set to "db", and the policy will be stored in the database.`,
Aliases: []string{"put", "update"},
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
policyPath, _ := cmd.Flags().GetString("file")
f, err := os.Open(policyPath)
@ -100,7 +99,6 @@ var setPolicy = &cobra.Command{
SuccessOutput(nil, "Policy updated.", "")
return nil
})
if err != nil {
return
}
@ -111,23 +109,26 @@ var checkPolicy = &cobra.Command{
Use: "check",
Short: "Check the Policy file for errors",
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
policyPath, _ := cmd.Flags().GetString("file")
f, err := os.Open(policyPath)
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error opening the policy file: %s", err), output)
return
}
defer f.Close()
policyBytes, err := io.ReadAll(f)
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error reading the policy file: %s", err), output)
return
}
_, err = policy.NewPolicyManager(policyBytes, nil, views.Slice[types.NodeView]{})
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error parsing the policy file: %s", err), output)
return
}
SuccessOutput(nil, "Policy is valid", "")

View File

@ -15,16 +15,10 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)
func init() {
rootCmd.AddCommand(preauthkeysCmd)
preauthkeysCmd.PersistentFlags().Uint64P("user", "u", 0, "User identifier (ID)")
preauthkeysCmd.PersistentFlags().StringP("namespace", "n", "", "User")
pakNamespaceFlag := preauthkeysCmd.PersistentFlags().Lookup("namespace")
pakNamespaceFlag.Deprecated = deprecateNamespaceMessage
pakNamespaceFlag.Hidden = true
err := preauthkeysCmd.MarkPersistentFlagRequired("user")
if err != nil {
log.Fatal().Err(err).Msg("")
@ -53,7 +47,7 @@ var listPreAuthKeys = &cobra.Command{
Short: "List the preauthkeys for this user",
Aliases: []string{"ls", "show"},
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
user, err := cmd.Flags().GetUint64("user")
if err != nil {
@ -130,7 +124,6 @@ var listPreAuthKeys = &cobra.Command{
}
return nil
})
if err != nil {
return
}
@ -142,7 +135,7 @@ var createPreAuthKeyCmd = &cobra.Command{
Short: "Creates a new preauthkey in the specified user",
Aliases: []string{"c", "new"},
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
user, err := cmd.Flags().GetUint64("user")
if err != nil {
@ -195,7 +188,6 @@ var createPreAuthKeyCmd = &cobra.Command{
SuccessOutput(response.GetPreAuthKey(), response.GetPreAuthKey().GetKey(), output)
return nil
})
if err != nil {
return
}
@ -214,7 +206,7 @@ var expirePreAuthKeyCmd = &cobra.Command{
return nil
},
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
user, err := cmd.Flags().GetUint64("user")
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
@ -240,7 +232,6 @@ var expirePreAuthKeyCmd = &cobra.Command{
SuccessOutput(response, "Key expired", output)
return nil
})
if err != nil {
return
}

View File

@ -14,7 +14,6 @@ import (
"github.com/tcnksm/go-latest"
)
var cfgFile string = ""
func init() {

View File

@ -28,11 +28,11 @@ func TestServeCommandArgs(t *testing.T) {
// Test that the Args function is defined and accepts any arguments
// The current implementation always returns nil (accepts any args)
assert.NotNil(t, serveCmd.Args)
// Test the args function directly
err := serveCmd.Args(serveCmd, []string{})
assert.NoError(t, err, "Args function should accept empty arguments")
err = serveCmd.Args(serveCmd, []string{"extra", "args"})
assert.NoError(t, err, "Args function should accept extra arguments")
}
@ -48,7 +48,7 @@ func TestServeCommandStructure(t *testing.T) {
// Test basic command structure
assert.Equal(t, "serve", serveCmd.Name())
assert.Equal(t, "Launches the headscale server", serveCmd.Short)
// Test that it has no subcommands (it's a leaf command)
subcommands := serveCmd.Commands()
assert.Empty(t, subcommands, "Serve command should not have subcommands")
@ -67,4 +67,4 @@ func TestServeCommandStructure(t *testing.T) {
// 4. Add graceful shutdown capabilities for testing
// 5. Allow server startup to be cancelled via context
//
// For now, we test the command structure and basic properties.
// For now, we test the command structure and basic properties.

View File

@ -8,10 +8,9 @@ import (
)
const (
deprecateNamespaceMessage = "use --user"
HeadscaleDateTimeFormat = "2006-01-02 15:04:05"
DefaultAPIKeyExpiry = "90d"
DefaultPreAuthKeyExpiry = "1h"
HeadscaleDateTimeFormat = "2006-01-02 15:04:05"
DefaultAPIKeyExpiry = "90d"
DefaultPreAuthKeyExpiry = "1h"
)
// FilterTableColumns filters table columns based on --columns flag
@ -23,7 +22,7 @@ func FilterTableColumns(cmd *cobra.Command, tableData pterm.TableData) pterm.Tab
headers := tableData[0]
wantedColumns := strings.Split(columns, ",")
// Find column indices
var indices []int
for _, wanted := range wantedColumns {
@ -53,4 +52,4 @@ func FilterTableColumns(cmd *cobra.Command, tableData pterm.TableData) pterm.Tab
}
return filtered
}
}

View File

@ -18,16 +18,12 @@ import (
func usernameAndIDFlag(cmd *cobra.Command) {
cmd.Flags().StringP("user", "u", "", "User identifier (ID, name, or email)")
cmd.Flags().Uint64P("identifier", "i", 0, "User identifier (ID) - deprecated, use --user")
identifierFlag := cmd.Flags().Lookup("identifier")
identifierFlag.Deprecated = "use --user"
identifierFlag.Hidden = true
cmd.Flags().StringP("name", "n", "", "Username")
}
// usernameAndIDFromFlag returns the user ID using smart lookup.
// userIDFromFlag returns the user ID using smart lookup.
// If no user is specified, it will exit the program with an error.
func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string) {
func userIDFromFlag(cmd *cobra.Command) uint64 {
userID, err := GetUserIdentifier(cmd)
if err != nil {
ErrorOutput(
@ -37,7 +33,7 @@ func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string) {
)
}
return userID, ""
return userID
}
func init() {
@ -52,11 +48,6 @@ func init() {
listUsersCmd.Flags().Uint64P("id", "", 0, "Filter by user ID")
listUsersCmd.Flags().StringP("name", "n", "", "Filter by username")
listUsersCmd.Flags().StringP("email", "e", "", "Filter by email address")
// Backward compatibility (deprecated)
listUsersCmd.Flags().Uint64P("identifier", "i", 0, "Filter by user ID - deprecated, use --id")
identifierFlag := listUsersCmd.Flags().Lookup("identifier")
identifierFlag.Deprecated = "use --id"
identifierFlag.Hidden = true
listUsersCmd.Flags().String("columns", "", "Comma-separated list of columns to display (ID,Name,Username,Email,Created)")
userCmd.AddCommand(destroyUserCmd)
usernameAndIDFlag(destroyUserCmd)
@ -117,7 +108,7 @@ var createUserCmd = &cobra.Command{
err := WithClient(func(ctx context.Context, client v1.HeadscaleServiceClient) error {
log.Trace().Interface("client", client).Msg("Obtained gRPC client")
log.Trace().Interface("request", request).Msg("Sending CreateUser request")
response, err := client.CreateUser(ctx, request)
if err != nil {
ErrorOutput(
@ -131,7 +122,6 @@ var createUserCmd = &cobra.Command{
SuccessOutput(response.GetUser(), "User created", output)
return nil
})
if err != nil {
return
}
@ -145,10 +135,9 @@ var destroyUserCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
output := GetOutputFlag(cmd)
id, username := usernameAndIDFromFlag(cmd)
id := userIDFromFlag(cmd)
request := &v1.ListUsersRequest{
Name: username,
Id: id,
Id: id,
}
var user *v1.User
@ -176,7 +165,6 @@ var destroyUserCmd = &cobra.Command{
user = users.GetUsers()[0]
return nil
})
if err != nil {
return
}
@ -212,7 +200,6 @@ var destroyUserCmd = &cobra.Command{
SuccessOutput(response, "User destroyed", output)
return nil
})
if err != nil {
return
}
@ -247,8 +234,6 @@ var listUsersCmd = &cobra.Command{
// Check specific filter flags
if id, _ := cmd.Flags().GetUint64("id"); id > 0 {
request.Id = id
} else if identifier, _ := cmd.Flags().GetUint64("identifier"); identifier > 0 {
request.Id = identifier // backward compatibility
} else if name, _ := cmd.Flags().GetString("name"); name != "" {
request.Name = name
} else if email, _ := cmd.Flags().GetString("email"); email != "" {
@ -296,7 +281,6 @@ var listUsersCmd = &cobra.Command{
}
return nil
})
if err != nil {
// Error already handled in closure
return
@ -311,13 +295,12 @@ var renameUserCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
output := GetOutputFlag(cmd)
id, username := usernameAndIDFromFlag(cmd)
id := userIDFromFlag(cmd)
newName, _ := cmd.Flags().GetString("new-name")
err := WithClient(func(ctx context.Context, client v1.HeadscaleServiceClient) error {
listReq := &v1.ListUsersRequest{
Name: username,
Id: id,
Id: id,
}
users, err := client.ListUsers(ctx, listReq)
@ -358,7 +341,6 @@ var renameUserCmd = &cobra.Command{
SuccessOutput(response.GetUser(), "User renamed", output)
return nil
})
if err != nil {
return
}

View File

@ -22,10 +22,6 @@ import (
"gopkg.in/yaml.v3"
)
const (
SocketWritePermissions = 0o666
)
func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
cfg, err := types.LoadServerConfig()
if err != nil {
@ -75,7 +71,7 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
// Try to give the user better feedback if we cannot write to the headscale
// socket.
socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) // nolint
socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, 0o666) // nolint
if err != nil {
if os.IsPermission(err) {
log.Fatal().
@ -210,21 +206,16 @@ func GetOutputFlag(cmd *cobra.Command) string {
return output
}
// GetNodeIdentifier returns the node ID using smart lookup via gRPC ListNodes call
func GetNodeIdentifier(cmd *cobra.Command) (uint64, error) {
nodeFlag, _ := cmd.Flags().GetString("node")
identifierFlag, _ := cmd.Flags().GetUint64("identifier")
// Check if --identifier (deprecated) was used
if identifierFlag > 0 {
return identifierFlag, nil
}
// Use --node flag
if nodeFlag == "" {
return 0, fmt.Errorf("--node flag is required")
}
// Use smart lookup via gRPC
return lookupNodeBySpecifier(nodeFlag)
}
@ -232,10 +223,10 @@ func GetNodeIdentifier(cmd *cobra.Command) (uint64, error) {
// lookupNodeBySpecifier performs smart lookup of a node by ID, name, hostname, or IP
func lookupNodeBySpecifier(specifier string) (uint64, error) {
var nodeID uint64
err := WithClient(func(ctx context.Context, client v1.HeadscaleServiceClient) error {
request := &v1.ListNodesRequest{}
// Detect what type of specifier this is and set appropriate filter
if id, err := strconv.ParseUint(specifier, 10, 64); err == nil && id > 0 {
// Looks like a numeric ID
@ -247,17 +238,17 @@ func lookupNodeBySpecifier(specifier string) (uint64, error) {
// Treat as hostname/name
request.Name = specifier
}
response, err := client.ListNodes(ctx, request)
if err != nil {
return fmt.Errorf("failed to lookup node: %w", err)
}
nodes := response.GetNodes()
if len(nodes) == 0 {
return fmt.Errorf("no node found matching '%s'", specifier)
}
if len(nodes) > 1 {
var nodeInfo []string
for _, node := range nodes {
@ -265,16 +256,15 @@ func lookupNodeBySpecifier(specifier string) (uint64, error) {
}
return fmt.Errorf("multiple nodes found matching '%s': %s", specifier, strings.Join(nodeInfo, ", "))
}
// Exactly one match - this is what we want
nodeID = nodes[0].GetId()
return nil
})
if err != nil {
return 0, err
}
return nodeID, nil
}
@ -295,21 +285,18 @@ func isIPAddress(s string) bool {
func GetUserIdentifier(cmd *cobra.Command) (uint64, error) {
userFlag, _ := cmd.Flags().GetString("user")
nameFlag, _ := cmd.Flags().GetString("name")
identifierFlag, _ := cmd.Flags().GetUint64("identifier")
var specifier string
// Determine which flag was used (prefer --user, fall back to legacy flags)
if userFlag != "" {
specifier = userFlag
} else if nameFlag != "" {
specifier = nameFlag
} else if identifierFlag > 0 {
return identifierFlag, nil // Direct ID, no lookup needed
} else {
return 0, fmt.Errorf("--user flag is required")
}
// Use smart lookup via gRPC
return lookupUserBySpecifier(specifier)
}
@ -317,10 +304,10 @@ func GetUserIdentifier(cmd *cobra.Command) (uint64, error) {
// lookupUserBySpecifier performs smart lookup of a user by ID, name, or email
func lookupUserBySpecifier(specifier string) (uint64, error) {
var userID uint64
err := WithClient(func(ctx context.Context, client v1.HeadscaleServiceClient) error {
request := &v1.ListUsersRequest{}
// Detect what type of specifier this is and set appropriate filter
if id, err := strconv.ParseUint(specifier, 10, 64); err == nil && id > 0 {
// Looks like a numeric ID
@ -332,17 +319,17 @@ func lookupUserBySpecifier(specifier string) (uint64, error) {
// Treat as username
request.Name = specifier
}
response, err := client.ListUsers(ctx, request)
if err != nil {
return fmt.Errorf("failed to lookup user: %w", err)
}
users := response.GetUsers()
if len(users) == 0 {
return fmt.Errorf("no user found matching '%s'", specifier)
}
if len(users) > 1 {
var userInfo []string
for _, user := range users {
@ -350,15 +337,14 @@ func lookupUserBySpecifier(specifier string) (uint64, error) {
}
return fmt.Errorf("multiple users found matching '%s': %s", specifier, strings.Join(userInfo, ", "))
}
// Exactly one match - this is what we want
userID = users[0].GetId()
return nil
})
if err != nil {
return 0, err
}
return userID, nil
}

View File

@ -172,4 +172,4 @@ func TestOutputWithEmptyData(t *testing.T) {
emptyMap := map[string]string{}
result = output(emptyMap, "fallback", "json")
assert.Equal(t, "{}", result)
}
}

View File

@ -14,7 +14,7 @@ var versionCmd = &cobra.Command{
Short: "Print the version",
Long: "The version of headscale",
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
output := GetOutputFlag(cmd)
SuccessOutput(map[string]string{
"version": types.Version,
"commit": types.GitCommitHash,

View File

@ -39,7 +39,7 @@ func TestVersionCommandFlags(t *testing.T) {
func TestVersionCommandRun(t *testing.T) {
// Test that Run function is set
assert.NotNil(t, versionCmd.Run)
// We can't easily test the actual execution without mocking SuccessOutput
// but we can verify the function exists and has the right signature
}
}

2
go.mod
View File

@ -81,7 +81,7 @@ require (
modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/sqlite v1.37.0 // indirect
modernc.org/sqlite v1.37.0
)
require (

View File

@ -4,6 +4,7 @@ import (
"cmp"
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"
"testing"
@ -18,7 +19,6 @@ import (
"github.com/juanfont/headscale/integration/tsic"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
"tailscale.com/tailcfg"
)
@ -95,7 +95,7 @@ func TestUserCommand(t *testing.T) {
"users",
"rename",
"--output=json",
fmt.Sprintf("--identifier=%d", listUsers[1].GetId()),
fmt.Sprintf("--user=%d", listUsers[1].GetId()),
"--new-name=newname",
},
)
@ -161,7 +161,7 @@ func TestUserCommand(t *testing.T) {
"list",
"--output",
"json",
"--identifier=1",
"--user=1",
},
&listByID,
)
@ -187,7 +187,7 @@ func TestUserCommand(t *testing.T) {
"destroy",
"--force",
// Delete "user1"
"--identifier=1",
"--user=1",
},
)
assert.NoError(t, err)
@ -354,7 +354,10 @@ func TestPreAuthKeyCommand(t *testing.T) {
continue
}
assert.Equal(t, []string{"tag:test1", "tag:test2"}, listedPreAuthKeys[index].GetAclTags())
// Sort tags for consistent comparison
tags := listedPreAuthKeys[index].GetAclTags()
slices.Sort(tags)
assert.Equal(t, []string{"tag:test1", "tag:test2"}, tags)
}
// Test key expiry
@ -604,7 +607,7 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) {
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
status, err := client.Status()
assert.NoError(ct, err)
assert.NotContains(ct, []string{"Starting", "Running"}, status.BackendState,
assert.NotContains(ct, []string{"Starting", "Running"}, status.BackendState,
"Expected node to be logged out, backend state: %s", status.BackendState)
}, 30*time.Second, 2*time.Second)
@ -869,7 +872,7 @@ func TestNodeTagCommand(t *testing.T) {
"headscale",
"nodes",
"tag",
"-i", "1",
"--node", "1",
"-t", "tag:test",
"--output", "json",
},
@ -884,7 +887,7 @@ func TestNodeTagCommand(t *testing.T) {
"headscale",
"nodes",
"tag",
"-i", "2",
"--node", "2",
"-t", "wrong-tag",
"--output", "json",
},
@ -1259,7 +1262,7 @@ func TestNodeCommand(t *testing.T) {
"headscale",
"nodes",
"delete",
"--identifier",
"--node",
// Delete the last added machine
"4",
"--output",
@ -1385,7 +1388,7 @@ func TestNodeExpireCommand(t *testing.T) {
"headscale",
"nodes",
"expire",
"--identifier",
"--node",
strconv.FormatUint(listAll[idx].GetId(), 10),
},
)
@ -1511,7 +1514,7 @@ func TestNodeRenameCommand(t *testing.T) {
"headscale",
"nodes",
"rename",
"--identifier",
"--node",
strconv.FormatUint(listAll[idx].GetId(), 10),
fmt.Sprintf("newnode-%d", idx+1),
},
@ -1549,7 +1552,7 @@ func TestNodeRenameCommand(t *testing.T) {
"headscale",
"nodes",
"rename",
"--identifier",
"--node",
strconv.FormatUint(listAll[4].GetId(), 10),
strings.Repeat("t", 64),
},
@ -1649,7 +1652,7 @@ func TestNodeMoveCommand(t *testing.T) {
"headscale",
"nodes",
"move",
"--identifier",
"--node",
strconv.FormatUint(node.GetId(), 10),
"--user",
strconv.FormatUint(userMap["new-user"].GetId(), 10),
@ -1687,7 +1690,7 @@ func TestNodeMoveCommand(t *testing.T) {
"headscale",
"nodes",
"move",
"--identifier",
"--node",
nodeID,
"--user",
"999",
@ -1708,7 +1711,7 @@ func TestNodeMoveCommand(t *testing.T) {
"headscale",
"nodes",
"move",
"--identifier",
"--node",
nodeID,
"--user",
strconv.FormatUint(userMap["old-user"].GetId(), 10),
@ -1727,7 +1730,7 @@ func TestNodeMoveCommand(t *testing.T) {
"headscale",
"nodes",
"move",
"--identifier",
"--node",
nodeID,
"--user",
strconv.FormatUint(userMap["old-user"].GetId(), 10),

View File

@ -38,10 +38,10 @@ func TestDebugCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Help text should contain expected information
assert.Contains(t, result, "debug", "help should mention debug command")
assert.Contains(t, result, "debug and testing commands", "help should contain command description")
assert.Contains(t, result, "debugging and testing", "help should contain command description")
assert.Contains(t, result, "create-node", "help should mention create-node subcommand")
})
@ -56,7 +56,7 @@ func TestDebugCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Help text should contain expected information
assert.Contains(t, result, "create-node", "help should mention create-node command")
assert.Contains(t, result, "name", "help should mention name flag")
@ -100,7 +100,7 @@ func TestDebugCreateNodeCommand(t *testing.T) {
nodeName := "debug-test-node"
// Generate a mock registration key (64 hex chars with nodekey prefix)
registrationKey := "nodekey:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
result, err := headscale.Execute(
[]string{
"headscale",
@ -112,7 +112,7 @@ func TestDebugCreateNodeCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Should output node creation confirmation
assert.Contains(t, result, "Node created", "should confirm node creation")
assert.Contains(t, result, nodeName, "should mention the created node name")
@ -122,7 +122,7 @@ func TestDebugCreateNodeCommand(t *testing.T) {
// Test debug create-node with advertised routes
nodeName := "debug-route-node"
registrationKey := "nodekey:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
result, err := headscale.Execute(
[]string{
"headscale",
@ -136,7 +136,7 @@ func TestDebugCreateNodeCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Should output node creation confirmation
assert.Contains(t, result, "Node created", "should confirm node creation")
assert.Contains(t, result, nodeName, "should mention the created node name")
@ -146,7 +146,7 @@ func TestDebugCreateNodeCommand(t *testing.T) {
// Test debug create-node with JSON output
nodeName := "debug-json-node"
registrationKey := "nodekey:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"
result, err := headscale.Execute(
[]string{
"headscale",
@ -159,7 +159,7 @@ func TestDebugCreateNodeCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Should produce valid JSON output
var node v1.Node
err = json.Unmarshal([]byte(result), &node)
@ -200,7 +200,7 @@ func TestDebugCreateNodeCommandValidation(t *testing.T) {
t.Run("test_debug_create_node_missing_name", func(t *testing.T) {
// Test debug create-node with missing name flag
registrationKey := "nodekey:1111111111111111111111111111111111111111111111111111111111111111"
_, err := headscale.Execute(
[]string{
"headscale",
@ -217,7 +217,7 @@ func TestDebugCreateNodeCommandValidation(t *testing.T) {
t.Run("test_debug_create_node_missing_user", func(t *testing.T) {
// Test debug create-node with missing user flag
registrationKey := "nodekey:2222222222222222222222222222222222222222222222222222222222222222"
_, err := headscale.Execute(
[]string{
"headscale",
@ -265,7 +265,7 @@ func TestDebugCreateNodeCommandValidation(t *testing.T) {
t.Run("test_debug_create_node_nonexistent_user", func(t *testing.T) {
// Test debug create-node with non-existent user
registrationKey := "nodekey:3333333333333333333333333333333333333333333333333333333333333333"
_, err := headscale.Execute(
[]string{
"headscale",
@ -285,7 +285,7 @@ func TestDebugCreateNodeCommandValidation(t *testing.T) {
nodeName := "duplicate-node"
registrationKey1 := "nodekey:4444444444444444444444444444444444444444444444444444444444444444"
registrationKey2 := "nodekey:5555555555555555555555555555555555555555555555555555555555555555"
// Create first node
_, err := headscale.Execute(
[]string{
@ -298,7 +298,7 @@ func TestDebugCreateNodeCommandValidation(t *testing.T) {
},
)
assertNoErr(t, err)
// Try to create second node with same name
_, err = headscale.Execute(
[]string{
@ -348,7 +348,7 @@ func TestDebugCreateNodeCommandEdgeCases(t *testing.T) {
// Test debug create-node with invalid route format
nodeName := "invalid-route-node"
registrationKey := "nodekey:6666666666666666666666666666666666666666666666666666666666666666"
_, err := headscale.Execute(
[]string{
"headscale",
@ -368,7 +368,7 @@ func TestDebugCreateNodeCommandEdgeCases(t *testing.T) {
// Test debug create-node with empty route
nodeName := "empty-route-node"
registrationKey := "nodekey:7777777777777777777777777777777777777777777777777777777777777777"
result, err := headscale.Execute(
[]string{
"headscale",
@ -395,7 +395,7 @@ func TestDebugCreateNodeCommandEdgeCases(t *testing.T) {
longName += "-very-long-segment"
}
registrationKey := "nodekey:8888888888888888888888888888888888888888888888888888888888888888"
_, _ = headscale.Execute(
[]string{
"headscale",
@ -420,4 +420,4 @@ func TestDebugCreateNodeCommandEdgeCases(t *testing.T) {
)
}, "should handle very long node names gracefully")
})
}
}

View File

@ -145,9 +145,9 @@ func derpServerScenario(
assert.NoError(ct, err, "Failed to get status for client %s", client.Hostname())
for _, health := range status.Health {
assert.NotContains(ct, health, "could not connect to any relay server",
assert.NotContains(ct, health, "could not connect to any relay server",
"Client %s should be connected to DERP relay", client.Hostname())
assert.NotContains(ct, health, "could not connect to the 'Headscale Embedded DERP' relay server.",
assert.NotContains(ct, health, "could not connect to the 'Headscale Embedded DERP' relay server.",
"Client %s should be connected to Headscale Embedded DERP", client.Hostname())
}
}, 30*time.Second, 2*time.Second)
@ -166,9 +166,9 @@ func derpServerScenario(
assert.NoError(ct, err, "Failed to get status for client %s", client.Hostname())
for _, health := range status.Health {
assert.NotContains(ct, health, "could not connect to any relay server",
assert.NotContains(ct, health, "could not connect to any relay server",
"Client %s should be connected to DERP relay after first run", client.Hostname())
assert.NotContains(ct, health, "could not connect to the 'Headscale Embedded DERP' relay server.",
assert.NotContains(ct, health, "could not connect to the 'Headscale Embedded DERP' relay server.",
"Client %s should be connected to Headscale Embedded DERP after first run", client.Hostname())
}
}, 30*time.Second, 2*time.Second)
@ -191,9 +191,9 @@ func derpServerScenario(
assert.NoError(ct, err, "Failed to get status for client %s", client.Hostname())
for _, health := range status.Health {
assert.NotContains(ct, health, "could not connect to any relay server",
assert.NotContains(ct, health, "could not connect to any relay server",
"Client %s should be connected to DERP relay after second run", client.Hostname())
assert.NotContains(ct, health, "could not connect to the 'Headscale Embedded DERP' relay server.",
assert.NotContains(ct, health, "could not connect to the 'Headscale Embedded DERP' relay server.",
"Client %s should be connected to Headscale Embedded DERP after second run", client.Hostname())
}
}, 30*time.Second, 2*time.Second)

View File

@ -564,10 +564,10 @@ func TestUpdateHostnameFromClient(t *testing.T) {
_, err = headscale.Execute(
[]string{
"headscale",
"node",
"nodes",
"rename",
givenName,
"--identifier",
"--node",
strconv.FormatUint(node.GetId(), 10),
})
assertNoErr(t, err)
@ -702,7 +702,7 @@ func TestExpireNode(t *testing.T) {
// TODO(kradalby): This is Headscale specific and would not play nicely
// with other implementations of the ControlServer interface
result, err := headscale.Execute([]string{
"headscale", "nodes", "expire", "--identifier", "1", "--output", "json",
"headscale", "nodes", "expire", "--node", "1", "--output", "json",
})
assertNoErr(t, err)
@ -1060,7 +1060,7 @@ func Test2118DeletingOnlineNodePanics(t *testing.T) {
"headscale",
"nodes",
"delete",
"--identifier",
"--node",
// Delete the last added machine
fmt.Sprintf("%d", nodeList[0].GetId()),
"--output",

View File

@ -37,7 +37,7 @@ func TestGenerateCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Help text should contain expected information
assert.Contains(t, result, "generate", "help should mention generate command")
assert.Contains(t, result, "Generate commands", "help should contain command description")
@ -54,7 +54,7 @@ func TestGenerateCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Should work with alias
assert.Contains(t, result, "generate", "alias should work and show generate help")
assert.Contains(t, result, "private-key", "alias help should mention private-key subcommand")
@ -71,7 +71,7 @@ func TestGenerateCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Help text should contain expected information
assert.Contains(t, result, "private-key", "help should mention private-key command")
assert.Contains(t, result, "Generate a private key", "help should contain command description")
@ -105,17 +105,17 @@ func TestGeneratePrivateKeyCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Should output a private key
assert.NotEmpty(t, result, "private key generation should produce output")
// Private key should start with expected prefix
trimmed := strings.TrimSpace(result)
assert.True(t, strings.HasPrefix(trimmed, "privkey:"),
assert.True(t, strings.HasPrefix(trimmed, "privkey:"),
"private key should start with 'privkey:' prefix, got: %s", trimmed)
// Should be reasonable length (64+ hex characters after prefix)
assert.True(t, len(trimmed) > 70,
assert.True(t, len(trimmed) > 70,
"private key should be reasonable length, got length: %d", len(trimmed))
})
@ -130,21 +130,21 @@ func TestGeneratePrivateKeyCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Should produce valid JSON output
var keyData map[string]interface{}
err = json.Unmarshal([]byte(result), &keyData)
assert.NoError(t, err, "private key generation should produce valid JSON output")
// Should contain private_key field
privateKey, exists := keyData["private_key"]
assert.True(t, exists, "JSON output should contain 'private_key' field")
assert.NotEmpty(t, privateKey, "private_key field should not be empty")
// Private key should be a string with correct format
privateKeyStr, ok := privateKey.(string)
assert.True(t, ok, "private_key should be a string")
assert.True(t, strings.HasPrefix(privateKeyStr, "privkey:"),
assert.True(t, strings.HasPrefix(privateKeyStr, "privkey:"),
"private key should start with 'privkey:' prefix")
})
@ -159,7 +159,7 @@ func TestGeneratePrivateKeyCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Should produce YAML output
assert.NotEmpty(t, result, "YAML output should not be empty")
assert.Contains(t, result, "private_key:", "YAML output should contain private_key field")
@ -169,7 +169,7 @@ func TestGeneratePrivateKeyCommand(t *testing.T) {
t.Run("test_generate_private_key_multiple_calls", func(t *testing.T) {
// Test that multiple calls generate different keys
var keys []string
for i := 0; i < 3; i++ {
result, err := headscale.Execute(
[]string{
@ -179,13 +179,13 @@ func TestGeneratePrivateKeyCommand(t *testing.T) {
},
)
assertNoErr(t, err)
trimmed := strings.TrimSpace(result)
keys = append(keys, trimmed)
assert.True(t, strings.HasPrefix(trimmed, "privkey:"),
assert.True(t, strings.HasPrefix(trimmed, "privkey:"),
"each generated private key should have correct prefix")
}
// All keys should be different
assert.NotEqual(t, keys[0], keys[1], "generated keys should be different")
assert.NotEqual(t, keys[1], keys[2], "generated keys should be different")
@ -221,12 +221,12 @@ func TestGeneratePrivateKeyCommandValidation(t *testing.T) {
"args",
},
)
// Should either succeed (ignoring extra args) or fail gracefully
if err == nil {
// If successful, should still produce valid key
trimmed := strings.TrimSpace(result)
assert.True(t, strings.HasPrefix(trimmed, "privkey:"),
assert.True(t, strings.HasPrefix(trimmed, "privkey:"),
"should produce valid private key even with extra args")
} else {
// If failed, should be a reasonable error, not a panic
@ -244,7 +244,7 @@ func TestGeneratePrivateKeyCommandValidation(t *testing.T) {
"--output", "invalid-format",
},
)
// Should handle invalid output format gracefully
// Might succeed with default format or fail gracefully
if err == nil {
@ -265,10 +265,10 @@ func TestGeneratePrivateKeyCommandValidation(t *testing.T) {
},
)
assertNoErr(t, err)
// Should still generate valid private key
trimmed := strings.TrimSpace(result)
assert.True(t, strings.HasPrefix(trimmed, "privkey:"),
assert.True(t, strings.HasPrefix(trimmed, "privkey:"),
"should generate valid private key with config flag")
})
}
@ -298,7 +298,7 @@ func TestGenerateCommandEdgeCases(t *testing.T) {
"generate",
},
)
// Should show help or list available subcommands
if err == nil {
assert.Contains(t, result, "private-key", "should show available subcommands")
@ -317,10 +317,12 @@ func TestGenerateCommandEdgeCases(t *testing.T) {
"nonexistent-command",
},
)
// Should fail gracefully for non-existent subcommand
assert.Error(t, err, "should fail for non-existent subcommand")
assert.NotContains(t, err.Error(), "panic", "should not panic on non-existent subcommand")
if err != nil {
assert.NotContains(t, err.Error(), "panic", "should not panic on non-existent subcommand")
}
})
t.Run("test_generate_key_format_consistency", func(t *testing.T) {
@ -333,24 +335,24 @@ func TestGenerateCommandEdgeCases(t *testing.T) {
},
)
assertNoErr(t, err)
trimmed := strings.TrimSpace(result)
// Check format consistency
assert.True(t, strings.HasPrefix(trimmed, "privkey:"),
assert.True(t, strings.HasPrefix(trimmed, "privkey:"),
"private key should start with 'privkey:' prefix")
// Should be hex characters after prefix
keyPart := strings.TrimPrefix(trimmed, "privkey:")
assert.True(t, len(keyPart) == 64,
assert.True(t, len(keyPart) == 64,
"private key should be 64 hex characters after prefix, got length: %d", len(keyPart))
// Should only contain valid hex characters
for _, char := range keyPart {
assert.True(t,
(char >= '0' && char <= '9') ||
(char >= 'a' && char <= 'f') ||
(char >= 'A' && char <= 'F'),
assert.True(t,
(char >= '0' && char <= '9') ||
(char >= 'a' && char <= 'f') ||
(char >= 'A' && char <= 'F'),
"private key should only contain hex characters, found: %c", char)
}
})
@ -365,7 +367,7 @@ func TestGenerateCommandEdgeCases(t *testing.T) {
},
)
assertNoErr(t, err1)
result2, err2 := headscale.Execute(
[]string{
"headscale",
@ -374,18 +376,18 @@ func TestGenerateCommandEdgeCases(t *testing.T) {
},
)
assertNoErr(t, err2)
// Both should produce valid keys (though different values)
trimmed1 := strings.TrimSpace(result1)
trimmed2 := strings.TrimSpace(result2)
assert.True(t, strings.HasPrefix(trimmed1, "privkey:"),
assert.True(t, strings.HasPrefix(trimmed1, "privkey:"),
"generate command should produce valid key")
assert.True(t, strings.HasPrefix(trimmed2, "privkey:"),
assert.True(t, strings.HasPrefix(trimmed2, "privkey:"),
"gen alias should produce valid key")
// Keys should be different (they're randomly generated)
assert.NotEqual(t, trimmed1, trimmed2,
assert.NotEqual(t, trimmed1, trimmed2,
"different calls should produce different keys")
})
}
}

View File

@ -1122,7 +1122,7 @@ func (t *HeadscaleInContainer) ApproveRoutes(id uint64, routes []netip.Prefix) (
command := []string{
"headscale", "nodes", "approve-routes",
"--output", "json",
"--identifier", strconv.FormatUint(id, 10),
"--node", strconv.FormatUint(id, 10),
"--routes=" + strings.Join(util.PrefixesToString(routes), ","),
}

View File

@ -112,7 +112,7 @@ func TestRouteCommand(t *testing.T) {
"headscale",
"nodes",
"list-routes",
"--identifier",
"--node",
fmt.Sprintf("%d", nodeID),
},
)
@ -124,7 +124,7 @@ func TestRouteCommand(t *testing.T) {
"headscale",
"nodes",
"approve-routes",
"--identifier",
"--node",
fmt.Sprintf("%d", nodeID),
"--routes",
"10.0.0.0/24",
@ -158,7 +158,7 @@ func TestRouteCommand(t *testing.T) {
"headscale",
"nodes",
"approve-routes",
"--identifier",
"--node",
fmt.Sprintf("%d", nodeID),
"--routes",
"", // Empty string removes all routes
@ -192,7 +192,7 @@ func TestRouteCommand(t *testing.T) {
"headscale",
"nodes",
"list-routes",
"--identifier",
"--node",
fmt.Sprintf("%d", nodeID),
"--output",
"json",
@ -231,7 +231,7 @@ func TestRouteCommandEdgeCases(t *testing.T) {
"headscale",
"nodes",
"list-routes",
"--identifier",
"--node",
"999999",
},
)
@ -246,7 +246,7 @@ func TestRouteCommandEdgeCases(t *testing.T) {
"headscale",
"nodes",
"approve-routes",
"--identifier",
"--node",
"1",
"--routes",
"invalid-cidr",
@ -284,10 +284,10 @@ func TestRouteCommandHelp(t *testing.T) {
},
)
assertNoErr(t, err)
// Verify help text contains expected information
assert.Contains(t, result, "list-routes", "help should mention list-routes command")
assert.Contains(t, result, "identifier", "help should mention identifier flag")
assert.Contains(t, result, "node", "help should mention node flag")
})
t.Run("test_approve_routes_help", func(t *testing.T) {
@ -300,10 +300,10 @@ func TestRouteCommandHelp(t *testing.T) {
},
)
assertNoErr(t, err)
// Verify help text contains expected information
assert.Contains(t, result, "approve-routes", "help should mention approve-routes command")
assert.Contains(t, result, "identifier", "help should mention identifier flag")
assert.Contains(t, result, "node", "help should mention node flag")
assert.Contains(t, result, "routes", "help should mention routes flag")
})
}
}

View File

@ -40,7 +40,7 @@ func TestServeCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Help text should contain expected information
assert.Contains(t, result, "serve", "help should mention serve command")
assert.Contains(t, result, "Launches the headscale server", "help should contain command description")
@ -83,7 +83,7 @@ func TestServeCommandValidation(t *testing.T) {
// We'll test that it accepts extra args without crashing immediately
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Use a goroutine to test that the command doesn't immediately fail
done := make(chan error, 1)
go func() {
@ -97,7 +97,7 @@ func TestServeCommandValidation(t *testing.T) {
)
done <- err
}()
select {
case err := <-done:
// If it returns an error quickly, it should be about args validation
@ -132,28 +132,28 @@ func TestServeCommandHealthCheck(t *testing.T) {
t.Run("test_serve_health_endpoint", func(t *testing.T) {
// Test that the serve command starts a server that responds to health checks
// This is effectively testing that the server is running and accessible
// Get the server endpoint
endpoint := headscale.GetEndpoint()
assert.NotEmpty(t, endpoint, "headscale endpoint should not be empty")
// Make a simple HTTP request to verify the server is running
healthURL := fmt.Sprintf("%s/health", endpoint)
// Use a timeout to avoid hanging
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get(healthURL)
if err != nil {
// If we can't connect, check if it's because server isn't ready
assert.Contains(t, err.Error(), "connection",
assert.Contains(t, err.Error(), "connection",
"health check failure should be connection-related if server not ready")
} else {
defer resp.Body.Close()
// If we can connect, verify we get a reasonable response
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500,
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500,
"health endpoint should return reasonable status code")
}
})
@ -162,24 +162,24 @@ func TestServeCommandHealthCheck(t *testing.T) {
// Test that the serve command starts a server with API endpoints
endpoint := headscale.GetEndpoint()
assert.NotEmpty(t, endpoint, "headscale endpoint should not be empty")
// Try to access a known API endpoint (version info)
// This tests that the gRPC gateway is running
versionURL := fmt.Sprintf("%s/api/v1/version", endpoint)
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get(versionURL)
if err != nil {
// Connection errors are acceptable if server isn't fully ready
assert.Contains(t, err.Error(), "connection",
assert.Contains(t, err.Error(), "connection",
"API endpoint failure should be connection-related if server not ready")
} else {
defer resp.Body.Close()
// If we can connect, check that we get some response
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500,
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500,
"API endpoint should return reasonable status code")
}
})
@ -205,7 +205,7 @@ func TestServeCommandServerBehavior(t *testing.T) {
t.Run("test_serve_accepts_connections", func(t *testing.T) {
// Test that the server accepts connections from clients
// This is a basic integration test to ensure serve works
// Create a user for testing
user := spec.Users[0]
_, err := headscale.Execute(
@ -217,7 +217,7 @@ func TestServeCommandServerBehavior(t *testing.T) {
},
)
assertNoErr(t, err)
// Create a pre-auth key
result, err := headscale.Execute(
[]string{
@ -229,7 +229,7 @@ func TestServeCommandServerBehavior(t *testing.T) {
},
)
assertNoErr(t, err)
// Verify the preauth key creation worked
assert.NotEmpty(t, result, "preauth key creation should produce output")
assert.Contains(t, result, "key", "preauth key output should contain key field")
@ -238,7 +238,7 @@ func TestServeCommandServerBehavior(t *testing.T) {
t.Run("test_serve_handles_node_operations", func(t *testing.T) {
// Test that the server can handle basic node operations
_ = spec.Users[0] // Test user for context
// List nodes (should work even if empty)
result, err := headscale.Execute(
[]string{
@ -249,10 +249,10 @@ func TestServeCommandServerBehavior(t *testing.T) {
},
)
assertNoErr(t, err)
// Should return valid JSON array (even if empty)
trimmed := strings.TrimSpace(result)
assert.True(t, strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]"),
assert.True(t, strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]"),
"nodes list should return JSON array")
})
@ -267,12 +267,12 @@ func TestServeCommandServerBehavior(t *testing.T) {
},
)
assertNoErr(t, err)
// Should return valid JSON array
trimmed := strings.TrimSpace(result)
assert.True(t, strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]"),
assert.True(t, strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]"),
"users list should return JSON array")
// Should contain our test user
assert.Contains(t, result, spec.Users[0], "users list should contain test user")
})
@ -299,7 +299,7 @@ func TestServeCommandEdgeCases(t *testing.T) {
// Test that the server can handle multiple rapid commands
// This tests the server's ability to handle concurrent requests
user := spec.Users[0]
// Create user first
_, err := headscale.Execute(
[]string{
@ -310,7 +310,7 @@ func TestServeCommandEdgeCases(t *testing.T) {
},
)
assertNoErr(t, err)
// Execute multiple commands rapidly
for i := 0; i < 3; i++ {
result, err := headscale.Execute(
@ -334,7 +334,7 @@ func TestServeCommandEdgeCases(t *testing.T) {
},
)
assertNoErr(t, err)
// Basic help should work
result, err := headscale.Execute(
[]string{
@ -357,7 +357,7 @@ func TestServeCommandEdgeCases(t *testing.T) {
)
// Should fail gracefully for non-existent commands
assert.Error(t, err, "should fail gracefully for non-existent commands")
// Should not cause server to crash (we can still execute other commands)
result, err := headscale.Execute(
[]string{
@ -369,4 +369,4 @@ func TestServeCommandEdgeCases(t *testing.T) {
assertNoErr(t, err)
assert.NotEmpty(t, result, "server should still work after malformed request")
})
}
}

View File

@ -24,7 +24,7 @@ const (
// derpPingTimeout defines the timeout for individual DERP ping operations
// Used in DERP connectivity tests to verify relay server communication
derpPingTimeout = 2 * time.Second
// derpPingCount defines the number of ping attempts for DERP connectivity tests
// Higher count provides better reliability assessment of DERP connectivity
derpPingCount = 10
@ -317,7 +317,7 @@ func assertValidNetcheck(t *testing.T, client TailscaleClient) {
// assertCommandOutputContains executes a command with exponential backoff retry until the output
// contains the expected string or timeout is reached (10 seconds).
// This implements eventual consistency patterns and should be used instead of time.Sleep
// This implements eventual consistency patterns and should be used instead of time.Sleep
// before executing commands that depend on network state propagation.
//
// Timeout: 10 seconds with exponential backoff

View File

@ -35,10 +35,10 @@ func TestVersionCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Version output should contain version information
assert.NotEmpty(t, result, "version output should not be empty")
// In development, version is "dev", in releases it would be semver like "1.0.0"
// In development, version is "dev", in releases it would be semver like "1.0.0"
trimmed := strings.TrimSpace(result)
assert.True(t, trimmed == "dev" || len(trimmed) > 2, "version should be 'dev' or valid version string")
})
@ -53,7 +53,7 @@ func TestVersionCommand(t *testing.T) {
},
)
assertNoErr(t, err)
// Help text should contain expected information
assert.Contains(t, result, "version", "help should mention version command")
assert.Contains(t, result, "version of headscale", "help should contain command description")
@ -81,7 +81,7 @@ func TestVersionCommand(t *testing.T) {
},
)
}, "version command should handle extra arguments gracefully")
// If it succeeds, should still contain version info
if err == nil {
assert.NotEmpty(t, result, "version output should not be empty")
@ -140,4 +140,4 @@ func TestVersionCommandEdgeCases(t *testing.T) {
)
}, "version command should handle invalid flags gracefully")
})
}
}