Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/skills/brev-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ brev copy my-instance:/remote/file ./local-path/

# Port forward
brev port-forward my-instance -p 8080:8080

# List public HTTP and TCP/UDP ports for an instance or external node
brev ports my-instance
brev ports my-node --json
```

### Listing Instances and Nodes
Expand Down
23 changes: 23 additions & 0 deletions .agents/skills/brev-cli/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,29 @@ brev port-forward my-instance -p 8080:8080
brev port-forward my-instance -p 3000:3000
```

### brev ports
List public HTTP applications and TCP/UDP port mappings for a managed instance
or registered compute node.

```bash
brev ports <instance-or-node> [flags]
```

**Flags:**
| Flag | Description |
|------|-------------|
| `--json` | Output the port mappings as JSON |

The table output includes endpoint, IP restrictions, public port, destination
port, and protocol. HTTP applications also include their authorization policy.

**Examples:**
```bash
brev ports my-instance
brev ports my-node
brev ports my-instance --json
```

## Organization Commands

### brev org ls
Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"github.com/brevdev/brev-cli/pkg/cmd/open"
"github.com/brevdev/brev-cli/pkg/cmd/org"
"github.com/brevdev/brev-cli/pkg/cmd/portforward"
"github.com/brevdev/brev-cli/pkg/cmd/ports"
"github.com/brevdev/brev-cli/pkg/cmd/profile"
"github.com/brevdev/brev-cli/pkg/cmd/proxy"
"github.com/brevdev/brev-cli/pkg/cmd/redeem"
Expand Down Expand Up @@ -276,6 +277,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor
cmd.AddCommand(invite.NewCmdInvite(t, loginCmdStore, noLoginCmdStore))
cmd.AddCommand(redeem.NewCmdRedeem(t, loginCmdStore, noLoginCmdStore))
cmd.AddCommand(portforward.NewCmdPortForwardSSH(loginCmdStore, t))
cmd.AddCommand(ports.NewCmdPorts(loginCmdStore))
cmd.AddCommand(login.NewCmdLogin(t, noLoginCmdStore, loginAuth))
cmd.AddCommand(logout.NewCmdLogout(loginAuth, noLoginCmdStore))
cmd.AddCommand(tasks.NewCmdTasks(t, noLoginCmdStore))
Expand Down
306 changes: 306 additions & 0 deletions pkg/cmd/ports/ports.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
// Package ports displays the public port mappings for an instance or external node.
package ports

import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"strconv"
"strings"

devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"
"connectrpc.com/connect"
"github.com/jedib0t/go-pretty/v6/table"
"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"
)

// Store contains the dependencies needed to resolve both managed instances and
// registered compute nodes.
type Store interface {
cmdutil.WorkspaceOrNodeResolver
}

// PortInfo is the stable JSON representation of a port mapping.
type PortInfo struct {
PortID string `json:"port_id"`
Kind string `json:"kind"`
Endpoint string `json:"endpoint"`
PublicPort int32 `json:"public_port"`
DestinationPort int32 `json:"destination_port"`
Protocol string `json:"protocol"`
AllowedSources []string `json:"allowed_sources"`
AuthorizedEmails []string `json:"authorized_emails"`
AllowPublicUnauthenticated bool `json:"allow_public_unauthenticated"`
Type string `json:"type"`
}

// NewCmdPorts creates the `brev ports` command.
func NewCmdPorts(portStore Store) *cobra.Command {
var jsonOutput bool

cmd := &cobra.Command{
Annotations: map[string]string{"access": ""},
Use: "ports <instance-or-node>",
DisableFlagsInUseLine: true,
Short: "List public ports for an instance or external node",
Example: `
brev ports my-instance
brev ports my-node --json`,
Args: cmderrors.TransformToValidationError(cobra.ExactArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
if err := Run(cmd.Context(), cmd.OutOrStdout(), portStore, args[0], jsonOutput); err != nil {
return breverrors.WrapAndTrace(err)
}
return nil
},
}

cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
return cmd
}

// Run resolves a managed instance or registered compute node and displays its ports.
func Run(ctx context.Context, out io.Writer, portStore Store, nameOrID string, jsonOutput bool) error {
target, err := cmdutil.ResolveWorkspaceOrNode(portStore, nameOrID)
if err != nil {
return breverrors.WrapAndTrace(err)
}

var apiPorts []*devplanev1.Port
if target.Workspace != nil {
client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL())
resp, err := client.GetNetworkInfo(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceGetNetworkInfoRequest{
EnvironmentId: target.Workspace.ID,
}))
if err != nil {
return fmt.Errorf("get ports for instance %q: %w", nameOrID, err)
}
if resp != nil && resp.Msg != nil && resp.Msg.GetNetworkInfo() != nil {
apiPorts = resp.Msg.GetNetworkInfo().GetPorts()
}
} else if target.Node != nil {
apiPorts = target.Node.GetPorts()
}
portInfos := toPortInfos(apiPorts)
if jsonOutput {
return writeJSON(out, portInfos)
}
return displayTables(out, nameOrID, portInfos)
}

func toPortInfos(apiPorts []*devplanev1.Port) []PortInfo {
portInfos := make([]PortInfo, 0, len(apiPorts))
for _, port := range apiPorts {
if port == nil {
continue
}
isHTTP := port.GetHttpProtocol() != devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_UNSPECIFIED
kind := "tcp_udp"
if isHTTP {
kind = "http"
}
portInfos = append(portInfos, PortInfo{
PortID: port.GetPortId(),
Kind: kind,
Endpoint: endpoint(port, isHTTP),
PublicPort: port.GetPortNumber(),
DestinationPort: port.GetServerPort(),
Protocol: protocolLabel(port, isHTTP),
AllowedSources: append([]string{}, port.GetAllowedSources()...),
AuthorizedEmails: append([]string{}, port.GetAuthorizedEmails()...),
AllowPublicUnauthenticated: port.GetAllowPublicUnauthenticated(),
Type: portTypeLabel(port.GetType()),
})
}
return portInfos
}

func endpoint(port *devplanev1.Port, isHTTP bool) string {
hostname := port.GetHostname()
if hostname == "" {
return ""
}
if isHTTP {
return "https://" + hostname
}
if port.GetPortNumber() == 0 {
return hostname
}
return net.JoinHostPort(hostname, strconv.Itoa(int(port.GetPortNumber())))
}

func protocolLabel(port *devplanev1.Port, isHTTP bool) string {
if isHTTP {
switch port.GetHttpProtocol() {
case devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTP:
return "HTTP"
case devplanev1.HttpPortProtocol_HTTP_PORT_PROTOCOL_HTTPS:
return "HTTPS"
default:
return "UNKNOWN"
}
}

switch port.GetProtocol() {
case devplanev1.PortProtocol_PORT_PROTOCOL_SSH:
return "SSH"
case devplanev1.PortProtocol_PORT_PROTOCOL_TCP:
return "TCP"
case devplanev1.PortProtocol_PORT_PROTOCOL_UDP:
return "UDP"
default:
return "UNKNOWN"
}
}

func portTypeLabel(portType devplanev1.PortType) string {
switch portType {
case devplanev1.PortType_PORT_TYPE_SYSTEM:
return "system"
case devplanev1.PortType_PORT_TYPE_USER:
return "user"
default:
return "unspecified"
}
}

func writeJSON(out io.Writer, portInfos []PortInfo) error {
encoded, err := json.MarshalIndent(portInfos, "", " ")
if err != nil {
return breverrors.WrapAndTrace(err)
}
_, err = fmt.Fprintln(out, string(encoded))
return breverrors.WrapAndTrace(err)
}

func displayTables(out io.Writer, nameOrID string, portInfos []PortInfo) error {
if len(portInfos) == 0 {
_, err := fmt.Fprintf(out, "No ports are open on %s.\n", nameOrID)
return breverrors.WrapAndTrace(err)
}

httpPorts := make([]PortInfo, 0, len(portInfos))
networkPorts := make([]PortInfo, 0, len(portInfos))
for _, port := range portInfos {
if port.Kind == "http" {
httpPorts = append(httpPorts, port)
} else {
networkPorts = append(networkPorts, port)
}
}

if len(httpPorts) > 0 {
if _, err := fmt.Fprintln(out, "HTTP APPLICATIONS"); err != nil {
return breverrors.WrapAndTrace(err)
}
displayHTTPTable(out, httpPorts)
}
if len(httpPorts) > 0 && len(networkPorts) > 0 {
if _, err := fmt.Fprintln(out); err != nil {
return breverrors.WrapAndTrace(err)
}
}
if len(networkPorts) > 0 {
if _, err := fmt.Fprintln(out, "TCP/UDP PORTS"); err != nil {
return breverrors.WrapAndTrace(err)
}
displayNetworkTable(out, networkPorts)
}
return nil
}

func displayHTTPTable(out io.Writer, portInfos []PortInfo) {
tw := newTable(out)
tw.AppendHeader(table.Row{"ENDPOINT", "AUTHORIZATION", "IP RESTRICTIONS", "PUBLIC", "DESTINATION", "PROTOCOL"})
for _, port := range portInfos {
destinationPort := port.DestinationPort
if destinationPort == 0 {
destinationPort = port.PublicPort
}
tw.AppendRow(table.Row{
valueOrDash(port.Endpoint),
authorizationLabel(port),
allowedSourcesLabel(port.AllowedSources),
portNumberLabel(port.PublicPort),
portNumberLabel(destinationPort),
port.Protocol,
})
}
tw.Render()
}

func displayNetworkTable(out io.Writer, portInfos []PortInfo) {
tw := newTable(out)
tw.AppendHeader(table.Row{"ENDPOINT", "IP RESTRICTIONS", "PUBLIC", "DESTINATION", "PROTOCOL"})
for _, port := range portInfos {
tw.AppendRow(table.Row{
valueOrDash(port.Endpoint),
allowedSourcesLabel(port.AllowedSources),
portNumberLabel(port.PublicPort),
portNumberLabel(port.DestinationPort),
port.Protocol,
})
}
tw.Render()
}

func newTable(out io.Writer) table.Writer {
tw := table.NewWriter()
tw.SetOutputMirror(out)
options := table.OptionsDefault
options.DrawBorder = false
options.SeparateColumns = false
options.SeparateRows = false
options.SeparateHeader = false
tw.Style().Options = options
return tw
}

func authorizationLabel(port PortInfo) string {
if port.AllowPublicUnauthenticated {
return "Public"
}
if len(port.AuthorizedEmails) > 0 {
return strings.Join(port.AuthorizedEmails, ", ")
}
return "-"
}

func allowedSourcesLabel(allowedSources []string) string {
if len(allowedSources) == 0 {
return "Anywhere"
}
allAnywhere := true
for _, source := range allowedSources {
if source != "0.0.0.0/0" {
allAnywhere = false
break
}
}
if allAnywhere {
return "Anywhere"
}
return strings.Join(allowedSources, ", ")
}

func portNumberLabel(port int32) string {
if port == 0 {
return "-"
}
return strconv.Itoa(int(port))
}

func valueOrDash(value string) string {
if value == "" {
return "-"
}
return value
}
Loading
Loading