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.
This commit is contained in:
Kristoffer Dalby
2026-06-17 15:55:16 +00:00
parent de85965bd8
commit 29fc14dd73
14 changed files with 526 additions and 508 deletions

View File

@@ -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")
}),
}

View File

@@ -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)})
}),
}

187
cmd/headscale/cli/client.go Normal file
View File

@@ -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
}

View File

@@ -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")
}),
}

View File

@@ -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, "")
}),
}

View File

@@ -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 <id> --user <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")
}),
}

View File

@@ -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

View File

@@ -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")
}),
}

View File

@@ -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,

View File

@@ -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")
}),
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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")

View File

@@ -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)")