From 94dba9417242a59cab597a0e2a300bba17a0bd5e Mon Sep 17 00:00:00 2001 From: Allen Chen Date: Wed, 12 Aug 2026 08:22:43 -0700 Subject: [PATCH 1/2] Add command to open instance ports --- .agents/skills/brev-cli/SKILL.md | 4 + .agents/skills/brev-cli/reference/commands.md | 26 ++ pkg/cmd/ports/open.go | 182 +++++++++++++ pkg/cmd/ports/open_test.go | 250 ++++++++++++++++++ pkg/cmd/ports/ports.go | 1 + 5 files changed, 463 insertions(+) create mode 100644 pkg/cmd/ports/open.go create mode 100644 pkg/cmd/ports/open_test.go diff --git a/.agents/skills/brev-cli/SKILL.md b/.agents/skills/brev-cli/SKILL.md index 50ce1101..06dd6569 100644 --- a/.agents/skills/brev-cli/SKILL.md +++ b/.agents/skills/brev-cli/SKILL.md @@ -174,6 +174,10 @@ brev port-forward my-instance -p 8080:8080 # List Skybridge-managed HTTP and network ports for an instance or external node brev ports my-instance brev ports my-node --json + +# Open a public port (TCP by default) +brev ports open my-instance 8080 +brev ports open my-node 53 --protocol udp --allow 203.0.113.10/32 ``` ### Listing Instances and Nodes diff --git a/.agents/skills/brev-cli/reference/commands.md b/.agents/skills/brev-cli/reference/commands.md index 2111490b..3067386a 100644 --- a/.agents/skills/brev-cli/reference/commands.md +++ b/.agents/skills/brev-cli/reference/commands.md @@ -510,6 +510,32 @@ brev ports my-node brev ports my-instance --json ``` +#### Open a port + +Open a raw TCP, UDP, or SSH port on a managed instance or registered compute +node. `add` is an alias for `open`. + +```bash +brev ports open [flags] +``` + +**Flags:** +| Flag | Description | +|------|-------------| +| `--protocol` | Port protocol: `tcp` (default), `udp`, or `ssh` | +| `--allow` | Source CIDR allowed to connect; repeat to add more than one | +| `--json` | Output the opened port as JSON | + +Omit `--allow` to allow connections from any source. + +**Examples:** +```bash +brev ports open my-instance 8080 +brev ports open my-node 53 --protocol udp +brev ports open my-instance 8080 --allow 203.0.113.10/32 +brev ports add my-node 2222 --protocol ssh --json +``` + ## Organization Commands ### brev org ls diff --git a/pkg/cmd/ports/open.go b/pkg/cmd/ports/open.go new file mode 100644 index 00000000..afa13eea --- /dev/null +++ b/pkg/cmd/ports/open.go @@ -0,0 +1,182 @@ +package ports + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/spf13/cobra" + + "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" + "github.com/brevdev/brev-cli/pkg/cmd/register" + cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" + "github.com/brevdev/brev-cli/pkg/config" + breverrors "github.com/brevdev/brev-cli/pkg/errors" +) + +// NewCmdOpenPort creates the `brev ports open` command. +func NewCmdOpenPort(portStore Store) *cobra.Command { + var protocol string + var allowedSources []string + var jsonOutput bool + + cmd := &cobra.Command{ + Annotations: map[string]string{"access": ""}, + Use: "open ", + Aliases: []string{"add"}, + DisableFlagsInUseLine: true, + Short: "Open a public port on an instance or external node", + Example: ` + brev ports open my-instance 8080 + brev ports open my-node 53 --protocol udp + brev ports open my-instance 8080 --allow 203.0.113.10/32`, + Args: cmderrors.TransformToValidationError(cobra.ExactArgs(2)), + RunE: func(cmd *cobra.Command, args []string) error { + portNumber, err := parsePortNumber(args[1]) + if err != nil { + return err + } + portProtocol, err := parseProtocol(protocol) + if err != nil { + return err + } + allowedSources, err = normalizeAllowedSources(allowedSources) + if err != nil { + return err + } + if err := Open(cmd.Context(), cmd.OutOrStdout(), portStore, args[0], portNumber, portProtocol, allowedSources, jsonOutput); err != nil { + return breverrors.WrapAndTrace(err) + } + return nil + }, + } + + cmd.Flags().StringVar(&protocol, "protocol", "tcp", "port protocol (tcp, udp, or ssh)") + cmd.Flags().StringArrayVar(&allowedSources, "allow", nil, "source CIDR allowed to connect (repeatable; omit to allow all)") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "output the opened port as JSON") + _ = cmd.RegisterFlagCompletionFunc("protocol", cobra.FixedCompletions( + []string{"tcp", "udp", "ssh"}, + cobra.ShellCompDirectiveNoFileComp, + )) + + return cmd +} + +// Open resolves a managed instance or registered compute node and opens a port. +func Open( + ctx context.Context, + out io.Writer, + portStore Store, + nameOrID string, + portNumber int32, + protocol devplanev1.PortProtocol, + allowedSources []string, + jsonOutput bool, +) error { + target, err := cmdutil.ResolveWorkspaceOrNode(portStore, nameOrID) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + var openedPort *devplanev1.Port + if target.Workspace != nil { + client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.OpenPort(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceOpenPortRequest{ + EnvironmentId: target.Workspace.ID, + Protocol: protocol, + PortNumber: portNumber, + AllowedSources: allowedSources, + })) + if err != nil { + return fmt.Errorf("open port on instance %q: %w", nameOrID, err) + } + if resp != nil && resp.Msg != nil { + openedPort = resp.Msg.GetPort() + } + } else if target.Node != nil { + client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.OpenPort(ctx, connect.NewRequest(&devplanev1.OpenPortRequest{ + ExternalNodeId: target.Node.GetExternalNodeId(), + Protocol: protocol, + PortNumber: portNumber, + AllowedSources: allowedSources, + })) + if err != nil { + return fmt.Errorf("open port on external node %q: %w", nameOrID, err) + } + if resp != nil && resp.Msg != nil { + openedPort = resp.Msg.GetPort() + } + } + + if openedPort == nil { + return fmt.Errorf("open port on %q: API returned no port", nameOrID) + } + return writeOpenResult(out, nameOrID, openedPort, jsonOutput) +} + +func parsePortNumber(value string) (int32, error) { + portNumber, err := strconv.ParseInt(value, 10, 32) + if err != nil || portNumber < 1 || portNumber > 65535 { + return 0, fmt.Errorf("invalid port %q: must be a number between 1 and 65535", value) + } + return int32(portNumber), nil +} + +func parseProtocol(value string) (devplanev1.PortProtocol, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "tcp": + return devplanev1.PortProtocol_PORT_PROTOCOL_TCP, nil + case "udp": + return devplanev1.PortProtocol_PORT_PROTOCOL_UDP, nil + case "ssh": + return devplanev1.PortProtocol_PORT_PROTOCOL_SSH, nil + default: + return devplanev1.PortProtocol_PORT_PROTOCOL_UNSPECIFIED, + fmt.Errorf("invalid protocol %q: must be tcp, udp, or ssh", value) + } +} + +func normalizeAllowedSources(values []string) ([]string, error) { + if len(values) == 0 { + return nil, nil + } + + normalized := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + return nil, fmt.Errorf("allowed source cannot be empty") + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + return normalized, nil +} + +func writeOpenResult(out io.Writer, nameOrID string, port *devplanev1.Port, jsonOutput bool) error { + portInfo := toPortInfos([]*devplanev1.Port{port})[0] + if jsonOutput { + encoded, err := json.MarshalIndent(portInfo, "", " ") + if err != nil { + return breverrors.WrapAndTrace(err) + } + _, err = fmt.Fprintln(out, string(encoded)) + return breverrors.WrapAndTrace(err) + } + + _, err := fmt.Fprintf(out, "Opened %s port %d on %s.\n", portInfo.Protocol, port.GetServerPort(), nameOrID) + if err != nil { + return breverrors.WrapAndTrace(err) + } + return displayTables(out, nameOrID, []PortInfo{portInfo}) +} diff --git a/pkg/cmd/ports/open_test.go b/pkg/cmd/ports/open_test.go new file mode 100644 index 00000000..ae4f734e --- /dev/null +++ b/pkg/cmd/ports/open_test.go @@ -0,0 +1,250 @@ +package ports + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + devplanev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/brevdev/brev-cli/pkg/entity" +) + +func newTestServer(t *testing.T, handler http.Handler) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + t.Setenv("BREV_PUBLIC_API_URL", server.URL) +} + +type fakeOpenEnvironmentService struct { + devplanev1connect.UnimplementedEnvironmentServiceHandler + t *testing.T + wantReq *devplanev1.EnvironmentServiceOpenPortRequest + port *devplanev1.Port +} + +func (s *fakeOpenEnvironmentService) OpenPort( + _ context.Context, + req *connect.Request[devplanev1.EnvironmentServiceOpenPortRequest], +) (*connect.Response[devplanev1.EnvironmentServiceOpenPortResponse], error) { + s.t.Helper() + assert.Equal(s.t, s.wantReq.GetEnvironmentId(), req.Msg.GetEnvironmentId()) + assert.Equal(s.t, s.wantReq.GetPortNumber(), req.Msg.GetPortNumber()) + assert.Equal(s.t, s.wantReq.GetProtocol(), req.Msg.GetProtocol()) + assert.Equal(s.t, s.wantReq.GetAllowedSources(), req.Msg.GetAllowedSources()) + return connect.NewResponse(&devplanev1.EnvironmentServiceOpenPortResponse{Port: s.port}), nil +} + +type fakeOpenNodeService struct { + devplanev1connect.UnimplementedExternalNodeServiceHandler + t *testing.T + node *devplanev1.ExternalNode + wantReq *devplanev1.OpenPortRequest + port *devplanev1.Port +} + +func (s *fakeOpenNodeService) ListNodes( + _ context.Context, + _ *connect.Request[devplanev1.ListNodesRequest], +) (*connect.Response[devplanev1.ListNodesResponse], error) { + return connect.NewResponse(&devplanev1.ListNodesResponse{Items: []*devplanev1.ExternalNode{s.node}}), nil +} + +func (s *fakeOpenNodeService) OpenPort( + _ context.Context, + req *connect.Request[devplanev1.OpenPortRequest], +) (*connect.Response[devplanev1.OpenPortResponse], error) { + s.t.Helper() + assert.Equal(s.t, s.wantReq.GetExternalNodeId(), req.Msg.GetExternalNodeId()) + assert.Equal(s.t, s.wantReq.GetPortNumber(), req.Msg.GetPortNumber()) + assert.Equal(s.t, s.wantReq.GetProtocol(), req.Msg.GetProtocol()) + assert.Equal(s.t, s.wantReq.GetAllowedSources(), req.Msg.GetAllowedSources()) + return connect.NewResponse(&devplanev1.OpenPortResponse{Port: s.port}), nil +} + +func TestOpenEnvironment(t *testing.T) { + service := &fakeOpenEnvironmentService{ + t: t, + wantReq: &devplanev1.EnvironmentServiceOpenPortRequest{ + EnvironmentId: "env123", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_TCP, + PortNumber: 8080, + AllowedSources: []string{"203.0.113.10/32"}, + }, + port: &devplanev1.Port{ + PortId: "port123", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_TCP, + PortNumber: 19001, + ServerPort: 8080, + AllowedSources: []string{"203.0.113.10/32"}, + }, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + newTestServer(t, handler) + + store := &fakeStore{ + workspaces: []entity.Workspace{{ID: "env123", Name: "my-instance", CreatedByUserID: "user1"}}, + user: &entity.User{ID: "user1"}, + org: &entity.Organization{ID: "org1"}, + } + var out bytes.Buffer + + err := Open( + context.Background(), + &out, + store, + "my-instance", + 8080, + devplanev1.PortProtocol_PORT_PROTOCOL_TCP, + []string{"203.0.113.10/32"}, + false, + ) + + require.NoError(t, err) + assert.Contains(t, out.String(), "Opened TCP port 8080 on my-instance.") + assert.Contains(t, out.String(), "19001") + assert.Contains(t, out.String(), "203.0.113.10/32") +} + +func TestOpenExternalNodeByIDJSON(t *testing.T) { + hostname := "global.prd.ga.run.brev.nvidia.com" + service := &fakeOpenNodeService{ + t: t, + node: &devplanev1.ExternalNode{ExternalNodeId: "unode123", Name: "my-node"}, + wantReq: &devplanev1.OpenPortRequest{ + ExternalNodeId: "unode123", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_UDP, + PortNumber: 53, + }, + port: &devplanev1.Port{ + PortId: "port53", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_UDP, + PortNumber: 19053, + ServerPort: 53, + Hostname: &hostname, + }, + } + _, handler := devplanev1connect.NewExternalNodeServiceHandler(service) + newTestServer(t, handler) + + store := &fakeStore{ + user: &entity.User{ID: "user1"}, + org: &entity.Organization{ID: "org1"}, + } + var out bytes.Buffer + + err := Open( + context.Background(), + &out, + store, + "unode123", + 53, + devplanev1.PortProtocol_PORT_PROTOCOL_UDP, + nil, + true, + ) + + require.NoError(t, err) + assert.JSONEq(t, `{ + "port_id": "port53", + "kind": "tcp_udp", + "endpoint": "global.prd.ga.run.brev.nvidia.com:19053", + "public_port": 19053, + "destination_port": 53, + "protocol": "UDP", + "allowed_sources": [], + "authorized_emails": [], + "allow_public_unauthenticated": false, + "type": "unspecified" + }`, out.String()) +} + +func TestNewCmdOpenPortParsesFlags(t *testing.T) { + service := &fakeOpenEnvironmentService{ + t: t, + wantReq: &devplanev1.EnvironmentServiceOpenPortRequest{ + EnvironmentId: "env123", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_SSH, + PortNumber: 2222, + AllowedSources: []string{"10.0.0.0/8", "192.0.2.0/24"}, + }, + port: &devplanev1.Port{ + PortId: "port2222", + Protocol: devplanev1.PortProtocol_PORT_PROTOCOL_SSH, + PortNumber: 19222, + ServerPort: 2222, + }, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + newTestServer(t, handler) + + store := &fakeStore{ + workspaces: []entity.Workspace{{ID: "env123", Name: "my-instance", CreatedByUserID: "user1"}}, + user: &entity.User{ID: "user1"}, + org: &entity.Organization{ID: "org1"}, + } + cmd := NewCmdPorts(store) + cmd.SetArgs([]string{ + "open", "my-instance", "2222", "--protocol", "SSH", + "--allow", "10.0.0.0/8", "--allow", "192.0.2.0/24", + "--allow", "10.0.0.0/8", + }) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Contains(t, out.String(), "Opened SSH port 2222 on my-instance.") +} + +func TestParsePortNumber(t *testing.T) { + tests := []struct { + value string + want int32 + err bool + }{ + {value: "1", want: 1}, + {value: "65535", want: 65535}, + {value: "0", err: true}, + {value: "65536", err: true}, + {value: "http", err: true}, + } + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + got, err := parsePortNumber(tt.value) + if tt.err { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseProtocol(t *testing.T) { + tests := []struct { + input string + want devplanev1.PortProtocol + }{ + {input: "tcp", want: devplanev1.PortProtocol_PORT_PROTOCOL_TCP}, + {input: "UDP", want: devplanev1.PortProtocol_PORT_PROTOCOL_UDP}, + {input: " ssh ", want: devplanev1.PortProtocol_PORT_PROTOCOL_SSH}, + } + for _, tt := range tests { + got, err := parseProtocol(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + + _, err := parseProtocol("http") + assert.EqualError(t, err, `invalid protocol "http": must be tcp, udp, or ssh`) +} diff --git a/pkg/cmd/ports/ports.go b/pkg/cmd/ports/ports.go index 868bc0b3..90b61150 100644 --- a/pkg/cmd/ports/ports.go +++ b/pkg/cmd/ports/ports.go @@ -69,6 +69,7 @@ func NewCmdPorts(portStore Store) *cobra.Command { } cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON") + cmd.AddCommand(NewCmdOpenPort(portStore)) return cmd } From 5a205d18c969f985fdf14bc7eed10c96f8baa753 Mon Sep 17 00:00:00 2001 From: Allen Chen Date: Fri, 14 Aug 2026 11:44:20 -0700 Subject: [PATCH 2/2] Add HTTP application port support --- .agents/skills/brev-cli/SKILL.md | 2 + .agents/skills/brev-cli/reference/commands.md | 18 +- pkg/cmd/ports/open.go | 255 +++++++++++++++--- pkg/cmd/ports/open_test.go | 206 +++++++++++++- 4 files changed, 437 insertions(+), 44 deletions(-) diff --git a/.agents/skills/brev-cli/SKILL.md b/.agents/skills/brev-cli/SKILL.md index 06dd6569..3b5f8b0c 100644 --- a/.agents/skills/brev-cli/SKILL.md +++ b/.agents/skills/brev-cli/SKILL.md @@ -178,6 +178,8 @@ brev ports my-node --json # Open a public port (TCP by default) brev ports open my-instance 8080 brev ports open my-node 53 --protocol udp --allow 203.0.113.10/32 +brev ports open my-instance 3000 --protocol http --public +brev ports open my-instance 8888 --protocol http --authorize me@example.com ``` ### Listing Instances and Nodes diff --git a/.agents/skills/brev-cli/reference/commands.md b/.agents/skills/brev-cli/reference/commands.md index 3067386a..4256d728 100644 --- a/.agents/skills/brev-cli/reference/commands.md +++ b/.agents/skills/brev-cli/reference/commands.md @@ -512,8 +512,8 @@ brev ports my-instance --json #### Open a port -Open a raw TCP, UDP, or SSH port on a managed instance or registered compute -node. `add` is an alias for `open`. +Open a raw TCP, UDP, or SSH port, or create an HTTP application endpoint, on a +managed instance or registered compute node. `add` is an alias for `open`. ```bash brev ports open [flags] @@ -522,11 +522,17 @@ brev ports open [flags] **Flags:** | Flag | Description | |------|-------------| -| `--protocol` | Port protocol: `tcp` (default), `udp`, or `ssh` | -| `--allow` | Source CIDR allowed to connect; repeat to add more than one | +| `--protocol` | Port protocol: `tcp` (default), `udp`, `ssh`, `http`, or `https` | +| `--allow` | Source CIDR for TCP, UDP, or SSH; repeat to add more than one | +| `--authorize` | Email authorized for an HTTP endpoint; repeat to add more than one | +| `--hostname` | HTTP endpoint hostname prefix; defaults to the destination port | +| `--public` | Disable authentication for an HTTP endpoint | | `--json` | Output the opened port as JSON | -Omit `--allow` to allow connections from any source. +Omit `--allow` to allow raw-port connections from any source. HTTP endpoints +default to authorizing the current user's email; use `--public` to make one +available without authentication. `--protocol http` connects the public HTTPS +endpoint to a plain-HTTP service, while `https` expects TLS on the destination. **Examples:** ```bash @@ -534,6 +540,8 @@ brev ports open my-instance 8080 brev ports open my-node 53 --protocol udp brev ports open my-instance 8080 --allow 203.0.113.10/32 brev ports add my-node 2222 --protocol ssh --json +brev ports open my-instance 3000 --protocol http --public +brev ports open my-instance 8888 --protocol http --authorize me@example.com ``` ## Organization Commands diff --git a/pkg/cmd/ports/open.go b/pkg/cmd/ports/open.go index afa13eea..9f216906 100644 --- a/pkg/cmd/ports/open.go +++ b/pkg/cmd/ports/open.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "regexp" "strconv" "strings" @@ -21,9 +22,7 @@ import ( // NewCmdOpenPort creates the `brev ports open` command. func NewCmdOpenPort(portStore Store) *cobra.Command { - var protocol string - var allowedSources []string - var jsonOutput bool + var opts openOptions cmd := &cobra.Command{ Annotations: map[string]string{"access": ""}, @@ -31,42 +30,185 @@ func NewCmdOpenPort(portStore Store) *cobra.Command { Aliases: []string{"add"}, DisableFlagsInUseLine: true, Short: "Open a public port on an instance or external node", - Example: ` - brev ports open my-instance 8080 - brev ports open my-node 53 --protocol udp - brev ports open my-instance 8080 --allow 203.0.113.10/32`, + Example: "\n brev ports open my-instance 8080" + + "\n brev ports open my-node 53 --protocol udp" + + "\n brev ports open my-instance 8080 --allow 203.0.113.10/32" + + "\n brev ports open my-instance 3000 --protocol http --public", Args: cmderrors.TransformToValidationError(cobra.ExactArgs(2)), RunE: func(cmd *cobra.Command, args []string) error { - portNumber, err := parsePortNumber(args[1]) - if err != nil { - return err - } - portProtocol, err := parseProtocol(protocol) - if err != nil { - return err - } - allowedSources, err = normalizeAllowedSources(allowedSources) - if err != nil { - return err - } - if err := Open(cmd.Context(), cmd.OutOrStdout(), portStore, args[0], portNumber, portProtocol, allowedSources, jsonOutput); err != nil { - return breverrors.WrapAndTrace(err) - } - return nil + return runOpenCommand(cmd.Context(), cmd.OutOrStdout(), portStore, args[0], args[1], opts) }, } - cmd.Flags().StringVar(&protocol, "protocol", "tcp", "port protocol (tcp, udp, or ssh)") - cmd.Flags().StringArrayVar(&allowedSources, "allow", nil, "source CIDR allowed to connect (repeatable; omit to allow all)") - cmd.Flags().BoolVar(&jsonOutput, "json", false, "output the opened port as JSON") + cmd.Flags().StringVar(&opts.protocol, "protocol", "tcp", "port protocol (tcp, udp, ssh, http, or https)") + cmd.Flags().StringArrayVar(&opts.allowedSources, "allow", nil, "source CIDR allowed to connect (repeatable; omit to allow all)") + cmd.Flags().StringArrayVar(&opts.authorizedEmails, "authorize", nil, "email authorized for an HTTP port (repeatable; defaults to you)") + cmd.Flags().StringVar(&opts.customHostname, "hostname", "", "hostname prefix for an HTTP port (defaults to the destination port)") + cmd.Flags().BoolVar(&opts.allowPublicUnauthenticated, "public", false, "disable authentication for an HTTP port") + cmd.Flags().BoolVar(&opts.jsonOutput, "json", false, "output the opened port as JSON") _ = cmd.RegisterFlagCompletionFunc("protocol", cobra.FixedCompletions( - []string{"tcp", "udp", "ssh"}, + []string{"tcp", "udp", "ssh", "http", "https"}, cobra.ShellCompDirectiveNoFileComp, )) return cmd } +type openOptions struct { + protocol string + allowedSources []string + authorizedEmails []string + customHostname string + allowPublicUnauthenticated bool + jsonOutput bool +} + +func runOpenCommand( + ctx context.Context, + out io.Writer, + portStore Store, + nameOrID string, + portValue string, + opts openOptions, +) error { + portNumber, err := parsePortNumber(portValue) + if err != nil { + return err + } + if isHTTPProtocol(opts.protocol) { + return runOpenHTTPCommand(ctx, out, portStore, nameOrID, portNumber, opts) + } + return runOpenNetworkCommand(ctx, out, portStore, nameOrID, portNumber, opts) +} + +func runOpenHTTPCommand( + ctx context.Context, + out io.Writer, + portStore Store, + nameOrID string, + portNumber int32, + opts openOptions, +) error { + httpProtocol, err := parseHTTPProtocol(opts.protocol) + if err != nil { + return err + } + if len(opts.allowedSources) > 0 { + return breverrors.NewValidationError("--allow is only supported for tcp, udp, and ssh ports") + } + authorizedEmails, err := normalizeAuthorizedEmails(opts.authorizedEmails) + if err != nil { + return err + } + if opts.allowPublicUnauthenticated && len(authorizedEmails) > 0 { + return breverrors.NewValidationError("--public and --authorize cannot be used together") + } + if err := validateHTTPHostname(opts.customHostname); err != nil { + return err + } + return breverrors.WrapAndTrace(OpenHTTP( + ctx, out, portStore, nameOrID, portNumber, httpProtocol, opts.customHostname, + authorizedEmails, opts.allowPublicUnauthenticated, opts.jsonOutput, + )) +} + +func runOpenNetworkCommand( + ctx context.Context, + out io.Writer, + portStore Store, + nameOrID string, + portNumber int32, + opts openOptions, +) error { + if opts.customHostname != "" || len(opts.authorizedEmails) > 0 || opts.allowPublicUnauthenticated { + return breverrors.NewValidationError("--hostname, --authorize, and --public are only supported for http and https ports") + } + portProtocol, err := parseProtocol(opts.protocol) + if err != nil { + return err + } + allowedSources, err := normalizeAllowedSources(opts.allowedSources) + if err != nil { + return err + } + return breverrors.WrapAndTrace(Open( + ctx, out, portStore, nameOrID, portNumber, portProtocol, allowedSources, opts.jsonOutput, + )) +} + +// OpenHTTP resolves a managed instance or registered compute node and creates +// an authenticated or public HTTP application endpoint. +func OpenHTTP( + ctx context.Context, + out io.Writer, + portStore Store, + nameOrID string, + portNumber int32, + httpProtocol devplanev1.HttpPortProtocol, + customHostname string, + authorizedEmails []string, + allowPublicUnauthenticated bool, + jsonOutput bool, +) error { + target, err := cmdutil.ResolveWorkspaceOrNodeWithContext(ctx, portStore, nameOrID) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + if !allowPublicUnauthenticated && len(authorizedEmails) == 0 { + user, err := portStore.GetCurrentUser() + if err != nil { + return fmt.Errorf("get current user for HTTP port authorization: %w", err) + } + if user == nil || strings.TrimSpace(user.Email) == "" { + return breverrors.NewValidationError("could not determine your email; use --authorize or --public") + } + authorizedEmails = []string{strings.TrimSpace(user.Email)} + } + + var openedPort *devplanev1.Port + if target.Workspace != nil { + hostname := buildHTTPHostname(customHostname, portNumber, target.Workspace.ID) + client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.OpenHTTPPort(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceOpenHTTPPortRequest{ + EnvironmentId: target.Workspace.ID, + PortNumber: portNumber, + CustomHostname: hostname, + HttpProtocol: httpProtocol, + AuthorizedEmails: authorizedEmails, + AllowPublicUnauthenticated: allowPublicUnauthenticated, + })) + if err != nil { + return fmt.Errorf("open HTTP port on instance %q: %w", nameOrID, err) + } + if resp != nil && resp.Msg != nil { + openedPort = resp.Msg.GetPort() + } + } else if target.Node != nil { + hostname := buildHTTPHostname(customHostname, portNumber, target.Node.GetExternalNodeId()) + client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.OpenHTTPPort(ctx, connect.NewRequest(&devplanev1.OpenHTTPPortRequest{ + ExternalNodeId: target.Node.GetExternalNodeId(), + PortNumber: portNumber, + CustomHostname: hostname, + HttpProtocol: httpProtocol, + AuthorizedEmails: authorizedEmails, + AllowPublicUnauthenticated: allowPublicUnauthenticated, + })) + if err != nil { + return fmt.Errorf("open HTTP port on external node %q: %w", nameOrID, err) + } + if resp != nil && resp.Msg != nil { + openedPort = resp.Msg.GetPort() + } + } + + if openedPort == nil { + return fmt.Errorf("open HTTP port on %q: API returned no port", nameOrID) + } + return writeOpenResult(out, nameOrID, openedPort, jsonOutput) +} + // Open resolves a managed instance or registered compute node and opens a port. func Open( ctx context.Context, @@ -78,7 +220,7 @@ func Open( allowedSources []string, jsonOutput bool, ) error { - target, err := cmdutil.ResolveWorkspaceOrNode(portStore, nameOrID) + target, err := cmdutil.ResolveWorkspaceOrNodeWithContext(ctx, portStore, nameOrID) if err != nil { return breverrors.WrapAndTrace(err) } @@ -138,11 +280,36 @@ func parseProtocol(value string) (devplanev1.PortProtocol, error) { return devplanev1.PortProtocol_PORT_PROTOCOL_SSH, nil default: return devplanev1.PortProtocol_PORT_PROTOCOL_UNSPECIFIED, - fmt.Errorf("invalid protocol %q: must be tcp, udp, or ssh", value) + fmt.Errorf("invalid protocol %q: must be tcp, udp, ssh, http, or https", value) + } +} + +func isHTTPProtocol(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + return value == "http" || value == "https" +} + +func parseHTTPProtocol(value string) (devplanev1.HttpPortProtocol, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "http": + return devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP, nil + case "https": + return devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTPS, nil + default: + return devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_UNSPECIFIED, + fmt.Errorf("invalid HTTP protocol %q: must be http or https", value) } } func normalizeAllowedSources(values []string) ([]string, error) { + return normalizeUniqueValues(values, "allowed source") +} + +func normalizeAuthorizedEmails(values []string) ([]string, error) { + return normalizeUniqueValues(values, "authorized email") +} + +func normalizeUniqueValues(values []string, label string) ([]string, error) { if len(values) == 0 { return nil, nil } @@ -152,7 +319,7 @@ func normalizeAllowedSources(values []string) ([]string, error) { for _, value := range values { value = strings.TrimSpace(value) if value == "" { - return nil, fmt.Errorf("allowed source cannot be empty") + return nil, fmt.Errorf("%s cannot be empty", label) } if _, ok := seen[value]; ok { continue @@ -163,6 +330,34 @@ func normalizeAllowedSources(values []string) ([]string, error) { return normalized, nil } +var httpHostnamePattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`) + +func validateHTTPHostname(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + if len(value) > 63 { + return breverrors.NewValidationError("hostname must be 63 characters or fewer") + } + if !httpHostnamePattern.MatchString(value) { + return breverrors.NewValidationError("hostname must contain only lowercase letters, digits, and hyphens, and must start and end with a letter or digit") + } + return nil +} + +func buildHTTPHostname(value string, portNumber int32, targetID string) string { + hostname := strings.TrimSpace(value) + if hostname == "" { + hostname = strconv.Itoa(int(portNumber)) + } + suffix := "-" + strings.ToLower(strings.TrimSpace(targetID)) + if targetID == "" || strings.HasSuffix(hostname, suffix) { + return hostname + } + return hostname + suffix +} + func writeOpenResult(out io.Writer, nameOrID string, port *devplanev1.Port, jsonOutput bool) error { portInfo := toPortInfos([]*devplanev1.Port{port})[0] if jsonOutput { diff --git a/pkg/cmd/ports/open_test.go b/pkg/cmd/ports/open_test.go index ae4f734e..fe5d1dfb 100644 --- a/pkg/cmd/ports/open_test.go +++ b/pkg/cmd/ports/open_test.go @@ -25,9 +25,38 @@ func newTestServer(t *testing.T, handler http.Handler) { type fakeOpenEnvironmentService struct { devplanev1connect.UnimplementedEnvironmentServiceHandler - t *testing.T - wantReq *devplanev1.EnvironmentServiceOpenPortRequest - port *devplanev1.Port + t *testing.T + wantReq *devplanev1.EnvironmentServiceOpenPortRequest + wantHTTPReq *devplanev1.EnvironmentServiceOpenHTTPPortRequest + port *devplanev1.Port + httpPort *devplanev1.Port +} + +type httpOpenRequest interface { + GetPortNumber() int32 + GetCustomHostname() string + GetHttpProtocol() devplanev1.HttpPortProtocol + GetAuthorizedEmails() []string + GetAllowPublicUnauthenticated() bool +} + +func assertHTTPOpenRequest(t *testing.T, want, got httpOpenRequest) { + t.Helper() + assert.Equal(t, want.GetPortNumber(), got.GetPortNumber()) + assert.Equal(t, want.GetCustomHostname(), got.GetCustomHostname()) + assert.Equal(t, want.GetHttpProtocol(), got.GetHttpProtocol()) + assert.Equal(t, want.GetAuthorizedEmails(), got.GetAuthorizedEmails()) + assert.Equal(t, want.GetAllowPublicUnauthenticated(), got.GetAllowPublicUnauthenticated()) +} + +func (s *fakeOpenEnvironmentService) OpenHTTPPort( + _ context.Context, + req *connect.Request[devplanev1.EnvironmentServiceOpenHTTPPortRequest], +) (*connect.Response[devplanev1.EnvironmentServiceOpenHTTPPortResponse], error) { + s.t.Helper() + assert.Equal(s.t, s.wantHTTPReq.GetEnvironmentId(), req.Msg.GetEnvironmentId()) + assertHTTPOpenRequest(s.t, s.wantHTTPReq, req.Msg) + return connect.NewResponse(&devplanev1.EnvironmentServiceOpenHTTPPortResponse{Port: s.httpPort}), nil } func (s *fakeOpenEnvironmentService) OpenPort( @@ -44,10 +73,22 @@ func (s *fakeOpenEnvironmentService) OpenPort( type fakeOpenNodeService struct { devplanev1connect.UnimplementedExternalNodeServiceHandler - t *testing.T - node *devplanev1.ExternalNode - wantReq *devplanev1.OpenPortRequest - port *devplanev1.Port + t *testing.T + node *devplanev1.ExternalNode + wantReq *devplanev1.OpenPortRequest + wantHTTPReq *devplanev1.OpenHTTPPortRequest + port *devplanev1.Port + httpPort *devplanev1.Port +} + +func (s *fakeOpenNodeService) OpenHTTPPort( + _ context.Context, + req *connect.Request[devplanev1.OpenHTTPPortRequest], +) (*connect.Response[devplanev1.OpenHTTPPortResponse], error) { + s.t.Helper() + assert.Equal(s.t, s.wantHTTPReq.GetExternalNodeId(), req.Msg.GetExternalNodeId()) + assertHTTPOpenRequest(s.t, s.wantHTTPReq, req.Msg) + return connect.NewResponse(&devplanev1.OpenHTTPPortResponse{Port: s.httpPort}), nil } func (s *fakeOpenNodeService) ListNodes( @@ -154,7 +195,7 @@ func TestOpenExternalNodeByIDJSON(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, `{ "port_id": "port53", - "kind": "tcp_udp", + "kind": "network", "endpoint": "global.prd.ga.run.brev.nvidia.com:19053", "public_port": 19053, "destination_port": 53, @@ -166,6 +207,107 @@ func TestOpenExternalNodeByIDJSON(t *testing.T) { }`, out.String()) } +func TestOpenHTTPEnvironmentDefaultsToCurrentUser(t *testing.T) { + hostname := "3000-env123.apps.run.brev.nvidia.com" + service := &fakeOpenEnvironmentService{ + t: t, + wantHTTPReq: &devplanev1.EnvironmentServiceOpenHTTPPortRequest{ + EnvironmentId: "env123", + PortNumber: 3000, + CustomHostname: "3000-env123", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP, + AuthorizedEmails: []string{"me@example.com"}, + }, + httpPort: &devplanev1.Port{ + PortId: "http3000", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP, + PortNumber: 443, + ServerPort: 3000, + Hostname: &hostname, + AuthorizedEmails: []string{"me@example.com"}, + }, + } + _, handler := devplanev1connect.NewEnvironmentServiceHandler(service) + newTestServer(t, handler) + store := &fakeStore{ + workspaces: []entity.Workspace{{ID: "env123", Name: "my-instance", CreatedByUserID: "user1"}}, + user: &entity.User{ID: "user1", Email: "me@example.com"}, + org: &entity.Organization{ID: "org1"}, + } + var out bytes.Buffer + + err := OpenHTTP( + context.Background(), + &out, + store, + "my-instance", + 3000, + devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP, + "", + nil, + false, + false, + ) + + require.NoError(t, err) + assert.Contains(t, out.String(), "Opened HTTP port 3000 on my-instance.") + assert.Contains(t, out.String(), "https://3000-env123.apps.run.brev.nvidia.com") + assert.Contains(t, out.String(), "me@example.com") +} + +func TestOpenHTTPExternalNodePublicJSON(t *testing.T) { + hostname := "demo-unode123.apps.run.brev.nvidia.com" + public := true + service := &fakeOpenNodeService{ + t: t, + node: &devplanev1.ExternalNode{ExternalNodeId: "unode123", Name: "my-node"}, + wantHTTPReq: &devplanev1.OpenHTTPPortRequest{ + ExternalNodeId: "unode123", + PortNumber: 8443, + CustomHostname: "demo-unode123", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTPS, + AllowPublicUnauthenticated: true, + }, + httpPort: &devplanev1.Port{ + PortId: "http8443", + HttpProtocol: devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTPS, + PortNumber: 443, + ServerPort: 8443, + Hostname: &hostname, + AllowPublicUnauthenticated: &public, + }, + } + _, handler := devplanev1connect.NewExternalNodeServiceHandler(service) + newTestServer(t, handler) + store := &fakeStore{ + user: &entity.User{ID: "user1", Email: "me@example.com"}, + org: &entity.Organization{ID: "org1"}, + } + cmd := NewCmdPorts(store) + cmd.SetArgs([]string{ + "open", "my-node", "8443", "--protocol", "HTTPS", + "--hostname", "demo", "--public", "--json", + }) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.NoError(t, err) + assert.JSONEq(t, `{ + "port_id": "http8443", + "kind": "http", + "endpoint": "https://demo-unode123.apps.run.brev.nvidia.com", + "public_port": 443, + "destination_port": 8443, + "protocol": "HTTPS", + "allowed_sources": [], + "authorized_emails": [], + "allow_public_unauthenticated": true, + "type": "unspecified" + }`, out.String()) +} + func TestNewCmdOpenPortParsesFlags(t *testing.T) { service := &fakeOpenEnvironmentService{ t: t, @@ -246,5 +388,51 @@ func TestParseProtocol(t *testing.T) { } _, err := parseProtocol("http") - assert.EqualError(t, err, `invalid protocol "http": must be tcp, udp, or ssh`) + assert.EqualError(t, err, `invalid protocol "http": must be tcp, udp, ssh, http, or https`) +} + +func TestOpenHTTPFlagValidation(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "public with authorized email", + args: []string{"open", "my-instance", "8080", "--protocol", "http", "--public", "--authorize", "me@example.com"}, + want: "--public and --authorize cannot be used together", + }, + { + name: "IP allow-list on HTTP", + args: []string{"open", "my-instance", "8080", "--protocol", "http", "--allow", "10.0.0.0/8"}, + want: "--allow is only supported for tcp, udp, and ssh ports", + }, + { + name: "HTTP flag on TCP", + args: []string{"open", "my-instance", "8080", "--public"}, + want: "--hostname, --authorize, and --public are only supported for http and https ports", + }, + { + name: "invalid hostname", + args: []string{"open", "my-instance", "8080", "--protocol", "http", "--hostname", "Not Valid"}, + want: "hostname must contain only lowercase letters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewCmdPorts(&fakeStore{}) + cmd.SetArgs(tt.args) + + err := cmd.Execute() + + assert.ErrorContains(t, err, tt.want) + }) + } +} + +func TestBuildHTTPHostname(t *testing.T) { + assert.Equal(t, "8080-env123", buildHTTPHostname("", 8080, "ENV123")) + assert.Equal(t, "demo-env123", buildHTTPHostname(" demo ", 8080, "ENV123")) + assert.Equal(t, "demo-env123", buildHTTPHostname("demo-env123", 8080, "ENV123")) }