diff --git a/cmd/admin/v2/commands.go b/cmd/admin/v2/commands.go index 277027b..e3ea879 100644 --- a/cmd/admin/v2/commands.go +++ b/cmd/admin/v2/commands.go @@ -24,6 +24,7 @@ func AddCmds(cmd *cobra.Command, c *config.Config) { adminCmd.AddCommand(newTaskCmd(c)) adminCmd.AddCommand(newTenantCmd(c)) adminCmd.AddCommand(newTokenCmd(c)) + adminCmd.AddCommand(newVPNCmd(c)) cmd.AddCommand(adminCmd) } diff --git a/cmd/admin/v2/vpn.go b/cmd/admin/v2/vpn.go new file mode 100644 index 0000000..b49d9de --- /dev/null +++ b/cmd/admin/v2/vpn.go @@ -0,0 +1,120 @@ +package v2 + +import ( + "fmt" + "strconv" + "time" + + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/cmd/config" + "github.com/metal-stack/cli/cmd/sorters" + "github.com/metal-stack/metal-lib/pkg/genericcli" + "github.com/metal-stack/metal-lib/pkg/genericcli/printers" + "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "google.golang.org/protobuf/types/known/durationpb" +) + +type vpn struct { + c *config.Config +} + +func newVPNCmd(c *config.Config) *cobra.Command { + w := &vpn{ + c: c, + } + + cmdsConfig := &genericcli.CmdsConfig[any, any, *apiv2.VPNNode]{ + BinaryName: config.BinaryName, + GenericCLI: genericcli.NewGenericCLI(w).WithFS(c.Fs), + Singular: "vpn", + Plural: "vpn", + Description: "manage vpn keys and list nodes connected", + Sorter: sorters.VpnNodeSorter(), + DescribePrinter: func() printers.Printer { return c.DescribePrinter }, + ListPrinter: func() printers.Printer { return c.ListPrinter }, + ListCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().String("project", "", "the project for which vpn nodes should be listed") + genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.Completion.ProjectListCompletion)) + }, + OnlyCmds: genericcli.OnlyCmds(genericcli.ListCmd), + ValidArgsFn: w.c.Completion.ProjectListCompletion, + } + + authKeyCmd := &cobra.Command{ + Use: "auth-key", + Short: "generate an auth key to connect to the vpn", + RunE: func(cmd *cobra.Command, args []string) error { + return w.authKey() + }, + ValidArgsFunction: c.Completion.ProjectListCompletion, + } + authKeyCmd.Flags().String("project", "", "the project for which the authkey should be generated") + authKeyCmd.Flags().String("reason", "", "the reason why the authkey should be generated") + authKeyCmd.Flags().Bool("ephemeral", true, "ephemeral defines if the key can only be used once") + authKeyCmd.Flags().Duration("expires", 1*time.Hour, "the duration after the generated key is not valid anymore") + genericcli.Must(authKeyCmd.MarkFlagRequired("project")) + genericcli.Must(authKeyCmd.RegisterFlagCompletionFunc("project", c.Completion.ProjectListCompletion)) + + return genericcli.NewCmds(cmdsConfig, authKeyCmd) +} + +func (v *vpn) authKey() error { + ctx, cancel := v.c.NewRequestContext() + defer cancel() + + req := &adminv2.VPNServiceAuthKeyRequest{ + Project: viper.GetString("project"), + Ephemeral: viper.GetBool("ephemeral"), + Expires: durationpb.New(viper.GetDuration("expires")), + Reason: viper.GetString("reason"), + } + + resp, err := v.c.Client.Adminv2().VPN().AuthKey(ctx, req) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(v.c.Out, "auth-key: %s\n", resp.AuthKey) + _, _ = fmt.Fprintf(v.c.Out, "vpn-endpoint: %s\n", resp.Address) + _, _ = fmt.Fprintf(v.c.Out, "ephemeral: %s\n", strconv.FormatBool(resp.Ephemeral)) + _, _ = fmt.Fprintf(v.c.Out, "expires in: %s\n", time.Until(resp.ExpiresAt.AsTime())) + + return nil +} + +func (v *vpn) Get(id string) (*apiv2.VPNNode, error) { + panic("unimplemented") +} + +func (v *vpn) List() ([]*apiv2.VPNNode, error) { + ctx, cancel := v.c.NewRequestContext() + defer cancel() + + resp, err := v.c.Client.Adminv2().VPN().ListNodes(ctx, &adminv2.VPNServiceListNodesRequest{ + Project: pointer.PointerOrNil(viper.GetString("project")), + }) + if err != nil { + return nil, fmt.Errorf("failed to list vpn nodes: %w", err) + } + + return resp.Nodes, nil +} + +func (v *vpn) Create(rq any) (*apiv2.VPNNode, error) { + panic("unimplemented") +} + +func (v *vpn) Delete(id string) (*apiv2.VPNNode, error) { + panic("unimplemented") +} + +func (v *vpn) Convert(r *apiv2.VPNNode) (string, any, any, error) { + panic("unimplemented") +} + +func (v *vpn) Update(rq any) (*apiv2.VPNNode, error) { + panic("unimplemented") +} diff --git a/cmd/sorters/vpn.go b/cmd/sorters/vpn.go new file mode 100644 index 0000000..5eb0eae --- /dev/null +++ b/cmd/sorters/vpn.go @@ -0,0 +1,20 @@ +package sorters + +import ( + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-lib/pkg/multisort" +) + +func VpnNodeSorter() *multisort.Sorter[*apiv2.VPNNode] { + return multisort.New(multisort.FieldMap[*apiv2.VPNNode]{ + "id": func(a, b *apiv2.VPNNode, descending bool) multisort.CompareResult { + return multisort.Compare(a.Id, b.Id, descending) + }, + "name": func(a, b *apiv2.VPNNode, descending bool) multisort.CompareResult { + return multisort.Compare(a.Name, b.Name, descending) + }, + "project": func(a, b *apiv2.VPNNode, descending bool) multisort.CompareResult { + return multisort.Compare(a.Project, b.Project, descending) + }, + }, multisort.Keys{{ID: "id"}}) +} diff --git a/cmd/tableprinters/common.go b/cmd/tableprinters/common.go index 3f02102..58c1c73 100644 --- a/cmd/tableprinters/common.go +++ b/cmd/tableprinters/common.go @@ -143,6 +143,11 @@ func (t *TablePrinter) ToHeaderAndRows(data any, wide bool) ([]string, [][]strin case []*apiv2.User: return t.UserTable(d, wide) + case *apiv2.VPNNode: + return t.VPNTable(pointer.WrapInSlice(d), wide) + case []*apiv2.VPNNode: + return t.VPNTable(d, wide) + default: return nil, nil, fmt.Errorf("unknown table printer for type: %T", d) } diff --git a/cmd/tableprinters/vpn.go b/cmd/tableprinters/vpn.go new file mode 100644 index 0000000..d772229 --- /dev/null +++ b/cmd/tableprinters/vpn.go @@ -0,0 +1,37 @@ +package tableprinters + +import ( + "fmt" + "strings" + "time" + + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" +) + +func (t *TablePrinter) VPNTable(data []*apiv2.VPNNode, _ bool) ([]string, [][]string, error) { + var ( + rows [][]string + header = []string{"ID", "Name", "Project", "IPS", "Last Seed"} + ) + + for _, node := range data { + var ( + lastSeen = node.LastSeen.AsTime().Format(time.DateTime) + ips = strings.Join(node.IpAddresses, ",") + ) + + row := []string{ + fmt.Sprintf("%d", node.Id), + node.Name, + node.Project, + ips, + lastSeen, + } + + rows = append(rows, row) + } + + t.t.DisableAutoWrap(false) + + return header, rows, nil +} diff --git a/docs/admin/metalctlv2_admin.md b/docs/admin/metalctlv2_admin.md index 8cca08e..ac589e9 100644 --- a/docs/admin/metalctlv2_admin.md +++ b/docs/admin/metalctlv2_admin.md @@ -38,4 +38,5 @@ these commands utilize the admin api, which can only be accessed by metal-stack * [metalctlv2 admin task](metalctlv2_admin_task.md) - manage task entities * [metalctlv2 admin tenant](metalctlv2_admin_tenant.md) - manage tenant entities * [metalctlv2 admin token](metalctlv2_admin_token.md) - manage token entities +* [metalctlv2 admin vpn](metalctlv2_admin_vpn.md) - manage vpn entities diff --git a/docs/admin/metalctlv2_admin_vpn.md b/docs/admin/metalctlv2_admin_vpn.md new file mode 100644 index 0000000..8dc9a29 --- /dev/null +++ b/docs/admin/metalctlv2_admin_vpn.md @@ -0,0 +1,33 @@ +## metalctlv2 admin vpn + +manage vpn entities + +### Synopsis + +manage vpn keys and list nodes connected + +### Options + +``` + -h, --help help for vpn +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin](metalctlv2_admin.md) - admin commands +* [metalctlv2 admin vpn auth-key](metalctlv2_admin_vpn_auth-key.md) - generate an auth key to connect to the vpn +* [metalctlv2 admin vpn list](metalctlv2_admin_vpn_list.md) - list all vpn + diff --git a/docs/admin/metalctlv2_admin_vpn_auth-key.md b/docs/admin/metalctlv2_admin_vpn_auth-key.md new file mode 100644 index 0000000..f68e20f --- /dev/null +++ b/docs/admin/metalctlv2_admin_vpn_auth-key.md @@ -0,0 +1,35 @@ +## metalctlv2 admin vpn auth-key + +generate an auth key to connect to the vpn + +``` +metalctlv2 admin vpn auth-key [flags] +``` + +### Options + +``` + --ephemeral ephemeral defines if the key can only be used once (default true) + --expires duration the duration after the generated key is not valid anymore (default 1h0m0s) + -h, --help help for auth-key + --project string the project for which the authkey should be generated + --reason string the reason why the authkey should be generated +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin vpn](metalctlv2_admin_vpn.md) - manage vpn entities + diff --git a/docs/admin/metalctlv2_admin_vpn_list.md b/docs/admin/metalctlv2_admin_vpn_list.md new file mode 100644 index 0000000..3c62ead --- /dev/null +++ b/docs/admin/metalctlv2_admin_vpn_list.md @@ -0,0 +1,33 @@ +## metalctlv2 admin vpn list + +list all vpn + +``` +metalctlv2 admin vpn list [flags] +``` + +### Options + +``` + -h, --help help for list + --project string the project for which vpn nodes should be listed + --sort-by strings sort by (comma separated) column(s), sort direction can be changed by appending :asc or :desc behind the column identifier. possible values: id|name|project +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin vpn](metalctlv2_admin_vpn.md) - manage vpn entities + diff --git a/tests/e2e/admin/vpn_test.go b/tests/e2e/admin/vpn_test.go new file mode 100644 index 0000000..c0ea94c --- /dev/null +++ b/tests/e2e/admin/vpn_test.go @@ -0,0 +1,98 @@ +package admin_e2e + +import ( + "testing" + "time" + + "connectrpc.com/connect" + "github.com/metal-stack/api/go/client" + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + e2erootcmd "github.com/metal-stack/cli/testing/e2e" + "github.com/metal-stack/cli/tests/e2e/testresources" + "github.com/metal-stack/metal-lib/pkg/genericcli/e2e" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func Test_AdminVpnCmd_List(t *testing.T) { + tests := []*e2e.Test[adminv2.VPNServiceListNodesResponse, apiv2.VPNNode]{ + { + Name: "list", + CmdArgs: []string{"admin", "vpn", "list"}, + NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ + ClientCalls: []client.ClientCall{ + { + WantRequest: &adminv2.VPNServiceListNodesRequest{}, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.VPNServiceListNodesResponse{ + Nodes: []*apiv2.VPNNode{ + testresources.VpnNode2(), + testresources.VpnNode1(), + }, + }) + }, + }, + }, + }), + Template: new("{{ .id }} {{ .project }}"), + WantTemplate: new(` +1 0d81bca7-73f6-4da3-8397-4a8c52a0c583 +2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c + `), + WantTable: new(` + ID NAME PROJECT IPS LAST SEED + 1 node-1 0d81bca7-73f6-4da3-8397-4a8c52a0c583 1.2.3.4,1.2.3.5 2000-01-01 00:00:00 + 2 node-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c 2.3.4.5 2000-01-01 00:01:00 + `), + WantMarkdown: new(` + | ID | NAME | PROJECT | IPS | LAST SEED | + |----|--------|--------------------------------------|-----------------|---------------------| + | 1 | node-1 | 0d81bca7-73f6-4da3-8397-4a8c52a0c583 | 1.2.3.4,1.2.3.5 | 2000-01-01 00:00:00 | + | 2 | node-2 | f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c | 2.3.4.5 | 2000-01-01 00:01:00 | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_AdminVpnCmd_AuthKey(t *testing.T) { + tests := []*e2e.Test[any, any]{ + { + Name: "list", + CmdArgs: []string{"admin", "vpn", "auth-key", "--project", testresources.Project1().Uuid}, + NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ + ClientCalls: []client.ClientCall{ + { + WantRequest: &adminv2.VPNServiceAuthKeyRequest{ + Project: testresources.Project1().Uuid, + Ephemeral: true, + Expires: durationpb.New(1 * time.Hour), + Reason: "", + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.VPNServiceAuthKeyResponse{ + Address: "1.2.3.4", + AuthKey: "abcdefghikl", + Ephemeral: true, + ExpiresAt: timestamppb.New(e2e.TimeBubbleStartTime().Add(1 * time.Hour)), + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + }) + }, + }, + }, + }), + WantDefault: new(` +auth-key: abcdefghikl +vpn-endpoint: 1.2.3.4 +ephemeral: true +expires in: 1h0m0s + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/testresources/vpn.go b/tests/e2e/testresources/vpn.go new file mode 100644 index 0000000..729562b --- /dev/null +++ b/tests/e2e/testresources/vpn.go @@ -0,0 +1,32 @@ +package testresources + +import ( + "time" + + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-lib/pkg/genericcli/e2e" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + VpnNode1 = func() *apiv2.VPNNode { + return &apiv2.VPNNode{ + Id: 1, + Name: "node-1", + Project: Project1().Uuid, + IpAddresses: []string{"1.2.3.4", "1.2.3.5"}, + LastSeen: timestamppb.New(e2e.TimeBubbleStartTime()), + Online: false, + } + } + VpnNode2 = func() *apiv2.VPNNode { + return &apiv2.VPNNode{ + Id: 2, + Name: "node-2", + Project: Project2().Uuid, + IpAddresses: []string{"2.3.4.5"}, + LastSeen: timestamppb.New(e2e.TimeBubbleStartTime().Add(1 * time.Minute)), + Online: true, + } + } +)