Skip to content

Commit 8bb6c08

Browse files
authored
[cloud] Implement port forwarding using mutagen (#461)
## Summary Implement port forwarding using mutagen. This implements the following: * `devbox cloud port-forward <port> | :<port> | <port1>:<port2>` * `devbox cloud port-forward list` * `devbox cloud port-forward terminate` Improvements: * port forward fails if port is in use * Automatically find a port if user uses ":<port>" argument * Better output after forwarding ## How was it tested? * Started cloud vm, installed and started apache * Started a few port forwards * Went to browser and confirmed it was working. * Tested `list` command, `terminate` command and `list` again.
1 parent d4bfa86 commit 8bb6c08

File tree

5 files changed

+260
-24
lines changed

5 files changed

+260
-24
lines changed

internal/boxcli/cloud.go

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,27 +53,74 @@ func cloudShellCmd() *cobra.Command {
5353

5454
func cloudPortForwardCmd() *cobra.Command {
5555
command := &cobra.Command{
56-
Use: "port-forward <local-port>:<remote-port>",
57-
Short: "Port forwards a local port to a remote devbox cloud port",
56+
Use: "port-forward <local-port>:<remote-port> | <port> | :<remote-port> | terminate",
57+
Short: "Port forwards a local port to a remote devbox cloud port",
58+
Long: "Port forwards a local port to a remote devbox cloud port. If a " +
59+
"single port is specified, it is used for local and remote. If no local" +
60+
" port is specified, we find a suitable local port. Use 'terminate' to " +
61+
"terminate all port forwards.",
5862
Hidden: true,
5963
Args: cobra.ExactArgs(1),
6064
RunE: func(cmd *cobra.Command, args []string) error {
61-
ports := strings.Split(args[0], ":")
65+
ports := []string{}
66+
if strings.ContainsRune(args[0], ':') {
67+
ports = strings.Split(args[0], ":")
68+
} else {
69+
ports = append(ports, args[0], args[0])
70+
}
71+
6272
if len(ports) != 2 {
6373
return usererr.New("Invalid port format. Expected <local-port>:<remote-port>")
6474
}
65-
err := cloud.PortForward(ports[0], ports[1])
75+
localPort, err := cloud.PortForward(ports[0], ports[1])
6676
if err != nil {
6777
return errors.WithStack(err)
6878
}
69-
cmd.PrintErrf("Port forwarding %s:%s\n", ports[0], ports[1])
79+
cmd.PrintErrf(
80+
"Port forwarding %s:%s\nTo view in browser, visit http://localhost:%[1]s\n",
81+
localPort,
82+
ports[1],
83+
)
7084
return nil
7185
},
7286
}
73-
87+
command.AddCommand(cloudPortForwardList())
88+
command.AddCommand(cloudPortForwardTerminateAllCmd())
7489
return command
7590
}
7691

92+
func cloudPortForwardTerminateAllCmd() *cobra.Command {
93+
return &cobra.Command{
94+
Use: "terminate",
95+
Short: "Terminates all port forwards managed by devbox",
96+
Hidden: true,
97+
Args: cobra.ExactArgs(0),
98+
RunE: func(cmd *cobra.Command, args []string) error {
99+
return cloud.PortForwardTerminateAll()
100+
},
101+
}
102+
}
103+
104+
func cloudPortForwardList() *cobra.Command {
105+
return &cobra.Command{
106+
Use: "list",
107+
Aliases: []string{"ls"},
108+
Short: "Lists all port forwards managed by devbox",
109+
Hidden: true,
110+
Args: cobra.ExactArgs(0),
111+
RunE: func(cmd *cobra.Command, args []string) error {
112+
l, err := cloud.PortForwardList()
113+
if err != nil {
114+
return errors.WithStack(err)
115+
}
116+
for _, p := range l {
117+
cmd.Println(p)
118+
}
119+
return nil
120+
},
121+
}
122+
}
123+
77124
func runCloudShellCmd(cmd *cobra.Command, flags *cloudShellCmdFlags) error {
78125
box, err := devbox.Open(flags.config.path, cmd.ErrOrStderr())
79126
if err != nil {

internal/cloud/cloud.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,20 @@ func Shell(w io.Writer, projectDir string, githubUsername string) error {
103103
return shell(username, vmHostname, projectDir)
104104
}
105105

106-
func PortForward(local, remote string) error {
106+
func PortForward(local, remote string) (string, error) {
107107
vmHostname := vmHostnameFromSSHControlPath()
108108
if vmHostname == "" {
109-
return usererr.New("No VM found. Please run `devbox cloud shell` first.")
109+
return "", usererr.New("No VM found. Please run `devbox cloud shell` first.")
110110
}
111-
portMap := fmt.Sprintf("%s:%s:%s", local, vmHostname, remote)
112-
return exec.Command("ssh", "-N", vmHostname, "-L", portMap).Run()
111+
return mutagenbox.ForwardCreate(vmHostname, local, remote)
112+
}
113+
114+
func PortForwardTerminateAll() error {
115+
return mutagenbox.ForwardTerminateAll()
116+
}
117+
118+
func PortForwardList() ([]string, error) {
119+
return mutagenbox.ForwardList()
113120
}
114121

115122
func getGithubUsername() string {

internal/cloud/mutagen/forward.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package mutagen
2+
3+
import (
4+
"encoding/json"
5+
6+
"github.com/pkg/errors"
7+
)
8+
9+
type Forward struct {
10+
Source struct {
11+
Connected bool `json:"connected"`
12+
Endpoint string `json:"endpoint"`
13+
} `json:"source"`
14+
Destination struct {
15+
Endpoint string `json:"endpoint"`
16+
} `json:"destination"`
17+
LastError string `json:"lastError"`
18+
}
19+
20+
// ForwardCreate creates a new port forward using mutagen.
21+
// local looks like tcp:127.0.0.1:<port>
22+
// remote looks like <host>:<ssh-port>:tcp::<port> (ssh-port is usually 22)
23+
func ForwardCreate(env map[string]string, local, remote string, labels map[string]string) error {
24+
args := []string{"forward", "create", local, remote}
25+
return execMutagenEnv(append(args, labelFlag(labels)...), env)
26+
}
27+
28+
func ForwardTerminate(env map[string]string, labels map[string]string) error {
29+
args := []string{"forward", "terminate"}
30+
return execMutagenEnv(append(args, labelSelectorFlag(labels)...), env)
31+
}
32+
33+
func ForwardList(env map[string]string, labels map[string]string) ([]Forward, error) {
34+
args := []string{"forward", "list", "--template", "{{json .}}"}
35+
out, err := execMutagenOut(append(args, labelSelectorFlag(labels)...), env)
36+
if err != nil {
37+
return nil, err
38+
}
39+
40+
list := []Forward{}
41+
42+
if err := json.Unmarshal(out, &list); err != nil {
43+
return nil, errors.WithStack(err)
44+
}
45+
46+
return list, errors.WithStack(json.Unmarshal(out, &list))
47+
}

internal/cloud/mutagen/wrapper.go

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package mutagen
22

33
import (
4+
"bytes"
45
"encoding/json"
5-
"errors"
66
"fmt"
77
"os"
88
"os/exec"
99
"strings"
1010

11+
"github.com/pkg/errors"
12+
1113
"go.jetpack.io/devbox/internal/debug"
1214
)
1315

@@ -55,7 +57,7 @@ func Create(spec *SessionSpec) error {
5557
}
5658
}
5759

58-
return execMutagen(args, spec.EnvVars)
60+
return execMutagenEnv(args, spec.EnvVars)
5961
}
6062

6163
func List(envVars map[string]string, names ...string) ([]Session, error) {
@@ -92,25 +94,25 @@ func List(envVars map[string]string, names ...string) ([]Session, error) {
9294
func Pause(names ...string) error {
9395
args := []string{"sync", "pause"}
9496
args = append(args, names...)
95-
return execMutagen(args, nil /*envVars*/)
97+
return execMutagen(args)
9698
}
9799

98100
func Resume(envVars map[string]string, names ...string) error {
99101
args := []string{"sync", "resume"}
100102
args = append(args, names...)
101-
return execMutagen(args, envVars)
103+
return execMutagenEnv(args, envVars)
102104
}
103105

104106
func Flush(names ...string) error {
105107
args := []string{"sync", "flush"}
106108
args = append(args, names...)
107-
return execMutagen(args, nil /*envVars*/)
109+
return execMutagen(args)
108110
}
109111

110112
func Reset(envVars map[string]string, names ...string) error {
111113
args := []string{"sync", "reset"}
112114
args = append(args, names...)
113-
return execMutagen(args, envVars)
115+
return execMutagenEnv(args, envVars)
114116
}
115117

116118
func Terminate(env map[string]string, labels map[string]string, names ...string) error {
@@ -121,27 +123,45 @@ func Terminate(env map[string]string, labels map[string]string, names ...string)
121123
}
122124

123125
args = append(args, names...)
124-
return execMutagen(args, env)
126+
return execMutagenEnv(args, env)
127+
}
128+
129+
func execMutagen(args []string) error {
130+
return execMutagenEnv(args, nil)
131+
}
132+
133+
func execMutagenEnv(args []string, envVars map[string]string) error {
134+
_, err := execMutagenOut(args, envVars)
135+
return err
125136
}
126137

127-
func execMutagen(args []string, envVars map[string]string) error {
138+
func execMutagenOut(args []string, envVars map[string]string) ([]byte, error) {
128139
binPath := ensureMutagen()
129140
cmd := exec.Command(binPath, args...)
130141
cmd.Env = envAsKeyValueStrings(envVars)
131142

143+
var stdout bytes.Buffer
144+
var stderr bytes.Buffer
145+
cmd.Stdout = &stdout
146+
cmd.Stderr = &stderr
147+
132148
debugPrintExecCmd(cmd)
133-
out, err := cmd.CombinedOutput()
134149

135-
if err != nil {
136-
debug.Log("execMutagen error: %s, out: %s", err, string(out))
150+
if err := cmd.Run(); err != nil {
151+
debug.Log(
152+
"execMutagen error: %s, stdout: %s, stderr: %s",
153+
err,
154+
stdout.String(),
155+
stderr.String(),
156+
)
137157
if e := (&exec.ExitError{}); errors.As(err, &e) {
138-
return errors.New(strings.TrimSpace(string(out)))
158+
return nil, errors.New(strings.TrimSpace(stderr.String()))
139159
}
140-
return err
160+
return nil, err
141161
}
142162

143163
debug.Log("execMutagen worked for cmd: %s", cmd)
144-
return nil
164+
return stdout.Bytes(), nil
145165
}
146166

147167
// debugPrintExecCmd prints the command to be run, along with MUTAGEN env-vars
@@ -201,3 +221,25 @@ func ensureMutagen() string {
201221
}
202222
return installPath
203223
}
224+
225+
func labelFlag(labels map[string]string) []string {
226+
if len(labels) == 0 {
227+
return []string{}
228+
}
229+
labelSlice := []string{}
230+
for k, v := range labels {
231+
labelSlice = append(labelSlice, fmt.Sprintf("%s=%s", k, v))
232+
}
233+
return []string{"--label", strings.Join(labelSlice, ",")}
234+
}
235+
236+
func labelSelectorFlag(labels map[string]string) []string {
237+
if len(labels) == 0 {
238+
return []string{}
239+
}
240+
labelSlice := []string{}
241+
for k, v := range labels {
242+
labelSlice = append(labelSlice, fmt.Sprintf("%s=%s", k, v))
243+
}
244+
return []string{"--label-selector", strings.Join(labelSlice, ",")}
245+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package mutagenbox
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"strings"
7+
8+
"github.com/pkg/errors"
9+
"github.com/samber/lo"
10+
"go.jetpack.io/devbox/internal/boxcli/usererr"
11+
"go.jetpack.io/devbox/internal/cloud/mutagen"
12+
)
13+
14+
func ForwardCreate(host, localPort, remotePort string) (string, error) {
15+
var err error
16+
if localPort == "" {
17+
localPort, err = getFreePort()
18+
if err != nil {
19+
return "", err
20+
}
21+
}
22+
23+
if !isPortAvailable(localPort) {
24+
return "", usererr.New("Port %s is not available", localPort)
25+
}
26+
27+
local := "tcp:127.0.0.1:" + localPort
28+
remote := host + ":22:tcp::" + remotePort
29+
labels := map[string]string{"devbox": "true"}
30+
env, err := DefaultEnv()
31+
if err != nil {
32+
return "", err
33+
}
34+
return localPort, mutagen.ForwardCreate(env, local, remote, labels)
35+
}
36+
37+
func ForwardTerminateAll() error {
38+
env, err := DefaultEnv()
39+
if err != nil {
40+
return err
41+
}
42+
return mutagen.ForwardTerminate(env, map[string]string{"devbox": "true"})
43+
}
44+
45+
func ForwardList() ([]string, error) {
46+
env, err := DefaultEnv()
47+
if err != nil {
48+
return nil, err
49+
}
50+
forwards, err := mutagen.ForwardList(env, map[string]string{"devbox": "true"})
51+
if err != nil {
52+
return nil, err
53+
}
54+
55+
result := []string{}
56+
for _, item := range forwards {
57+
srcParts := strings.Split(item.Source.Endpoint, ":")
58+
destParts := strings.Split(item.Destination.Endpoint, ":")
59+
result = append(result, fmt.Sprintf(
60+
"%s:%s connected: %t %s",
61+
srcParts[len(srcParts)-1],
62+
destParts[len(destParts)-1],
63+
item.Source.Connected,
64+
lo.Ternary(item.LastError != "", "Error: "+item.LastError, ""),
65+
))
66+
}
67+
68+
return result, nil
69+
70+
}
71+
72+
func isPortAvailable(port string) bool {
73+
ln, err := net.Listen("tcp", net.JoinHostPort("localhost", port))
74+
if err != nil {
75+
return false
76+
}
77+
_ = ln.Close()
78+
return true
79+
}
80+
81+
func getFreePort() (string, error) {
82+
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
83+
if err != nil {
84+
return "", errors.WithStack(err)
85+
}
86+
87+
l, err := net.ListenTCP("tcp", addr)
88+
if err != nil {
89+
return "", errors.WithStack(err)
90+
}
91+
defer l.Close()
92+
return fmt.Sprintf("%d", l.Addr().(*net.TCPAddr).Port), nil
93+
}

0 commit comments

Comments
 (0)