From 29fc14dd731c8463746ab77ca588075d50dca71d Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 17 Jun 2026 15:55:16 +0000 Subject: [PATCH] cmd/headscale: run the CLI on the generated v1 HTTP client Serve the API over the unix socket (auth bypassed; socket permissions are the trust boundary) and convert every CLI command from the gRPC client to the generated ogen client. Remote CLI now uses the HTTP API URL. --- cmd/headscale/cli/api_key.go | 46 +++--- cmd/headscale/cli/auth.go | 41 +++--- cmd/headscale/cli/client.go | 187 ++++++++++++++++++++++++ cmd/headscale/cli/debug.go | 18 ++- cmd/headscale/cli/health.go | 8 +- cmd/headscale/cli/nodes.go | 235 ++++++++++++------------------- cmd/headscale/cli/policy.go | 24 ++-- cmd/headscale/cli/preauthkeys.go | 76 +++++----- cmd/headscale/cli/root_test.go | 6 +- cmd/headscale/cli/users.go | 91 ++++++------ cmd/headscale/cli/utils.go | 193 ++----------------------- docs/v1-ogen/CHANGES.md | 30 ++++ hscontrol/api/v1/auth.go | 20 +++ hscontrol/app.go | 59 +++++--- 14 files changed, 526 insertions(+), 508 deletions(-) create mode 100644 cmd/headscale/cli/client.go diff --git a/cmd/headscale/cli/api_key.go b/cmd/headscale/cli/api_key.go index 5c684814..254783b0 100644 --- a/cmd/headscale/cli/api_key.go +++ b/cmd/headscale/cli/api_key.go @@ -5,7 +5,7 @@ import ( "fmt" "strconv" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + apiv1 "github.com/juanfont/headscale/gen/api/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/spf13/cobra" ) @@ -43,26 +43,26 @@ var listAPIKeys = &cobra.Command{ Use: cmdList, Short: "List the Api keys for headscale", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - response, err := client.ListApiKeys(ctx, &v1.ListApiKeysRequest{}) + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { + resp, err := client.ListApiKeys(ctx) if err != nil { return fmt.Errorf("listing api keys: %w", err) } - return printListOutput(cmd, response.GetApiKeys(), func() error { - rows := make([][]string, 0, len(response.GetApiKeys())) - for _, key := range response.GetApiKeys() { + return printListOutput(cmd, resp.ApiKeys, func() error { + rows := make([][]string, 0, len(resp.ApiKeys)) + for _, key := range resp.ApiKeys { expiration := "-" - if key.GetExpiration() != nil { - expiration = ColourTime(key.GetExpiration().AsTime()) + if key.Expiration.Set { + expiration = ColourTime(key.Expiration.Value) } rows = append(rows, []string{ - strconv.FormatUint(key.GetId(), util.Base10), - key.GetPrefix(), + strconv.FormatUint(key.ID.Value, util.Base10), + key.Prefix.Value, expiration, - key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat), + key.CreatedAt.Value.Format(HeadscaleDateTimeFormat), }) } @@ -79,20 +79,20 @@ Creates a new Api key, the Api key is only visible on creation and cannot be retrieved again. If you lose a key, create a new one and revoke (expire) the old one.`, Aliases: []string{"c", cmdNew}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { expiration, err := expirationFromFlag(cmd) if err != nil { return err } - response, err := client.CreateApiKey(ctx, &v1.CreateApiKeyRequest{ + resp, err := client.CreateApiKey(ctx, &apiv1.CreateApiKeyReq{ Expiration: expiration, }) if err != nil { return fmt.Errorf("creating api key: %w", err) } - return printOutput(cmd, response.GetApiKey(), response.GetApiKey()) + return printOutput(cmd, resp.ApiKey.Value, resp.ApiKey.Value) }), } @@ -116,21 +116,21 @@ var expireAPIKeyCmd = &cobra.Command{ Use: cmdExpire, Short: "Expire an ApiKey", Aliases: []string{"revoke", aliasExp, "e"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { id, prefix, err := apiKeyIDOrPrefix(cmd) if err != nil { return err } - response, err := client.ExpireApiKey(ctx, &v1.ExpireApiKeyRequest{ - Id: id, - Prefix: prefix, + err = client.ExpireApiKey(ctx, &apiv1.ExpireApiKeyReq{ + ID: optUint64(id), + Prefix: optString(prefix), }) if err != nil { return fmt.Errorf("expiring api key: %w", err) } - return printOutput(cmd, response, "Key expired") + return printOutput(cmd, map[string]string{colResult: "Key expired"}, "Key expired") }), } @@ -138,20 +138,20 @@ var deleteAPIKeyCmd = &cobra.Command{ Use: cmdDelete, Short: "Delete an ApiKey", Aliases: []string{"remove", aliasDel}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { id, prefix, err := apiKeyIDOrPrefix(cmd) if err != nil { return err } - response, err := client.DeleteApiKey(ctx, &v1.DeleteApiKeyRequest{ - Id: id, + err = client.DeleteApiKey(ctx, apiv1.DeleteApiKeyParams{ + ID: optUint64(id), Prefix: prefix, }) if err != nil { return fmt.Errorf("deleting api key: %w", err) } - return printOutput(cmd, response, "Key deleted") + return printOutput(cmd, map[string]string{colResult: "Key deleted"}, "Key deleted") }), } diff --git a/cmd/headscale/cli/auth.go b/cmd/headscale/cli/auth.go index 85a17a3c..243c9c8f 100644 --- a/cmd/headscale/cli/auth.go +++ b/cmd/headscale/cli/auth.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + apiv1 "github.com/juanfont/headscale/gen/api/v1" "github.com/spf13/cobra" ) @@ -33,45 +33,42 @@ var authCmd = &cobra.Command{ var authRegisterCmd = &cobra.Command{ Use: "register", Short: "Register a node to your network", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") authID, _ := cmd.Flags().GetString("auth-id") - request := &v1.AuthRegisterRequest{ - AuthId: authID, - User: user, - } - - response, err := client.AuthRegister(ctx, request) + resp, err := client.AuthRegister(ctx, &apiv1.AuthRegisterReq{ + AuthId: apiv1.NewOptString(authID), + User: apiv1.NewOptString(user), + }) if err != nil { return fmt.Errorf("registering node: %w", err) } return printOutput( cmd, - response.GetNode(), - fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()), + resp.Node.Value, + fmt.Sprintf("Node %s registered", resp.Node.Value.GivenName.Value), ) }), } // authDecisionRunE builds a RunE for an auth decision command (approve or -// reject) that reads the auth-id flag, invokes the given gRPC call, and prints -// the response. errVerb is used in the error message; okMsg is printed on -// success. -func authDecisionRunE[Resp any]( +// reject) that reads the auth-id flag, invokes the given API call, and prints a +// result. errVerb is used in the error message; okMsg is printed on success. +func authDecisionRunE( errVerb, okMsg string, - call func(ctx context.Context, client v1.HeadscaleServiceClient, authID string) (Resp, error), + call func(ctx context.Context, client *apiv1.Client, authID string) error, ) func(*cobra.Command, []string) error { - return grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + return apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { authID, _ := cmd.Flags().GetString("auth-id") - response, err := call(ctx, client, authID) + err := call(ctx, client, authID) if err != nil { return fmt.Errorf("%s auth request: %w", errVerb, err) } - return printOutput(cmd, response, okMsg) + return printOutput(cmd, map[string]string{colResult: okMsg}, okMsg) }) } @@ -79,8 +76,8 @@ var authApproveCmd = &cobra.Command{ Use: "approve", Short: "Approve a pending authentication request", RunE: authDecisionRunE("approving", "Auth request approved", - func(ctx context.Context, client v1.HeadscaleServiceClient, authID string) (*v1.AuthApproveResponse, error) { - return client.AuthApprove(ctx, &v1.AuthApproveRequest{AuthId: authID}) + func(ctx context.Context, client *apiv1.Client, authID string) error { + return client.AuthApprove(ctx, &apiv1.AuthApproveReq{AuthId: apiv1.NewOptString(authID)}) }), } @@ -88,7 +85,7 @@ var authRejectCmd = &cobra.Command{ Use: "reject", Short: "Reject a pending authentication request", RunE: authDecisionRunE("rejecting", "Auth request rejected", - func(ctx context.Context, client v1.HeadscaleServiceClient, authID string) (*v1.AuthRejectResponse, error) { - return client.AuthReject(ctx, &v1.AuthRejectRequest{AuthId: authID}) + func(ctx context.Context, client *apiv1.Client, authID string) error { + return client.AuthReject(ctx, &apiv1.AuthRejectReq{AuthId: apiv1.NewOptString(authID)}) }), } diff --git a/cmd/headscale/cli/client.go b/cmd/headscale/cli/client.go new file mode 100644 index 00000000..52a3ccb5 --- /dev/null +++ b/cmd/headscale/cli/client.go @@ -0,0 +1,187 @@ +package cli + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "os" + "strings" + "time" + + apiv1 "github.com/juanfont/headscale/gen/api/v1" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/prometheus/common/model" + "github.com/spf13/cobra" +) + +// apiRunE wraps a cobra [cobra.Command.RunE] func, injecting a ready v1 API +// client and context. Connection lifecycle is managed by the wrapper. +func apiRunE( + fn func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error, +) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + ctx, client, cancel, err := newHeadscaleAPIClient() + if err != nil { + return fmt.Errorf("connecting to headscale: %w", err) + } + defer cancel() + + return fn(ctx, client, cmd, args) + } +} + +// withAPI opens a v1 API client, runs fn with it, and cancels the context +// afterwards. It is the building block for commands that branch on a flag +// before deciding to talk to the server. +func withAPI(fn func(ctx context.Context, client *apiv1.Client) error) error { + ctx, client, cancel, err := newHeadscaleAPIClient() + if err != nil { + return fmt.Errorf("connecting to headscale: %w", err) + } + defer cancel() + + return fn(ctx, client) +} + +// newHeadscaleAPIClient builds a v1 HTTP API client. With no configured +// address it talks to the local unix socket (filesystem permissions are the +// trust boundary, no API key needed); otherwise it uses HTTPS with the API key. +func newHeadscaleAPIClient() (context.Context, *apiv1.Client, context.CancelFunc, error) { + cfg, err := types.LoadCLIConfig() + if err != nil { + return nil, nil, nil, fmt.Errorf("loading configuration: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout) + + if cfg.CLI.Address == "" { + client, err := localSocketClient(cfg.UnixSocket) + if err != nil { + cancel() + return nil, nil, nil, err + } + + return ctx, client, cancel, nil + } + + client, err := remoteClient(cfg.CLI.Address, cfg.CLI.APIKey, cfg.CLI.Insecure) + if err != nil { + cancel() + return nil, nil, nil, err + } + + return ctx, client, cancel, nil +} + +func localSocketClient(socketPath string) (*apiv1.Client, error) { + err := checkSocketPermissions(socketPath) + if err != nil { + return nil, err + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) + }, + }, + } + + // The socket bypasses bearer auth; the token is a placeholder. + return apiv1.NewClient("http://unix", cliToken("local-socket"), apiv1.WithClient(httpClient)) +} + +func remoteClient(address, apiKey string, insecure bool) (*apiv1.Client, error) { + if apiKey == "" { + return nil, errAPIKeyNotSet + } + + transport := &http.Transport{} + if insecure { + //nolint:gosec // G402: insecure is an explicit, documented opt-in. + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + + return apiv1.NewClient( + serverURLFromAddress(address), + cliToken(apiKey), + apiv1.WithClient(&http.Client{Transport: transport}), + ) +} + +// serverURLFromAddress turns a configured CLI address into a base URL, +// defaulting to https when no scheme is given. +func serverURLFromAddress(address string) string { + if strings.Contains(address, "://") { + return address + } + + return "https://" + address +} + +// checkSocketPermissions gives a friendlier error than a dial failure when the +// user cannot access the headscale socket. +func checkSocketPermissions(socketPath string) error { + socket, err := os.OpenFile(socketPath, os.O_WRONLY, SocketWritePermissions) //nolint + if err != nil { + if os.IsPermission(err) { + return fmt.Errorf( + "unable to read/write to headscale socket %q, do you have the correct permissions? %w", + socketPath, err, + ) + } + + // ENXIO and similar are expected for a socket opened with O_WRONLY; the + // real connection uses net.Dial which handles sockets properly. + return nil + } + + socket.Close() + + return nil +} + +// optString / optUint64 / optTime build optional API request values from flag +// inputs, treating zero values as "unset". + +func optString(s string) apiv1.OptString { + if s == "" { + return apiv1.OptString{} + } + + return apiv1.NewOptString(s) +} + +func optUint64(v uint64) apiv1.OptUint64 { + if v == 0 { + return apiv1.OptUint64{} + } + + return apiv1.NewOptUint64(v) +} + +// expirationFromFlag parses the --expiration flag as a Prometheus-style +// duration (e.g. "90d", "1h") and returns it as an absolute optional timestamp. +// An empty flag yields an unset value. +func expirationFromFlag(cmd *cobra.Command) (apiv1.OptDateTime, error) { + durationStr, _ := cmd.Flags().GetString("expiration") + if durationStr == "" { + return apiv1.OptDateTime{}, nil + } + + duration, err := model.ParseDuration(durationStr) + if err != nil { + return apiv1.OptDateTime{}, fmt.Errorf("parsing duration: %w", err) + } + + return apiv1.NewOptDateTime(time.Now().UTC().Add(time.Duration(duration))), nil +} + +// cliToken is an [apiv1.SecuritySource] that supplies a fixed bearer token. +type cliToken string + +func (t cliToken) BearerAuth(context.Context, apiv1.OperationName) (apiv1.BearerAuth, error) { + return apiv1.BearerAuth{Token: string(t)}, nil +} diff --git a/cmd/headscale/cli/debug.go b/cmd/headscale/cli/debug.go index 1f934f0b..d7486d41 100644 --- a/cmd/headscale/cli/debug.go +++ b/cmd/headscale/cli/debug.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + apiv1 "github.com/juanfont/headscale/gen/api/v1" "github.com/juanfont/headscale/hscontrol/types" "github.com/spf13/cobra" ) @@ -32,7 +32,7 @@ var debugCmd = &cobra.Command{ var createNodeCmd = &cobra.Command{ Use: "create-node", Short: "Create a node that can be registered with `auth register <>` command", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") name, _ := cmd.Flags().GetString("name") registrationID, _ := cmd.Flags().GetString("key") @@ -44,18 +44,16 @@ var createNodeCmd = &cobra.Command{ routes, _ := cmd.Flags().GetStringSlice("route") - request := &v1.DebugCreateNodeRequest{ - Key: registrationID, - Name: name, - User: user, + resp, err := client.DebugCreateNode(ctx, &apiv1.DebugCreateNodeReq{ + Key: apiv1.NewOptString(registrationID), + Name: apiv1.NewOptString(name), + User: apiv1.NewOptString(user), Routes: routes, - } - - response, err := client.DebugCreateNode(ctx, request) + }) if err != nil { return fmt.Errorf("creating node: %w", err) } - return printOutput(cmd, response.GetNode(), "Node created") + return printOutput(cmd, resp.Node.Value, "Node created") }), } diff --git a/cmd/headscale/cli/health.go b/cmd/headscale/cli/health.go index b3b4f430..927112dd 100644 --- a/cmd/headscale/cli/health.go +++ b/cmd/headscale/cli/health.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + apiv1 "github.com/juanfont/headscale/gen/api/v1" "github.com/spf13/cobra" ) @@ -16,12 +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.", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - response, err := client.Health(ctx, &v1.HealthRequest{}) + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { + resp, err := client.Health(ctx) if err != nil { return fmt.Errorf("checking health: %w", err) } - return printOutput(cmd, response, "") + return printOutput(cmd, resp, "") }), } diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index bc081048..6f0b32af 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -8,12 +8,11 @@ import ( "strings" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + apiv1 "github.com/juanfont/headscale/gen/api/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/pterm/pterm" "github.com/samber/lo" "github.com/spf13/cobra" - "google.golang.org/protobuf/types/known/timestamppb" "tailscale.com/types/key" ) @@ -67,24 +66,22 @@ var registerNodeCmd = &cobra.Command{ Use: "register", Short: "Registers a node to your network", Deprecated: "use 'headscale auth register --auth-id --user ' instead", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") registrationID, _ := cmd.Flags().GetString("key") - request := &v1.RegisterNodeRequest{ - Key: registrationID, - User: user, - } - - response, err := client.RegisterNode(ctx, request) + resp, err := client.RegisterNode(ctx, apiv1.RegisterNodeParams{ + Key: optString(registrationID), + User: optString(user), + }) if err != nil { return fmt.Errorf("registering node: %w", err) } return printOutput( cmd, - response.GetNode(), - fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()), + resp.Node.Value, + fmt.Sprintf("Node %s registered", resp.Node.Value.GivenName.Value), ) }), } @@ -93,16 +90,16 @@ var listNodesCmd = &cobra.Command{ Use: cmdList, Short: "List nodes", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") - response, err := client.ListNodes(ctx, &v1.ListNodesRequest{User: user}) + resp, err := client.ListNodes(ctx, apiv1.ListNodesParams{User: optString(user)}) if err != nil { return fmt.Errorf("listing nodes: %w", err) } - return printListOutput(cmd, response.GetNodes(), func() error { - tableData, err := nodesToPtables(response.GetNodes()) + return printListOutput(cmd, resp.Nodes, func() error { + tableData, err := nodesToPtables(resp.Nodes) if err != nil { return fmt.Errorf("converting to table: %w", err) } @@ -116,27 +113,27 @@ var listNodeRoutesCmd = &cobra.Command{ Use: "list-routes", Short: "List routes available on nodes", Aliases: []string{"lsr", "routes"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") - response, err := client.ListNodes(ctx, &v1.ListNodesRequest{}) + resp, err := client.ListNodes(ctx, apiv1.ListNodesParams{}) if err != nil { return fmt.Errorf("listing nodes: %w", err) } - nodes := response.GetNodes() + nodes := resp.Nodes if identifier != 0 { - for _, node := range response.GetNodes() { - if node.GetId() == identifier { - nodes = []*v1.Node{node} + for _, node := range resp.Nodes { + if node.ID.Value == identifier { + nodes = []apiv1.Node{node} break } } } - nodes = lo.Filter(nodes, func(n *v1.Node, _ int) bool { - return len(n.GetSubnetRoutes()) > 0 || len(n.GetApprovedRoutes()) > 0 || len(n.GetAvailableRoutes()) > 0 + nodes = lo.Filter(nodes, func(n apiv1.Node, _ int) bool { + return len(n.SubnetRoutes) > 0 || len(n.ApprovedRoutes) > 0 || len(n.AvailableRoutes) > 0 }) return printListOutput(cmd, nodes, func() error { @@ -152,23 +149,21 @@ var expireNodeCmd = &cobra.Command{ Use --disable to disable key expiry (node will never expire).`, Aliases: []string{"logout", aliasExp, "e"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") disableExpiry, _ := cmd.Flags().GetBool("disable") // Handle disable expiry - node will never expire. if disableExpiry { - request := &v1.ExpireNodeRequest{ - NodeId: identifier, - DisableExpiry: true, - } - - response, err := client.ExpireNode(ctx, request) + resp, err := client.ExpireNode(ctx, apiv1.ExpireNodeParams{ + NodeID: identifier, + DisableExpiry: apiv1.NewOptBool(true), + }) if err != nil { return fmt.Errorf("disabling node expiry: %w", err) } - return printOutput(cmd, response.GetNode(), "Node expiry disabled") + return printOutput(cmd, resp.Node.Value, "Node expiry disabled") } expiry, _ := cmd.Flags().GetString("expiry") @@ -186,28 +181,26 @@ Use --disable to disable key expiry (node will never expire).`, } } - request := &v1.ExpireNodeRequest{ - NodeId: identifier, - Expiry: timestamppb.New(expiryTime), - } - - response, err := client.ExpireNode(ctx, request) + resp, err := client.ExpireNode(ctx, apiv1.ExpireNodeParams{ + NodeID: identifier, + Expiry: apiv1.NewOptDateTime(expiryTime), + }) if err != nil { return fmt.Errorf("expiring node: %w", err) } if now.Equal(expiryTime) || now.After(expiryTime) { - return printOutput(cmd, response.GetNode(), "Node expired") + return printOutput(cmd, resp.Node.Value, "Node expired") } - return printOutput(cmd, response.GetNode(), "Node expiration updated") + return printOutput(cmd, resp.Node.Value, "Node expiration updated") }), } var renameNodeCmd = &cobra.Command{ Use: "rename NEW_NAME", Short: "Renames a node in your network", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") newName := "" @@ -215,17 +208,15 @@ var renameNodeCmd = &cobra.Command{ newName = args[0] } - request := &v1.RenameNodeRequest{ - NodeId: identifier, + resp, err := client.RenameNode(ctx, apiv1.RenameNodeParams{ + NodeID: identifier, NewName: newName, - } - - response, err := client.RenameNode(ctx, request) + }) if err != nil { return fmt.Errorf("renaming node: %w", err) } - return printOutput(cmd, response.GetNode(), "Node renamed") + return printOutput(cmd, resp.Node.Value, "Node renamed") }), } @@ -233,30 +224,22 @@ var deleteNodeCmd = &cobra.Command{ Use: cmdDelete, Short: "Delete a node", Aliases: []string{aliasDel}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") - getRequest := &v1.GetNodeRequest{ - NodeId: identifier, - } - - getResponse, err := client.GetNode(ctx, getRequest) + getResp, err := client.GetNode(ctx, apiv1.GetNodeParams{NodeID: identifier}) if err != nil { return fmt.Errorf("getting node: %w", err) } - deleteRequest := &v1.DeleteNodeRequest{ - NodeId: identifier, - } - if !confirmAction(cmd, fmt.Sprintf( "Do you want to remove the node %s?", - getResponse.GetNode().GetName(), + getResp.Node.Value.Name.Value, )) { return printOutput(cmd, map[string]string{colResult: "Node not deleted"}, "Node not deleted") } - _, err = client.DeleteNode(ctx, deleteRequest) + err = client.DeleteNode(ctx, apiv1.DeleteNodeParams{NodeID: identifier}) if err != nil { return fmt.Errorf("deleting node: %w", err) } @@ -289,23 +272,24 @@ be assigned to nodes.`, return nil } - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() + ctx, client, cancel, err := newHeadscaleAPIClient() if err != nil { return fmt.Errorf("connecting to headscale: %w", err) } defer cancel() - defer conn.Close() - changes, err := client.BackfillNodeIPs(ctx, &v1.BackfillNodeIPsRequest{Confirmed: true}) + resp, err := client.BackfillNodeIPs(ctx, apiv1.BackfillNodeIPsParams{ + Confirmed: apiv1.NewOptBool(true), + }) if err != nil { return fmt.Errorf("backfilling IPs: %w", err) } - return printOutput(cmd, changes, "Node IPs backfilled successfully") + return printOutput(cmd, resp.Changes, "Node IPs backfilled successfully") }, } -func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) { +func nodesToPtables(nodes []apiv1.Node) (pterm.TableData, error) { tableHeader := []string{ "ID", "Hostname", @@ -325,75 +309,52 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) { tableData[0] = tableHeader for _, node := range nodes { - var ephemeral bool - if node.GetPreAuthKey() != nil && node.GetPreAuthKey().GetEphemeral() { - ephemeral = true + ephemeral := node.PreAuthKey.Set && node.PreAuthKey.Value.Ephemeral.Value + + var lastSeenTime string + if node.LastSeen.Set { + lastSeenTime = node.LastSeen.Value.Format(HeadscaleDateTimeFormat) } - var ( - lastSeen time.Time - lastSeenTime string - ) - - if node.GetLastSeen() != nil { - lastSeen = node.GetLastSeen().AsTime() - lastSeenTime = lastSeen.Format(HeadscaleDateTimeFormat) - } - - var ( - expiry time.Time - expiryTime string - ) - - if node.GetExpiry() != nil { - expiry = node.GetExpiry().AsTime() - expiryTime = expiry.Format(HeadscaleDateTimeFormat) - } else { - expiryTime = "N/A" + expiryTime := "N/A" + if node.Expiry.Set { + expiryTime = node.Expiry.Value.Format(HeadscaleDateTimeFormat) } var machineKey key.MachinePublic - err := machineKey.UnmarshalText( - []byte(node.GetMachineKey()), - ) + err := machineKey.UnmarshalText([]byte(node.MachineKey.Value)) if err != nil { machineKey = key.MachinePublic{} } var nodeKey key.NodePublic - err = nodeKey.UnmarshalText( - []byte(node.GetNodeKey()), - ) + err = nodeKey.UnmarshalText([]byte(node.NodeKey.Value)) if err != nil { return nil, err } - var online string - if node.GetOnline() { + online := pterm.LightRed("offline") + if node.Online.Value { online = pterm.LightGreen("online") - } else { - online = pterm.LightRed("offline") } - var expired string - if node.GetExpiry() != nil && node.GetExpiry().AsTime().Before(time.Now()) { + expired := pterm.LightGreen("no") + if node.Expiry.Set && node.Expiry.Value.Before(time.Now()) { expired = pterm.LightRed("yes") - } else { - expired = pterm.LightGreen("no") } - tags := strings.Join(node.GetTags(), "\n") + tags := strings.Join(node.Tags, "\n") var user string - if node.GetUser() != nil { - user = node.GetUser().GetName() + if node.User.Set { + user = node.User.Value.Name.Value } var ipBuilder strings.Builder - for _, addr := range node.GetIpAddresses() { + for _, addr := range node.IpAddresses { ip, err := netip.ParseAddr(addr) if err == nil { if ipBuilder.Len() > 0 { @@ -404,35 +365,28 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) { } } - ipAddresses := ipBuilder.String() - nodeData := []string{ - strconv.FormatUint(node.GetId(), util.Base10), - node.GetName(), - node.GetGivenName(), + strconv.FormatUint(node.ID.Value, util.Base10), + node.Name.Value, + node.GivenName.Value, machineKey.ShortString(), nodeKey.ShortString(), user, tags, - ipAddresses, + ipBuilder.String(), strconv.FormatBool(ephemeral), lastSeenTime, expiryTime, online, expired, } - tableData = append( - tableData, - nodeData, - ) + tableData = append(tableData, nodeData) } return tableData, nil } -func nodeRoutesToPtables( - nodes []*v1.Node, -) pterm.TableData { +func nodeRoutesToPtables(nodes []apiv1.Node) pterm.TableData { tableHeader := []string{ "ID", "Hostname", @@ -445,16 +399,13 @@ func nodeRoutesToPtables( for _, node := range nodes { nodeData := []string{ - strconv.FormatUint(node.GetId(), util.Base10), - node.GetGivenName(), - strings.Join(node.GetApprovedRoutes(), "\n"), - strings.Join(node.GetAvailableRoutes(), "\n"), - strings.Join(node.GetSubnetRoutes(), "\n"), + strconv.FormatUint(node.ID.Value, util.Base10), + node.GivenName.Value, + strings.Join(node.ApprovedRoutes, "\n"), + strings.Join(node.AvailableRoutes, "\n"), + strings.Join(node.SubnetRoutes, "\n"), } - tableData = append( - tableData, - nodeData, - ) + tableData = append(tableData, nodeData) } return tableData @@ -464,43 +415,39 @@ var tagCmd = &cobra.Command{ Use: "tag", Short: "Manage the tags of a node", Aliases: []string{"tags", "t"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") tagsToSet, _ := cmd.Flags().GetStringSlice("tags") - // Sending tags to node - request := &v1.SetTagsRequest{ - NodeId: identifier, - Tags: tagsToSet, - } - - resp, err := client.SetTags(ctx, request) + resp, err := client.SetTags( + ctx, + &apiv1.SetTagsReq{Tags: tagsToSet}, + apiv1.SetTagsParams{NodeID: identifier}, + ) if err != nil { return fmt.Errorf("setting tags: %w", err) } - return printOutput(cmd, resp.GetNode(), "Node updated") + return printOutput(cmd, resp.Node.Value, "Node updated") }), } var approveRoutesCmd = &cobra.Command{ Use: "approve-routes", Short: "Manage the approved routes of a node", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") routes, _ := cmd.Flags().GetStringSlice("routes") - // Sending routes to node - request := &v1.SetApprovedRoutesRequest{ - NodeId: identifier, - Routes: routes, - } - - resp, err := client.SetApprovedRoutes(ctx, request) + resp, err := client.SetApprovedRoutes( + ctx, + &apiv1.SetApprovedRoutesReq{Routes: routes}, + apiv1.SetApprovedRoutesParams{NodeID: identifier}, + ) if err != nil { return fmt.Errorf("setting approved routes: %w", err) } - return printOutput(cmd, resp.GetNode(), "Node updated") + return printOutput(cmd, resp.Node.Value, "Node updated") }), } diff --git a/cmd/headscale/cli/policy.go b/cmd/headscale/cli/policy.go index 09496054..a2cca0bb 100644 --- a/cmd/headscale/cli/policy.go +++ b/cmd/headscale/cli/policy.go @@ -6,7 +6,7 @@ import ( "fmt" "os" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + apiv1 "github.com/juanfont/headscale/gen/api/v1" "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/policy" "github.com/juanfont/headscale/hscontrol/types" @@ -90,13 +90,13 @@ var getPolicy = &cobra.Command{ policyData = pol.Data } else { - err := withGRPC(func(ctx context.Context, client v1.HeadscaleServiceClient) error { - response, err := client.GetPolicy(ctx, &v1.GetPolicyRequest{}) + err := withAPI(func(ctx context.Context, client *apiv1.Client) error { + resp, err := client.GetPolicy(ctx) if err != nil { return fmt.Errorf("loading ACL policy: %w", err) } - policyData = response.GetPolicy() + policyData = resp.Policy.Value return nil }) @@ -150,10 +150,10 @@ var setPolicy = &cobra.Command{ return fmt.Errorf("setting ACL policy: %w", err) } } else { - request := &v1.SetPolicyRequest{Policy: string(policyBytes)} - - err := withGRPC(func(ctx context.Context, client v1.HeadscaleServiceClient) error { - _, err := client.SetPolicy(ctx, request) + err := withAPI(func(ctx context.Context, client *apiv1.Client) error { + _, err := client.SetPolicy(ctx, &apiv1.SetPolicyReq{ + Policy: apiv1.NewOptString(string(policyBytes)), + }) if err != nil { return fmt.Errorf("setting ACL policy: %w", err) } @@ -223,10 +223,10 @@ var checkPolicy = &cobra.Command{ return nil } - err = withGRPC(func(ctx context.Context, client v1.HeadscaleServiceClient) error { - _, err := client.CheckPolicy(ctx, &v1.CheckPolicyRequest{Policy: string(policyBytes)}) - - return err + err = withAPI(func(ctx context.Context, client *apiv1.Client) error { + return client.CheckPolicy(ctx, &apiv1.CheckPolicyReq{ + Policy: apiv1.NewOptString(string(policyBytes)), + }) }) if err != nil { return err diff --git a/cmd/headscale/cli/preauthkeys.go b/cmd/headscale/cli/preauthkeys.go index 06e2f07f..d77d4500 100644 --- a/cmd/headscale/cli/preauthkeys.go +++ b/cmd/headscale/cli/preauthkeys.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + apiv1 "github.com/juanfont/headscale/gen/api/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/spf13/cobra" ) @@ -44,37 +44,39 @@ var listPreAuthKeys = &cobra.Command{ Use: cmdList, Short: "List all preauthkeys", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - response, err := client.ListPreAuthKeys(ctx, &v1.ListPreAuthKeysRequest{}) + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { + resp, err := client.ListPreAuthKeys(ctx) if err != nil { return fmt.Errorf("listing preauthkeys: %w", err) } - return printListOutput(cmd, response.GetPreAuthKeys(), func() error { - rows := make([][]string, 0, len(response.GetPreAuthKeys())) - for _, key := range response.GetPreAuthKeys() { + return printListOutput(cmd, resp.PreAuthKeys, func() error { + rows := make([][]string, 0, len(resp.PreAuthKeys)) + for _, key := range resp.PreAuthKeys { expiration := "-" - if key.GetExpiration() != nil { - expiration = ColourTime(key.GetExpiration().AsTime()) + if key.Expiration.Set { + expiration = ColourTime(key.Expiration.Value) } var owner string - if len(key.GetAclTags()) > 0 { - owner = strings.Join(key.GetAclTags(), "\n") - } else if key.GetUser() != nil { - owner = key.GetUser().GetName() - } else { + + switch { + case len(key.AclTags) > 0: + owner = strings.Join(key.AclTags, "\n") + case key.User.Set: + owner = key.User.Value.Name.Value + default: owner = "-" } rows = append(rows, []string{ - strconv.FormatUint(key.GetId(), util.Base10), - key.GetKey(), - strconv.FormatBool(key.GetReusable()), - strconv.FormatBool(key.GetEphemeral()), - strconv.FormatBool(key.GetUsed()), + strconv.FormatUint(key.ID.Value, util.Base10), + key.Key.Value, + strconv.FormatBool(key.Reusable.Value), + strconv.FormatBool(key.Ephemeral.Value), + strconv.FormatBool(key.Used.Value), expiration, - key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat), + key.CreatedAt.Value.Format(HeadscaleDateTimeFormat), owner, }) } @@ -97,7 +99,7 @@ var createPreAuthKeyCmd = &cobra.Command{ Use: "create", Short: "Creates a new preauthkey", Aliases: []string{"c", cmdNew}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetUint64("user") reusable, _ := cmd.Flags().GetBool("reusable") ephemeral, _ := cmd.Flags().GetBool("ephemeral") @@ -108,20 +110,18 @@ var createPreAuthKeyCmd = &cobra.Command{ return err } - request := &v1.CreatePreAuthKeyRequest{ - User: user, - Reusable: reusable, - Ephemeral: ephemeral, + resp, err := client.CreatePreAuthKey(ctx, &apiv1.CreatePreAuthKeyReq{ + User: optUint64(user), + Reusable: apiv1.NewOptBool(reusable), + Ephemeral: apiv1.NewOptBool(ephemeral), AclTags: tags, Expiration: expiration, - } - - response, err := client.CreatePreAuthKey(ctx, request) + }) if err != nil { return fmt.Errorf("creating preauthkey: %w", err) } - return printOutput(cmd, response.GetPreAuthKey(), response.GetPreAuthKey().GetKey()) + return printOutput(cmd, resp.PreAuthKey.Value, resp.PreAuthKey.Value.Key.Value) }), } @@ -139,22 +139,18 @@ var expirePreAuthKeyCmd = &cobra.Command{ Use: cmdExpire, Short: "Expire a preauthkey", Aliases: []string{"revoke", aliasExp, "e"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { id, err := preAuthKeyID(cmd) if err != nil { return err } - request := &v1.ExpirePreAuthKeyRequest{ - Id: id, - } - - response, err := client.ExpirePreAuthKey(ctx, request) + err = client.ExpirePreAuthKey(ctx, &apiv1.ExpirePreAuthKeyReq{ID: apiv1.NewOptUint64(id)}) if err != nil { return fmt.Errorf("expiring preauthkey: %w", err) } - return printOutput(cmd, response, "Key expired") + return printOutput(cmd, map[string]string{colResult: "Key expired"}, "Key expired") }), } @@ -162,21 +158,17 @@ var deletePreAuthKeyCmd = &cobra.Command{ Use: cmdDelete, Short: "Delete a preauthkey", Aliases: []string{aliasDel, "rm", "d"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { id, err := preAuthKeyID(cmd) if err != nil { return err } - request := &v1.DeletePreAuthKeyRequest{ - Id: id, - } - - response, err := client.DeletePreAuthKey(ctx, request) + err = client.DeletePreAuthKey(ctx, apiv1.DeletePreAuthKeyParams{ID: apiv1.NewOptUint64(id)}) if err != nil { return fmt.Errorf("deleting preauthkey: %w", err) } - return printOutput(cmd, response, "Key deleted") + return printOutput(cmd, map[string]string{colResult: "Key deleted"}, "Key deleted") }), } diff --git a/cmd/headscale/cli/root_test.go b/cmd/headscale/cli/root_test.go index 68d1ae52..fb1fefd1 100644 --- a/cmd/headscale/cli/root_test.go +++ b/cmd/headscale/cli/root_test.go @@ -213,7 +213,8 @@ func TestFilterPreReleasesIfStable(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := filterPreReleasesIfStable(func() string { return tt.currentVersion })(tt.tag) if result != tt.expectedFilter { - t.Errorf("%s: got %v, want %v\nDescription: %s\nCurrent version: %s, Tag: %s", + t.Errorf( + "%s: got %v, want %v\nDescription: %s\nCurrent version: %s, Tag: %s", tt.name, result, tt.expectedFilter, @@ -293,7 +294,8 @@ func TestIsPreReleaseVersion(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := isPreReleaseVersion(tt.version) if result != tt.expected { - t.Errorf("%s: got %v, want %v\nDescription: %s\nVersion: %s", + t.Errorf( + "%s: got %v, want %v\nDescription: %s\nVersion: %s", tt.name, result, tt.expected, diff --git a/cmd/headscale/cli/users.go b/cmd/headscale/cli/users.go index a74d64e8..f03e3133 100644 --- a/cmd/headscale/cli/users.go +++ b/cmd/headscale/cli/users.go @@ -7,10 +7,8 @@ import ( "net/url" "strconv" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + apiv1 "github.com/juanfont/headscale/gen/api/v1" "github.com/juanfont/headscale/hscontrol/util" - "github.com/juanfont/headscale/hscontrol/util/zlog/zf" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -45,27 +43,27 @@ func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string, error) { // returning the raw flag id and the matched user. func resolveSingleUser( ctx context.Context, - client v1.HeadscaleServiceClient, + client *apiv1.Client, cmd *cobra.Command, -) (uint64, *v1.User, error) { +) (uint64, *apiv1.User, error) { id, username, err := usernameAndIDFromFlag(cmd) if err != nil { return 0, nil, err } - users, err := client.ListUsers(ctx, &v1.ListUsersRequest{ - Name: username, - Id: id, + resp, err := client.ListUsers(ctx, apiv1.ListUsersParams{ + Name: optString(username), + ID: optUint64(id), }) if err != nil { return 0, nil, fmt.Errorf("listing users: %w", err) } - if len(users.GetUsers()) != 1 { + if len(resp.Users) != 1 { return 0, nil, errMultipleUsersMatch } - return id, users.GetUsers()[0], nil + return id, &resp.Users[0], nil } func init() { @@ -102,37 +100,32 @@ var createUserCmd = &cobra.Command{ return nil }, - 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") - - request := &v1.CreateUserRequest{Name: userName} + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { + req := &apiv1.CreateUserReq{Name: apiv1.NewOptString(args[0])} if displayName, _ := cmd.Flags().GetString("display-name"); displayName != "" { - request.DisplayName = displayName + req.DisplayName = apiv1.NewOptString(displayName) } if email, _ := cmd.Flags().GetString("email"); email != "" { - request.Email = email + req.Email = apiv1.NewOptString(email) } if pictureURL, _ := cmd.Flags().GetString("picture-url"); pictureURL != "" { - if _, err := url.Parse(pictureURL); err != nil { //nolint:noinlineerr + _, err := url.Parse(pictureURL) + if err != nil { return fmt.Errorf("invalid picture URL: %w", err) } - request.PictureUrl = pictureURL + req.PictureUrl = apiv1.NewOptString(pictureURL) } - log.Trace().Interface(zf.Request, request).Msg("sending CreateUser request") - - response, err := client.CreateUser(ctx, request) + resp, err := client.CreateUser(ctx, req) if err != nil { return fmt.Errorf("creating user: %w", err) } - return printOutput(cmd, response.GetUser(), "User created") + return printOutput(cmd, resp.User.Value, "User created") }), } @@ -140,7 +133,7 @@ var destroyUserCmd = &cobra.Command{ Use: "destroy --identifier ID or --name NAME", Short: "Destroys a user", Aliases: []string{cmdDelete}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { _, user, err := resolveSingleUser(ctx, client, cmd) if err != nil { return err @@ -148,19 +141,17 @@ var destroyUserCmd = &cobra.Command{ if !confirmAction(cmd, fmt.Sprintf( "Do you want to remove the user %q (%d) and any associated preauthkeys?", - user.GetName(), user.GetId(), + user.Name.Value, user.ID.Value, )) { return printOutput(cmd, map[string]string{colResult: "User not destroyed"}, "User not destroyed") } - deleteRequest := &v1.DeleteUserRequest{Id: user.GetId()} - - response, err := client.DeleteUser(ctx, deleteRequest) + err = client.DeleteUser(ctx, apiv1.DeleteUserParams{ID: user.ID.Value}) if err != nil { return fmt.Errorf("destroying user: %w", err) } - return printOutput(cmd, response, "User destroyed") + return printOutput(cmd, map[string]string{colResult: "User destroyed"}, "User destroyed") }), } @@ -168,8 +159,8 @@ var listUsersCmd = &cobra.Command{ Use: cmdList, Short: "List all the users", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - request := &v1.ListUsersRequest{} + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { + var params apiv1.ListUsersParams id, _ := cmd.Flags().GetInt64("identifier") username, _ := cmd.Flags().GetString("name") @@ -178,29 +169,29 @@ var listUsersCmd = &cobra.Command{ // filter by one param at most switch { case id > 0: - request.Id = uint64(id) + params.ID = apiv1.NewOptUint64(uint64(id)) case username != "": - request.Name = username + params.Name = apiv1.NewOptString(username) case email != "": - request.Email = email + params.Email = apiv1.NewOptString(email) } - response, err := client.ListUsers(ctx, request) + resp, err := client.ListUsers(ctx, params) if err != nil { return fmt.Errorf("listing users: %w", err) } - return printListOutput(cmd, response.GetUsers(), func() error { - rows := make([][]string, 0, len(response.GetUsers())) - for _, user := range response.GetUsers() { + return printListOutput(cmd, resp.Users, func() error { + rows := make([][]string, 0, len(resp.Users)) + for _, user := range resp.Users { rows = append( rows, []string{ - strconv.FormatUint(user.GetId(), util.Base10), - user.GetDisplayName(), - user.GetName(), - user.GetEmail(), - user.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat), + strconv.FormatUint(user.ID.Value, util.Base10), + user.DisplayName.Value, + user.Name.Value, + user.Email.Value, + user.CreatedAt.Value.Format(HeadscaleDateTimeFormat), }, ) } @@ -214,7 +205,7 @@ var renameUserCmd = &cobra.Command{ Use: "rename", Short: "Renames a user", Aliases: []string{"mv"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error { id, _, err := resolveSingleUser(ctx, client, cmd) if err != nil { return err @@ -222,16 +213,14 @@ var renameUserCmd = &cobra.Command{ newName, _ := cmd.Flags().GetString("new-name") - renameReq := &v1.RenameUserRequest{ - OldId: id, + resp, err := client.RenameUser(ctx, apiv1.RenameUserParams{ + OldID: id, NewName: newName, - } - - response, err := client.RenameUser(ctx, renameReq) + }) if err != nil { return fmt.Errorf("renaming user: %w", err) } - return printOutput(cmd, response.GetUser(), "User renamed") + return printOutput(cmd, resp.User.Value, "User renamed") }), } diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 1187de42..8282b0b4 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -1,28 +1,17 @@ package cli import ( - "context" - "crypto/tls" "encoding/json" "errors" "fmt" "os" "slices" - "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" - "github.com/juanfont/headscale/hscontrol/util/zlog/zf" - "github.com/prometheus/common/model" "github.com/pterm/pterm" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/protobuf/types/known/timestamppb" "gopkg.in/yaml.v3" ) @@ -69,141 +58,6 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) { return app, nil } -// grpcRunE wraps a cobra [cobra.Command.RunE] func, injecting a ready -// gRPC client and context. Connection lifecycle is managed by the -// wrapper — callers never see the underlying conn or cancel func. -func grpcRunE( - fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error, -) func(*cobra.Command, []string) error { - 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() - - return fn(ctx, client, cmd, args) - } -} - -// withGRPC opens a gRPC client, runs fn with it, and tears the -// connection down afterwards. It is the building block for commands -// that branch on a flag before deciding to talk to the server, where -// grpcRunE's whole-RunE wrapping does not fit. -func withGRPC( - fn func(ctx context.Context, client v1.HeadscaleServiceClient) error, -) error { - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() - if err != nil { - return fmt.Errorf("connecting to headscale: %w", err) - } - defer cancel() - defer conn.Close() - - return fn(ctx, client) -} - -func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *grpc.ClientConn, context.CancelFunc, error) { - cfg, err := types.LoadCLIConfig() - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("loading configuration: %w", err) - } - - log.Debug(). - Dur("timeout", cfg.CLI.Timeout). - Msgf("Setting timeout") - - ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout) - - grpcOptions := []grpc.DialOption{ - grpc.WithBlock(), //nolint:staticcheck // SA1019: deprecated but supported in 1.x - } - - address := cfg.CLI.Address - - // If the address is not set, we assume that we are on the server hosting [hscontrol]. - if address == "" { - log.Debug(). - Str("socket", cfg.UnixSocket). - Msgf("HEADSCALE_CLI_ADDRESS environment is not set, connecting to unix socket.") - - address = cfg.UnixSocket - - // Try to give the user better feedback if we cannot write to the headscale - // socket. Note: [os.OpenFile] on a Unix domain socket returns ENXIO on - // 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) { - cancel() - - 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, - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithContextDialer(util.GrpcSocketDialer), - ) - } else { - // If we are not connecting to a local server, require an API key for authentication - apiKey := cfg.CLI.APIKey - if apiKey == "" { - cancel() - - return nil, nil, nil, nil, errAPIKeyNotSet - } - - grpcOptions = append( - grpcOptions, - grpc.WithPerRPCCredentials(tokenAuth{ - token: apiKey, - }), - ) - - if cfg.CLI.Insecure { - tlsConfig := &tls.Config{ - // turn of gosec as we are intentionally setting - // insecure. - //nolint:gosec - InsecureSkipVerify: true, - } - - grpcOptions = append( - grpcOptions, - grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), - ) - } else { - grpcOptions = append( - grpcOptions, - grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")), - ) - } - } - - log.Trace().Caller().Str(zf.Address, address).Msg("connecting via gRPC") - - conn, err := grpc.DialContext(ctx, address, grpcOptions...) //nolint:staticcheck // SA1019: deprecated but supported in 1.x - if err != nil { - cancel() - - return nil, nil, nil, nil, fmt.Errorf("connecting to %s: %w", address, err) - } - - client := v1.NewHeadscaleServiceClient(conn) - - 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) { @@ -223,7 +77,21 @@ func formatOutput(result any, override string, outputFormat string) (string, err return string(b), nil case outputFormatYAML: - b, err := yaml.Marshal(result) + // Route through JSON so types with a custom MarshalJSON (the generated + // API types) serialise by their JSON shape, then convert to YAML. + j, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("marshalling output: %w", err) + } + + var generic any + + err = yaml.Unmarshal(j, &generic) + if err != nil { + return "", fmt.Errorf("converting output to YAML: %w", err) + } + + b, err := yaml.Marshal(generic) if err != nil { return "", fmt.Errorf("marshalling YAML output: %w", err) } @@ -249,19 +117,6 @@ func printOutput(cmd *cobra.Command, result any, override string) error { return nil } -// expirationFromFlag parses the --expiration flag as a Prometheus-style -// duration (e.g. "90d", "1h") and returns an absolute timestamp. -func expirationFromFlag(cmd *cobra.Command) (*timestamppb.Timestamp, error) { - durationStr, _ := cmd.Flags().GetString("expiration") - - duration, err := model.ParseDuration(durationStr) - if err != nil { - return nil, fmt.Errorf("parsing duration: %w", err) - } - - return timestamppb.New(time.Now().UTC().Add(time.Duration(duration))), nil -} - // confirmAction returns true when the user confirms a prompt, or when // --force is set. Callers decide what to do when it returns false. func confirmAction(cmd *cobra.Command, prompt string) bool { @@ -323,21 +178,3 @@ func hasMachineOutputFlag() bool { return arg == outputFormatJSON || arg == outputFormatJSONLine || arg == outputFormatYAML }) } - -type tokenAuth struct { - token string -} - -// Return value is mapped to request headers. -func (t tokenAuth) GetRequestMetadata( - ctx context.Context, - in ...string, -) (map[string]string, error) { - return map[string]string{ - "authorization": "Bearer " + t.token, - }, nil -} - -func (tokenAuth) RequireTransportSecurity() bool { - return true -} diff --git a/docs/v1-ogen/CHANGES.md b/docs/v1-ogen/CHANGES.md index b2b12f84..580c6c29 100644 --- a/docs/v1-ogen/CHANGES.md +++ b/docs/v1-ogen/CHANGES.md @@ -69,6 +69,36 @@ the new error shape. **Client impact:** none beyond the problem-document error shape above. +## CLI + +### Remote CLI connects to the HTTP API, not the gRPC port + +**What:** with a configured `cli.address` (or `HEADSCALE_CLI_ADDRESS`), the CLI +now speaks HTTP to the headscale API URL rather than gRPC to `grpc_listen_addr`. +A bare `host:port` is assumed to be `https://host:port`. Locally (no address) +the CLI talks HTTP over the existing unix socket, unchanged in spirit — no API +key needed, filesystem permissions are the trust boundary. + +**Why:** the gRPC service and its TCP listener are removed; the CLI runs on the +generated HTTP client. + +**Client impact:** point `cli.address` at the headscale HTTP server (the same +URL `server_url` is reachable on) instead of the gRPC address. `cli.api_key` +and `cli.insecure` are unchanged. + +### `delete`/`expire` commands print a result message + +**What:** commands whose API operation has no response body (user/node/key +delete, key expire, auth approve/reject) print a small +`{"result": "..."}`-style object (or the human-readable message) instead of the +previous empty `{}`. + +**Why:** the operations return no content; a result message is more useful than +an empty object. + +**Client impact:** scripts parsing the empty `{}` should read the `result` +field (machine-readable output) or rely on the exit code. + ## Delivery note (not a shipped behaviour change) The grpc-gateway HTTP facade is replaced wholesale at `/api/v1` by the ogen diff --git a/hscontrol/api/v1/auth.go b/hscontrol/api/v1/auth.go index 6476ab04..0f6eedbb 100644 --- a/hscontrol/api/v1/auth.go +++ b/hscontrol/api/v1/auth.go @@ -11,6 +11,21 @@ import ( var errAuthRejected = errors.New("auth request rejected") +type socketAuthContextKey struct{} + +// WithSocketAuth marks ctx as originating from the trusted local unix socket, +// where filesystem permissions are the trust boundary. Requests carrying this +// marker bypass bearer-token validation. The server mounts this on the +// socket-only listener. +func WithSocketAuth(ctx context.Context) context.Context { + return context.WithValue(ctx, socketAuthContextKey{}, true) +} + +func isSocketAuth(ctx context.Context) bool { + v, _ := ctx.Value(socketAuthContextKey{}).(bool) + return v +} + // HandleBearerAuth validates the API key bearer token against the state layer. // A missing or malformed Authorization header is reported by ogen before this // is reached. Any validation failure — a malformed/unknown key (which @@ -22,6 +37,11 @@ func (s *Server) HandleBearerAuth( _ oas.OperationName, t oas.BearerAuth, ) (context.Context, error) { + // Requests from the local unix socket are trusted via filesystem permissions. + if isSocketAuth(ctx) { + return ctx, nil + } + valid, err := s.state.ValidateAPIKey(t.Token) if err != nil || !valid { return ctx, apiError(http.StatusUnauthorized, "invalid API key") diff --git a/hscontrol/app.go b/hscontrol/app.go index 4862e985..ae831a20 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -50,7 +50,6 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" "google.golang.org/grpc/peer" - "google.golang.org/grpc/reflection" "google.golang.org/grpc/status" "tailscale.com/envknob" "tailscale.com/tailcfg" @@ -534,6 +533,22 @@ func securityHeaders(next http.Handler) http.Handler { }) } +// unixSocketHandler wraps the API router for the local unix socket, marking +// every request as socket-authenticated so the v1 API bypasses bearer-token +// validation. The socket's filesystem permissions are the trust boundary. +func unixSocketHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // ogen only invokes its security handler when an Authorization header + // is present; inject a placeholder so socket requests reach it (where + // the socket-auth marker then short-circuits validation). + if r.Header.Get("Authorization") == "" { + r.Header.Set("Authorization", "Bearer local-socket") + } + + next.ServeHTTP(w, r.WithContext(apiv1.WithSocketAuth(r.Context()))) + }) +} + func (h *Headscale) createRouter(apiV1 http.Handler) *chi.Mux { r := chi.NewRouter() r.Use(metrics.Collector(metrics.CollectorOpts{ @@ -731,16 +746,23 @@ func (h *Headscale) Serve() error { return fmt.Errorf("changing gRPC socket permission: %w", err) } - // Start the local gRPC server without TLS and without authentication - grpcSocket := grpc.NewServer( - // Uncomment to debug grpc communication. - // zerolog.UnaryInterceptor(), - ) + // Build the v1 API handler and HTTP router once; both the local unix + // socket and the public HTTP listener serve it. + apiV1Handler, err := apiv1.NewHandler(h.state, h.cfg, h.Change) + if err != nil { + return fmt.Errorf("building v1 API handler: %w", err) + } - v1.RegisterHeadscaleServiceServer(grpcSocket, newHeadscaleV1APIServer(h)) - reflection.Register(grpcSocket) + router := h.createRouter(apiV1Handler) - errorGroup.Go(func() error { return grpcSocket.Serve(socketListener) }) + // Serve the API over the local unix socket without bearer auth: the + // socket's filesystem permissions are the trust boundary. + socketHTTPServer := &http.Server{ + Handler: unixSocketHandler(router), + ReadTimeout: types.HTTPTimeout, + } + + errorGroup.Go(func() error { return socketHTTPServer.Serve(socketListener) }) // // @@ -808,15 +830,8 @@ func (h *Headscale) Serve() error { // // HTTP setup // - // This is the regular router that we expose - // over our main Addr - apiV1Handler, err := apiv1.NewHandler(h.state, h.cfg, h.Change) - if err != nil { - return fmt.Errorf("building v1 API handler: %w", err) - } - - router := h.createRouter(apiV1Handler) - + // This is the regular router that we expose over our main Addr; it is the + // same router served over the unix socket above. httpServer := &http.Server{ Addr: h.cfg.Addr, Handler: router, @@ -956,8 +971,12 @@ func (h *Headscale) Serve() error { info("waiting for netmap stream to close") h.clientStreamsOpen.Wait() - info("shutting down grpc server (socket)") - grpcSocket.GracefulStop() + info("shutting down socket http server") + + socketErr := socketHTTPServer.Close() + if socketErr != nil { + log.Error().Err(socketErr).Msg("failed to shutdown socket http") + } if grpcServer != nil { info("shutting down grpc server (external)")