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)
cmd := &clibase.Cmd{
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"},
Long: formatExamples(
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"
"fmt"
"io"
"net"
"net/url"
"os"
"os/exec"
@ -27,7 +26,6 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/agent/agentssh"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/autobuild/notify"
@ -53,6 +51,7 @@ func (r *RootCmd) ssh() *clibase.Cmd {
waitEnum string
noWait bool
logDirPath string
remoteForward string
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
@ -122,6 +121,16 @@ func (r *RootCmd) ssh() *clibase.Cmd {
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])
if err != nil {
return err
@ -198,6 +207,7 @@ func (r *RootCmd) ssh() *clibase.Cmd {
}
defer conn.Close()
conn.AwaitReachable(ctx)
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
defer stopPolling()
@ -300,6 +310,19 @@ func (r *RootCmd) ssh() *clibase.Cmd {
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)
stdinFile, validIn := inv.Stdin.(*os.File)
if validOut && validIn && isatty.IsTerminal(stdoutFile.Fd()) {
@ -424,6 +447,13 @@ func (r *RootCmd) ssh() *clibase.Cmd {
FlagShorthand: "l",
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
}
@ -568,8 +598,15 @@ func getWorkspaceAndAgent(ctx context.Context, inv *clibase.Invocation, client *
// of the CLI running simultaneously.
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()))
condition := notifyCondition(ctx, client, workspace.ID, lock)
return notify.Notify(condition, workspacePollInterval, autostopNotifyCountdown...)
conditionCtx, cancelCondition := context.WithCancel(ctx)
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.
@ -752,56 +789,3 @@ func remoteGPGAgentSocket(sshClient *gossh.Client) (string, error) {
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",
}
return sshForwardRemote(ctx, stderr, sshClient, localAddr, remoteAddr)
return sshRemoteForward(ctx, stderr, sshClient, localAddr, remoteAddr)
}

View File

@ -10,6 +10,8 @@ import (
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
@ -408,6 +410,58 @@ func TestSSH(t *testing.T) {
<-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.Parallel()

View File

@ -101,5 +101,5 @@ func forwardGPGAgent(ctx context.Context, stderr io.Writer, sshClient *gossh.Cli
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
netcheck Print network debug information for DERP and STUN
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
rename Rename a workspace
reset-password Directly connect to the database to reset a user's

View File

@ -1,6 +1,7 @@
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

View File

@ -27,6 +27,9 @@ Start a shell into a workspace
behavior as non-blocking.
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
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
| Name | Purpose |
| ------------------------------------------------------ | ---------------------------------------------------------------------- |
| [<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>delete</code>](./cli/delete.md) | Delete a workspace |
| [<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>groups</code>](./cli/groups.md) | Manage groups |
| [<code>licenses</code>](./cli/licenses.md) | Add, delete, and list licenses |
| [<code>list</code>](./cli/list.md) | List workspaces |
| [<code>login</code>](./cli/login.md) | Authenticate with Coder deployment |
| [<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>ping</code>](./cli/ping.md) | Ping a workspace |
| [<code>port-forward</code>](./cli/port-forward.md) | Forward ports from machine to a workspace |
| [<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>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>restart</code>](./cli/restart.md) | Restart a workspace |
| [<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>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>ssh</code>](./cli/ssh.md) | Start a shell into 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>state</code>](./cli/state.md) | Manually manage Terraform state to fix broken workspaces |
| [<code>stop</code>](./cli/stop.md) | Stop a workspace |
| [<code>templates</code>](./cli/templates.md) | Manage templates |
| [<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>users</code>](./cli/users.md) | Manage users |
| [<code>version</code>](./cli/version.md) | Show coder version |
| Name | Purpose |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [<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>delete</code>](./cli/delete.md) | Delete a workspace |
| [<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>groups</code>](./cli/groups.md) | Manage groups |
| [<code>licenses</code>](./cli/licenses.md) | Add, delete, and list licenses |
| [<code>list</code>](./cli/list.md) | List workspaces |
| [<code>login</code>](./cli/login.md) | Authenticate with Coder deployment |
| [<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>ping</code>](./cli/ping.md) | Ping 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>publickey</code>](./cli/publickey.md) | Output your Coder public key used for Git operations |
| [<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>restart</code>](./cli/restart.md) | Restart a workspace |
| [<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>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>ssh</code>](./cli/ssh.md) | Start a shell into 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>state</code>](./cli/state.md) | Manually manage Terraform state to fix broken workspaces |
| [<code>stop</code>](./cli/stop.md) | Stop a workspace |
| [<code>templates</code>](./cli/templates.md) | Manage templates |
| [<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>users</code>](./cli/users.md) | Manage users |
| [<code>version</code>](./cli/version.md) | Show coder version |
## Options

View File

@ -2,7 +2,7 @@
# 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:

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.
### -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
| | |

View File

@ -610,7 +610,7 @@
},
{
"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"
},
{