feat(cli): implement ssh remote forward (#8515)

This commit is contained in:
Marcin Tojek 2023-07-20 12:05:39 +02:00 committed by GitHub
parent c68e80970d
commit 9689bca5d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 255 additions and 98 deletions

View File

@ -32,7 +32,7 @@ func (r *RootCmd) portForward() *clibase.Cmd {
client := new(codersdk.Client) client := new(codersdk.Client)
cmd := &clibase.Cmd{ cmd := &clibase.Cmd{
Use: "port-forward <workspace>", Use: "port-forward <workspace>",
Short: "Forward ports from machine to a workspace", Short: `Forward ports from a workspace to the local machine. Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R".`,
Aliases: []string{"tunnel"}, Aliases: []string{"tunnel"},
Long: formatExamples( Long: formatExamples(
example{ example{

104
cli/remoteforward.go Normal file
View File

@ -0,0 +1,104 @@
package cli
import (
"context"
"fmt"
"io"
"net"
"regexp"
"strconv"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/xerrors"
"github.com/coder/coder/agent/agentssh"
)
// cookieAddr is a special net.Addr accepted by sshRemoteForward() which includes a
// cookie which is written to the connection before forwarding.
type cookieAddr struct {
net.Addr
cookie []byte
}
// Format:
// remote_port:local_address:local_port
var remoteForwardRegex = regexp.MustCompile(`^(\d+):(.+):(\d+)$`)
func validateRemoteForward(flag string) bool {
return remoteForwardRegex.MatchString(flag)
}
func parseRemoteForward(flag string) (net.Addr, net.Addr, error) {
matches := remoteForwardRegex.FindStringSubmatch(flag)
remotePort, err := strconv.Atoi(matches[1])
if err != nil {
return nil, nil, xerrors.Errorf("remote port is invalid: %w", err)
}
localAddress, err := net.ResolveIPAddr("ip", matches[2])
if err != nil {
return nil, nil, xerrors.Errorf("local address is invalid: %w", err)
}
localPort, err := strconv.Atoi(matches[3])
if err != nil {
return nil, nil, xerrors.Errorf("local port is invalid: %w", err)
}
localAddr := &net.TCPAddr{
IP: localAddress.IP,
Port: localPort,
}
remoteAddr := &net.TCPAddr{
IP: net.ParseIP("127.0.0.1"),
Port: remotePort,
}
return localAddr, remoteAddr, nil
}
// sshRemoteForward starts forwarding connections from a remote listener to a
// local address via SSH in a goroutine.
//
// Accepts a `cookieAddr` as the local address.
func sshRemoteForward(ctx context.Context, stderr io.Writer, sshClient *gossh.Client, localAddr, remoteAddr net.Addr) (io.Closer, error) {
listener, err := sshClient.Listen(remoteAddr.Network(), remoteAddr.String())
if err != nil {
return nil, xerrors.Errorf("listen on remote SSH address %s: %w", remoteAddr.String(), err)
}
go func() {
for {
remoteConn, err := listener.Accept()
if err != nil {
if ctx.Err() == nil {
_, _ = fmt.Fprintf(stderr, "Accept SSH listener connection: %+v\n", err)
}
return
}
go func() {
defer remoteConn.Close()
localConn, err := net.Dial(localAddr.Network(), localAddr.String())
if err != nil {
_, _ = fmt.Fprintf(stderr, "Dial local address %s: %+v\n", localAddr.String(), err)
return
}
defer localConn.Close()
if c, ok := localAddr.(cookieAddr); ok {
_, err = localConn.Write(c.cookie)
if err != nil {
_, _ = fmt.Fprintf(stderr, "Write cookie to local connection: %+v\n", err)
return
}
}
agentssh.Bicopy(ctx, localConn, remoteConn)
}()
}
}()
return listener, nil
}

View File

@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
@ -27,7 +26,6 @@ import (
"cdr.dev/slog" "cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman" "cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/agent/agentssh"
"github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui" "github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/autobuild/notify" "github.com/coder/coder/coderd/autobuild/notify"
@ -53,6 +51,7 @@ func (r *RootCmd) ssh() *clibase.Cmd {
waitEnum string waitEnum string
noWait bool noWait bool
logDirPath string logDirPath string
remoteForward string
) )
client := new(codersdk.Client) client := new(codersdk.Client)
cmd := &clibase.Cmd{ cmd := &clibase.Cmd{
@ -122,6 +121,16 @@ func (r *RootCmd) ssh() *clibase.Cmd {
client.SetLogger(logger) client.SetLogger(logger)
} }
if remoteForward != "" {
isValid := validateRemoteForward(remoteForward)
if !isValid {
return xerrors.Errorf(`invalid format of remote-forward, expected: remote_port:local_address:local_port`)
}
if isValid && stdio {
return xerrors.Errorf(`remote-forward can't be enabled in the stdio mode`)
}
}
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, codersdk.Me, inv.Args[0]) workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, codersdk.Me, inv.Args[0])
if err != nil { if err != nil {
return err return err
@ -198,6 +207,7 @@ func (r *RootCmd) ssh() *clibase.Cmd {
} }
defer conn.Close() defer conn.Close()
conn.AwaitReachable(ctx) conn.AwaitReachable(ctx)
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace) stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
defer stopPolling() defer stopPolling()
@ -300,6 +310,19 @@ func (r *RootCmd) ssh() *clibase.Cmd {
defer closer.Close() defer closer.Close()
} }
if remoteForward != "" {
localAddr, remoteAddr, err := parseRemoteForward(remoteForward)
if err != nil {
return err
}
closer, err := sshRemoteForward(ctx, inv.Stderr, sshClient, localAddr, remoteAddr)
if err != nil {
return xerrors.Errorf("ssh remote forward: %w", err)
}
defer closer.Close()
}
stdoutFile, validOut := inv.Stdout.(*os.File) stdoutFile, validOut := inv.Stdout.(*os.File)
stdinFile, validIn := inv.Stdin.(*os.File) stdinFile, validIn := inv.Stdin.(*os.File)
if validOut && validIn && isatty.IsTerminal(stdoutFile.Fd()) { if validOut && validIn && isatty.IsTerminal(stdoutFile.Fd()) {
@ -424,6 +447,13 @@ func (r *RootCmd) ssh() *clibase.Cmd {
FlagShorthand: "l", FlagShorthand: "l",
Value: clibase.StringOf(&logDirPath), Value: clibase.StringOf(&logDirPath),
}, },
{
Flag: "remote-forward",
Description: "Enable remote port forwarding (remote_port:local_address:local_port).",
Env: "CODER_SSH_REMOTE_FORWARD",
FlagShorthand: "R",
Value: clibase.StringOf(&remoteForward),
},
} }
return cmd return cmd
} }
@ -568,8 +598,15 @@ func getWorkspaceAndAgent(ctx context.Context, inv *clibase.Invocation, client *
// of the CLI running simultaneously. // of the CLI running simultaneously.
func tryPollWorkspaceAutostop(ctx context.Context, client *codersdk.Client, workspace codersdk.Workspace) (stop func()) { func tryPollWorkspaceAutostop(ctx context.Context, client *codersdk.Client, workspace codersdk.Workspace) (stop func()) {
lock := flock.New(filepath.Join(os.TempDir(), "coder-autostop-notify-"+workspace.ID.String())) lock := flock.New(filepath.Join(os.TempDir(), "coder-autostop-notify-"+workspace.ID.String()))
condition := notifyCondition(ctx, client, workspace.ID, lock) conditionCtx, cancelCondition := context.WithCancel(ctx)
return notify.Notify(condition, workspacePollInterval, autostopNotifyCountdown...) condition := notifyCondition(conditionCtx, client, workspace.ID, lock)
stopFunc := notify.Notify(condition, workspacePollInterval, autostopNotifyCountdown...)
return func() {
// With many "ssh" processes running, `lock.TryLockContext` can be hanging until the context canceled.
// Without this cancellation, a CLI process with failed remote-forward could be hanging indefinitely.
cancelCondition()
stopFunc()
}
} }
// Notify the user if the workspace is due to shutdown. // Notify the user if the workspace is due to shutdown.
@ -752,56 +789,3 @@ func remoteGPGAgentSocket(sshClient *gossh.Client) (string, error) {
return string(bytes.TrimSpace(remoteSocket)), nil return string(bytes.TrimSpace(remoteSocket)), nil
} }
// cookieAddr is a special net.Addr accepted by sshForward() which includes a
// cookie which is written to the connection before forwarding.
type cookieAddr struct {
net.Addr
cookie []byte
}
// sshForwardRemote starts forwarding connections from a remote listener to a
// local address via SSH in a goroutine.
//
// Accepts a `cookieAddr` as the local address.
func sshForwardRemote(ctx context.Context, stderr io.Writer, sshClient *gossh.Client, localAddr, remoteAddr net.Addr) (io.Closer, error) {
listener, err := sshClient.Listen(remoteAddr.Network(), remoteAddr.String())
if err != nil {
return nil, xerrors.Errorf("listen on remote SSH address %s: %w", remoteAddr.String(), err)
}
go func() {
for {
remoteConn, err := listener.Accept()
if err != nil {
if ctx.Err() == nil {
_, _ = fmt.Fprintf(stderr, "Accept SSH listener connection: %+v\n", err)
}
return
}
go func() {
defer remoteConn.Close()
localConn, err := net.Dial(localAddr.Network(), localAddr.String())
if err != nil {
_, _ = fmt.Fprintf(stderr, "Dial local address %s: %+v\n", localAddr.String(), err)
return
}
defer localConn.Close()
if c, ok := localAddr.(cookieAddr); ok {
_, err = localConn.Write(c.cookie)
if err != nil {
_, _ = fmt.Fprintf(stderr, "Write cookie to local connection: %+v\n", err)
return
}
}
agentssh.Bicopy(ctx, localConn, remoteConn)
}()
}
}()
return listener, nil
}

View File

@ -44,5 +44,5 @@ func forwardGPGAgent(ctx context.Context, stderr io.Writer, sshClient *gossh.Cli
Net: "unix", Net: "unix",
} }
return sshForwardRemote(ctx, stderr, sshClient, localAddr, remoteAddr) return sshRemoteForward(ctx, stderr, sshClient, localAddr, remoteAddr)
} }

View File

@ -10,6 +10,8 @@ import (
"fmt" "fmt"
"io" "io"
"net" "net"
"net/http"
"net/http/httptest"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -408,6 +410,58 @@ func TestSSH(t *testing.T) {
<-cmdDone <-cmdDone
}) })
t.Run("RemoteForward", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Test not supported on windows")
}
t.Parallel()
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello world"))
}))
defer httpServer.Close()
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(agentToken)
agentCloser := agent.New(agent.Options{
Client: agentClient,
Logger: slogtest.Make(t, nil).Named("agent"),
})
defer agentCloser.Close()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
inv, root := clitest.New(t,
"ssh",
workspace.Name,
"--remote-forward",
"8222:"+httpServer.Listener.Addr().String(),
)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output()
cmdDone := tGo(t, func() {
err := inv.WithContext(ctx).Run()
assert.NoError(t, err, "ssh command failed")
})
// Wait for the prompt or any output really to indicate the command has
// started and accepting input on stdin.
_ = pty.Peek(ctx, 1)
// Download the test page
pty.WriteLine("curl localhost:8222")
pty.ExpectMatch("hello world")
// And we're done.
pty.WriteLine("exit")
<-cmdDone
})
t.Run("FileLogging", func(t *testing.T) { t.Run("FileLogging", func(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -101,5 +101,5 @@ func forwardGPGAgent(ctx context.Context, stderr io.Writer, sshClient *gossh.Cli
Net: "unix", Net: "unix",
} }
return sshForwardRemote(ctx, stderr, sshClient, localAddr, remoteAddr) return sshRemoteForward(ctx, stderr, sshClient, localAddr, remoteAddr)
} }

View File

@ -21,7 +21,9 @@ Coder v0.0.0-devel — A tool for provisioning self-hosted development environme
logout Unauthenticate your local session logout Unauthenticate your local session
netcheck Print network debug information for DERP and STUN netcheck Print network debug information for DERP and STUN
ping Ping a workspace ping Ping a workspace
port-forward Forward ports from machine to a workspace port-forward Forward ports from a workspace to the local machine.
Forward ports from a workspace to the local machine. For
reverse port forwarding, use "coder ssh -R".
publickey Output your Coder public key used for Git operations publickey Output your Coder public key used for Git operations
rename Rename a workspace rename Rename a workspace
reset-password Directly connect to the database to reset a user's reset-password Directly connect to the database to reset a user's

View File

@ -1,6 +1,7 @@
Usage: coder port-forward [flags] <workspace> Usage: coder port-forward [flags] <workspace>
Forward ports from machine to a workspace Forward ports from a workspace to the local machine. Forward ports from a
workspace to the local machine. For reverse port forwarding, use "coder ssh -R".
Aliases: tunnel Aliases: tunnel

View File

@ -27,6 +27,9 @@ Start a shell into a workspace
behavior as non-blocking. behavior as non-blocking.
DEPRECATED: Use --wait instead. DEPRECATED: Use --wait instead.
-R, --remote-forward string, $CODER_SSH_REMOTE_FORWARD
Enable remote port forwarding (remote_port:local_address:local_port).
--stdio bool, $CODER_SSH_STDIO --stdio bool, $CODER_SSH_STDIO
Specifies whether to emit SSH output over stdin/stdout. Specifies whether to emit SSH output over stdin/stdout.

View File

@ -23,40 +23,40 @@ Coder — A tool for provisioning self-hosted development environments with Terr
## Subcommands ## Subcommands
| Name | Purpose | | Name | Purpose |
| ------------------------------------------------------ | ---------------------------------------------------------------------- | | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [<code>config-ssh</code>](./cli/config-ssh.md) | Add an SSH Host entry for your workspaces "ssh coder.workspace" | | [<code>config-ssh</code>](./cli/config-ssh.md) | Add an SSH Host entry for your workspaces "ssh coder.workspace" |
| [<code>create</code>](./cli/create.md) | Create a workspace | | [<code>create</code>](./cli/create.md) | Create a workspace |
| [<code>delete</code>](./cli/delete.md) | Delete a workspace | | [<code>delete</code>](./cli/delete.md) | Delete a workspace |
| [<code>dotfiles</code>](./cli/dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | | [<code>dotfiles</code>](./cli/dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository |
| [<code>features</code>](./cli/features.md) | List Enterprise features | | [<code>features</code>](./cli/features.md) | List Enterprise features |
| [<code>groups</code>](./cli/groups.md) | Manage groups | | [<code>groups</code>](./cli/groups.md) | Manage groups |
| [<code>licenses</code>](./cli/licenses.md) | Add, delete, and list licenses | | [<code>licenses</code>](./cli/licenses.md) | Add, delete, and list licenses |
| [<code>list</code>](./cli/list.md) | List workspaces | | [<code>list</code>](./cli/list.md) | List workspaces |
| [<code>login</code>](./cli/login.md) | Authenticate with Coder deployment | | [<code>login</code>](./cli/login.md) | Authenticate with Coder deployment |
| [<code>logout</code>](./cli/logout.md) | Unauthenticate your local session | | [<code>logout</code>](./cli/logout.md) | Unauthenticate your local session |
| [<code>netcheck</code>](./cli/netcheck.md) | Print network debug information for DERP and STUN | | [<code>netcheck</code>](./cli/netcheck.md) | Print network debug information for DERP and STUN |
| [<code>ping</code>](./cli/ping.md) | Ping a workspace | | [<code>ping</code>](./cli/ping.md) | Ping a workspace |
| [<code>port-forward</code>](./cli/port-forward.md) | Forward ports from machine to a workspace | | [<code>port-forward</code>](./cli/port-forward.md) | Forward ports from a workspace to the local machine. Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". |
| [<code>provisionerd</code>](./cli/provisionerd.md) | Manage provisioner daemons | | [<code>provisionerd</code>](./cli/provisionerd.md) | Manage provisioner daemons |
| [<code>publickey</code>](./cli/publickey.md) | Output your Coder public key used for Git operations | | [<code>publickey</code>](./cli/publickey.md) | Output your Coder public key used for Git operations |
| [<code>rename</code>](./cli/rename.md) | Rename a workspace | | [<code>rename</code>](./cli/rename.md) | Rename a workspace |
| [<code>reset-password</code>](./cli/reset-password.md) | Directly connect to the database to reset a user's password | | [<code>reset-password</code>](./cli/reset-password.md) | Directly connect to the database to reset a user's password |
| [<code>restart</code>](./cli/restart.md) | Restart a workspace | | [<code>restart</code>](./cli/restart.md) | Restart a workspace |
| [<code>schedule</code>](./cli/schedule.md) | Schedule automated start and stop times for workspaces | | [<code>schedule</code>](./cli/schedule.md) | Schedule automated start and stop times for workspaces |
| [<code>server</code>](./cli/server.md) | Start a Coder server | | [<code>server</code>](./cli/server.md) | Start a Coder server |
| [<code>show</code>](./cli/show.md) | Display details of a workspace's resources and agents | | [<code>show</code>](./cli/show.md) | Display details of a workspace's resources and agents |
| [<code>speedtest</code>](./cli/speedtest.md) | Run upload and download tests from your machine to a workspace | | [<code>speedtest</code>](./cli/speedtest.md) | Run upload and download tests from your machine to a workspace |
| [<code>ssh</code>](./cli/ssh.md) | Start a shell into a workspace | | [<code>ssh</code>](./cli/ssh.md) | Start a shell into a workspace |
| [<code>start</code>](./cli/start.md) | Start a workspace | | [<code>start</code>](./cli/start.md) | Start a workspace |
| [<code>stat</code>](./cli/stat.md) | Show resource usage for the current workspace. | | [<code>stat</code>](./cli/stat.md) | Show resource usage for the current workspace. |
| [<code>state</code>](./cli/state.md) | Manually manage Terraform state to fix broken workspaces | | [<code>state</code>](./cli/state.md) | Manually manage Terraform state to fix broken workspaces |
| [<code>stop</code>](./cli/stop.md) | Stop a workspace | | [<code>stop</code>](./cli/stop.md) | Stop a workspace |
| [<code>templates</code>](./cli/templates.md) | Manage templates | | [<code>templates</code>](./cli/templates.md) | Manage templates |
| [<code>tokens</code>](./cli/tokens.md) | Manage personal access tokens | | [<code>tokens</code>](./cli/tokens.md) | Manage personal access tokens |
| [<code>update</code>](./cli/update.md) | Will update and start a given workspace if it is out of date | | [<code>update</code>](./cli/update.md) | Will update and start a given workspace if it is out of date |
| [<code>users</code>](./cli/users.md) | Manage users | | [<code>users</code>](./cli/users.md) | Manage users |
| [<code>version</code>](./cli/version.md) | Show coder version | | [<code>version</code>](./cli/version.md) | Show coder version |
## Options ## Options

View File

@ -2,7 +2,7 @@
# port-forward # port-forward
Forward ports from machine to a workspace Forward ports from a workspace to the local machine. Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R".
Aliases: Aliases:

View File

@ -57,6 +57,15 @@ Specify the directory containing SSH diagnostic log files.
Enter workspace immediately after the agent has connected. This is the default if the template has configured the agent startup script behavior as non-blocking. Enter workspace immediately after the agent has connected. This is the default if the template has configured the agent startup script behavior as non-blocking.
### -R, --remote-forward
| | |
| ----------- | -------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_SSH_REMOTE_FORWARD</code> |
Enable remote port forwarding (remote_port:local_address:local_port).
### --stdio ### --stdio
| | | | | |

View File

@ -610,7 +610,7 @@
}, },
{ {
"title": "port-forward", "title": "port-forward",
"description": "Forward ports from machine to a workspace", "description": "Forward ports from a workspace to the local machine. Forward ports from a workspace to the local machine. For reverse port forwarding, use \"coder ssh -R\".",
"path": "cli/port-forward.md" "path": "cli/port-forward.md"
}, },
{ {