diff --git a/.gitignore b/.gitignore index 5657f6e..94251e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -vendor \ No newline at end of file +vendor +bin +.vscode +sshcode +sshcode.exe diff --git a/.sail/Dockerfile b/.sail/Dockerfile index 2e5b81d..00842a3 100644 --- a/.sail/Dockerfile +++ b/.sail/Dockerfile @@ -1,3 +1,6 @@ FROM codercom/ubuntu-dev-go +# Go module tooling is completely broken. +ENV GO111MODULE=off + LABEL project_root "~/go/src/go.coder.com" diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e88a4c5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +dist: xenial +language: go +go: +- 1.12.x +go_import_path: go.coder.com/retry +env: + - GO111MODULE=on +script: +- ./ci/ensuremod.sh +- ./ci/lint.sh +- go test -v ./... diff --git a/README.md b/README.md index 283d15e..3882f94 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,52 @@ # sshcode -`sshcode` is a CLI to automatically install and run [code-server](https://github.com/codercom/code-server) over SSH. +**This project has been deprecated in favour of the [code-server install script](https://github.com/cdr/code-server#quick-install)** + +**See the discussion in [#185](https://github.com/cdr/sshcode/issues/185)** + +--- + +[!["Open Issues"](https://img.shields.io/github/issues-raw/cdr/sshcode.svg)](https://github.com/cdr/sshcode/issues) +[!["Latest Release"](https://img.shields.io/github/release/cdr/sshcode.svg)](https://github.com/cdr/sshcode/releases/latest) +[![MIT license](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/cdr/sshcode/blob/master/LICENSE) +[![Discord](https://img.shields.io/discord/463752820026376202.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/zxSwN8Z) +[![Build Status](https://travis-ci.org/cdr/sshcode.svg?branch=master)](https://travis-ci.org/cdr/sshcode) + +`sshcode` is a CLI to automatically install and run [code-server](https://github.com/cdr/code-server) over SSH. + +It uploads your extensions and settings automatically, so you can seamlessly use +remote servers as [VS Code](https://code.visualstudio.com) hosts. + +If you have Chrome installed, it opens the browser in app mode. That means +there's no keybind conflicts, address bar, or indication that you're coding within a browser. +**It feels just like native VS Code.** ![Demo](/demo.gif) ## Install -Chrome is recommended. +**Have Chrome installed for the best experience.** + +Install with `go`: ```bash -go get go.coder.com/sshcode +go get -u go.coder.com/sshcode ``` +Or, grab a [pre-built binary](https://github.com/cdr/sshcode/releases). + +### OS Support + +We currently support: +- Linux +- MacOS +- WSL + +For the remote server, we currently only support Linux `x86_64` (64-bit) +servers with `glibc`. `musl` libc (which is most notably used by Alpine Linux) +is currently not supported on the remote server: +[#122](https://github.com/cdr/sshcode/issues/122). + ## Usage ```bash @@ -22,7 +57,7 @@ sshcode kyle@dev.kwc.io You can specify a remote directory as the second argument: ```bash -sshcode kyle@dev.kwc.io ~/projects/sourcegraph +sshcode kyle@dev.kwc.io "~/projects/sourcegraph" ``` ## Extensions & Settings Sync @@ -31,6 +66,34 @@ By default, `sshcode` will `rsync` your local VS Code settings and extensions to the remote server every time you connect. This operation may take a while on a slow connections, but will be fast -on follow-on connections to the same server. +on follow-up connections to the same server. + +To disable this feature entirely, pass the `--skipsync` flag. + +### Custom settings directories + +If you're using an alternate release of VS Code such as VS Code Insiders, you +must specify your settings directories through the `VSCODE_CONFIG_DIR` and +`VSCODE_EXTENSIONS_DIR` environment variables. + +The following will make `sshcode` work with VS Code Insiders: + +**MacOS** + +```bash +export VSCODE_CONFIG_DIR="$HOME/Library/Application Support/Code - Insiders/User" +export VSCODE_EXTENSIONS_DIR="$HOME/.vscode-insiders/extensions" +``` + +**Linux** + +```bash +export VSCODE_CONFIG_DIR="$HOME/.config/Code - Insiders/User" +export VSCODE_EXTENSIONS_DIR="$HOME/.vscode-insiders/extensions" +``` + +### Sync-back -To disable this feature entirely, pass the `--skipsync` flag. \ No newline at end of file +By default, VS Code changes on the remote server won't be synced back +when the connection closes. To synchronize back to local when the connection ends, +pass the `-b` flag. diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..9e30b09 --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,21 @@ +#!/bin/bash +export GOARCH=amd64 + +tag=$(git describe --tags) + +mkdir -p bin + +build(){ + tmpdir=$(mktemp -d) + go build -ldflags "-X main.version=${tag}" -o $tmpdir/sshcode + + pushd $tmpdir + tarname=sshcode-$GOOS-$GOARCH.tar.gz + tar -czf $tarname sshcode + popd + cp $tmpdir/$tarname bin + rm -rf $tmpdir +} + +GOOS=darwin build +GOOS=linux build diff --git a/ci/ensuremod.sh b/ci/ensuremod.sh new file mode 100755 index 0000000..a974d6b --- /dev/null +++ b/ci/ensuremod.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# inspired by nhooyr's days as CI overlord + +set -eou pipefail + +function help() { + echo + echo "you may need to update go.mod/go.sum via:" + echo "go list all > /dev/null" + echo "go mod tidy" + exit 1 +} + +go list -mod=readonly all > /dev/null + +go mod tidy + +if [[ $(git diff --name-only) != "" ]]; then + git diff + help +fi diff --git a/ci/lint.sh b/ci/lint.sh new file mode 100755 index 0000000..76d26c1 --- /dev/null +++ b/ci/lint.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# Inspired by nhooyr's days as CI overlord. + +set -euo pipefail + +files=$(gofmt -l -s .) + +if [ ! -z "$files" ]; +then + echo "The following files need to be formatted:" + echo "$files" + echo "Please run 'gofmt -w -s .'" + exit 1 +fi + +go vet -composites=false . diff --git a/go.mod b/go.mod index 5548305..e378ffe 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,14 @@ module go.coder.com/sshcode go 1.12 require ( + github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 + github.com/pkg/errors v0.8.1 // indirect + github.com/spf13/pflag v1.0.3 + github.com/stretchr/testify v1.3.0 + go.coder.com/cli v0.4.0 go.coder.com/flog v0.0.0-20190129195112-eaed154a0db8 + go.coder.com/retry v0.0.0-20180926062817-cf12c95974ac + golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be // indirect - golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 ) diff --git a/go.sum b/go.sum index 527a8dd..19ceba8 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,38 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98= +github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +go.coder.com/cli v0.4.0 h1:PruDGwm/CPFndyK/eMowZG3vzg5CgohRWeXWCTr3zi8= +go.coder.com/cli v0.4.0/go.mod h1:hRTOURCR3LJF1FRW9arecgrzX+AHG7mfYMwThPIgq+w= go.coder.com/flog v0.0.0-20190129195112-eaed154a0db8 h1:PtQ3moPi4EAz3cyQhkUs1IGIXa2QgJpP60yMjOdu0kk= go.coder.com/flog v0.0.0-20190129195112-eaed154a0db8/go.mod h1:83JsYgXYv0EOaXjIMnaZ1Fl6ddNB3fJnDZ/8845mUJ8= +go.coder.com/retry v0.0.0-20180926062817-cf12c95974ac h1:ekdpsuykRy/E+SDq5BquFomNhRCk8OOyhtnACW9Bi50= +go.coder.com/retry v0.0.0-20180926062817-cf12c95974ac/go.mod h1:h7MQcGZ698RYUan++Yu4aDcBvquTI2cSsup+GSy8D2Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd h1:sMHc2rZHuzQmrbVoSpt9HgerkXPyIeCSO6k0zUMGfFk= +golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be h1:mI+jhqkn68ybP0ORJqunXn+fq+Eeb4hHKqLQcFICjAc= golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373 h1:PPwnA7z1Pjf7XYaBP9GL1VAMZmcIWyFz7QCMSIIa3Bg= -golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index cf0d82b..674f983 100644 --- a/main.go +++ b/main.go @@ -1,21 +1,16 @@ package main import ( - "context" - "flag" "fmt" "math/rand" - "net" - "net/http" "os" - "os/exec" - "path/filepath" "runtime" - "strconv" + "strings" "time" - "golang.org/x/xerrors" + "github.com/spf13/pflag" + "go.coder.com/cli" "go.coder.com/flog" ) @@ -23,235 +18,107 @@ func init() { rand.Seed(time.Now().Unix()) } +const helpTabWidth = 5 + +var ( + helpTab = strings.Repeat(" ", helpTabWidth) + // version is overwritten by ci/build.sh. + version string +) + func main() { - skipSyncFlag := flag.Bool("skipsync", false, "skip syncing local settings and extensions to remote host") - sshFlags := flag.String("ssh-flags", "", "custom SSH flags") - flag.Usage = func() { - fmt.Printf(`Usage: [-skipsync] %v HOST [DIR] [SSH ARGS...] + cli.RunRoot(&rootCmd{}) +} -Start code-server over SSH. -More info: https://github.com/codercom/sshcode -`, os.Args[0], - ) +var _ interface { + cli.Command + cli.FlaggedCommand +} = new(rootCmd) + +type rootCmd struct { + skipSync bool + syncBack bool + printVersion bool + noReuseConnection bool + bindAddr string + sshFlags string + uploadCodeServer string +} + +func (c *rootCmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "sshcode", + Usage: c.usage(), + Desc: c.description(), } +} - flag.Parse() - host := flag.Arg(0) +func (c *rootCmd) RegisterFlags(fl *pflag.FlagSet) { + fl.BoolVar(&c.skipSync, "skipsync", false, "skip syncing local settings and extensions to remote host") + fl.BoolVar(&c.syncBack, "b", false, "sync extensions back on termination") + fl.BoolVar(&c.printVersion, "version", false, "print version information and exit") + fl.BoolVar(&c.noReuseConnection, "no-reuse-connection", false, "do not reuse SSH connection via control socket") + fl.StringVar(&c.bindAddr, "bind", "", "local bind address for SSH tunnel, in [HOST][:PORT] syntax (default: 127.0.0.1)") + fl.StringVar(&c.sshFlags, "ssh-flags", "", "custom SSH flags") + fl.StringVar(&c.uploadCodeServer, "upload-code-server", "", "custom code-server binary to upload to the remote host") +} +func (c *rootCmd) Run(fl *pflag.FlagSet) { + if c.printVersion { + fmt.Printf("%v\n", version) + os.Exit(0) + } + + host := fl.Arg(0) if host == "" { // If no host is specified output the usage. - flag.Usage() + fl.Usage() os.Exit(1) } - dir := flag.Arg(1) + dir := fl.Arg(1) if dir == "" { dir = "~" } - flog.Info("ensuring code-server is updated...") - - const codeServerPath = "/tmp/codessh-code-server" - - // Downloads the latest code-server and allows it to be executed. - sshCmd := exec.Command("ssh", - "-tt", - host, - `/bin/bash -c 'set -euxo pipefail || exit 1 -wget -q https://codesrv-ci.cdr.sh/latest-linux -O `+codeServerPath+` -mkdir -p ~/.local/share/code-server -cd `+filepath.Dir(codeServerPath)+` -wget -N https://codesrv-ci.cdr.sh/latest-linux -[ -f `+codeServerPath+` ] && rm `+codeServerPath+` -ln latest-linux `+codeServerPath+` -chmod +x `+codeServerPath+` -'`, - ) - sshCmd.Stdout = os.Stdout - sshCmd.Stderr = os.Stderr - err := sshCmd.Run() - if err != nil { - flog.Fatal("failed to update code-server: %v", err) + // Get linux relative path if on windows. + if runtime.GOOS == "windows" { + dir = gitbashWindowsDir(dir) } - if !(*skipSyncFlag) { - start := time.Now() - flog.Info("syncing settings") - err = syncUserSettings(host) - if err != nil { - flog.Fatal("failed to sync settings: %v", err) - } - flog.Info("synced settings in %s", time.Since(start)) + err := sshCode(host, dir, options{ + skipSync: c.skipSync, + sshFlags: c.sshFlags, + bindAddr: c.bindAddr, + syncBack: c.syncBack, + reuseConnection: !c.noReuseConnection, + uploadCodeServer: c.uploadCodeServer, + }) - flog.Info("syncing extensions") - err = syncExtensions(host) - if err != nil { - flog.Fatal("failed to sync extensions: %v", err) - } - flog.Info("synced extensions in %s", time.Since(start)) - } - - flog.Info("starting code-server...") - localPort, err := randomPort() - if err != nil { - flog.Fatal("failed to find available port: %v", err) - } - - sshCmdStr := fmt.Sprintf("ssh -tt -q -L %v %v %v 'cd %v; %v --host 127.0.0.1 --allow-http --no-auth --port=%v'", - localPort+":localhost:"+localPort, *sshFlags, host, dir, codeServerPath, localPort, - ) - - // Starts code-server and forwards the remote port. - sshCmd = exec.Command("sh", "-c", - sshCmdStr, - ) - sshCmd.Stdin = os.Stdin - sshCmd.Stdout = os.Stdout - sshCmd.Stderr = os.Stderr - err = sshCmd.Start() if err != nil { - flog.Fatal("failed to start code-server: %v", err) + flog.Fatal("error: %v", err) } - - url := "http://127.0.0.1:" + localPort - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - for { - if ctx.Err() != nil { - flog.Fatal("code-server didn't start in time %v", ctx.Err()) - } - // Waits for code-server to be available before opening the browser. - r, _ := http.NewRequest("GET", url, nil) - r = r.WithContext(ctx) - resp, err := http.DefaultClient.Do(r) - if err != nil { - continue - } - resp.Body.Close() - break - } - - openBrowser(url) - sshCmd.Wait() } -func openBrowser(url string) { - var openCmd *exec.Cmd - switch { - case commandExists("google-chrome"): - openCmd = exec.Command("google-chrome", fmtChromeOptions(url)...) - case commandExists("chromium"): - openCmd = exec.Command("chromium", fmtChromeOptions(url)...) - case commandExists("chromium-browser"): - openCmd = exec.Command("chromium-browser", fmtChromeOptions(url)...) - case commandExists("firefox"): - openCmd = exec.Command("firefox", "--url="+url, "-safe-mode") - case pathExists("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"): - openCmd = exec.Command("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", fmtChromeOptions(url)...) - default: - flog.Info("unable to find a browser to open: sshcode only supports firefox, chrome, and chromium") - } - - err := openCmd.Start() - if err != nil { - flog.Fatal("failed to open browser: %v", err) - } +func (c *rootCmd) usage() string { + return "[FLAGS] HOST [DIR]" } -func fmtChromeOptions(url string) []string { - return []string{"--app=" + url, "--disable-extensions", "--disable-plugins"} -} +func (c *rootCmd) description() string { + return fmt.Sprintf(`Start VS Code via code-server over SSH. -// Checks if a command exists locally. -func commandExists(name string) bool { - _, err := exec.LookPath(name) - return err == nil -} +Environment variables: +%v%v use special VS Code settings dir. +%v%v use special VS Code extensions dir. -func pathExists(name string) bool { - _, err := os.Stat(name) - return err == nil -} +More info: https://github.com/cdr/sshcode -// randomPort picks a random port to start code-server on. -func randomPort() (string, error) { - const ( - minPort = 1024 - maxPort = 65535 - maxTries = 10 +Arguments: +%vHOST is passed into the ssh command. Valid formats are '' or 'gcp:'. +%vDIR is optional.`, + helpTab, vsCodeConfigDirEnv, + helpTab, vsCodeExtensionsDirEnv, + helpTab, + helpTab, ) - for i := 0; i < maxTries; i++ { - port := rand.Intn(maxPort-minPort+1) + minPort - l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) - if err == nil { - _ = l.Close() - return strconv.Itoa(port), nil - } - flog.Info("port taken: %d", port) - } - - return "", xerrors.Errorf("max number of tries exceeded: %d", maxTries) -} - -func syncUserSettings(host string) error { - localConfDir, err := configDir() - if err != nil { - return err - } - const remoteSettingsDir = ".local/share/code-server/User" - - // Append "/" to have rsync copy the contents of the dir. - return rsync(localConfDir+"/", remoteSettingsDir, host, "workspaceStorage", "logs", "CachedData") -} - -func syncExtensions(host string) error { - localExtensionsDir, err := extensionsDir() - if err != nil { - return err - } - const remoteExtensionsDir = ".local/share/code-server/extensions" - - return rsync(localExtensionsDir+"/", remoteExtensionsDir, host) -} - -func rsync(src string, dest string, host string, excludePaths ...string) error { - remoteDest := fmt.Sprintf("%s:%s", host, dest) - excludeFlags := make([]string, len(excludePaths)) - for i, path := range excludePaths { - excludeFlags[i] = "--exclude=" + path - } - - cmd := exec.Command("rsync", append(excludeFlags, "-azv", "--copy-unsafe-links", src, remoteDest)...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err := cmd.Run() - if err != nil { - return xerrors.Errorf("failed to rsync '%s' to '%s': %w", src, remoteDest, err) - } - - return nil -} - -func configDir() (string, error) { - var path string - switch runtime.GOOS { - case "linux": - path = os.ExpandEnv("$HOME/.config/Code/User") - case "darwin": - path = os.ExpandEnv("$HOME/Library/Application Support/Code/User") - default: - return "", xerrors.Errorf("unsupported platform: %s", runtime.GOOS) - } - return filepath.Clean(path), nil -} - -func extensionsDir() (string, error) { - var path string - switch runtime.GOOS { - case "linux", "darwin": - path = os.ExpandEnv("$HOME/.vscode/extensions") - default: - return "", xerrors.Errorf("unsupported platform: %s", runtime.GOOS) - } - return filepath.Clean(path), nil } diff --git a/settings.go b/settings.go new file mode 100644 index 0000000..e88c260 --- /dev/null +++ b/settings.go @@ -0,0 +1,50 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + + "golang.org/x/xerrors" +) + +const ( + vsCodeConfigDirEnv = "VSCODE_CONFIG_DIR" + vsCodeExtensionsDirEnv = "VSCODE_EXTENSIONS_DIR" +) + +func configDir() (string, error) { + if env, ok := os.LookupEnv(vsCodeConfigDirEnv); ok { + return os.ExpandEnv(env), nil + } + + var path string + switch runtime.GOOS { + case "linux": + path = os.ExpandEnv("$HOME/.config/Code/User/") + case "darwin": + path = os.ExpandEnv("$HOME/Library/Application Support/Code/User/") + case "windows": + return os.ExpandEnv("/c/Users/$USERNAME/AppData/Roaming/Code/User"), nil + default: + return "", xerrors.Errorf("unsupported platform: %s", runtime.GOOS) + } + return filepath.Clean(path), nil +} + +func extensionsDir() (string, error) { + if env, ok := os.LookupEnv(vsCodeExtensionsDirEnv); ok { + return os.ExpandEnv(env), nil + } + + var path string + switch runtime.GOOS { + case "linux", "darwin": + path = os.ExpandEnv("$HOME/.vscode/extensions/") + case "windows": + return os.ExpandEnv("/c/Users/$USERNAME/.vscode/extensions"), nil + default: + return "", xerrors.Errorf("unsupported platform: %s", runtime.GOOS) + } + return filepath.Clean(path), nil +} diff --git a/sshcode.go b/sshcode.go new file mode 100644 index 0000000..5021c09 --- /dev/null +++ b/sshcode.go @@ -0,0 +1,653 @@ +package main + +import ( + "context" + "fmt" + "math/rand" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/pkg/browser" + "go.coder.com/flog" + "golang.org/x/xerrors" +) + +const codeServerPath = "~/.cache/sshcode/sshcode-server" + +const ( + sshDirectory = "~/.ssh" + sshDirectoryUnsafeModeMask = 0022 + sshControlPath = sshDirectory + "/control-%h-%p-%r" +) + +type options struct { + skipSync bool + syncBack bool + noOpen bool + reuseConnection bool + bindAddr string + remotePort string + sshFlags string + uploadCodeServer string +} + +func sshCode(host, dir string, o options) error { + host, extraSSHFlags, err := parseHost(host) + if err != nil { + return xerrors.Errorf("failed to parse host IP: %w", err) + } + if extraSSHFlags != "" { + o.sshFlags = strings.Join([]string{extraSSHFlags, o.sshFlags}, " ") + } + + o.bindAddr, err = parseBindAddr(o.bindAddr) + if err != nil { + return xerrors.Errorf("failed to parse bind address: %w", err) + } + + if o.remotePort == "" { + o.remotePort, err = randomPort() + } + if err != nil { + return xerrors.Errorf("failed to find available remote port: %w", err) + } + + // Check the SSH directory's permissions and warn the user if it is not safe. + o.reuseConnection = checkSSHDirectory(sshDirectory, o.reuseConnection) + + // Start SSH master connection socket. This prevents multiple password prompts from appearing as authentication + // only happens on the initial connection. + if o.reuseConnection { + flog.Info("starting SSH master connection...") + newSSHFlags, cancel, err := startSSHMaster(o.sshFlags, sshControlPath, host) + defer cancel() + if err != nil { + flog.Error("failed to start SSH master connection: %v", err) + o.reuseConnection = false + } else { + o.sshFlags = newSSHFlags + } + } + + // Upload local code-server or download code-server from CI server. + if o.uploadCodeServer != "" { + flog.Info("uploading local code-server binary...") + err = copyCodeServerBinary(o.sshFlags, host, o.uploadCodeServer, codeServerPath) + if err != nil { + return xerrors.Errorf("failed to upload local code-server binary to remote server: %w", err) + } + + sshCmdStr := + fmt.Sprintf("ssh %v %v 'chmod +x %v'", + o.sshFlags, host, codeServerPath, + ) + + sshCmd := exec.Command("sh", "-l", "-c", sshCmdStr) + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + err = sshCmd.Run() + if err != nil { + return xerrors.Errorf("failed to make code-server binary executable:\n---ssh cmd---\n%s: %w", + sshCmdStr, + err, + ) + } + } else { + flog.Info("ensuring code-server is updated...") + dlScript := downloadScript(codeServerPath) + + // Downloads the latest code-server and allows it to be executed. + sshCmdStr := fmt.Sprintf("ssh %v %v '/usr/bin/env bash -l'", o.sshFlags, host) + sshCmd := exec.Command("sh", "-l", "-c", sshCmdStr) + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + sshCmd.Stdin = strings.NewReader(dlScript) + err = sshCmd.Run() + if err != nil { + return xerrors.Errorf("failed to update code-server:\n---ssh cmd---\n%s"+ + "\n---download script---\n%s: %w", + sshCmdStr, + dlScript, + err, + ) + } + } + + if !o.skipSync { + start := time.Now() + flog.Info("syncing settings") + err = syncUserSettings(o.sshFlags, host, false) + if err != nil { + return xerrors.Errorf("failed to sync settings: %w", err) + } + + flog.Info("synced settings in %s", time.Since(start)) + + flog.Info("syncing extensions") + err = syncExtensions(o.sshFlags, host, false) + if err != nil { + return xerrors.Errorf("failed to sync extensions: %w", err) + } + flog.Info("synced extensions in %s", time.Since(start)) + } + + flog.Info("starting code-server...") + + flog.Info("Tunneling remote port %v to %v", o.remotePort, o.bindAddr) + + sshCmdStr := + fmt.Sprintf("ssh -tt -q -L %v:localhost:%v %v %v '%v %v --host 127.0.0.1 --auth none --port=%v'", + o.bindAddr, o.remotePort, o.sshFlags, host, codeServerPath, dir, o.remotePort, + ) + // Starts code-server and forwards the remote port. + sshCmd := exec.Command("sh", "-l", "-c", sshCmdStr) + sshCmd.Stdin = os.Stdin + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + err = sshCmd.Start() + if err != nil { + return xerrors.Errorf("failed to start code-server: %w", err) + } + + url := fmt.Sprintf("http://%s", o.bindAddr) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client := http.Client{ + Timeout: time.Second * 3, + } + for { + if ctx.Err() != nil { + return xerrors.Errorf("code-server didn't start in time: %w", ctx.Err()) + } + // Waits for code-server to be available before opening the browser. + resp, err := client.Get(url) + if err != nil { + continue + } + resp.Body.Close() + break + } + + ctx, cancel = context.WithCancel(context.Background()) + + if !o.noOpen { + openBrowser(url) + } + + go func() { + defer cancel() + sshCmd.Wait() + }() + + c := make(chan os.Signal) + signal.Notify(c, os.Interrupt) + + select { + case <-ctx.Done(): + case <-c: + } + + flog.Info("shutting down") + if !o.syncBack || o.skipSync { + return nil + } + + flog.Info("synchronizing VS Code back to local") + + err = syncExtensions(o.sshFlags, host, true) + if err != nil { + return xerrors.Errorf("failed to sync extensions back: %w", err) + } + + err = syncUserSettings(o.sshFlags, host, true) + if err != nil { + return xerrors.Errorf("failed to sync user settings back: %w", err) + } + + return nil +} + +// expandPath returns an expanded version of path. +func expandPath(path string) string { + path = filepath.Clean(os.ExpandEnv(path)) + + // Replace tilde notation in path with the home directory. You can't replace the first instance of `~` in the + // string with the homedir as having a tilde in the middle of a filename is valid. + homedir := os.Getenv("HOME") + if homedir != "" { + if path == "~" { + path = homedir + } else if strings.HasPrefix(path, "~/") { + path = filepath.Join(homedir, path[2:]) + } + } + + return filepath.Clean(path) +} + +func parseBindAddr(bindAddr string) (string, error) { + if !strings.Contains(bindAddr, ":") { + bindAddr += ":" + } + + host, port, err := net.SplitHostPort(bindAddr) + if err != nil { + return "", err + } + + if host == "" { + host = "127.0.0.1" + } + + if port == "" { + port, err = randomPort() + } + if err != nil { + return "", err + } + + return net.JoinHostPort(host, port), nil +} + +func openBrowser(url string) { + var openCmd *exec.Cmd + + const ( + macPath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + wslPath = "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" + winPath = "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe" + ) + + switch { + case commandExists("chrome"): + openCmd = exec.Command("chrome", chromeOptions(url)...) + case commandExists("google-chrome"): + openCmd = exec.Command("google-chrome", chromeOptions(url)...) + case commandExists("google-chrome-stable"): + openCmd = exec.Command("google-chrome-stable", chromeOptions(url)...) + case commandExists("chromium"): + openCmd = exec.Command("chromium", chromeOptions(url)...) + case commandExists("chromium-browser"): + openCmd = exec.Command("chromium-browser", chromeOptions(url)...) + case pathExists(macPath): + openCmd = exec.Command(macPath, chromeOptions(url)...) + case pathExists(wslPath): + openCmd = exec.Command(wslPath, chromeOptions(url)...) + case pathExists(winPath): + openCmd = exec.Command(winPath, chromeOptions(url)...) + default: + err := browser.OpenURL(url) + if err != nil { + flog.Error("failed to open browser: %v", err) + } + return + } + + // We do not use CombinedOutput because if there is no chrome instance, this will block + // and become the parent process instead of using an existing chrome instance. + err := openCmd.Start() + if err != nil { + flog.Error("failed to open browser: %v", err) + } +} + +func chromeOptions(url string) []string { + return []string{"--app=" + url, "--disable-extensions", "--disable-plugins", "--incognito"} +} + +// Checks if a command exists locally. +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func pathExists(name string) bool { + _, err := os.Stat(name) + return err == nil +} + +// randomPort picks a random port to start code-server on. +func randomPort() (string, error) { + const ( + minPort = 1024 + maxPort = 65535 + maxTries = 10 + ) + for i := 0; i < maxTries; i++ { + port := rand.Intn(maxPort-minPort+1) + minPort + l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err == nil { + _ = l.Close() + return strconv.Itoa(port), nil + } + flog.Info("port taken: %d", port) + } + + return "", xerrors.Errorf("max number of tries exceeded: %d", maxTries) +} + +// checkSSHDirectory performs sanity and safety checks on sshDirectory, and +// returns a new value for o.reuseConnection depending on the checks. +func checkSSHDirectory(sshDirectory string, reuseConnection bool) bool { + if runtime.GOOS == "windows" { + flog.Info("OS is windows, disabling connection reuse feature") + return false + } + + sshDirectoryMode, err := os.Lstat(expandPath(sshDirectory)) + if err != nil { + if reuseConnection { + flog.Info("failed to stat %v directory, disabling connection reuse feature: %v", sshDirectory, err) + } + reuseConnection = false + } else { + if !sshDirectoryMode.IsDir() { + if reuseConnection { + flog.Info("%v is not a directory, disabling connection reuse feature", sshDirectory) + } else { + flog.Info("warning: %v is not a directory", sshDirectory) + } + reuseConnection = false + } + if sshDirectoryMode.Mode().Perm()&sshDirectoryUnsafeModeMask != 0 { + flog.Info("warning: the %v directory has unsafe permissions, they should only be writable by "+ + "the owner (and files inside should be set to 0600)", sshDirectory) + } + } + return reuseConnection +} + +// startSSHMaster starts an SSH master connection and waits for it to be ready. +// It returns a new set of SSH flags for child SSH processes to use. +func startSSHMaster(sshFlags string, sshControlPath string, host string) (string, func(), error) { + ctx, cancel := context.WithCancel(context.Background()) + + newSSHFlags := fmt.Sprintf(`%v -o "ControlPath=%v"`, sshFlags, sshControlPath) + + // -MN means "start a master socket and don't open a session, just connect". + sshCmdStr := fmt.Sprintf(`exec ssh %v -MNq %v`, newSSHFlags, host) + sshMasterCmd := exec.CommandContext(ctx, "sh", "-c", sshCmdStr) + sshMasterCmd.Stdin = os.Stdin + sshMasterCmd.Stderr = os.Stderr + + // Gracefully stop the SSH master. + stopSSHMaster := func() { + if sshMasterCmd.Process != nil { + if sshMasterCmd.ProcessState != nil && sshMasterCmd.ProcessState.Exited() { + return + } + err := sshMasterCmd.Process.Signal(syscall.SIGTERM) + if err != nil { + flog.Error("failed to send SIGTERM to SSH master process: %v", err) + } + } + cancel() + } + + // Start ssh master and wait. Waiting prevents the process from becoming a zombie process if it dies before + // sshcode does, and allows sshMasterCmd.ProcessState to be populated. + err := sshMasterCmd.Start() + go sshMasterCmd.Wait() + if err != nil { + return "", stopSSHMaster, err + } + err = checkSSHMaster(sshMasterCmd, newSSHFlags, host) + if err != nil { + stopSSHMaster() + return "", stopSSHMaster, xerrors.Errorf("SSH master wasn't ready on time: %w", err) + } + return newSSHFlags, stopSSHMaster, nil +} + +// checkSSHMaster polls every second for 30 seconds to check if the SSH master +// is ready. +func checkSSHMaster(sshMasterCmd *exec.Cmd, sshFlags string, host string) error { + var ( + maxTries = 30 + sleepDur = time.Second + err error + ) + for i := 0; i < maxTries; i++ { + // Check if the master is running. + if sshMasterCmd.Process == nil || (sshMasterCmd.ProcessState != nil && sshMasterCmd.ProcessState.Exited()) { + return xerrors.Errorf("SSH master process is not running") + } + + // Check if it's ready. + sshCmdStr := fmt.Sprintf(`ssh %v -O check %v`, sshFlags, host) + sshCmd := exec.Command("sh", "-c", sshCmdStr) + err = sshCmd.Run() + if err == nil { + return nil + } + time.Sleep(sleepDur) + } + return xerrors.Errorf("max number of tries exceeded: %d", maxTries) +} + +// copyCodeServerBinary copies a code-server binary from local to remote. +func copyCodeServerBinary(sshFlags string, host string, localPath string, remotePath string) error { + if err := validateIsFile(localPath); err != nil { + return err + } + + var ( + src = localPath + dest = host + ":" + remotePath + ) + + return rsync(src, dest, sshFlags) +} + +func syncUserSettings(sshFlags string, host string, back bool) error { + localConfDir, err := configDir() + if err != nil { + return err + } + + err = ensureDir(localConfDir) + if err != nil { + return err + } + + var remoteSettingsDir = "~/.local/share/code-server/User/" + if runtime.GOOS == "windows" { + remoteSettingsDir = ".local/share/code-server/User/" + } + var ( + src = localConfDir + "/" + dest = host + ":" + remoteSettingsDir + ) + + if back { + dest, src = src, dest + } + + // Append "/" to have rsync copy the contents of the dir. + return rsync(src, dest, sshFlags, "workspaceStorage", "logs", "CachedData") +} + +func syncExtensions(sshFlags string, host string, back bool) error { + localExtensionsDir, err := extensionsDir() + if err != nil { + return err + } + + err = ensureDir(localExtensionsDir) + if err != nil { + return err + } + + var remoteExtensionsDir = "~/.local/share/code-server/extensions/" + if runtime.GOOS == "windows" { + remoteExtensionsDir = ".local/share/code-server/extensions/" + } + + var ( + src = localExtensionsDir + "/" + dest = host + ":" + remoteExtensionsDir + ) + if back { + dest, src = src, dest + } + + return rsync(src, dest, sshFlags) +} + +func rsync(src string, dest string, sshFlags string, excludePaths ...string) error { + excludeFlags := make([]string, len(excludePaths)) + for i, path := range excludePaths { + excludeFlags[i] = "--exclude=" + path + } + + cmd := exec.Command("rsync", append(excludeFlags, "-azvr", + "-e", "ssh "+sshFlags, + // Only update newer directories, and sync times + // to keep things simple. + "-u", "--times", + // This is more unsafe, but it's obnoxious having to enter VS Code + // locally in order to properly delete an extension. + "--delete", + "--copy-unsafe-links", + "-zz", + src, dest, + )..., + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + return xerrors.Errorf("failed to rsync '%s' to '%s': %w", src, dest, err) + } + + return nil +} + +func downloadScript(codeServerPath string) string { + return fmt.Sprintf( + `set -euxo pipefail || exit 1 + +[ "$(uname -m)" != "x86_64" ] && echo "Unsupported server architecture $(uname -m). code-server only has releases for x86_64 systems." && exit 1 +pkill -f %v || true +mkdir -p $HOME/.local/share/code-server %v +cd %v +curlflags="-o latest-linux" +if [ -f latest-linux ]; then + curlflags="$curlflags -z latest-linux" +fi +curl $curlflags https://codesrv-ci.cdr.sh/latest-linux +[ -f %v ] && rm %v +ln latest-linux %v +chmod +x %v`, + codeServerPath, + filepath.ToSlash(filepath.Dir(codeServerPath)), + filepath.ToSlash(filepath.Dir(codeServerPath)), + codeServerPath, + codeServerPath, + codeServerPath, + codeServerPath, + ) +} + +// ensureDir creates a directory if it does not exist. +func ensureDir(path string) error { + _, err := os.Stat(path) + if os.IsNotExist(err) { + // This fixes a issue where Go reads `/c/` as `C:\c\` and creates + // empty directories on the client that don't need to exist. + if runtime.GOOS == "windows" && strings.HasPrefix(path, "/c/") { + path = "C:" + path[2:] + } + err = os.MkdirAll(path, 0750) + } + + if err != nil { + return err + } + + return nil +} + +// validateIsFile tries to stat the specified path and ensure it's a file. +func validateIsFile(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return xerrors.New("path is a directory") + } + return nil +} + +// parseHost parses the host argument. If 'gcp:' is prefixed to the +// host then a lookup is done using gcloud to determine the external IP and any +// additional SSH arguments that should be used for ssh commands. Otherwise, host +// is returned. +func parseHost(host string) (parsedHost string, additionalFlags string, err error) { + host = strings.TrimSpace(host) + switch { + case strings.HasPrefix(host, "gcp:"): + instance := strings.TrimPrefix(host, "gcp:") + return parseGCPSSHCmd(instance) + default: + return host, "", nil + } +} + +// parseGCPSSHCmd parses the IP address and flags used by 'gcloud' when +// ssh'ing to an instance. +func parseGCPSSHCmd(instance string) (ip, sshFlags string, err error) { + dryRunCmd := fmt.Sprintf("gcloud compute ssh --dry-run %v", instance) + + out, err := exec.Command("sh", "-l", "-c", dryRunCmd).CombinedOutput() + if err != nil { + return "", "", xerrors.Errorf("%s: %w", out, err) + } + + toks := strings.Split(string(out), " ") + if len(toks) < 2 { + return "", "", xerrors.Errorf("unexpected output for '%v' command, %s", dryRunCmd, out) + } + + // Slice off the '/usr/bin/ssh' prefix and the '@' suffix. + sshFlags = strings.Join(toks[1:len(toks)-1], " ") + + // E.g. foo@1.2.3.4. + userIP := toks[len(toks)-1] + + return strings.TrimSpace(userIP), sshFlags, nil +} + +// gitbashWindowsDir strips a the msys2 install directory from the beginning of +// the path. On msys2, if a user provides `/workspace` sshcode will receive +// `C:/msys64/workspace` which won't work on the remote host. +func gitbashWindowsDir(dir string) string { + + // Don't bother figuring out path if it's relative to home dir. + if strings.HasPrefix(dir, "~/") { + if dir == "~" { + return "~/" + } + return dir + } + + mingwPrefix, err := exec.Command("sh", "-c", "{ cd / && pwd -W; }").Output() + if err != nil { + // Default to a sane location. + mingwPrefix = []byte("C:/mingw64") + } + + prefix := strings.TrimSuffix(string(mingwPrefix), "/\n") + return strings.TrimPrefix(dir, prefix) +} diff --git a/sshcode_test.go b/sshcode_test.go new file mode 100644 index 0000000..096bff6 --- /dev/null +++ b/sshcode_test.go @@ -0,0 +1,316 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os/exec" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.coder.com/retry" + "golang.org/x/crypto/ssh" +) + +func TestSSHCode(t *testing.T) { + sshPort, err := randomPort() + require.NoError(t, err) + + // start up our jank ssh server + defer trassh(t, sshPort).Close() + + localPort := randomPortExclude(t, sshPort) + require.NotEmpty(t, localPort) + + remotePort := randomPortExclude(t, sshPort, localPort) + require.NotEmpty(t, remotePort) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + err := sshCode("foo@127.0.0.1", "", options{ + sshFlags: testSSHArgs(sshPort), + bindAddr: net.JoinHostPort("127.0.0.1", localPort), + remotePort: remotePort, + noOpen: true, + }) + require.NoError(t, err) + }() + + waitForSSHCode(t, localPort, time.Second*30) + waitForSSHCode(t, remotePort, time.Second*30) + + // Typically we'd do an os.Stat call here but the os package doesn't expand '~' + out, err := exec.Command("sh", "-l", "-c", "stat "+codeServerPath).CombinedOutput() + require.NoError(t, err, "%s", out) + + out, err = exec.Command("pkill", filepath.Base(codeServerPath)).CombinedOutput() + require.NoError(t, err, "%s", out) + + wg.Wait() +} + +// trassh is an incomplete, local, insecure ssh server +// used for the purpose of testing the implementation without +// requiring the user to have their own remote server. +func trassh(t *testing.T, port string) io.Closer { + private, err := ssh.ParsePrivateKey([]byte(fakeRSAKey)) + require.NoError(t, err) + + conf := &ssh.ServerConfig{ + NoClientAuth: true, + } + + conf.AddHostKey(private) + + listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", port)) + require.NoError(t, err) + + go func() { + for { + func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + + sshConn, chans, reqs, err := ssh.NewServerConn(conn, conf) + require.NoError(t, err) + + go ssh.DiscardRequests(reqs) + + for c := range chans { + switch c.ChannelType() { + case "direct-tcpip": + var req directTCPIPReq + + err := ssh.Unmarshal(c.ExtraData(), &req) + if err != nil { + t.Logf("failed to unmarshal tcpip data: %v", err) + continue + } + + ch, _, err := c.Accept() + if err != nil { + c.Reject(ssh.ConnectionFailed, fmt.Sprintf("unable to accept channel: %v", err)) + continue + } + + go handleDirectTCPIP(ch, &req, t) + case "session": + ch, inReqs, err := c.Accept() + if err != nil { + c.Reject(ssh.ConnectionFailed, fmt.Sprintf("unable to accept channel: %v", err)) + continue + } + + go handleSession(ch, inReqs, t) + default: + t.Logf("unsupported session type: %v\n", c.ChannelType()) + c.Reject(ssh.UnknownChannelType, "unknown channel type") + } + } + + sshConn.Wait() + }() + } + }() + return listener +} + +func handleDirectTCPIP(ch ssh.Channel, req *directTCPIPReq, t *testing.T) { + defer ch.Close() + + dstAddr := net.JoinHostPort(req.Host, strconv.Itoa(int(req.Port))) + + conn, err := net.Dial("tcp", dstAddr) + if err != nil { + return + } + defer conn.Close() + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + defer ch.Close() + + io.Copy(ch, conn) + }() + + wg.Add(1) + go func() { + defer wg.Done() + defer conn.Close() + + io.Copy(conn, ch) + }() + wg.Wait() +} + +// execReq describes an exec payload. +type execReq struct { + Command string +} + +// directTCPIPReq describes the extra data sent in a +// direct-tcpip request containing the host/port for the ssh server. +type directTCPIPReq struct { + Host string + Port uint32 + + Orig string + OrigPort uint32 +} + +// exitStatus describes an 'exit-status' message +// returned after a request. +type exitStatus struct { + Status uint32 +} + +func handleSession(ch ssh.Channel, in <-chan *ssh.Request, t *testing.T) { + defer ch.Close() + + for req := range in { + if req.WantReply { + req.Reply(true, nil) + } + + // TODO support the rest of the types e.g. env, pty, etc. + // Right now they aren't necessary for the tests. + if req.Type != "exec" { + t.Logf("Unsupported session type %v, only 'exec' is supported", req.Type) + continue + } + + var exReq execReq + err := ssh.Unmarshal(req.Payload, &exReq) + if err != nil { + t.Logf("failed to unmarshal exec payload %s", req.Payload) + return + } + + cmd := exec.Command("sh", "-l", "-c", exReq.Command) + + stdin, err := cmd.StdinPipe() + require.NoError(t, err) + + go func() { + defer stdin.Close() + io.Copy(stdin, ch) + }() + + cmd.Stdout = ch + cmd.Stderr = ch.Stderr() + err = cmd.Run() + + var exit exitStatus + if err != nil { + exErr, ok := err.(*exec.ExitError) + require.True(t, ok, "Not an exec.ExitError, was %T", err) + + exit.Status = uint32(exErr.ExitCode()) + } + + _, err = ch.SendRequest("exit-status", false, ssh.Marshal(&exit)) + if err != nil { + t.Logf("unable to send status: %v", err) + } + break + } +} + +func waitForSSHCode(t *testing.T, port string, timeout time.Duration) { + var ( + url = fmt.Sprintf("http://localhost:%v/", port) + client = &http.Client{ + Timeout: time.Second, + } + ) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + backoff := &retry.Backoff{ + Floor: time.Second, + Ceil: time.Second, + } + + for { + resp, err := client.Get(url) + if err == nil { + require.Equal(t, http.StatusOK, resp.StatusCode) + return + } + err = backoff.Wait(ctx) + require.NoError(t, err) + } +} + +// fakeRSAKey isn't used for anything other than the trassh ssh +// server. +const fakeRSAKey = `-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAsbbGAxPQeqti2OgdzuMgJGBAwXe/bFhQTPuk0bIvavkZwX/a +NhmXV0dhLino5KtjR8oEazLxOgnOkJ6mpwVEgUhNMZhD9jEHZ7at4DtBIwfxjHjv +nF+kJAt4xX4AZYbwIfLN9TsDGGhv4wPlB7mbwv+lhmPK+HsLbajO4n69k3s0WW94 +LafJntx/98o9gL2R7hpbMxgUu8cSZjYakkRBQdab0xUuTiceq0HfAOBCQpEw0meF +cmhMeeu7H5UwKGj573pBxON0G1SJgipkcs4TD2rZ9wjc29gDJjHjf3Ko/JzX1WFL +db21fzqRGWelgCHCUsIvUBeExk4jM1d63JrmFQIDAQABAoIBAQCdc9OSjG6tEMYe +aeFnGQK0V/dnskIOq1xSKK7J/7ZVb+iq8S0Tu67D7IEklos6dsMaqtkpZVQm2OOE +bJw45MjiRn3mUAL+0EfAUzFQtw8qC3Kuw8N/55kVOnjBeba+PUTqvyZNfQBsErP3 +Dc9Q/dkMdtZf8HC3oMTqXqMWN7adQBQRBspUBkLQeSemYsUm2cc+YSnCwKel98uN +EuDJaTZwutxTUF1FBoXlejYlVKcldk1w5HtKkjGdW+mbo2xUpu8W0620Rs/fXNpU ++guAlpB1/Wx5foZqZx33Ul8HINfDre/uqHwCd+ucDIyV7TfIh9JV5w3iRLa0QCz0 +kFe/GsEtAoGBAODRa1GwfyK+gcgxF2qwfsxF3I+DQhqWFiCA0o5kO2fpiUR3rDQj +XhBoPxr/qYBSBtGErHIiB7WFeQ6GjVTEgY/cEkIIh1tY95UWQ3/oIZWW498dQGRh +SUGXm3lMrSsVCyXxNexSH5yTrRzyZ2u4mZupMeyACoGRGkNTVppOU4XbAoGBAMpc +1ifX3kr5m8CXa6mI+NWFAQlhW0Ak0hjhM/WDzMrSimYxLLSkaKyUSHnFP/8V4asA +tV173lVut2Cjv5v5FcrOnI33Li2IcNlOzCRiLHzZ43HXckcoQDcU8iKTBq1a0Dx1 +eXr2rs+a/2pTy7IMsxyJVCSP6IDBI9+2iW+Cxh7PAoGBAMOa0hJAS02yjX7d367v +I1OeETo4jQJOxa/ABfLoGJvfoJQWv5iZkRUbbpSSDytbsx0Gn3eqTiTMnbhar4sq +ckP1yVj0zLhY3wkzVsVp9haOM3ODouvzjWZpf1d5tE2AwLNhfHZCOcjk4EEIU51w +/w1ll89a1ElJM52SXA5jyd3zAoGBAKGtpKi2rvMGFKu+DxWnyu+FUXu2HhrUkEuy +ejn5MMEHj+3v8gDtrnfcDT/FGclrKR7f9QeYtN1bFQYQLkGmtAOSKcC/MVTNwyPL +8gxLp7GkwDSvZq11ekDH6mE3SMluWhtD3Ggi+S4Db3f7NS6vONde3SxNEfz00v2l +MI84U6Q/AoGAVTZGT5weqRTJSqnri6Noz+5j/73QMf/QiZDgHMMCF0giC2mxqOgR +QF6+cxHQe0sbMQ/xJU5RYhgnqSa2TjLMju4N2nQ9i/HqI/3p0CPwjFsZWlXmWEK9 +5kdld52W7Bu2vQuFbg2Oy7aPhnI+1CqlubOFRgMe4AJND2t9SMTV+rc= +-----END RSA PRIVATE KEY----- +` + +func testSSHArgs(port string) string { + return "-o StrictHostKeyChecking=no -p " + port +} + +func randomPortExclude(t *testing.T, exludedPorts ...string) string { + valid := func(port string) bool { + for _, exPort := range exludedPorts { + if exPort == port { + return false + } + } + return true + } + + maxTries := 10 + for i := 0; i < maxTries; i++ { + port, err := randomPort() + require.NoError(t, err) + + if valid(port) { + return port + } + } + + return "" +}