@@ -6,8 +6,11 @@ import (
66 "os/exec"
77 "os/signal"
88 "path/filepath"
9+ "strconv"
910 "strings"
1011
12+ nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"
13+
1114 "github.com/brevdev/brev-cli/pkg/cmd/cmderrors"
1215 "github.com/brevdev/brev-cli/pkg/cmd/completions"
1316 "github.com/brevdev/brev-cli/pkg/cmd/refresh"
@@ -20,7 +23,7 @@ import (
2023
2124var (
2225 sshLinkLong = "Port forward your Brev machine's port to your local port"
23- sshLinkExample = "brev port-forward <ws_name > -p local_port:remote_port"
26+ sshLinkExample = "brev port-forward <name > -p local_port:remote_port"
2427)
2528
2629type PortforwardStore interface {
@@ -53,7 +56,7 @@ func NewCmdPortForwardSSH(pfStore PortforwardStore, t *terminal.Terminal) *cobra
5356 return nil
5457 },
5558 }
56- cmd .Flags ().StringVarP (& port , "port" , "p" , "" , "port forward flag describe me better " )
59+ cmd .Flags ().StringVarP (& port , "port" , "p" , "" , "port forward string, local_port:remote_port " )
5760 cmd .Flags ().BoolVar (& useHost , "host" , false , "Use the -host version of the instance" )
5861 err := cmd .RegisterFlagCompletionFunc ("port" , func (cmd * cobra.Command , args []string , toComplete string ) ([]string , cobra.ShellCompDirective ) {
5962 return nil , cobra .ShellCompDirectiveNoSpace
@@ -66,30 +69,47 @@ func NewCmdPortForwardSSH(pfStore PortforwardStore, t *terminal.Terminal) *cobra
6669 return cmd
6770}
6871
72+ // parsePortString validates and splits a "local:remote" port string.
73+ func parsePortString (portString string ) (localPort , remotePort string , err error ) {
74+ if ! strings .Contains (portString , ":" ) {
75+ return "" , "" , breverrors .NewValidationError ("port format invalid, use local_port:remote_port" )
76+ }
77+ parts := strings .Split (portString , ":" )
78+ if len (parts ) != 2 {
79+ return "" , "" , breverrors .NewValidationError ("port format invalid, use local_port:remote_port" )
80+ }
81+ return parts [0 ], parts [1 ], nil
82+ }
83+
84+ // isPortAlreadyAllocatedError returns true if the error indicates the port is already open.
85+ func isPortAlreadyAllocatedError (err error ) bool {
86+ return err != nil && strings .Contains (err .Error (), "already allocated" )
87+ }
88+
6989func RunPortforward (pfStore PortforwardStore , nameOrID string , portString string , useHost bool ) error {
70- var portSplit []string
71- if strings .Contains (portString , ":" ) {
72- portSplit = strings .Split (portString , ":" )
73- if len (portSplit ) != 2 {
74- return breverrors .NewValidationError ("port format invalid, use local_port:remote_port" )
75- }
76- } else {
77- return breverrors .NewValidationError ("port format invalid, use local_port:remote_port" )
90+ localPort , remotePort , err := parsePortString (portString )
91+ if err != nil {
92+ return err
7893 }
7994
8095 res := refresh .RunRefreshAsync (pfStore )
8196
8297 sshName , err := ConvertNametoSSHName (pfStore , nameOrID , useHost )
8398 if err != nil {
84- return breverrors .WrapAndTrace (err )
99+ // Workspace not found — try external nodes.
100+ node , nodeErr := util .FindExternalNode (pfStore , nameOrID )
101+ if nodeErr != nil || node == nil {
102+ return breverrors .WrapAndTrace (err ) // return original workspace error
103+ }
104+ return portForwardExternalNode (pfStore , res , node , localPort , remotePort )
85105 }
86106
87107 err = res .Await ()
88108 if err != nil {
89109 return breverrors .WrapAndTrace (err )
90110 }
91111
92- _ , err = RunSSHPortForward ("-L" , portSplit [ 0 ], portSplit [ 1 ] , sshName )
112+ _ , err = RunSSHPortForward ("-L" , localPort , remotePort , sshName )
93113 if err != nil {
94114 return breverrors .WrapAndTrace (err )
95115 }
@@ -108,6 +128,49 @@ func ConvertNametoSSHName(store PortforwardStore, workspaceNameOrID string, useH
108128 return sshName , nil
109129}
110130
131+ func portForwardExternalNode (pfStore PortforwardStore , res * refresh.RefreshRes , node * nodev1.ExternalNode , localPort , remotePort string ) error {
132+ info , err := util .ResolveExternalNodeSSH (pfStore , node )
133+ if err != nil {
134+ return breverrors .WrapAndTrace (err )
135+ }
136+
137+ // Parse the remote port so we can open it via the OpenPort RPC.
138+ remotePortNum , err := strconv .ParseInt (remotePort , 10 , 32 )
139+ if err != nil {
140+ return breverrors .WrapAndTrace (fmt .Errorf ("invalid remote port %q: %w" , remotePort , err ))
141+ }
142+
143+ // Open the port on the netbird side so it's accessible.
144+ // This binding persists after the CLI exits — it won't be closed on Ctrl+C.
145+ fmt .Printf ("Opening port %s on node %q...\n " , remotePort , node .GetName ())
146+ _ , err = util .OpenPort (pfStore , node .GetExternalNodeId (), int32 (remotePortNum ), nodev1 .PortProtocol_PORT_PROTOCOL_TCP )
147+ if err != nil {
148+ // Port already allocated is not a real error — it's already open.
149+ if isPortAlreadyAllocatedError (err ) {
150+ fmt .Printf ("Port %s is already open on the remote node.\n " , remotePort )
151+ } else {
152+ return breverrors .WrapAndTrace (err )
153+ }
154+ } else {
155+ fmt .Printf ("Port %s is now bound on the remote node. Note: this binding persists after this command exits.\n " , remotePort )
156+ }
157+
158+ if err := res .Await (); err != nil {
159+ return breverrors .WrapAndTrace (err )
160+ }
161+
162+ // The SSH tunnel forwards local traffic through the SSH connection to the
163+ // actual port on the box. Ctrl+C stops the local forward but not the
164+ // remote port binding.
165+ alias := info .SSHAlias ()
166+ fmt .Printf ("Setting up local forward: localhost:%s -> %s:%s\n " , localPort , alias , remotePort )
167+ _ , err = RunSSHPortForward ("-L" , localPort , remotePort , alias )
168+ if err != nil {
169+ return breverrors .WrapAndTrace (err )
170+ }
171+ return nil
172+ }
173+
111174func RunSSHPortForward (forwardType string , localPort string , remotePort string , sshName string ) (* os.Process , error ) {
112175 signals := make (chan os.Signal , 1 )
113176 signal .Notify (signals , os .Interrupt )
0 commit comments