mirror of
https://github.com/juanfont/headscale.git
synced 2026-08-07 07:48:44 +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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user