cmd/headscale/cli: switch to RunE with grpcRunE and error returns

Rename grpcRun to grpcRunE: the inner closure now returns error
and the wrapper returns a cobra RunE-compatible function.

Change newHeadscaleCLIWithConfig to return an error instead of
calling log.Fatal/os.Exit, making connection failures propagate
through the normal error path.

Add formatOutput (returns error) and printOutput (writes to stdout)
as non-exiting replacements for the old output/SuccessOutput pair.
Extract output format string literals into package-level constants.
Mark the old ErrorOutput, SuccessOutput and output helpers as
deprecated; they remain temporarily for the unconverted commands.

Convert all 22 grpcRunE commands from Run+ErrorOutput+SuccessOutput
to RunE+fmt.Errorf+printOutput. Change usernameAndIDFromFlag to
return an error instead of calling ErrorOutput directly.

Update backfillNodeIPsCmd and policy.go callers of
newHeadscaleCLIWithConfig for the new 5-return signature while
keeping their Run-based pattern for now.
This commit is contained in:
Kristoffer Dalby
2026-02-18 13:44:35 +00:00
parent e6546b2cea
commit e4fe216e45
8 changed files with 287 additions and 483 deletions

View File

@@ -47,22 +47,18 @@ var listAPIKeys = &cobra.Command{
Use: "list",
Short: "List the Api keys for headscale",
Aliases: []string{"ls", "show"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
format, _ := cmd.Flags().GetString("output")
request := &v1.ListApiKeysRequest{}
response, err := client.ListApiKeys(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error getting the list of keys: %s", err),
output,
)
return fmt.Errorf("listing api keys: %w", err)
}
if output != "" {
SuccessOutput(response.GetApiKeys(), "", output)
if format != "" {
return printOutput(cmd, response.GetApiKeys(), "")
}
tableData := pterm.TableData{
@@ -86,12 +82,10 @@ var listAPIKeys = &cobra.Command{
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Failed to render pterm table: %s", err),
output,
)
return fmt.Errorf("rendering table: %w", err)
}
return nil
}),
}
@@ -103,20 +97,14 @@ Creates a new Api key, the Api key is only visible on creation
and cannot be retrieved again.
If you loose a key, create a new one and revoke (expire) the old one.`,
Aliases: []string{"c", "new"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
request := &v1.CreateApiKeyRequest{}
durationStr, _ := cmd.Flags().GetString("expiration")
duration, err := model.ParseDuration(durationStr)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Could not parse duration: %s\n", err),
output,
)
return fmt.Errorf("parsing duration: %w", err)
}
expiration := time.Now().UTC().Add(time.Duration(duration))
@@ -125,14 +113,10 @@ If you loose a key, create a new one and revoke (expire) the old one.`,
response, err := client.CreateApiKey(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Cannot create Api Key: %s\n", err),
output,
)
return fmt.Errorf("creating api key: %w", err)
}
SuccessOutput(response.GetApiKey(), response.GetApiKey(), output)
return printOutput(cmd, response.GetApiKey(), response.GetApiKey())
}),
}
@@ -140,25 +124,15 @@ var expireAPIKeyCmd = &cobra.Command{
Use: "expire",
Short: "Expire an ApiKey",
Aliases: []string{"revoke", "exp", "e"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id")
prefix, _ := cmd.Flags().GetString("prefix")
switch {
case id == 0 && prefix == "":
ErrorOutput(
errMissingParameter,
"Either --id or --prefix must be provided",
output,
)
return fmt.Errorf("either --id or --prefix must be provided: %w", errMissingParameter)
case id != 0 && prefix != "":
ErrorOutput(
errMissingParameter,
"Only one of --id or --prefix can be provided",
output,
)
return fmt.Errorf("only one of --id or --prefix can be provided: %w", errMissingParameter)
}
request := &v1.ExpireApiKeyRequest{}
@@ -170,14 +144,10 @@ var expireAPIKeyCmd = &cobra.Command{
response, err := client.ExpireApiKey(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Cannot expire Api Key: %s\n", err),
output,
)
return fmt.Errorf("expiring api key: %w", err)
}
SuccessOutput(response, "Key expired", output)
return printOutput(cmd, response, "Key expired")
}),
}
@@ -185,25 +155,15 @@ var deleteAPIKeyCmd = &cobra.Command{
Use: "delete",
Short: "Delete an ApiKey",
Aliases: []string{"remove", "del"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id")
prefix, _ := cmd.Flags().GetString("prefix")
switch {
case id == 0 && prefix == "":
ErrorOutput(
errMissingParameter,
"Either --id or --prefix must be provided",
output,
)
return fmt.Errorf("either --id or --prefix must be provided: %w", errMissingParameter)
case id != 0 && prefix != "":
ErrorOutput(
errMissingParameter,
"Only one of --id or --prefix can be provided",
output,
)
return fmt.Errorf("only one of --id or --prefix can be provided: %w", errMissingParameter)
}
request := &v1.DeleteApiKeyRequest{}
@@ -215,13 +175,9 @@ var deleteAPIKeyCmd = &cobra.Command{
response, err := client.DeleteApiKey(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Cannot delete Api Key: %s\n", err),
output,
)
return fmt.Errorf("deleting api key: %w", err)
}
SuccessOutput(response, "Key deleted", output)
return printOutput(cmd, response, "Key deleted")
}),
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
)
// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors
@@ -60,48 +59,30 @@ var debugCmd = &cobra.Command{
var createNodeCmd = &cobra.Command{
Use: "create-node",
Short: "Create a node that can be registered with `nodes register <>` command",
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
user, err := cmd.Flags().GetString("user")
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
return fmt.Errorf("getting user flag: %w", err)
}
name, err := cmd.Flags().GetString("name")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error getting node from flag: %s", err),
output,
)
return fmt.Errorf("getting name flag: %w", err)
}
registrationID, err := cmd.Flags().GetString("key")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error getting key from flag: %s", err),
output,
)
return fmt.Errorf("getting key flag: %w", err)
}
_, err = types.RegistrationIDFromString(registrationID)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Failed to parse machine key from flag: %s", err),
output,
)
return fmt.Errorf("parsing machine key: %w", err)
}
routes, err := cmd.Flags().GetStringSlice("route")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error getting routes from flag: %s", err),
output,
)
return fmt.Errorf("getting routes flag: %w", err)
}
request := &v1.DebugCreateNodeRequest{
@@ -113,13 +94,9 @@ var createNodeCmd = &cobra.Command{
response, err := client.DebugCreateNode(ctx, request)
if err != nil {
ErrorOutput(
err,
"Cannot create node: "+status.Convert(err).Message(),
output,
)
return fmt.Errorf("creating node: %w", err)
}
SuccessOutput(response.GetNode(), "Node created", output)
return printOutput(cmd, response.GetNode(), "Node created")
}),
}

View File

@@ -2,6 +2,7 @@ package cli
import (
"context"
"fmt"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/spf13/cobra"
@@ -15,14 +16,12 @@ var healthCmd = &cobra.Command{
Use: "health",
Short: "Check the health of the Headscale server",
Long: "Check the health of the Headscale server. This command will return an exit code of 0 if the server is healthy, or 1 if it is not.",
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
response, err := client.Health(ctx, &v1.HealthRequest{})
if err != nil {
ErrorOutput(err, "Error checking health", output)
return fmt.Errorf("checking health: %w", err)
}
SuccessOutput(response, "", output)
return printOutput(cmd, response, "")
}),
}

View File

@@ -104,21 +104,15 @@ var nodeCmd = &cobra.Command{
var registerNodeCmd = &cobra.Command{
Use: "register",
Short: "Registers a node to your network",
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
user, err := cmd.Flags().GetString("user")
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
return fmt.Errorf("getting user flag: %w", err)
}
registrationID, err := cmd.Flags().GetString("key")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error getting node key from flag: %s", err),
output,
)
return fmt.Errorf("getting key flag: %w", err)
}
request := &v1.RegisterNodeRequest{
@@ -128,19 +122,13 @@ var registerNodeCmd = &cobra.Command{
response, err := client.RegisterNode(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf(
"Cannot register node: %s\n",
status.Convert(err).Message(),
),
output,
)
return fmt.Errorf("registering node: %w", err)
}
SuccessOutput(
return printOutput(
cmd,
response.GetNode(),
fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()), output)
fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()))
}),
}
@@ -148,12 +136,11 @@ var listNodesCmd = &cobra.Command{
Use: "list",
Short: "List nodes",
Aliases: []string{"ls", "show"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
format, _ := cmd.Flags().GetString("output")
user, err := cmd.Flags().GetString("user")
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
return fmt.Errorf("getting user flag: %w", err)
}
request := &v1.ListNodesRequest{
@@ -162,30 +149,24 @@ var listNodesCmd = &cobra.Command{
response, err := client.ListNodes(ctx, request)
if err != nil {
ErrorOutput(
err,
"Cannot get nodes: "+status.Convert(err).Message(),
output,
)
return fmt.Errorf("listing nodes: %w", err)
}
if output != "" {
SuccessOutput(response.GetNodes(), "", output)
if format != "" {
return printOutput(cmd, response.GetNodes(), "")
}
tableData, err := nodesToPtables(user, response.GetNodes())
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error converting to table: %s", err), output)
return fmt.Errorf("converting to table: %w", err)
}
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Failed to render pterm table: %s", err),
output,
)
return fmt.Errorf("rendering table: %w", err)
}
return nil
}),
}
@@ -193,27 +174,18 @@ var listNodeRoutesCmd = &cobra.Command{
Use: "list-routes",
Short: "List routes available on nodes",
Aliases: []string{"lsr", "routes"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
format, _ := cmd.Flags().GetString("output")
identifier, err := cmd.Flags().GetUint64("identifier")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error converting ID to integer: %s", err),
output,
)
return fmt.Errorf("getting identifier flag: %w", err)
}
request := &v1.ListNodesRequest{}
response, err := client.ListNodes(ctx, request)
if err != nil {
ErrorOutput(
err,
"Cannot get nodes: "+status.Convert(err).Message(),
output,
)
return fmt.Errorf("listing nodes: %w", err)
}
nodes := response.GetNodes()
@@ -221,6 +193,7 @@ var listNodeRoutesCmd = &cobra.Command{
for _, node := range response.GetNodes() {
if node.GetId() == identifier {
nodes = []*v1.Node{node}
break
}
}
@@ -230,21 +203,18 @@ var listNodeRoutesCmd = &cobra.Command{
return (n.GetSubnetRoutes() != nil && len(n.GetSubnetRoutes()) > 0) || (n.GetApprovedRoutes() != nil && len(n.GetApprovedRoutes()) > 0) || (n.GetAvailableRoutes() != nil && len(n.GetAvailableRoutes()) > 0)
})
if output != "" {
SuccessOutput(nodes, "", output)
return
if format != "" {
return printOutput(cmd, nodes, "")
}
tableData := nodeRoutesToPtables(nodes)
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Failed to render pterm table: %s", err),
output,
)
return fmt.Errorf("rendering table: %w", err)
}
return nil
}),
}
@@ -253,27 +223,15 @@ var expireNodeCmd = &cobra.Command{
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.",
Aliases: []string{"logout", "exp", "e"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
identifier, err := cmd.Flags().GetUint64("identifier")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error converting ID to integer: %s", err),
output,
)
return fmt.Errorf("getting identifier flag: %w", err)
}
expiry, err := cmd.Flags().GetString("expiry")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error converting expiry to string: %s", err),
output,
)
return
return fmt.Errorf("getting expiry flag: %w", err)
}
now := time.Now()
@@ -282,13 +240,7 @@ var expireNodeCmd = &cobra.Command{
if expiry != "" {
expiryTime, err = time.Parse(time.RFC3339, expiry)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error converting expiry to string: %s", err),
output,
)
return
return fmt.Errorf("parsing expiry time: %w", err)
}
}
@@ -299,37 +251,24 @@ var expireNodeCmd = &cobra.Command{
response, err := client.ExpireNode(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf(
"Cannot expire node: %s\n",
status.Convert(err).Message(),
),
output,
)
return fmt.Errorf("expiring node: %w", err)
}
if now.Equal(expiryTime) || now.After(expiryTime) {
SuccessOutput(response.GetNode(), "Node expired", output)
} else {
SuccessOutput(response.GetNode(), "Node expiration updated", output)
return printOutput(cmd, response.GetNode(), "Node expired")
}
return printOutput(cmd, response.GetNode(), "Node expiration updated")
}),
}
var renameNodeCmd = &cobra.Command{
Use: "rename NEW_NAME",
Short: "Renames a node in your network",
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
identifier, err := cmd.Flags().GetUint64("identifier")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error converting ID to integer: %s", err),
output,
)
return fmt.Errorf("getting identifier flag: %w", err)
}
newName := ""
@@ -344,17 +283,10 @@ var renameNodeCmd = &cobra.Command{
response, err := client.RenameNode(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf(
"Cannot rename node: %s\n",
status.Convert(err).Message(),
),
output,
)
return fmt.Errorf("renaming node: %w", err)
}
SuccessOutput(response.GetNode(), "Node renamed", output)
return printOutput(cmd, response.GetNode(), "Node renamed")
}),
}
@@ -362,16 +294,10 @@ var deleteNodeCmd = &cobra.Command{
Use: "delete",
Short: "Delete a node",
Aliases: []string{"del"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
identifier, err := cmd.Flags().GetUint64("identifier")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error converting ID to integer: %s", err),
output,
)
return fmt.Errorf("getting identifier flag: %w", err)
}
getRequest := &v1.GetNodeRequest{
@@ -380,11 +306,7 @@ var deleteNodeCmd = &cobra.Command{
getResponse, err := client.GetNode(ctx, getRequest)
if err != nil {
ErrorOutput(
err,
"Error getting node node: "+status.Convert(err).Message(),
output,
)
return fmt.Errorf("getting node: %w", err)
}
deleteRequest := &v1.DeleteNodeRequest{
@@ -403,28 +325,20 @@ var deleteNodeCmd = &cobra.Command{
if confirm || force {
response, err := client.DeleteNode(ctx, deleteRequest)
if output != "" {
SuccessOutput(response, "", output)
return
}
if err != nil {
ErrorOutput(
err,
"Error deleting node: "+status.Convert(err).Message(),
output,
)
return fmt.Errorf("deleting node: %w", err)
}
SuccessOutput(
_ = response // consumed for structured output if needed
return printOutput(
cmd,
map[string]string{"Result": "Node deleted"},
"Node deleted",
output,
)
} else {
SuccessOutput(map[string]string{"Result": "Node not deleted"}, "Node not deleted", output)
}
return printOutput(cmd, map[string]string{"Result": "Node not deleted"}, "Node not deleted")
}),
}
@@ -454,7 +368,10 @@ be assigned to nodes.`,
}
if confirm || force {
ctx, client, conn, cancel := newHeadscaleCLIWithConfig()
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error connecting: %s", err), output)
}
defer cancel()
defer conn.Close()
@@ -547,14 +464,12 @@ func nodesToPtables(
}
var expired string
if expiry.IsZero() || expiry.After(time.Now()) {
expired = pterm.LightGreen("no")
} else {
if node.GetExpiry() != nil && node.GetExpiry().AsTime().Before(time.Now()) {
expired = pterm.LightRed("yes")
} else {
expired = pterm.LightGreen("no")
}
// TODO(kradalby): as part of CLI rework, we should add the posibility to show "unusable" tags as mentioned in
// https://github.com/juanfont/headscale/issues/2981
var tagsBuilder strings.Builder
for _, tag := range node.GetTags() {
@@ -563,29 +478,26 @@ func nodesToPtables(
tags := tagsBuilder.String()
tags = strings.TrimLeft(tags, "\n")
var user string
if currentUser == "" || (currentUser == node.GetUser().GetName()) {
user = pterm.LightMagenta(node.GetUser().GetName())
} else {
// Shared into this user
user = pterm.LightYellow(node.GetUser().GetName())
if node.GetUser() != nil {
user = node.GetUser().GetName()
}
var (
IPV4Address string
IPV6Address string
ipAddresses string
ipAddressesSb485 strings.Builder
)
for _, addr := range node.GetIpAddresses() {
if netip.MustParseAddr(addr).Is4() {
IPV4Address = addr
} else {
IPV6Address = addr
ip, err := netip.ParseAddr(addr)
if err == nil {
ipAddressesSb485.WriteString(ip.String() + "\n")
}
}
ipAddresses += ipAddressesSb485.String()
ipAddresses = strings.TrimRight(ipAddresses, "\n")
nodeData := []string{
strconv.FormatUint(node.GetId(), util.Base10),
node.GetName(),
@@ -594,7 +506,7 @@ func nodesToPtables(
nodeKey.ShortString(),
user,
tags,
strings.Join([]string{IPV4Address, IPV6Address}, ", "),
ipAddresses,
strconv.FormatBool(ephemeral),
lastSeenTime,
expiryTime,
@@ -643,26 +555,16 @@ var tagCmd = &cobra.Command{
Use: "tag",
Short: "Manage the tags of a node",
Aliases: []string{"tags", "t"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
// retrieve flags from CLI
identifier, err := cmd.Flags().GetUint64("identifier")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error converting ID to integer: %s", err),
output,
)
return fmt.Errorf("getting identifier flag: %w", err)
}
tagsToSet, err := cmd.Flags().GetStringSlice("tags")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error retrieving list of tags to add to node, %v", err),
output,
)
return fmt.Errorf("getting tags flag: %w", err)
}
// Sending tags to node
@@ -673,46 +575,30 @@ var tagCmd = &cobra.Command{
resp, err := client.SetTags(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error while sending tags to headscale: %s", err),
output,
)
return fmt.Errorf("setting tags: %w", err)
}
if resp != nil {
SuccessOutput(
resp.GetNode(),
"Node updated",
output,
)
return printOutput(cmd, resp.GetNode(), "Node updated")
}
return nil
}),
}
var approveRoutesCmd = &cobra.Command{
Use: "approve-routes",
Short: "Manage the approved routes of a node",
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
// retrieve flags from CLI
identifier, err := cmd.Flags().GetUint64("identifier")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error converting ID to integer: %s", err),
output,
)
return fmt.Errorf("getting identifier flag: %w", err)
}
routes, err := cmd.Flags().GetStringSlice("routes")
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error retrieving list of routes to add to node, %v", err),
output,
)
return fmt.Errorf("getting routes flag: %w", err)
}
// Sending routes to node
@@ -723,19 +609,13 @@ var approveRoutesCmd = &cobra.Command{
resp, err := client.SetApprovedRoutes(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error while sending routes to headscale: %s", err),
output,
)
return fmt.Errorf("setting approved routes: %w", err)
}
if resp != nil {
SuccessOutput(
resp.GetNode(),
"Node updated",
output,
)
return printOutput(cmd, resp.GetNode(), "Node updated")
}
return nil
}),
}

View File

@@ -92,7 +92,10 @@ var getPolicy = &cobra.Command{
policy = pol.Data
} else {
ctx, client, conn, cancel := newHeadscaleCLIWithConfig()
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error connecting: %s", err), output)
}
defer cancel()
defer conn.Close()
@@ -179,7 +182,10 @@ var setPolicy = &cobra.Command{
} else {
request := &v1.SetPolicyRequest{Policy: string(policyBytes)}
ctx, client, conn, cancel := newHeadscaleCLIWithConfig()
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
ErrorOutput(err, fmt.Sprintf("Error connecting: %s", err), output)
}
defer cancel()
defer conn.Close()

View File

@@ -47,22 +47,16 @@ var listPreAuthKeys = &cobra.Command{
Use: "list",
Short: "List all preauthkeys",
Aliases: []string{"ls", "show"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
format, _ := cmd.Flags().GetString("output")
response, err := client.ListPreAuthKeys(ctx, &v1.ListPreAuthKeysRequest{})
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Error getting the list of keys: %s", err),
output,
)
return
return fmt.Errorf("listing preauthkeys: %w", err)
}
if output != "" {
SuccessOutput(response.GetPreAuthKeys(), "", output)
if format != "" {
return printOutput(cmd, response.GetPreAuthKeys(), "")
}
tableData := pterm.TableData{
@@ -107,12 +101,10 @@ var listPreAuthKeys = &cobra.Command{
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Failed to render pterm table: %s", err),
output,
)
return fmt.Errorf("rendering table: %w", err)
}
return nil
}),
}
@@ -120,9 +112,7 @@ var createPreAuthKeyCmd = &cobra.Command{
Use: "create",
Short: "Creates a new preauthkey",
Aliases: []string{"c", "new"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
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")
ephemeral, _ := cmd.Flags().GetBool("ephemeral")
@@ -139,11 +129,7 @@ var createPreAuthKeyCmd = &cobra.Command{
duration, err := model.ParseDuration(durationStr)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Could not parse duration: %s\n", err),
output,
)
return fmt.Errorf("parsing duration: %w", err)
}
expiration := time.Now().UTC().Add(time.Duration(duration))
@@ -152,14 +138,10 @@ var createPreAuthKeyCmd = &cobra.Command{
response, err := client.CreatePreAuthKey(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Cannot create Pre Auth Key: %s\n", err),
output,
)
return fmt.Errorf("creating preauthkey: %w", err)
}
SuccessOutput(response.GetPreAuthKey(), response.GetPreAuthKey().GetKey(), output)
return printOutput(cmd, response.GetPreAuthKey(), response.GetPreAuthKey().GetKey())
}),
}
@@ -167,18 +149,11 @@ var expirePreAuthKeyCmd = &cobra.Command{
Use: "expire",
Short: "Expire a preauthkey",
Aliases: []string{"revoke", "exp", "e"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id")
if id == 0 {
ErrorOutput(
errMissingParameter,
"Error: missing --id parameter",
output,
)
return
return fmt.Errorf("missing --id parameter: %w", errMissingParameter)
}
request := &v1.ExpirePreAuthKeyRequest{
@@ -187,14 +162,10 @@ var expirePreAuthKeyCmd = &cobra.Command{
response, err := client.ExpirePreAuthKey(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Cannot expire Pre Auth Key: %s\n", err),
output,
)
return fmt.Errorf("expiring preauthkey: %w", err)
}
SuccessOutput(response, "Key expired", output)
return printOutput(cmd, response, "Key expired")
}),
}
@@ -202,18 +173,11 @@ var deletePreAuthKeyCmd = &cobra.Command{
Use: "delete",
Short: "Delete a preauthkey",
Aliases: []string{"del", "rm", "d"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id")
if id == 0 {
ErrorOutput(
errMissingParameter,
"Error: missing --id parameter",
output,
)
return
return fmt.Errorf("missing --id parameter: %w", errMissingParameter)
}
request := &v1.DeletePreAuthKeyRequest{
@@ -222,13 +186,9 @@ var deletePreAuthKeyCmd = &cobra.Command{
response, err := client.DeletePreAuthKey(ctx, request)
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Cannot delete Pre Auth Key: %s\n", err),
output,
)
return fmt.Errorf("deleting preauthkey: %w", err)
}
SuccessOutput(response, "Key deleted", output)
return printOutput(cmd, response, "Key deleted")
}),
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/pterm/pterm"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
)
// CLI user errors.
@@ -28,20 +27,21 @@ func usernameAndIDFlag(cmd *cobra.Command) {
}
// usernameAndIDFromFlag returns the username and ID from the flags of the command.
// If both are empty, it will exit the program with an error.
func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string) {
func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string, error) {
username, _ := cmd.Flags().GetString("name")
identifier, _ := cmd.Flags().GetInt64("identifier")
if username == "" && identifier < 0 {
ErrorOutput(
errFlagRequired,
"Cannot rename user: "+status.Convert(errFlagRequired).Message(),
"",
)
return 0, "", errFlagRequired
}
return uint64(identifier), username
// Normalise unset/negative identifiers to 0 so the uint64
// conversion does not produce a bogus large value.
if identifier < 0 {
identifier = 0
}
return uint64(identifier), username, nil //nolint:gosec // identifier is clamped to >= 0 above
}
func init() {
@@ -81,9 +81,7 @@ var createUserCmd = &cobra.Command{
return nil
},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
userName := args[0]
log.Trace().Interface(zf.Client, client).Msg("obtained gRPC client")
@@ -100,14 +98,7 @@ var createUserCmd = &cobra.Command{
if pictureURL, _ := cmd.Flags().GetString("picture-url"); pictureURL != "" {
if _, err := url.Parse(pictureURL); err != nil { //nolint:noinlineerr
ErrorOutput(
err,
fmt.Sprintf(
"Invalid Picture URL: %s",
err,
),
output,
)
return fmt.Errorf("invalid picture URL: %w", err)
}
request.PictureUrl = pictureURL
@@ -117,14 +108,10 @@ var createUserCmd = &cobra.Command{
response, err := client.CreateUser(ctx, request)
if err != nil {
ErrorOutput(
err,
"Cannot create user: "+status.Convert(err).Message(),
output,
)
return fmt.Errorf("creating user: %w", err)
}
SuccessOutput(response.GetUser(), "User created", output)
return printOutput(cmd, response.GetUser(), "User created")
}),
}
@@ -132,10 +119,12 @@ var destroyUserCmd = &cobra.Command{
Use: "destroy --identifier ID or --name NAME",
Short: "Destroys a user",
Aliases: []string{"delete"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, username, err := usernameAndIDFromFlag(cmd)
if err != nil {
return err
}
id, username := usernameAndIDFromFlag(cmd)
request := &v1.ListUsersRequest{
Name: username,
Id: id,
@@ -143,20 +132,11 @@ var destroyUserCmd = &cobra.Command{
users, err := client.ListUsers(ctx, request)
if err != nil {
ErrorOutput(
err,
"Error: "+status.Convert(err).Message(),
output,
)
return fmt.Errorf("listing users: %w", err)
}
if len(users.GetUsers()) != 1 {
err := errMultipleUsersMatch
ErrorOutput(
err,
"Error: "+status.Convert(err).Message(),
output,
)
return errMultipleUsersMatch
}
user := users.GetUsers()[0]
@@ -176,17 +156,13 @@ var destroyUserCmd = &cobra.Command{
response, err := client.DeleteUser(ctx, request)
if err != nil {
ErrorOutput(
err,
"Cannot destroy user: "+status.Convert(err).Message(),
output,
)
return fmt.Errorf("destroying user: %w", err)
}
SuccessOutput(response, "User destroyed", output)
} else {
SuccessOutput(map[string]string{"Result": "User not destroyed"}, "User not destroyed", output)
return printOutput(cmd, response, "User destroyed")
}
return printOutput(cmd, map[string]string{"Result": "User not destroyed"}, "User not destroyed")
}),
}
@@ -194,8 +170,8 @@ var listUsersCmd = &cobra.Command{
Use: "list",
Short: "List all the users",
Aliases: []string{"ls", "show"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
format, _ := cmd.Flags().GetString("output")
request := &v1.ListUsersRequest{}
@@ -215,15 +191,11 @@ var listUsersCmd = &cobra.Command{
response, err := client.ListUsers(ctx, request)
if err != nil {
ErrorOutput(
err,
"Cannot get users: "+status.Convert(err).Message(),
output,
)
return fmt.Errorf("listing users: %w", err)
}
if output != "" {
SuccessOutput(response.GetUsers(), "", output)
if format != "" {
return printOutput(cmd, response.GetUsers(), "")
}
tableData := pterm.TableData{{"ID", "Name", "Username", "Email", "Created"}}
@@ -242,12 +214,10 @@ var listUsersCmd = &cobra.Command{
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
if err != nil {
ErrorOutput(
err,
fmt.Sprintf("Failed to render pterm table: %s", err),
output,
)
return fmt.Errorf("rendering table: %w", err)
}
return nil
}),
}
@@ -255,10 +225,12 @@ var renameUserCmd = &cobra.Command{
Use: "rename",
Short: "Renames a user",
Aliases: []string{"mv"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, username, err := usernameAndIDFromFlag(cmd)
if err != nil {
return err
}
id, username := usernameAndIDFromFlag(cmd)
listReq := &v1.ListUsersRequest{
Name: username,
Id: id,
@@ -266,20 +238,11 @@ var renameUserCmd = &cobra.Command{
users, err := client.ListUsers(ctx, listReq)
if err != nil {
ErrorOutput(
err,
"Error: "+status.Convert(err).Message(),
output,
)
return fmt.Errorf("listing users: %w", err)
}
if len(users.GetUsers()) != 1 {
err := errMultipleUsersMatch
ErrorOutput(
err,
"Error: "+status.Convert(err).Message(),
output,
)
return errMultipleUsersMatch
}
newName, _ := cmd.Flags().GetString("new-name")
@@ -291,13 +254,9 @@ var renameUserCmd = &cobra.Command{
response, err := client.RenameUser(ctx, renameReq)
if err != nil {
ErrorOutput(
err,
"Cannot rename user: "+status.Convert(err).Message(),
output,
)
return fmt.Errorf("renaming user: %w", err)
}
SuccessOutput(response.GetUser(), "User renamed", output)
return printOutput(cmd, response.GetUser(), "User renamed")
}),
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"os"
@@ -23,8 +24,14 @@ import (
const (
HeadscaleDateTimeFormat = "2006-01-02 15:04:05"
SocketWritePermissions = 0o666
outputFormatJSON = "json"
outputFormatJSONLine = "json-line"
outputFormatYAML = "yaml"
)
var errAPIKeyNotSet = errors.New("HEADSCALE_CLI_API_KEY environment variable needs to be set")
func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
cfg, err := types.LoadServerConfig()
if err != nil {
@@ -42,29 +49,28 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
return app, nil
}
// grpcRun wraps a cobra RunFunc, injecting a ready gRPC client and context.
// Connection lifecycle is managed by the wrapper — callers never see
// the underlying conn or cancel func.
func grpcRun(
fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string),
) func(*cobra.Command, []string) {
return func(cmd *cobra.Command, args []string) {
ctx, client, conn, cancel := newHeadscaleCLIWithConfig()
// 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.
func grpcRunE(
fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error,
) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
defer conn.Close()
fn(ctx, client, cmd, args)
return fn(ctx, client, cmd, args)
}
}
func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *grpc.ClientConn, context.CancelFunc) {
func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *grpc.ClientConn, context.CancelFunc, error) {
cfg, err := types.LoadCLIConfig()
if err != nil {
log.Fatal().
Err(err).
Caller().
Msgf("Failed to load configuration")
os.Exit(-1) // we get here if logging is suppressed (i.e., json output)
return nil, nil, nil, nil, fmt.Errorf("loading configuration: %w", err)
}
log.Debug().
@@ -88,18 +94,23 @@ 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.
socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) // nolint
// 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.
socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) //nolint
if err != nil {
if os.IsPermission(err) {
log.Fatal().
Err(err).
Str("socket", cfg.UnixSocket).
Msgf("Unable to read/write to headscale socket, do you have the correct permissions?")
}
}
cancel()
socket.Close()
return nil, nil, nil, nil, fmt.Errorf(
"unable to read/write to headscale socket %q, do you have the correct permissions? %w",
cfg.UnixSocket,
err,
)
}
} else {
socket.Close()
}
grpcOptions = append(
grpcOptions,
@@ -110,7 +121,9 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
// If we are not connecting to a local server, require an API key for authentication
apiKey := cfg.CLI.APIKey
if apiKey == "" {
log.Fatal().Caller().Msgf("HEADSCALE_CLI_API_KEY environment variable needs to be set")
cancel()
return nil, nil, nil, nil, errAPIKeyNotSet
}
grpcOptions = append(grpcOptions,
@@ -141,15 +154,65 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
conn, err := grpc.DialContext(ctx, address, grpcOptions...) //nolint:staticcheck // SA1019: deprecated but supported in 1.x
if err != nil {
log.Fatal().Caller().Err(err).Msgf("could not connect: %v", err)
os.Exit(-1) // we get here if logging is suppressed (i.e., json output)
cancel()
return nil, nil, nil, nil, fmt.Errorf("connecting to %s: %w", address, err)
}
client := v1.NewHeadscaleServiceClient(conn)
return ctx, client, conn, cancel
return ctx, client, conn, cancel, nil
}
// formatOutput serialises result into the requested format. For the
// default (empty) format the human-readable override string is returned.
func formatOutput(result any, override string, outputFormat string) (string, error) {
switch outputFormat {
case outputFormatJSON:
b, err := json.MarshalIndent(result, "", "\t")
if err != nil {
return "", fmt.Errorf("marshalling JSON output: %w", err)
}
return string(b), nil
case outputFormatJSONLine:
b, err := json.Marshal(result)
if err != nil {
return "", fmt.Errorf("marshalling JSON-line output: %w", err)
}
return string(b), nil
case outputFormatYAML:
b, err := yaml.Marshal(result)
if err != nil {
return "", fmt.Errorf("marshalling YAML output: %w", err)
}
return string(b), nil
default:
return override, nil
}
}
// printOutput formats result and writes it to stdout. It reads the --output
// flag from cmd to decide the serialisation format.
func printOutput(cmd *cobra.Command, result any, override string) error {
format, _ := cmd.Flags().GetString("output")
out, err := formatOutput(result, override, format)
if err != nil {
return err
}
fmt.Println(out)
return nil
}
// output formats result into the requested format. It calls log.Fatal on
// marshal failure.
//
// Deprecated: use formatOutput instead.
func output(result any, override string, outputFormat string) string {
var (
jsonBytes []byte
@@ -157,17 +220,17 @@ func output(result any, override string, outputFormat string) string {
)
switch outputFormat {
case "json":
case outputFormatJSON:
jsonBytes, err = json.MarshalIndent(result, "", "\t")
if err != nil {
log.Fatal().Err(err).Msg("unmarshalling output")
}
case "json-line":
case outputFormatJSONLine:
jsonBytes, err = json.Marshal(result)
if err != nil {
log.Fatal().Err(err).Msg("unmarshalling output")
}
case "yaml":
case outputFormatYAML:
jsonBytes, err = yaml.Marshal(result)
if err != nil {
log.Fatal().Err(err).Msg("unmarshalling output")
@@ -181,12 +244,16 @@ func output(result any, override string, outputFormat string) string {
}
// SuccessOutput prints the result to stdout and exits with status code 0.
//
// Deprecated: use printOutput instead.
func SuccessOutput(result any, override string, outputFormat string) {
fmt.Println(output(result, override, outputFormat))
os.Exit(0)
}
// ErrorOutput prints an error message to stderr and exits with status code 1.
//
// Deprecated: use fmt.Errorf and return the error instead.
func ErrorOutput(errResult error, override string, outputFormat string) {
type errOutput struct {
Error string `json:"error"`
@@ -216,11 +283,11 @@ func printError(err error, outputFormat string) {
var formatted []byte
switch outputFormat {
case "json":
case outputFormatJSON:
formatted, _ = json.MarshalIndent(e, "", "\t") //nolint:errchkjson // errOutput contains only a string field
case "json-line":
case outputFormatJSONLine:
formatted, _ = json.Marshal(e) //nolint:errchkjson // errOutput contains only a string field
case "yaml":
case outputFormatYAML:
formatted, _ = yaml.Marshal(e)
default:
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
@@ -233,7 +300,7 @@ func printError(err error, outputFormat string) {
func HasMachineOutputFlag() bool {
for _, arg := range os.Args {
if arg == "json" || arg == "json-line" || arg == "yaml" {
if arg == outputFormatJSON || arg == outputFormatJSONLine || arg == outputFormatYAML {
return true
}
}