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", Use: "list",
Short: "List the Api keys for headscale", Short: "List the Api keys for headscale",
Aliases: []string{"ls", "show"}, Aliases: []string{"ls", "show"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
output, _ := cmd.Flags().GetString("output") format, _ := cmd.Flags().GetString("output")
request := &v1.ListApiKeysRequest{} request := &v1.ListApiKeysRequest{}
response, err := client.ListApiKeys(ctx, request) response, err := client.ListApiKeys(ctx, request)
if err != nil { if err != nil {
ErrorOutput( return fmt.Errorf("listing api keys: %w", err)
err,
fmt.Sprintf("Error getting the list of keys: %s", err),
output,
)
} }
if output != "" { if format != "" {
SuccessOutput(response.GetApiKeys(), "", output) return printOutput(cmd, response.GetApiKeys(), "")
} }
tableData := pterm.TableData{ tableData := pterm.TableData{
@ -86,12 +82,10 @@ var listAPIKeys = &cobra.Command{
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
if err != nil { if err != nil {
ErrorOutput( return fmt.Errorf("rendering table: %w", err)
err,
fmt.Sprintf("Failed to render pterm table: %s", err),
output,
)
} }
return nil
}), }),
} }
@ -103,20 +97,14 @@ Creates a new Api key, the Api key is only visible on creation
and cannot be retrieved again. and cannot be retrieved again.
If you loose a key, create a new one and revoke (expire) the old one.`, If you loose a key, create a new one and revoke (expire) the old one.`,
Aliases: []string{"c", "new"}, Aliases: []string{"c", "new"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
output, _ := cmd.Flags().GetString("output")
request := &v1.CreateApiKeyRequest{} request := &v1.CreateApiKeyRequest{}
durationStr, _ := cmd.Flags().GetString("expiration") durationStr, _ := cmd.Flags().GetString("expiration")
duration, err := model.ParseDuration(durationStr) duration, err := model.ParseDuration(durationStr)
if err != nil { if err != nil {
ErrorOutput( return fmt.Errorf("parsing duration: %w", err)
err,
fmt.Sprintf("Could not parse duration: %s\n", err),
output,
)
} }
expiration := time.Now().UTC().Add(time.Duration(duration)) 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) response, err := client.CreateApiKey(ctx, request)
if err != nil { if err != nil {
ErrorOutput( return fmt.Errorf("creating api key: %w", err)
err,
fmt.Sprintf("Cannot create Api Key: %s\n", err),
output,
)
} }
SuccessOutput(response.GetApiKey(), response.GetApiKey(), output) return printOutput(cmd, response.GetApiKey(), response.GetApiKey())
}), }),
} }
@ -140,25 +124,15 @@ var expireAPIKeyCmd = &cobra.Command{
Use: "expire", Use: "expire",
Short: "Expire an ApiKey", Short: "Expire an ApiKey",
Aliases: []string{"revoke", "exp", "e"}, Aliases: []string{"revoke", "exp", "e"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
output, _ := cmd.Flags().GetString("output")
id, _ := cmd.Flags().GetUint64("id") id, _ := cmd.Flags().GetUint64("id")
prefix, _ := cmd.Flags().GetString("prefix") prefix, _ := cmd.Flags().GetString("prefix")
switch { switch {
case id == 0 && prefix == "": case id == 0 && prefix == "":
ErrorOutput( return fmt.Errorf("either --id or --prefix must be provided: %w", errMissingParameter)
errMissingParameter,
"Either --id or --prefix must be provided",
output,
)
case id != 0 && prefix != "": case id != 0 && prefix != "":
ErrorOutput( return fmt.Errorf("only one of --id or --prefix can be provided: %w", errMissingParameter)
errMissingParameter,
"Only one of --id or --prefix can be provided",
output,
)
} }
request := &v1.ExpireApiKeyRequest{} request := &v1.ExpireApiKeyRequest{}
@ -170,14 +144,10 @@ var expireAPIKeyCmd = &cobra.Command{
response, err := client.ExpireApiKey(ctx, request) response, err := client.ExpireApiKey(ctx, request)
if err != nil { if err != nil {
ErrorOutput( return fmt.Errorf("expiring api key: %w", err)
err,
fmt.Sprintf("Cannot expire Api Key: %s\n", err),
output,
)
} }
SuccessOutput(response, "Key expired", output) return printOutput(cmd, response, "Key expired")
}), }),
} }
@ -185,25 +155,15 @@ var deleteAPIKeyCmd = &cobra.Command{
Use: "delete", Use: "delete",
Short: "Delete an ApiKey", Short: "Delete an ApiKey",
Aliases: []string{"remove", "del"}, Aliases: []string{"remove", "del"},
Run: grpcRun(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
output, _ := cmd.Flags().GetString("output")
id, _ := cmd.Flags().GetUint64("id") id, _ := cmd.Flags().GetUint64("id")
prefix, _ := cmd.Flags().GetString("prefix") prefix, _ := cmd.Flags().GetString("prefix")
switch { switch {
case id == 0 && prefix == "": case id == 0 && prefix == "":
ErrorOutput( return fmt.Errorf("either --id or --prefix must be provided: %w", errMissingParameter)
errMissingParameter,
"Either --id or --prefix must be provided",
output,
)
case id != 0 && prefix != "": case id != 0 && prefix != "":
ErrorOutput( return fmt.Errorf("only one of --id or --prefix can be provided: %w", errMissingParameter)
errMissingParameter,
"Only one of --id or --prefix can be provided",
output,
)
} }
request := &v1.DeleteApiKeyRequest{} request := &v1.DeleteApiKeyRequest{}
@ -215,13 +175,9 @@ var deleteAPIKeyCmd = &cobra.Command{
response, err := client.DeleteApiKey(ctx, request) response, err := client.DeleteApiKey(ctx, request)
if err != nil { if err != nil {
ErrorOutput( return fmt.Errorf("deleting api key: %w", err)
err,
fmt.Sprintf("Cannot delete Api Key: %s\n", err),
output,
)
} }
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/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/spf13/cobra" "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 // 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{ var createNodeCmd = &cobra.Command{
Use: "create-node", Use: "create-node",
Short: "Create a node that can be registered with `nodes register <>` command", 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) { RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
output, _ := cmd.Flags().GetString("output")
user, err := cmd.Flags().GetString("user") user, err := cmd.Flags().GetString("user")
if err != nil { 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") name, err := cmd.Flags().GetString("name")
if err != nil { if err != nil {
ErrorOutput( return fmt.Errorf("getting name flag: %w", err)
err,
fmt.Sprintf("Error getting node from flag: %s", err),
output,
)
} }
registrationID, err := cmd.Flags().GetString("key") registrationID, err := cmd.Flags().GetString("key")
if err != nil { if err != nil {
ErrorOutput( return fmt.Errorf("getting key flag: %w", err)
err,
fmt.Sprintf("Error getting key from flag: %s", err),
output,
)
} }
_, err = types.RegistrationIDFromString(registrationID) _, err = types.RegistrationIDFromString(registrationID)
if err != nil { if err != nil {
ErrorOutput( return fmt.Errorf("parsing machine key: %w", err)
err,
fmt.Sprintf("Failed to parse machine key from flag: %s", err),
output,
)
} }
routes, err := cmd.Flags().GetStringSlice("route") routes, err := cmd.Flags().GetStringSlice("route")
if err != nil { if err != nil {
ErrorOutput( return fmt.Errorf("getting routes flag: %w", err)
err,
fmt.Sprintf("Error getting routes from flag: %s", err),
output,
)
} }
request := &v1.DebugCreateNodeRequest{ request := &v1.DebugCreateNodeRequest{
@ -113,13 +94,9 @@ var createNodeCmd = &cobra.Command{
response, err := client.DebugCreateNode(ctx, request) response, err := client.DebugCreateNode(ctx, request)
if err != nil { if err != nil {
ErrorOutput( return fmt.Errorf("creating node: %w", err)
err,
"Cannot create node: "+status.Convert(err).Message(),
output,
)
} }
SuccessOutput(response.GetNode(), "Node created", output) return printOutput(cmd, response.GetNode(), "Node created")
}), }),
} }

View File

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

View File

@ -92,7 +92,10 @@ var getPolicy = &cobra.Command{
policy = pol.Data policy = pol.Data
} else { } 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 cancel()
defer conn.Close() defer conn.Close()
@ -179,7 +182,10 @@ var setPolicy = &cobra.Command{
} else { } else {
request := &v1.SetPolicyRequest{Policy: string(policyBytes)} 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 cancel()
defer conn.Close() defer conn.Close()

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
@ -23,8 +24,14 @@ import (
const ( const (
HeadscaleDateTimeFormat = "2006-01-02 15:04:05" HeadscaleDateTimeFormat = "2006-01-02 15:04:05"
SocketWritePermissions = 0o666 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) { func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
cfg, err := types.LoadServerConfig() cfg, err := types.LoadServerConfig()
if err != nil { if err != nil {
@ -42,29 +49,28 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
return app, nil return app, nil
} }
// grpcRun wraps a cobra RunFunc, injecting a ready gRPC client and context. // grpcRunE wraps a cobra RunE func, injecting a ready gRPC client and
// Connection lifecycle is managed by the wrapper — callers never see // context. Connection lifecycle is managed by the wrapper — callers
// the underlying conn or cancel func. // never see the underlying conn or cancel func.
func grpcRun( func grpcRunE(
fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string), fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error,
) func(*cobra.Command, []string) { ) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) { return func(cmd *cobra.Command, args []string) error {
ctx, client, conn, cancel := newHeadscaleCLIWithConfig() ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel() defer cancel()
defer conn.Close() 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() cfg, err := types.LoadCLIConfig()
if err != nil { if err != nil {
log.Fatal(). return nil, nil, nil, nil, fmt.Errorf("loading configuration: %w", err)
Err(err).
Caller().
Msgf("Failed to load configuration")
os.Exit(-1) // we get here if logging is suppressed (i.e., json output)
} }
log.Debug(). log.Debug().
@ -88,18 +94,23 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
address = cfg.UnixSocket address = cfg.UnixSocket
// Try to give the user better feedback if we cannot write to the headscale // Try to give the user better feedback if we cannot write to the headscale
// socket. // socket. Note: os.OpenFile on a Unix domain socket returns ENXIO on
socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) // nolint // 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 err != nil {
if os.IsPermission(err) { if os.IsPermission(err) {
log.Fatal(). cancel()
Err(err).
Str("socket", cfg.UnixSocket).
Msgf("Unable to read/write to headscale socket, do you have the correct permissions?")
}
}
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 = append(
grpcOptions, 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 // If we are not connecting to a local server, require an API key for authentication
apiKey := cfg.CLI.APIKey apiKey := cfg.CLI.APIKey
if 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, 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 conn, err := grpc.DialContext(ctx, address, grpcOptions...) //nolint:staticcheck // SA1019: deprecated but supported in 1.x
if err != nil { if err != nil {
log.Fatal().Caller().Err(err).Msgf("could not connect: %v", err) cancel()
os.Exit(-1) // we get here if logging is suppressed (i.e., json output)
return nil, nil, nil, nil, fmt.Errorf("connecting to %s: %w", address, err)
} }
client := v1.NewHeadscaleServiceClient(conn) 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 { func output(result any, override string, outputFormat string) string {
var ( var (
jsonBytes []byte jsonBytes []byte
@ -157,17 +220,17 @@ func output(result any, override string, outputFormat string) string {
) )
switch outputFormat { switch outputFormat {
case "json": case outputFormatJSON:
jsonBytes, err = json.MarshalIndent(result, "", "\t") jsonBytes, err = json.MarshalIndent(result, "", "\t")
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("unmarshalling output") log.Fatal().Err(err).Msg("unmarshalling output")
} }
case "json-line": case outputFormatJSONLine:
jsonBytes, err = json.Marshal(result) jsonBytes, err = json.Marshal(result)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("unmarshalling output") log.Fatal().Err(err).Msg("unmarshalling output")
} }
case "yaml": case outputFormatYAML:
jsonBytes, err = yaml.Marshal(result) jsonBytes, err = yaml.Marshal(result)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("unmarshalling output") 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. // SuccessOutput prints the result to stdout and exits with status code 0.
//
// Deprecated: use printOutput instead.
func SuccessOutput(result any, override string, outputFormat string) { func SuccessOutput(result any, override string, outputFormat string) {
fmt.Println(output(result, override, outputFormat)) fmt.Println(output(result, override, outputFormat))
os.Exit(0) os.Exit(0)
} }
// ErrorOutput prints an error message to stderr and exits with status code 1. // 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) { func ErrorOutput(errResult error, override string, outputFormat string) {
type errOutput struct { type errOutput struct {
Error string `json:"error"` Error string `json:"error"`
@ -216,11 +283,11 @@ func printError(err error, outputFormat string) {
var formatted []byte var formatted []byte
switch outputFormat { switch outputFormat {
case "json": case outputFormatJSON:
formatted, _ = json.MarshalIndent(e, "", "\t") //nolint:errchkjson // errOutput contains only a string field 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 formatted, _ = json.Marshal(e) //nolint:errchkjson // errOutput contains only a string field
case "yaml": case outputFormatYAML:
formatted, _ = yaml.Marshal(e) formatted, _ = yaml.Marshal(e)
default: default:
fmt.Fprintf(os.Stderr, "Error: %s\n", err) fmt.Fprintf(os.Stderr, "Error: %s\n", err)
@ -233,7 +300,7 @@ func printError(err error, outputFormat string) {
func HasMachineOutputFlag() bool { func HasMachineOutputFlag() bool {
for _, arg := range os.Args { for _, arg := range os.Args {
if arg == "json" || arg == "json-line" || arg == "yaml" { if arg == outputFormatJSON || arg == outputFormatJSONLine || arg == outputFormatYAML {
return true return true
} }
} }