mirror of
https://github.com/juanfont/headscale.git
synced 2026-08-08 00:18:46 +09:00
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:
@@ -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")
|
||||
}),
|
||||
}
|
||||
|
||||
+19
-22
@@ -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)})
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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, "")
|
||||
}),
|
||||
}
|
||||
|
||||
+91
-144
@@ -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")
|
||||
}),
|
||||
}
|
||||
|
||||
+12
-12
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+40
-51
@@ -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")
|
||||
}),
|
||||
}
|
||||
|
||||
+15
-178
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
+39
-20
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user