From dd192d7c068974b8d8ba2d04425bc818703ee237 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Tue, 23 Apr 2019 10:34:57 -0500 Subject: [PATCH 01/46] Document insiders config for MacOS --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 693a31d..2bf1caa 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,15 @@ must specify your settings directories through the `VSCODE_CONFIG_DIR` and The following will make `sshcode` work with VS Code Insiders: +**MacOS** + +```bash +export VSCODE_CONFIG_DIR="$HOME/.config/Code - Insiders/User" +export VSCODE_CONFIG_DIR="$HOME/Library/Application Support/Code - Insiders/User" +``` + +**Linux** + ```bash export VSCODE_CONFIG_DIR="$HOME/.config/Code - Insiders/User" export VSCODE_EXTENSIONS_DIR="$HOME/.vscode-insiders/extensions" From ccb6159b8cc0c83a426c90ae8fff5a4d48d6b419 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Tue, 23 Apr 2019 11:07:52 -0500 Subject: [PATCH 02/46] Emphasize our recommendation to use Chrome --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2bf1caa..e8b9818 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ there's no keybind conflicts, address bar, or indication that you're coding with ## Install -Chrome is recommended. +**Have Chrome installed for the best experience.** Install with `go`: From 91622d4934b94dc28e6337ce2df907847bcf02ae Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Tue, 23 Apr 2019 17:25:41 -0500 Subject: [PATCH 03/46] Add a flag that prints version information Resolves #51 --- ci/build.sh | 4 +++- main.go | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ci/build.sh b/ci/build.sh index ac2b405..da1565b 100755 --- a/ci/build.sh +++ b/ci/build.sh @@ -1,9 +1,11 @@ #!/bin/bash export GOARCH=amd64 +tag=$(git describe --tags) + build(){ tmpdir=$(mktemp -d) - go build -o $tmpdir/sshcode + go build -ldflags "-X main.version=${tag}" -o $tmpdir/sshcode pushd $tmpdir tarname=sshcode-$GOOS-$GOARCH.tar diff --git a/main.go b/main.go index 0dc40b8..c31b93d 100644 --- a/main.go +++ b/main.go @@ -55,11 +55,15 @@ func flagHelp() string { return bd.String() } +// version is overwritten by ci/build.sh. +var version string + func main() { var ( skipSyncFlag = flag.Bool("skipsync", false, "skip syncing local settings and extensions to remote host") sshFlags = flag.String("ssh-flags", "", "custom SSH flags") syncBack = flag.Bool("b", false, "sync extensions back on termination") + printVersion = flag.Bool("version", false, "print version information and exit") ) flag.Usage = func() { @@ -81,6 +85,11 @@ Arguments: } flag.Parse() + if *printVersion { + fmt.Printf("%v\n", version) + os.Exit(0) + } + host := flag.Arg(0) if host == "" { From 7e5b44a177e997309be5b878ef869e8b931adbd3 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Wed, 24 Apr 2019 09:22:11 -0500 Subject: [PATCH 04/46] Correct MacOS Insiders config --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e8b9818..3863b73 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,8 @@ The following will make `sshcode` work with VS Code Insiders: **MacOS** ```bash -export VSCODE_CONFIG_DIR="$HOME/.config/Code - Insiders/User" export VSCODE_CONFIG_DIR="$HOME/Library/Application Support/Code - Insiders/User" +export VSCODE_EXTENSIONS_DIR="$HOME/.vscode-insiders/extensions" ``` **Linux** From acf0057863767ee30befba69ba53abd311d6ce17 Mon Sep 17 00:00:00 2001 From: Alex Gartner Date: Fri, 26 Apr 2019 18:51:05 -0700 Subject: [PATCH 05/46] Add path to Chrome in Windows Subsystem for Linux --- main.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index c31b93d..d8bb605 100644 --- a/main.go +++ b/main.go @@ -225,6 +225,12 @@ chmod +x ` + codeServerPath 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" + ) + switch { case commandExists("google-chrome"): openCmd = exec.Command("google-chrome", chromeOptions(url)...) @@ -234,8 +240,10 @@ func openBrowser(url string) { openCmd = exec.Command("chromium", chromeOptions(url)...) case commandExists("chromium-browser"): openCmd = exec.Command("chromium-browser", chromeOptions(url)...) - case pathExists("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"): - openCmd = exec.Command("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", chromeOptions(url)...) + case pathExists(macPath): + openCmd = exec.Command(macPath, chromeOptions(url)...) + case pathExists(wslPath): + openCmd = exec.Command(wslPath, chromeOptions(url)...) default: err := browser.OpenURL(url) if err != nil { From e4a815a8594508645bfc134d755b4fecf32401ee Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sat, 27 Apr 2019 14:27:31 -0500 Subject: [PATCH 06/46] Update GitHub username --- README.md | 10 +++++----- main.go | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3863b73..8529065 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # sshcode -[!["Open Issues"](https://img.shields.io/github/issues-raw/codercom/sshcode.svg)](https://github.com/codercom/sshcode/issues) -[!["Latest Release"](https://img.shields.io/github/release/codercom/sshcode.svg)](https://github.com/codercom/sshcode/releases/latest) -[![MIT license](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/codercom/sshcode/blob/master/LICENSE) +[!["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) -`sshcode` is a CLI to automatically install and run [code-server](https://github.com/codercom/code-server) over SSH. +`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 hosts. @@ -26,7 +26,7 @@ Install with `go`: go get -u go.coder.com/sshcode ``` -Or, grab a [pre-built binary](https://github.com/codercom/sshcode/releases). +Or, grab a [pre-built binary](https://github.com/cdr/sshcode/releases). ## Usage diff --git a/main.go b/main.go index d8bb605..f6e7c20 100644 --- a/main.go +++ b/main.go @@ -74,7 +74,7 @@ Environment variables: `+vsCodeConfigDirEnv+` use special VS Code settings dir. `+vsCodeExtensionsDirEnv+` use special VS Code extensions dir. -More info: https://github.com/codercom/sshcode +More info: https://github.com/cdr/sshcode Arguments: `+helpTab+`HOST is passed into the ssh command. From dd4825b1da02ef4c6afa97b13063247a1895f190 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sat, 27 Apr 2019 14:28:45 -0500 Subject: [PATCH 07/46] Add OS support to README --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 8529065..a8913aa 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,13 @@ 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 + ## Usage ```bash From c9894590a19dbaaa8982142229134c6542ec44db Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sat, 27 Apr 2019 14:33:23 -0500 Subject: [PATCH 08/46] Ensure bin directory is created in build.sh --- ci/build.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ci/build.sh b/ci/build.sh index da1565b..1f14096 100755 --- a/ci/build.sh +++ b/ci/build.sh @@ -3,6 +3,8 @@ export GOARCH=amd64 tag=$(git describe --tags) +mkdir -p bin + build(){ tmpdir=$(mktemp -d) go build -ldflags "-X main.version=${tag}" -o $tmpdir/sshcode From 6319b9430afdfffbaf7e8dcd72877e752a9d468d Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 29 Apr 2019 14:17:14 -0500 Subject: [PATCH 09/46] Add CI --- .gitignore | 1 + .travis.yml | 10 ++++++++++ ci/ensuremod.sh | 22 ++++++++++++++++++++++ ci/lint.sh | 17 +++++++++++++++++ 4 files changed, 50 insertions(+) create mode 100644 .travis.yml create mode 100755 ci/ensuremod.sh create mode 100755 ci/lint.sh diff --git a/.gitignore b/.gitignore index b873546..6eec620 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ vendor bin +.vscode diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5b1b861 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: go + +go: +- 1.12.x +env: + - GO111MODULE=on +script: +- ./ci/ensuremod.sh +- ./ci/lint.sh +- go test -v ./... 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 . From 326938c855601950a1ef2b624aacb5cae565766b Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 24 Apr 2019 12:59:59 -0500 Subject: [PATCH 10/46] Add a sanity test - Refactor and break out code from main() - Use a random remote port --- .travis.yml | 3 +- go.mod | 4 + go.sum | 20 +++ main.go | 335 +++++++---------------------------------------- sshcode.go | 342 ++++++++++++++++++++++++++++++++++++++++++++++++ sshcode_test.go | 315 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 727 insertions(+), 292 deletions(-) create mode 100644 sshcode.go create mode 100644 sshcode_test.go diff --git a/.travis.yml b/.travis.yml index 5b1b861..e88a4c5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,8 @@ +dist: xenial language: go - go: - 1.12.x +go_import_path: go.coder.com/retry env: - GO111MODULE=on script: diff --git a/go.mod b/go.mod index c5ea990..b9fb5da 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,11 @@ go 1.12 require ( github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 + github.com/pkg/errors v0.8.1 // indirect + github.com/stretchr/testify v1.3.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 ) diff --git a/go.sum b/go.sum index 804a9d7..8ebc634 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +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= @@ -6,9 +8,27 @@ 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/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/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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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= diff --git a/main.go b/main.go index f6e7c20..fd7ead3 100644 --- a/main.go +++ b/main.go @@ -1,24 +1,15 @@ package main import ( - "context" "flag" "fmt" "math/rand" - "net" - "net/http" "os" - "os/exec" - "os/signal" - "path/filepath" - "strconv" "strings" "text/tabwriter" "time" - "github.com/pkg/browser" "go.coder.com/flog" - "golang.org/x/xerrors" ) func init() { @@ -29,32 +20,6 @@ const helpTabWidth = 5 var helpTab = strings.Repeat(" ", helpTabWidth) -// flagHelp generates a friendly help string for all globally registered command -// line flags. -func flagHelp() string { - var bd strings.Builder - - w := tabwriter.NewWriter(&bd, 3, 10, helpTabWidth, ' ', 0) - - fmt.Fprintf(w, "Flags:\n") - var count int - flag.VisitAll(func(f *flag.Flag) { - count++ - if f.DefValue == "" { - fmt.Fprintf(w, "\t-%v\t%v\n", f.Name, f.Usage) - } else { - fmt.Fprintf(w, "\t-%v\t%v\t(%v)\n", f.Name, f.Usage, f.DefValue) - } - }) - if count == 0 { - return "\n" - } - - w.Flush() - - return bd.String() -} - // version is overwritten by ci/build.sh. var version string @@ -66,23 +31,7 @@ func main() { printVersion = flag.Bool("version", false, "print version information and exit") ) - flag.Usage = func() { - fmt.Printf(`Usage: %v [FLAGS] HOST [DIR] -Start VS Code via code-server over SSH. - -Environment variables: - `+vsCodeConfigDirEnv+` use special VS Code settings dir. - `+vsCodeExtensionsDirEnv+` use special VS Code extensions dir. - -More info: https://github.com/cdr/sshcode - -Arguments: -`+helpTab+`HOST is passed into the ssh command. -`+helpTab+`DIR is optional. - -%v`, os.Args[0], flagHelp(), - ) - } + flag.Usage = usage flag.Parse() if *printVersion { @@ -103,260 +52,64 @@ Arguments: dir = "~" } - flog.Info("ensuring code-server is updated...") - - const codeServerPath = "/tmp/codessh-code-server" - - downloadScript := `set -euxo pipefail || exit 1 - -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 - // Downloads the latest code-server and allows it to be executed. - sshCmdStr := fmt.Sprintf("ssh" + - " " + *sshFlags + " " + - host + " /bin/bash", - ) - sshCmd := exec.Command("sh", "-c", sshCmdStr) - sshCmd.Stdout = os.Stdout - sshCmd.Stderr = os.Stderr - sshCmd.Stdin = strings.NewReader(downloadScript) - err := sshCmd.Run() - if err != nil { - flog.Fatal("failed to update code-server: %v\n---ssh cmd---\n%s\n---download script---\n%s", err, - sshCmdStr, - downloadScript, - ) - } - - if !*skipSyncFlag { - start := time.Now() - flog.Info("syncing settings") - err = syncUserSettings(*sshFlags, host, false) - if err != nil { - flog.Fatal("failed to sync settings: %v", err) - } - flog.Info("synced settings in %s", time.Since(start)) - - flog.Info("syncing extensions") - err = syncExtensions(*sshFlags, host, false) - 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) - } - - 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 - } - - ctx, cancel = context.WithCancel(context.Background()) - openBrowser(url) - - go func() { - defer cancel() - sshCmd.Wait() - }() - - c := make(chan os.Signal) - signal.Notify(c, os.Interrupt) - - select { - case <-ctx.Done(): - case <-c: - } - - if !*syncBack || *skipSyncFlag { - flog.Info("shutting down") - return - } - - flog.Info("synchronizing VS Code back to local") - - err = syncExtensions(*sshFlags, host, true) - if err != nil { - flog.Fatal("failed to sync extensions back: %v", err) - } - - err = syncUserSettings(*sshFlags, host, true) - if err != nil { - flog.Fatal("failed to user settigns extensions back: %v", err) - } -} - -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" - ) - - switch { - 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)...) - default: - err := browser.OpenURL(url) - if err != nil { - flog.Error("failed to open browser: %v", err) - } - return - } + err := sshCode(host, dir, options{ + skipSync: *skipSyncFlag, + sshFlags: *sshFlags, + syncBack: *syncBack, + }) - // 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) + flog.Fatal("error: %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) - } +func usage() { + fmt.Printf(`Usage: %v [FLAGS] HOST [DIR] +Start VS Code via code-server over SSH. - return "", xerrors.Errorf("max number of tries exceeded: %d", maxTries) -} +Environment variables: + %v use special VS Code settings dir. + %v use special VS Code extensions dir. -func syncUserSettings(sshFlags string, host string, back bool) error { - localConfDir, err := configDir() - if err != nil { - return err - } - const remoteSettingsDir = ".local/share/code-server/User/" +More info: https://github.com/cdr/sshcode - var ( - src = localConfDir + "/" - dest = host + ":" + remoteSettingsDir +Arguments: +%vHOST is passed into the ssh command. +%vDIR is optional. + +%v`, + os.Args[0], + vsCodeConfigDirEnv, + vsCodeExtensionsDirEnv, + helpTab, + helpTab, + flagHelp(), ) - 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 - } - const remoteExtensionsDir = ".local/share/code-server/extensions/" - - var ( - src = localExtensionsDir + "/" - dest = host + ":" + remoteExtensionsDir - ) - if back { - dest, src = src, dest - } +// flagHelp generates a friendly help string for all globally registered command +// line flags. +func flagHelp() string { + var bd strings.Builder - return rsync(src, dest, sshFlags) -} + w := tabwriter.NewWriter(&bd, 3, 10, helpTabWidth, ' ', 0) -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 + fmt.Fprintf(w, "Flags:\n") + var count int + flag.VisitAll(func(f *flag.Flag) { + count++ + if f.DefValue == "" { + fmt.Fprintf(w, "\t-%v\t%v\n", f.Name, f.Usage) + } else { + fmt.Fprintf(w, "\t-%v\t%v\t(%v)\n", f.Name, f.Usage, f.DefValue) + } + }) + if count == 0 { + return "\n" } - 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", - 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) - } + w.Flush() - return nil + return bd.String() } diff --git a/sshcode.go b/sshcode.go new file mode 100644 index 0000000..1a39e31 --- /dev/null +++ b/sshcode.go @@ -0,0 +1,342 @@ +package main + +import ( + "context" + "fmt" + "math/rand" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/pkg/browser" + "go.coder.com/flog" + "golang.org/x/xerrors" +) + +type options struct { + skipSync bool + syncBack bool + localPort string + remotePort string + sshFlags string +} + +func sshCode(host, dir string, o options) error { + flog.Info("ensuring code-server is updated...") + + const codeServerPath = "/tmp/sshcode-code-server" + + dlScript := downloadScript(codeServerPath) + + // Downloads the latest code-server and allows it to be executed. + sshCmdStr := fmt.Sprintf("ssh %v %v /bin/bash", o.sshFlags, host) + + sshCmd := exec.Command("sh", "-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...") + + if o.localPort == "" { + o.localPort, err = randomPort() + } + if err != nil { + return xerrors.Errorf("failed to find available local port: %w", err) + } + + if o.remotePort == "" { + o.remotePort, err = randomPort() + } + if err != nil { + return xerrors.Errorf("failed to find available remote port: %w", err) + } + + flog.Info("Tunneling local port %v to remote port %v", o.localPort, o.remotePort) + + sshCmdStr = fmt.Sprintf("ssh -tt -q -L %v %v %v 'cd %v; %v --host 127.0.0.1 --allow-http --no-auth --port=%v'", + o.localPort+":localhost:"+o.remotePort, o.sshFlags, host, dir, codeServerPath, o.remotePort, + ) + + // 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) + } + + url := "http://127.0.0.1:" + o.localPort + 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 os.Getenv("DISPLAY") != "" { + openBrowser(url) + } + + go func() { + defer cancel() + sshCmd.Wait() + }() + + c := make(chan os.Signal) + signal.Notify(c, os.Interrupt) + + select { + case <-ctx.Done(): + case <-c: + } + + if !o.syncBack || o.skipSync { + flog.Info("shutting down") + 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 settings back: %w", err) + } + + return 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" + ) + + switch { + 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)...) + 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) +} + +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 + } + + const 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 + } + + const 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", + 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 + +mkdir -p ~/.local/share/code-server +cd %v +wget -N https://codesrv-ci.cdr.sh/latest-linux +[ -f %v ] && rm %v +ln latest-linux %v +chmod +x %v`, + 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) { + err = os.MkdirAll(path, 0750) + } + + if err != nil { + return err + } + + return nil +} diff --git a/sshcode_test.go b/sshcode_test.go new file mode 100644 index 0000000..3c87d3c --- /dev/null +++ b/sshcode_test.go @@ -0,0 +1,315 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "strconv" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.coder.com/retry" + "golang.org/x/crypto/ssh" +) + +func TestSSHCode(t *testing.T) { + // Avoid opening a browser window. + err := os.Unsetenv("DISPLAY") + require.NoError(t, err) + + 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("127.0.0.1", "", options{ + sshFlags: testSSHArgs(sshPort), + localPort: localPort, + remotePort: remotePort, + }) + require.NoError(t, err) + }() + + waitForSSHCode(t, localPort, time.Second*30) + waitForSSHCode(t, remotePort, time.Second*30) + + out, err := exec.Command("pkill", "sshcode-code").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", "-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 trashh 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 "" +} From 57ccf3a9139a50590d3743f9e16e60c5ce182f0a Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 30 Apr 2019 14:31:18 -0500 Subject: [PATCH 11/46] Support multiple users using same machine - code-server is now downloaded to ~/.cache/sshcode --- sshcode.go | 9 +++++---- sshcode_test.go | 7 ++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/sshcode.go b/sshcode.go index 1a39e31..c644c7d 100644 --- a/sshcode.go +++ b/sshcode.go @@ -19,6 +19,8 @@ import ( "golang.org/x/xerrors" ) +const codeServerPath = "~/.cache/sshcode/sshcode-server" + type options struct { skipSync bool syncBack bool @@ -30,8 +32,6 @@ type options struct { func sshCode(host, dir string, o options) error { flog.Info("ensuring code-server is updated...") - const codeServerPath = "/tmp/sshcode-code-server" - dlScript := downloadScript(codeServerPath) // Downloads the latest code-server and allows it to be executed. @@ -97,7 +97,7 @@ func sshCode(host, dir string, o options) error { sshCmd.Stderr = os.Stderr err = sshCmd.Start() if err != nil { - flog.Fatal("failed to start code-server: %v", err) + return xerrors.Errorf("failed to start code-server: %w", err) } url := "http://127.0.0.1:" + o.localPort @@ -313,12 +313,13 @@ func downloadScript(codeServerPath string) string { return fmt.Sprintf( `set -euxo pipefail || exit 1 -mkdir -p ~/.local/share/code-server +mkdir -p ~/.local/share/code-server %v cd %v wget -N https://codesrv-ci.cdr.sh/latest-linux [ -f %v ] && rm %v ln latest-linux %v chmod +x %v`, + filepath.Dir(codeServerPath), filepath.Dir(codeServerPath), codeServerPath, codeServerPath, diff --git a/sshcode_test.go b/sshcode_test.go index 3c87d3c..e486460 100644 --- a/sshcode_test.go +++ b/sshcode_test.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "strconv" "sync" "testing" @@ -50,7 +51,11 @@ func TestSSHCode(t *testing.T) { waitForSSHCode(t, localPort, time.Second*30) waitForSSHCode(t, remotePort, time.Second*30) - out, err := exec.Command("pkill", "sshcode-code").CombinedOutput() + // Typically we'd do an os.Stat call here but the os package doesn't expand '~' + out, err := exec.Command("sh", "-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() From 0d19aede58fb11c1763358fc924dd8ee39ca85e4 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 30 Apr 2019 18:14:39 -0500 Subject: [PATCH 12/46] Add support for specifying GCP instances - Specifying 'gcp:' for the host argument is now supported for indicating a Google Cloud compute instance --- main.go | 2 +- sshcode.go | 62 ++++++++++++++++++++++++++++++++++++++++++++++++- sshcode_test.go | 2 +- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index fd7ead3..2d0866f 100644 --- a/main.go +++ b/main.go @@ -74,7 +74,7 @@ Environment variables: More info: https://github.com/cdr/sshcode Arguments: -%vHOST is passed into the ssh command. +%vHOST is passed into the ssh command. Valid formats are '' or 'gcp:'. %vDIR is optional. %v`, diff --git a/sshcode.go b/sshcode.go index c644c7d..cfff58b 100644 --- a/sshcode.go +++ b/sshcode.go @@ -32,6 +32,14 @@ type options struct { func sshCode(host, dir string, o options) error { flog.Info("ensuring code-server is updated...") + host, extraSSHFlags, err := parseIP(host) + if err != nil { + return xerrors.Errorf("failed to parse host IP: %w", err) + } + if extraSSHFlags != "" { + o.sshFlags = strings.Join([]string{extraSSHFlags, o.sshFlags}, " ") + } + dlScript := downloadScript(codeServerPath) // Downloads the latest code-server and allows it to be executed. @@ -41,7 +49,7 @@ func sshCode(host, dir string, o options) error { sshCmd.Stdout = os.Stdout sshCmd.Stderr = os.Stderr sshCmd.Stdin = strings.NewReader(dlScript) - err := sshCmd.Run() + 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, @@ -341,3 +349,55 @@ func ensureDir(path string) error { return nil } + +// parseIP parses the host to a valid IP address. 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. +func parseIP(host string) (ip string, additionalFlags string, err error) { + host = strings.TrimSpace(host) + switch { + case strings.HasPrefix(host, "gcp:"): + instance := strings.TrimPrefix(host, "gcp:") + return parseGCPSSHCmd(instance) + default: + if net.ParseIP(host) == nil { + return "", "", xerrors.New("host argument is not a valid IP address") + } + 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", "-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] + toks = strings.Split(userIP, "@") + // Assume the '@' is missing. + if len(toks) < 2 { + ip = strings.TrimSpace(toks[0]) + } else { + ip = strings.TrimSpace(toks[1]) + } + + if net.ParseIP(ip) == nil { + return "", "", xerrors.Errorf("parsed invalid ip address %v", ip) + } + + return ip, sshFlags, nil +} diff --git a/sshcode_test.go b/sshcode_test.go index e486460..318f7da 100644 --- a/sshcode_test.go +++ b/sshcode_test.go @@ -261,7 +261,7 @@ func waitForSSHCode(t *testing.T, port string, timeout time.Duration) { } } -// fakeRSAKey isn't used for anything other than the trashh ssh +// fakeRSAKey isn't used for anything other than the trassh ssh // server. const fakeRSAKey = `-----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEAsbbGAxPQeqti2OgdzuMgJGBAwXe/bFhQTPuk0bIvavkZwX/a From 304ae28863f3fab06baa05087b3bc1ac0e25b7a8 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 30 Apr 2019 10:32:46 -0500 Subject: [PATCH 13/46] Use go.coder.com/cli for argument/flag parsing --- go.mod | 1 + go.sum | 2 + main.go | 113 ++++++++++++++++++++++++++------------------------------ 3 files changed, 56 insertions(+), 60 deletions(-) diff --git a/go.mod b/go.mod index b9fb5da..26daea9 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/pkg/errors v0.8.1 // indirect github.com/stretchr/testify v1.3.0 + go.coder.com/cli v0.1.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 diff --git a/go.sum b/go.sum index 8ebc634..c24ed7b 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ 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.1.0 h1:ZAjpjXJxMnwj1TqXUi7nnXXuxiPRfwfoC2kViN93oMM= +go.coder.com/cli v0.1.0/go.mod h1:pbVagI9YH/HHMManxPFML4P527GDREwsb+yciZ7mtB8= 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= diff --git a/main.go b/main.go index 2d0866f..a09df53 100644 --- a/main.go +++ b/main.go @@ -6,9 +6,9 @@ import ( "math/rand" "os" "strings" - "text/tabwriter" "time" + "go.coder.com/cli" "go.coder.com/flog" ) @@ -18,44 +18,65 @@ func init() { const helpTabWidth = 5 -var helpTab = strings.Repeat(" ", helpTabWidth) - -// version is overwritten by ci/build.sh. -var version string +var ( + helpTab = strings.Repeat(" ", helpTabWidth) + // version is overwritten by ci/build.sh. + version string +) func main() { - var ( - skipSyncFlag = flag.Bool("skipsync", false, "skip syncing local settings and extensions to remote host") - sshFlags = flag.String("ssh-flags", "", "custom SSH flags") - syncBack = flag.Bool("b", false, "sync extensions back on termination") - printVersion = flag.Bool("version", false, "print version information and exit") - ) + cli.RunRoot(&rootCmd{}) +} + +var _ interface { + cli.Command + cli.FlaggedCommand +} = new(rootCmd) - flag.Usage = usage +type rootCmd struct { + skipSync bool + syncBack bool + printVersion bool + sshFlags string +} + +func (c *rootCmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "sshcode", + Usage: c.usage(), + Desc: c.description(), + } +} + +func (c *rootCmd) RegisterFlags(fl *flag.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.StringVar(&c.sshFlags, "ssh-flags", "", "custom SSH flags") +} - flag.Parse() - if *printVersion { +func (c *rootCmd) Run(fl *flag.FlagSet) { + if c.printVersion { fmt.Printf("%v\n", version) os.Exit(0) } - host := flag.Arg(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 = "~" } err := sshCode(host, dir, options{ - skipSync: *skipSyncFlag, - sshFlags: *sshFlags, - syncBack: *syncBack, + skipSync: c.skipSync, + sshFlags: c.sshFlags, + syncBack: c.syncBack, }) if err != nil { @@ -63,53 +84,25 @@ func main() { } } -func usage() { - fmt.Printf(`Usage: %v [FLAGS] HOST [DIR] -Start VS Code via code-server over SSH. +func (c *rootCmd) usage() string { + return "[FLAGS] HOST [DIR]" +} + +func (c *rootCmd) description() string { + return fmt.Sprintf(`Start VS Code via code-server over SSH. Environment variables: - %v use special VS Code settings dir. - %v use special VS Code extensions dir. +%v%v use special VS Code settings dir. +%v%v use special VS Code extensions dir. More info: https://github.com/cdr/sshcode Arguments: %vHOST is passed into the ssh command. Valid formats are '' or 'gcp:'. -%vDIR is optional. - -%v`, - os.Args[0], - vsCodeConfigDirEnv, - vsCodeExtensionsDirEnv, +%vDIR is optional.`, + helpTab, vsCodeConfigDirEnv, + helpTab, vsCodeExtensionsDirEnv, helpTab, helpTab, - flagHelp(), ) - -} - -// flagHelp generates a friendly help string for all globally registered command -// line flags. -func flagHelp() string { - var bd strings.Builder - - w := tabwriter.NewWriter(&bd, 3, 10, helpTabWidth, ' ', 0) - - fmt.Fprintf(w, "Flags:\n") - var count int - flag.VisitAll(func(f *flag.Flag) { - count++ - if f.DefValue == "" { - fmt.Fprintf(w, "\t-%v\t%v\n", f.Name, f.Usage) - } else { - fmt.Fprintf(w, "\t-%v\t%v\t(%v)\n", f.Name, f.Usage, f.DefValue) - } - }) - if count == 0 { - return "\n" - } - - w.Flush() - - return bd.String() } From 08f00dc8ef0eb84192d41c4658aabe6c427ca1af Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 1 May 2019 19:05:58 -0500 Subject: [PATCH 14/46] Fix sshcode only accepting ip addresses --- sshcode.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/sshcode.go b/sshcode.go index cfff58b..5ffda40 100644 --- a/sshcode.go +++ b/sshcode.go @@ -32,7 +32,7 @@ type options struct { func sshCode(host, dir string, o options) error { flog.Info("ensuring code-server is updated...") - host, extraSSHFlags, err := parseIP(host) + host, extraSSHFlags, err := parseHost(host) if err != nil { return xerrors.Errorf("failed to parse host IP: %w", err) } @@ -350,19 +350,17 @@ func ensureDir(path string) error { return nil } -// parseIP parses the host to a valid IP address. If 'gcp:' is prefixed to the +// 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. -func parseIP(host string) (ip string, additionalFlags string, err error) { +// 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: - if net.ParseIP(host) == nil { - return "", "", xerrors.New("host argument is not a valid IP address") - } return host, "", nil } } From d911c14b059a9928b8bd07c224afe589a863cfec Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 1 May 2019 19:13:45 -0500 Subject: [PATCH 15/46] Add badge for CI builds --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a8913aa..7323641 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [!["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. From 2f80f3f35ebf62e80bddbf81ef5f0c79b6526daa Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 1 May 2019 20:18:08 -0500 Subject: [PATCH 16/46] Kill preexisting sshcode remote process for a user --- sshcode.go | 2 ++ sshcode_test.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/sshcode.go b/sshcode.go index 5ffda40..e3bfd02 100644 --- a/sshcode.go +++ b/sshcode.go @@ -321,12 +321,14 @@ func downloadScript(codeServerPath string) string { return fmt.Sprintf( `set -euxo pipefail || exit 1 +pkill -f %v || true mkdir -p ~/.local/share/code-server %v cd %v wget -N https://codesrv-ci.cdr.sh/latest-linux [ -f %v ] && rm %v ln latest-linux %v chmod +x %v`, + codeServerPath, filepath.Dir(codeServerPath), filepath.Dir(codeServerPath), codeServerPath, diff --git a/sshcode_test.go b/sshcode_test.go index 318f7da..f6bd681 100644 --- a/sshcode_test.go +++ b/sshcode_test.go @@ -40,7 +40,7 @@ func TestSSHCode(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - err := sshCode("127.0.0.1", "", options{ + err := sshCode("foo@127.0.0.1", "", options{ sshFlags: testSSHArgs(sshPort), localPort: localPort, remotePort: remotePort, From 60b72fe66ec90fabe1c3848644d5adf16790cd5d Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Fri, 3 May 2019 13:26:52 -0500 Subject: [PATCH 17/46] Revert behavior using DISPLAY to open app window - Previously the DISPLAY environment variable was consulted when determining whether to open a browser app window. However, it appears the environment variable isn't set in some supported environments, so the behavior has been updating to always open a window. --- sshcode.go | 3 ++- sshcode_test.go | 6 +----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/sshcode.go b/sshcode.go index e3bfd02..1d539e5 100644 --- a/sshcode.go +++ b/sshcode.go @@ -24,6 +24,7 @@ const codeServerPath = "~/.cache/sshcode/sshcode-server" type options struct { skipSync bool syncBack bool + noOpen bool localPort string remotePort string sshFlags string @@ -130,7 +131,7 @@ func sshCode(host, dir string, o options) error { ctx, cancel = context.WithCancel(context.Background()) - if os.Getenv("DISPLAY") != "" { + if !o.noOpen { openBrowser(url) } diff --git a/sshcode_test.go b/sshcode_test.go index f6bd681..b2ae89d 100644 --- a/sshcode_test.go +++ b/sshcode_test.go @@ -6,7 +6,6 @@ import ( "io" "net" "net/http" - "os" "os/exec" "path/filepath" "strconv" @@ -20,10 +19,6 @@ import ( ) func TestSSHCode(t *testing.T) { - // Avoid opening a browser window. - err := os.Unsetenv("DISPLAY") - require.NoError(t, err) - sshPort, err := randomPort() require.NoError(t, err) @@ -44,6 +39,7 @@ func TestSSHCode(t *testing.T) { sshFlags: testSSHArgs(sshPort), localPort: localPort, remotePort: remotePort, + noOpen: true, }) require.NoError(t, err) }() From 46ef157584977cce7839c1c87e58920d52d0b5ba Mon Sep 17 00:00:00 2001 From: tokikanno Date: Mon, 6 May 2019 23:23:22 +0800 Subject: [PATCH 18/46] Some updates for document (#89) * Update docs to specify working folder parameter MUST be quoted on some OS (like MacOS) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7323641..7d42339 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,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 From dbc0ff5e15e12c4988950689c163a1149ba1a42d Mon Sep 17 00:00:00 2001 From: Robert M Date: Tue, 7 May 2019 20:11:17 -0400 Subject: [PATCH 19/46] Add a --bind flag for local port (#82) - Added the ability to specify the local bind address of the ssh tunnel, for example: '--bind=0.0.0.0:8080'. This enables ChromeOS clients to access the web browser --- .gitignore | 1 + main.go | 3 +++ sshcode.go | 36 ++++++++++++++++++++++++++---------- sshcode_test.go | 2 +- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 6eec620..dc0daa9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ vendor bin .vscode +sshcode diff --git a/main.go b/main.go index a09df53..bc0de4a 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,7 @@ type rootCmd struct { skipSync bool syncBack bool printVersion bool + bindAddr string sshFlags string } @@ -52,6 +53,7 @@ func (c *rootCmd) RegisterFlags(fl *flag.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.StringVar(&c.bindAddr, "bind", "", "local bind address for ssh tunnel") fl.StringVar(&c.sshFlags, "ssh-flags", "", "custom SSH flags") } @@ -76,6 +78,7 @@ func (c *rootCmd) Run(fl *flag.FlagSet) { err := sshCode(host, dir, options{ skipSync: c.skipSync, sshFlags: c.sshFlags, + bindAddr: c.bindAddr, syncBack: c.syncBack, }) diff --git a/sshcode.go b/sshcode.go index 1d539e5..f7a7b6b 100644 --- a/sshcode.go +++ b/sshcode.go @@ -25,7 +25,7 @@ type options struct { skipSync bool syncBack bool noOpen bool - localPort string + bindAddr string remotePort string sshFlags string } @@ -79,11 +79,9 @@ func sshCode(host, dir string, o options) error { flog.Info("starting code-server...") - if o.localPort == "" { - o.localPort, err = randomPort() - } + o.bindAddr, err = parseBindAddr(o.bindAddr) if err != nil { - return xerrors.Errorf("failed to find available local port: %w", err) + return xerrors.Errorf("failed to parse bind address: %w", err) } if o.remotePort == "" { @@ -93,11 +91,12 @@ func sshCode(host, dir string, o options) error { return xerrors.Errorf("failed to find available remote port: %w", err) } - flog.Info("Tunneling local port %v to remote port %v", o.localPort, o.remotePort) + flog.Info("Tunneling remote port %v to %v", o.remotePort, o.bindAddr) - sshCmdStr = fmt.Sprintf("ssh -tt -q -L %v %v %v 'cd %v; %v --host 127.0.0.1 --allow-http --no-auth --port=%v'", - o.localPort+":localhost:"+o.remotePort, o.sshFlags, host, dir, codeServerPath, o.remotePort, - ) + sshCmdStr = + fmt.Sprintf("ssh -tt -q -L %v:localhost:%v %v %v 'cd %v; %v --host 127.0.0.1 --allow-http --no-auth --port=%v'", + o.bindAddr, o.remotePort, o.sshFlags, host, dir, codeServerPath, o.remotePort, + ) // Starts code-server and forwards the remote port. sshCmd = exec.Command("sh", "-c", sshCmdStr) @@ -109,7 +108,7 @@ func sshCode(host, dir string, o options) error { return xerrors.Errorf("failed to start code-server: %w", err) } - url := "http://127.0.0.1:" + o.localPort + url := fmt.Sprintf("http://%s", o.bindAddr) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -168,6 +167,23 @@ func sshCode(host, dir string, o options) error { return nil } +func parseBindAddr(bindAddr string) (string, error) { + 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 diff --git a/sshcode_test.go b/sshcode_test.go index b2ae89d..fc6eb7d 100644 --- a/sshcode_test.go +++ b/sshcode_test.go @@ -37,7 +37,7 @@ func TestSSHCode(t *testing.T) { defer wg.Done() err := sshCode("foo@127.0.0.1", "", options{ sshFlags: testSSHArgs(sshPort), - localPort: localPort, + bindAddr: net.JoinHostPort("127.0.0.1", localPort), remotePort: remotePort, noOpen: true, }) From ffe788b21237780d8a4ede9b95b90316ec96921f Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 7 May 2019 19:31:05 -0500 Subject: [PATCH 20/46] Fix bug where an empty bind address errors --- sshcode.go | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/sshcode.go b/sshcode.go index f7a7b6b..a31e3d8 100644 --- a/sshcode.go +++ b/sshcode.go @@ -41,6 +41,18 @@ func sshCode(host, dir string, o options) error { 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) + } + dlScript := downloadScript(codeServerPath) // Downloads the latest code-server and allows it to be executed. @@ -79,18 +91,6 @@ func sshCode(host, dir string, o options) error { flog.Info("starting code-server...") - 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) - } - flog.Info("Tunneling remote port %v to %v", o.remotePort, o.bindAddr) sshCmdStr = @@ -168,19 +168,26 @@ func sshCode(host, dir string, o options) error { } func parseBindAddr(bindAddr string) (string, error) { + if 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 } From f8ccd3a23d5b6d7f40dffe9835dd80c581dd8713 Mon Sep 17 00:00:00 2001 From: Robert M Date: Tue, 14 May 2019 12:28:38 -0400 Subject: [PATCH 21/46] Expand VS Code (#101) * Updated README to link to VS Code homepage --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d42339..a5db262 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ `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 hosts. +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. From ff049771df28169049f5ad90fc18eab6505c5d47 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 27 May 2019 16:13:52 +1000 Subject: [PATCH 22/46] add x86_64 check in downloadScript --- sshcode.go | 1 + 1 file changed, 1 insertion(+) diff --git a/sshcode.go b/sshcode.go index a31e3d8..e185d15 100644 --- a/sshcode.go +++ b/sshcode.go @@ -345,6 +345,7 @@ 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 ~/.local/share/code-server %v cd %v From 4195d9966553a1b98d794e82624d589ee19f102d Mon Sep 17 00:00:00 2001 From: Christos KK Loverdos Date: Sun, 2 Jun 2019 21:08:42 +0300 Subject: [PATCH 23/46] Use bash in a more portable way --- sshcode.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sshcode.go b/sshcode.go index a31e3d8..97e7502 100644 --- a/sshcode.go +++ b/sshcode.go @@ -56,7 +56,7 @@ func sshCode(host, dir string, o options) error { dlScript := downloadScript(codeServerPath) // Downloads the latest code-server and allows it to be executed. - sshCmdStr := fmt.Sprintf("ssh %v %v /bin/bash", o.sshFlags, host) + sshCmdStr := fmt.Sprintf("ssh %v %v '/usr/bin/env bash'", o.sshFlags, host) sshCmd := exec.Command("sh", "-c", sshCmdStr) sshCmd.Stdout = os.Stdout From e646d57b0a8c8aba960a7d54c5972971aa5faa6f Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 12 Jun 2019 16:22:52 +1000 Subject: [PATCH 24/46] add SSH master connection feature By default, sshcode will now start a master connection with no command so that users only need to authenticate once and multiple connections don't need to be established. This speeds up load times significantly as there are less handshakes required. To disable this behaviour you can use `--no-reuse-connection`. --- main.go | 23 +++++++----- sshcode.go | 108 ++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 104 insertions(+), 27 deletions(-) diff --git a/main.go b/main.go index bc0de4a..ddd8eb3 100644 --- a/main.go +++ b/main.go @@ -34,11 +34,12 @@ var _ interface { } = new(rootCmd) type rootCmd struct { - skipSync bool - syncBack bool - printVersion bool - bindAddr string - sshFlags string + skipSync bool + syncBack bool + printVersion bool + noReuseConnection bool + bindAddr string + sshFlags string } func (c *rootCmd) Spec() cli.CommandSpec { @@ -53,6 +54,7 @@ func (c *rootCmd) RegisterFlags(fl *flag.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") fl.StringVar(&c.sshFlags, "ssh-flags", "", "custom SSH flags") } @@ -76,10 +78,11 @@ func (c *rootCmd) Run(fl *flag.FlagSet) { } err := sshCode(host, dir, options{ - skipSync: c.skipSync, - sshFlags: c.sshFlags, - bindAddr: c.bindAddr, - syncBack: c.syncBack, + skipSync: c.skipSync, + sshFlags: c.sshFlags, + bindAddr: c.bindAddr, + syncBack: c.syncBack, + noReuseConnection: c.noReuseConnection, }) if err != nil { @@ -101,7 +104,7 @@ Environment variables: More info: https://github.com/cdr/sshcode Arguments: -%vHOST is passed into the ssh command. Valid formats are '' or 'gcp:'. +%vHOST is passed into the ssh command. Valid formats are '' or 'gcp:'. %vDIR is optional.`, helpTab, vsCodeConfigDirEnv, helpTab, vsCodeExtensionsDirEnv, diff --git a/sshcode.go b/sshcode.go index 97e7502..859651a 100644 --- a/sshcode.go +++ b/sshcode.go @@ -20,14 +20,16 @@ import ( ) const codeServerPath = "~/.cache/sshcode/sshcode-server" +const sshControlPath = "~/.ssh/control-%h-%p-%r" type options struct { - skipSync bool - syncBack bool - noOpen bool - bindAddr string - remotePort string - sshFlags string + skipSync bool + syncBack bool + noOpen bool + noReuseConnection bool + bindAddr string + remotePort string + sshFlags string } func sshCode(host, dir string, o options) error { @@ -53,6 +55,41 @@ func sshCode(host, dir string, o options) error { return xerrors.Errorf("failed to find available remote port: %w", err) } + // Start SSH master connection socket. This prevents multiple password prompts from appearing as authentication + // only happens on the initial connection. + var sshMasterCmd *exec.Cmd + if !o.noReuseConnection { + newSSHFlags := fmt.Sprintf(`%v -o "ControlPath=%v"`, o.sshFlags, sshControlPath) + + // -MN means "start a master socket and don't open a session, just connect". + sshMasterCmdStr := fmt.Sprintf(`ssh %v -MN %v`, newSSHFlags, host) + sshMasterCmd = exec.Command("sh", "-c", sshMasterCmdStr) + sshMasterCmd.Stdin = os.Stdin + sshMasterCmd.Stdout = os.Stdout + sshMasterCmd.Stderr = os.Stderr + err = sshMasterCmd.Start() + if err != nil { + flog.Error("failed to start SSH master connection, disabling connection reuse feature: %v", err) + o.noReuseConnection = true + } else { + // Wait for master to be ready. + err = checkSSHMaster(newSSHFlags, host) + if err != nil { + flog.Error("SSH master failed to start in time, disabling connection reuse feature: %v", err) + o.noReuseConnection = true + if sshMasterCmd.Process != nil { + err = sshMasterCmd.Process.Kill() + if err != nil { + flog.Error("failed to kill SSH master connection, ignoring: %v", err) + } + } + } else { + sshMasterCmd.Stdin = nil + o.sshFlags = newSSHFlags + } + } + } + dlScript := downloadScript(codeServerPath) // Downloads the latest code-server and allows it to be executed. @@ -146,22 +183,39 @@ func sshCode(host, dir string, o options) error { case <-ctx.Done(): case <-c: } + flog.Info("exiting") - if !o.syncBack || o.skipSync { - flog.Info("shutting down") - return nil - } + if o.syncBack && !o.skipSync { + flog.Info("synchronizing VS Code back to local") - flog.Info("synchronizing VS Code back to local") + err = syncExtensions(o.sshFlags, host, true) + if err != nil { + flog.Error("failed to sync extensions back: %v", err) + } - 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 { + flog.Error("failed to sync user settings settings back: %v", err) + } } - err = syncUserSettings(o.sshFlags, host, true) - if err != nil { - return xerrors.Errorf("failed to sync user settings settings back: %w", err) + // Kill the master connection if we made one. + if !o.noReuseConnection { + // Try using the -O exit syntax first before killing the master. + sshCmdStr = fmt.Sprintf(`ssh %v -O exit %v`, o.sshFlags, host) + sshCmd = exec.Command("sh", "-c", sshCmdStr) + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + err = sshCmd.Run() + if err != nil { + flog.Error("failed to gracefully stop SSH master connection, killing: %v", err) + if sshMasterCmd.Process != nil { + err = sshMasterCmd.Process.Kill() + if err != nil { + flog.Error("failed to kill SSH master connection, ignoring: %v", err) + } + } + } } return nil @@ -263,6 +317,26 @@ func randomPort() (string, error) { return "", xerrors.Errorf("max number of tries exceeded: %d", maxTries) } +// checkSSHMaster polls every second for 30 seconds to check if the SSH master +// is ready. +func checkSSHMaster(sshFlags string, host string) (err error) { + maxTries := 30 + check := func() error { + sshCmdStr := fmt.Sprintf(`ssh %v -O check %v`, sshFlags, host) + sshCmd := exec.Command("sh", "-c", sshCmdStr) + return sshCmd.Run() + } + + for i := 0; i < maxTries; i++ { + err = check() + if err == nil { + return nil + } + time.Sleep(time.Second) + } + return err +} + func syncUserSettings(sshFlags string, host string, back bool) error { localConfDir, err := configDir() if err != nil { From 3141f7f4bb93be3a3614e21b2b5510916c81dbdd Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 13 Jun 2019 18:53:51 +1000 Subject: [PATCH 25/46] move SSH master process tidyup to a deferred func - Replace the `ssh -O exit` tidyup command with just a SIGTERM on the master - Add `exec` to the front of the SSH master cmd so it replaces the `sh` process (so we can send SIGTERM to it easier) --- sshcode.go | 66 ++++++++++++++++++++++-------------------------------- 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/sshcode.go b/sshcode.go index 859651a..51a432e 100644 --- a/sshcode.go +++ b/sshcode.go @@ -11,6 +11,7 @@ import ( "os/signal" "path/filepath" "strconv" + "syscall" "strings" "time" @@ -57,32 +58,36 @@ func sshCode(host, dir string, o options) error { // Start SSH master connection socket. This prevents multiple password prompts from appearing as authentication // only happens on the initial connection. - var sshMasterCmd *exec.Cmd if !o.noReuseConnection { newSSHFlags := fmt.Sprintf(`%v -o "ControlPath=%v"`, o.sshFlags, sshControlPath) // -MN means "start a master socket and don't open a session, just connect". - sshMasterCmdStr := fmt.Sprintf(`ssh %v -MN %v`, newSSHFlags, host) - sshMasterCmd = exec.Command("sh", "-c", sshMasterCmdStr) + sshCmdStr := fmt.Sprintf(`exec ssh %v -MN %v`, newSSHFlags, host) + sshMasterCmd := exec.Command("sh", "-c", sshCmdStr) sshMasterCmd.Stdin = os.Stdin sshMasterCmd.Stdout = os.Stdout sshMasterCmd.Stderr = os.Stderr + stopSSHMaster := func () { + if sshMasterCmd.Process != nil { + err := sshMasterCmd.Process.Signal(syscall.SIGTERM) + if err != nil { + flog.Error("failed to send SIGTERM to SSH master process: %v", err) + } + } + } + defer stopSSHMaster() + err = sshMasterCmd.Start() if err != nil { flog.Error("failed to start SSH master connection, disabling connection reuse feature: %v", err) o.noReuseConnection = true + stopSSHMaster() } else { - // Wait for master to be ready. err = checkSSHMaster(newSSHFlags, host) if err != nil { - flog.Error("SSH master failed to start in time, disabling connection reuse feature: %v", err) + flog.Error("SSH master failed to be ready in time, disabling connection reuse feature: %v", err) o.noReuseConnection = true - if sshMasterCmd.Process != nil { - err = sshMasterCmd.Process.Kill() - if err != nil { - flog.Error("failed to kill SSH master connection, ignoring: %v", err) - } - } + stopSSHMaster() } else { sshMasterCmd.Stdin = nil o.sshFlags = newSSHFlags @@ -183,39 +188,22 @@ func sshCode(host, dir string, o options) error { case <-ctx.Done(): case <-c: } - flog.Info("exiting") - if o.syncBack && !o.skipSync { - flog.Info("synchronizing VS Code back to local") + flog.Info("shutting down") + if !o.syncBack || o.skipSync { + return nil + } - err = syncExtensions(o.sshFlags, host, true) - if err != nil { - flog.Error("failed to sync extensions back: %v", err) - } + flog.Info("synchronizing VS Code back to local") - err = syncUserSettings(o.sshFlags, host, true) - if err != nil { - flog.Error("failed to sync user settings settings back: %v", err) - } + err = syncExtensions(o.sshFlags, host, true) + if err != nil { + return xerrors.Errorf("failed to sync extensions back: %v", err) } - // Kill the master connection if we made one. - if !o.noReuseConnection { - // Try using the -O exit syntax first before killing the master. - sshCmdStr = fmt.Sprintf(`ssh %v -O exit %v`, o.sshFlags, host) - sshCmd = exec.Command("sh", "-c", sshCmdStr) - sshCmd.Stdout = os.Stdout - sshCmd.Stderr = os.Stderr - err = sshCmd.Run() - if err != nil { - flog.Error("failed to gracefully stop SSH master connection, killing: %v", err) - if sshMasterCmd.Process != nil { - err = sshMasterCmd.Process.Kill() - if err != nil { - flog.Error("failed to kill SSH master connection, ignoring: %v", err) - } - } - } + err = syncUserSettings(o.sshFlags, host, true) + if err != nil { + return xerrors.Errorf("failed to sync user settings settings back: %v", err) } return nil From 4d64fc0393636307296b2bd5db7577ecb5ea28a4 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 13 Jun 2019 19:11:31 +1000 Subject: [PATCH 26/46] add "process is running" check to checkSSHMaster() Checks if the master process is running by sending signal 0 to it. According to kill(2), sending a signal of 0 will send no signal but will still perform error checking. To prevent the SSH master from becoming a zombie process, a wait call was added in a goroutine. --- sshcode.go | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/sshcode.go b/sshcode.go index 51a432e..41f6e23 100644 --- a/sshcode.go +++ b/sshcode.go @@ -11,8 +11,8 @@ import ( "os/signal" "path/filepath" "strconv" - "syscall" "strings" + "syscall" "time" "github.com/pkg/browser" @@ -67,9 +67,13 @@ func sshCode(host, dir string, o options) error { sshMasterCmd.Stdin = os.Stdin sshMasterCmd.Stdout = os.Stdout sshMasterCmd.Stderr = os.Stderr - stopSSHMaster := func () { + stopSSHMaster := func() { if sshMasterCmd.Process != nil { - err := sshMasterCmd.Process.Signal(syscall.SIGTERM) + err := sshMasterCmd.Process.Signal(syscall.Signal(0)) + if err != nil { + return + } + err = sshMasterCmd.Process.Signal(syscall.SIGTERM) if err != nil { flog.Error("failed to send SIGTERM to SSH master process: %v", err) } @@ -78,12 +82,13 @@ func sshCode(host, dir string, o options) error { defer stopSSHMaster() err = sshMasterCmd.Start() + go sshMasterCmd.Wait() if err != nil { flog.Error("failed to start SSH master connection, disabling connection reuse feature: %v", err) o.noReuseConnection = true stopSSHMaster() } else { - err = checkSSHMaster(newSSHFlags, host) + err = checkSSHMaster(sshMasterCmd, newSSHFlags, host) if err != nil { flog.Error("SSH master failed to be ready in time, disabling connection reuse feature: %v", err) o.noReuseConnection = true @@ -307,22 +312,32 @@ func randomPort() (string, error) { // checkSSHMaster polls every second for 30 seconds to check if the SSH master // is ready. -func checkSSHMaster(sshFlags string, host string) (err error) { - maxTries := 30 - check := func() error { +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 { + return xerrors.Errorf("SSH master process not running") + } + err = sshMasterCmd.Process.Signal(syscall.Signal(0)) + if err != nil { + return xerrors.Errorf("failed to check if SSH master process was alive: %v", err) + } + + // Check if it's ready sshCmdStr := fmt.Sprintf(`ssh %v -O check %v`, sshFlags, host) sshCmd := exec.Command("sh", "-c", sshCmdStr) - return sshCmd.Run() - } - - for i := 0; i < maxTries; i++ { - err = check() + err = sshCmd.Run() if err == nil { return nil } - time.Sleep(time.Second) + time.Sleep(sleepDur) } - return err + return xerrors.Errorf("max number of tries exceeded: %d", maxTries) } func syncUserSettings(sshFlags string, host string, back bool) error { From eee34f58bd4cac785dd6ef58eb8e1a4d80975a4a Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Sun, 16 Jun 2019 03:12:09 +1000 Subject: [PATCH 27/46] add ~/.ssh directory sanity check before starting Checks: - if it exists - if it's a directory (if not warn and disable reuse connection feature) - if it has safe permissions (not writable by anyone except the owner, if not then warn) --- sshcode.go | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/sshcode.go b/sshcode.go index 41f6e23..92b7f3c 100644 --- a/sshcode.go +++ b/sshcode.go @@ -21,7 +21,9 @@ import ( ) const codeServerPath = "~/.cache/sshcode/sshcode-server" -const sshControlPath = "~/.ssh/control-%h-%p-%r" +const sshDirectory = "~/.ssh" +const sshDirectoryUnsafeModeMask = 0022 +const sshControlPath = sshDirectory + "/control-%h-%p-%r" type options struct { skipSync bool @@ -34,8 +36,6 @@ type options struct { } func sshCode(host, dir string, o options) error { - flog.Info("ensuring code-server is updated...") - host, extraSSHFlags, err := parseHost(host) if err != nil { return xerrors.Errorf("failed to parse host IP: %w", err) @@ -56,6 +56,28 @@ func sshCode(host, dir string, o options) error { 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. + sshDirectoryMode, err := os.Lstat(expandPath(sshDirectory)) + if err != nil { + if !o.noReuseConnection { + flog.Info("failed to stat %v directory, disabling connection reuse feature: %v", sshDirectory, err) + o.noReuseConnection = true + } + } else { + if !sshDirectoryMode.IsDir() { + if !o.noReuseConnection { + flog.Info("%v is not a directory, disabling connection reuse feature", sshDirectory) + o.noReuseConnection = true + } else { + flog.Info("warning: %v is not a directory", sshDirectory) + } + } + 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) + } + } + // Start SSH master connection socket. This prevents multiple password prompts from appearing as authentication // only happens on the initial connection. if !o.noReuseConnection { @@ -100,6 +122,7 @@ func sshCode(host, dir string, o options) error { } } + flog.Info("ensuring code-server is updated...") dlScript := downloadScript(codeServerPath) // Downloads the latest code-server and allows it to be executed. @@ -214,6 +237,23 @@ func sshCode(host, dir string, o options) error { 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. + 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 bindAddr == "" { bindAddr = ":" From c8ca9fc372b78484544bc96c787b0f484cf7c749 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Sun, 16 Jun 2019 20:24:46 +1000 Subject: [PATCH 28/46] update --bind flag help text Clarify how to specify --bind without a host by providing the syntax for it. --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index bc0de4a..51433aa 100644 --- a/main.go +++ b/main.go @@ -53,7 +53,7 @@ func (c *rootCmd) RegisterFlags(fl *flag.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.StringVar(&c.bindAddr, "bind", "", "local bind address for ssh tunnel") + 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") } From 65655b960ed1f04aa241f17de45965e8506f3df1 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Sun, 16 Jun 2019 20:28:41 +1000 Subject: [PATCH 29/46] undo changing quotes to backticks --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 51433aa..0d16036 100644 --- a/main.go +++ b/main.go @@ -53,7 +53,7 @@ func (c *rootCmd) RegisterFlags(fl *flag.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.StringVar(&c.bindAddr, "bind", "", `local bind address for ssh tunnel, in [HOST]:PORT syntax (default: 127.0.0.1)`) + 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") } From 5ea05ebdfaad49bf3fcf0e076f97c15d17910bcd Mon Sep 17 00:00:00 2001 From: Eduardo Argollo Date: Mon, 17 Jun 2019 20:36:49 -0700 Subject: [PATCH 30/46] Solves proxy issue, closes #74 when proxy is set at .profile --- sshcode.go | 8 ++++---- sshcode_test.go | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sshcode.go b/sshcode.go index 97e7502..c519420 100644 --- a/sshcode.go +++ b/sshcode.go @@ -56,9 +56,9 @@ func sshCode(host, dir string, o options) error { dlScript := downloadScript(codeServerPath) // Downloads the latest code-server and allows it to be executed. - sshCmdStr := fmt.Sprintf("ssh %v %v '/usr/bin/env bash'", o.sshFlags, host) + sshCmdStr := fmt.Sprintf("ssh %v %v '/usr/bin/env bash -l'", o.sshFlags, host) - sshCmd := exec.Command("sh", "-c", sshCmdStr) + sshCmd := exec.Command("sh", "-l", "-c", sshCmdStr) sshCmd.Stdout = os.Stdout sshCmd.Stderr = os.Stderr sshCmd.Stdin = strings.NewReader(dlScript) @@ -99,7 +99,7 @@ func sshCode(host, dir string, o options) error { ) // Starts code-server and forwards the remote port. - sshCmd = exec.Command("sh", "-c", sshCmdStr) + sshCmd = exec.Command("sh", "-l", "-c", sshCmdStr) sshCmd.Stdin = os.Stdin sshCmd.Stdout = os.Stdout sshCmd.Stderr = os.Stderr @@ -396,7 +396,7 @@ func parseHost(host string) (parsedHost string, additionalFlags string, err erro func parseGCPSSHCmd(instance string) (ip, sshFlags string, err error) { dryRunCmd := fmt.Sprintf("gcloud compute ssh --dry-run %v", instance) - out, err := exec.Command("sh", "-c", dryRunCmd).CombinedOutput() + out, err := exec.Command("sh", "-l", "-c", dryRunCmd).CombinedOutput() if err != nil { return "", "", xerrors.Errorf("%s: %w", out, err) } diff --git a/sshcode_test.go b/sshcode_test.go index fc6eb7d..096bff6 100644 --- a/sshcode_test.go +++ b/sshcode_test.go @@ -48,7 +48,7 @@ func TestSSHCode(t *testing.T) { 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", "-c", "stat "+codeServerPath).CombinedOutput() + 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() @@ -200,7 +200,7 @@ func handleSession(ch ssh.Channel, in <-chan *ssh.Request, t *testing.T) { return } - cmd := exec.Command("sh", "-c", exReq.Command) + cmd := exec.Command("sh", "-l", "-c", exReq.Command) stdin, err := cmd.StdinPipe() require.NoError(t, err) From 780633703d399ebe8574460021e9dd649aa03349 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 27 Jun 2019 03:15:40 +0000 Subject: [PATCH 31/46] add remote OS support paragraph to README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index a5db262..dddd19e 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,11 @@ We currently support: - 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 From 0b43319a1239a71532e94d3747006dc676fddd97 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 27 Jun 2019 14:15:24 +1000 Subject: [PATCH 32/46] make :PORT optional in parseBindAddr --- main.go | 2 +- sshcode.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 0d16036..f637438 100644 --- a/main.go +++ b/main.go @@ -53,7 +53,7 @@ func (c *rootCmd) RegisterFlags(fl *flag.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.StringVar(&c.bindAddr, "bind", "", "local bind address for ssh tunnel, in [HOST]:PORT syntax (default: 127.0.0.1)") + 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") } diff --git a/sshcode.go b/sshcode.go index 97e7502..c43f0f7 100644 --- a/sshcode.go +++ b/sshcode.go @@ -168,8 +168,8 @@ func sshCode(host, dir string, o options) error { } func parseBindAddr(bindAddr string) (string, error) { - if bindAddr == "" { - bindAddr = ":" + if !strings.Contains(bindAddr, ":") { + bindAddr += ":" } host, port, err := net.SplitHostPort(bindAddr) From 379475593da8f175df7d40394b8efb22156b9235 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 27 Jun 2019 16:12:48 +1000 Subject: [PATCH 33/46] restructure SSH master code, apply requested fixes --- main.go | 10 ++-- sshcode.go | 172 +++++++++++++++++++++++++++++------------------------ 2 files changed, 100 insertions(+), 82 deletions(-) diff --git a/main.go b/main.go index ddd8eb3..b53b21f 100644 --- a/main.go +++ b/main.go @@ -78,11 +78,11 @@ func (c *rootCmd) Run(fl *flag.FlagSet) { } err := sshCode(host, dir, options{ - skipSync: c.skipSync, - sshFlags: c.sshFlags, - bindAddr: c.bindAddr, - syncBack: c.syncBack, - noReuseConnection: c.noReuseConnection, + skipSync: c.skipSync, + sshFlags: c.sshFlags, + bindAddr: c.bindAddr, + syncBack: c.syncBack, + reuseConnection: !c.noReuseConnection, }) if err != nil { diff --git a/sshcode.go b/sshcode.go index 92b7f3c..9f80fe0 100644 --- a/sshcode.go +++ b/sshcode.go @@ -21,18 +21,21 @@ import ( ) const codeServerPath = "~/.cache/sshcode/sshcode-server" -const sshDirectory = "~/.ssh" -const sshDirectoryUnsafeModeMask = 0022 -const sshControlPath = sshDirectory + "/control-%h-%p-%r" + +const ( + sshDirectory = "~/.ssh" + sshDirectoryUnsafeModeMask = 0022 + sshControlPath = sshDirectory + "/control-%h-%p-%r" +) type options struct { - skipSync bool - syncBack bool - noOpen bool - noReuseConnection bool - bindAddr string - remotePort string - sshFlags string + skipSync bool + syncBack bool + noOpen bool + reuseConnection bool + bindAddr string + remotePort string + sshFlags string } func sshCode(host, dir string, o options) error { @@ -57,68 +60,19 @@ func sshCode(host, dir string, o options) error { } // Check the SSH directory's permissions and warn the user if it is not safe. - sshDirectoryMode, err := os.Lstat(expandPath(sshDirectory)) - if err != nil { - if !o.noReuseConnection { - flog.Info("failed to stat %v directory, disabling connection reuse feature: %v", sshDirectory, err) - o.noReuseConnection = true - } - } else { - if !sshDirectoryMode.IsDir() { - if !o.noReuseConnection { - flog.Info("%v is not a directory, disabling connection reuse feature", sshDirectory) - o.noReuseConnection = true - } else { - flog.Info("warning: %v is not a directory", sshDirectory) - } - } - 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) - } - } + 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.noReuseConnection { - newSSHFlags := fmt.Sprintf(`%v -o "ControlPath=%v"`, o.sshFlags, sshControlPath) - - // -MN means "start a master socket and don't open a session, just connect". - sshCmdStr := fmt.Sprintf(`exec ssh %v -MN %v`, newSSHFlags, host) - sshMasterCmd := exec.Command("sh", "-c", sshCmdStr) - sshMasterCmd.Stdin = os.Stdin - sshMasterCmd.Stdout = os.Stdout - sshMasterCmd.Stderr = os.Stderr - stopSSHMaster := func() { - if sshMasterCmd.Process != nil { - err := sshMasterCmd.Process.Signal(syscall.Signal(0)) - if err != nil { - return - } - err = sshMasterCmd.Process.Signal(syscall.SIGTERM) - if err != nil { - flog.Error("failed to send SIGTERM to SSH master process: %v", err) - } - } - } - defer stopSSHMaster() - - err = sshMasterCmd.Start() - go sshMasterCmd.Wait() + 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, disabling connection reuse feature: %v", err) - o.noReuseConnection = true - stopSSHMaster() + flog.Error("failed to start SSH master connection: %v", err) + o.reuseConnection = false } else { - err = checkSSHMaster(sshMasterCmd, newSSHFlags, host) - if err != nil { - flog.Error("SSH master failed to be ready in time, disabling connection reuse feature: %v", err) - o.noReuseConnection = true - stopSSHMaster() - } else { - sshMasterCmd.Stdin = nil - o.sshFlags = newSSHFlags - } + o.sshFlags = newSSHFlags } } @@ -226,12 +180,12 @@ func sshCode(host, dir string, o options) error { err = syncExtensions(o.sshFlags, host, true) if err != nil { - return xerrors.Errorf("failed to sync extensions back: %v", err) + 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 settings back: %v", err) + return xerrors.Errorf("failed to sync user settings settings back: %w", err) } return nil @@ -350,6 +304,74 @@ func randomPort() (string, error) { 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 { + 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 { @@ -359,16 +381,12 @@ func checkSSHMaster(sshMasterCmd *exec.Cmd, sshFlags string, host string) error err error ) for i := 0; i < maxTries; i++ { - // Check if the master is running - if sshMasterCmd.Process == nil { - return xerrors.Errorf("SSH master process not running") - } - err = sshMasterCmd.Process.Signal(syscall.Signal(0)) - if err != nil { - return xerrors.Errorf("failed to check if SSH master process was alive: %v", err) + // 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 + // Check if it's ready. sshCmdStr := fmt.Sprintf(`ssh %v -O check %v`, sshFlags, host) sshCmd := exec.Command("sh", "-c", sshCmdStr) err = sshCmd.Run() From dbf7a484881632d2fc1f0129d756632923cb022e Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 27 Jun 2019 16:21:29 +1000 Subject: [PATCH 34/46] add comment about tildes in expandPath --- sshcode.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sshcode.go b/sshcode.go index 9f80fe0..565ac43 100644 --- a/sshcode.go +++ b/sshcode.go @@ -195,7 +195,8 @@ func sshCode(host, dir string, o options) error { func expandPath(path string) string { path = filepath.Clean(os.ExpandEnv(path)) - // Replace tilde notation in path with the home directory. + // 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 == "~" { From 1eaed4cacd0f7f3d0262366d0841580007ba0048 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 11 Jul 2019 13:55:57 +1000 Subject: [PATCH 35/46] replace wget with curl --- sshcode.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sshcode.go b/sshcode.go index f525126..ad2235e 100644 --- a/sshcode.go +++ b/sshcode.go @@ -485,7 +485,11 @@ func downloadScript(codeServerPath string) string { pkill -f %v || true mkdir -p ~/.local/share/code-server %v cd %v -wget -N https://codesrv-ci.cdr.sh/latest-linux +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`, From 8c729652acd1d6aa8449b90fb9977e12f620c95c Mon Sep 17 00:00:00 2001 From: Sahil Soni Date: Thu, 18 Jul 2019 19:08:01 +0530 Subject: [PATCH 36/46] Return not the , but the for GCP instances Signed-off-by: Sahil Soni --- sshcode.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sshcode.go b/sshcode.go index ad2235e..ddd3343 100644 --- a/sshcode.go +++ b/sshcode.go @@ -564,5 +564,5 @@ func parseGCPSSHCmd(instance string) (ip, sshFlags string, err error) { return "", "", xerrors.Errorf("parsed invalid ip address %v", ip) } - return ip, sshFlags, nil + return strings.TrimSpace(userIP), sshFlags, nil } From 70153311764c7b95dab2e822adb3aa0eb73bcba8 Mon Sep 17 00:00:00 2001 From: Sahil Soni Date: Fri, 26 Jul 2019 23:46:51 +0530 Subject: [PATCH 37/46] Remove the IP address check in parseGCPSSHCmd This is not needed since dry-run will provide a working host, or will throw error anyways --- sshcode.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/sshcode.go b/sshcode.go index ddd3343..e4a623d 100644 --- a/sshcode.go +++ b/sshcode.go @@ -552,17 +552,6 @@ func parseGCPSSHCmd(instance string) (ip, sshFlags string, err error) { // E.g. foo@1.2.3.4. userIP := toks[len(toks)-1] - toks = strings.Split(userIP, "@") - // Assume the '@' is missing. - if len(toks) < 2 { - ip = strings.TrimSpace(toks[0]) - } else { - ip = strings.TrimSpace(toks[1]) - } - - if net.ParseIP(ip) == nil { - return "", "", xerrors.Errorf("parsed invalid ip address %v", ip) - } return strings.TrimSpace(userIP), sshFlags, nil } From d5dc7c7d177603afb3305c13777486af4cdbf6b6 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 26 Aug 2019 17:23:21 -0500 Subject: [PATCH 38/46] Update go.coder.com/cli to v0.4.0 --- go.mod | 3 ++- go.sum | 6 ++++-- main.go | 7 ++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 26daea9..e8637c0 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,9 @@ 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.1.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 diff --git a/go.sum b/go.sum index c24ed7b..df0d6a1 100644 --- a/go.sum +++ b/go.sum @@ -13,12 +13,14 @@ 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.1.0 h1:ZAjpjXJxMnwj1TqXUi7nnXXuxiPRfwfoC2kViN93oMM= -go.coder.com/cli v0.1.0/go.mod h1:pbVagI9YH/HHMManxPFML4P527GDREwsb+yciZ7mtB8= +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= diff --git a/main.go b/main.go index 2a63211..d2c2b8e 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,14 @@ package main import ( - "flag" "fmt" "math/rand" "os" "strings" "time" + "github.com/spf13/pflag" + "go.coder.com/cli" "go.coder.com/flog" ) @@ -50,7 +51,7 @@ func (c *rootCmd) Spec() cli.CommandSpec { } } -func (c *rootCmd) RegisterFlags(fl *flag.FlagSet) { +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") @@ -59,7 +60,7 @@ func (c *rootCmd) RegisterFlags(fl *flag.FlagSet) { fl.StringVar(&c.sshFlags, "ssh-flags", "", "custom SSH flags") } -func (c *rootCmd) Run(fl *flag.FlagSet) { +func (c *rootCmd) Run(fl *pflag.FlagSet) { if c.printVersion { fmt.Printf("%v\n", version) os.Exit(0) From 1985f23b500598a30b772e5b6356fde8a93c0190 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Fri, 23 Aug 2019 23:53:00 +0000 Subject: [PATCH 39/46] Add ability to upload local binary --- main.go | 3 ++ sshcode.go | 87 +++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/main.go b/main.go index d2c2b8e..f3a9b58 100644 --- a/main.go +++ b/main.go @@ -41,6 +41,7 @@ type rootCmd struct { noReuseConnection bool bindAddr string sshFlags string + codeServerPath string } func (c *rootCmd) Spec() cli.CommandSpec { @@ -58,6 +59,7 @@ func (c *rootCmd) RegisterFlags(fl *pflag.FlagSet) { 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.codeServerPath, "code-server-path", "", "custom code-server binary to upload") } func (c *rootCmd) Run(fl *pflag.FlagSet) { @@ -84,6 +86,7 @@ func (c *rootCmd) Run(fl *pflag.FlagSet) { bindAddr: c.bindAddr, syncBack: c.syncBack, reuseConnection: !c.noReuseConnection, + codeServerPath: c.codeServerPath, }) if err != nil { diff --git a/sshcode.go b/sshcode.go index e4a623d..2e47332 100644 --- a/sshcode.go +++ b/sshcode.go @@ -36,6 +36,7 @@ type options struct { bindAddr string remotePort string sshFlags string + codeServerPath string } func sshCode(host, dir string, o options) error { @@ -76,23 +77,49 @@ func sshCode(host, dir string, o options) error { } } - flog.Info("ensuring code-server is updated...") - dlScript := downloadScript(codeServerPath) + // Upload local code-server or download code-server from CI server. + if o.codeServerPath != "" { + flog.Info("uploading local code-server binary...") + err = copyCodeServerBinary(o.sshFlags, host, o.codeServerPath, codeServerPath) + if err != nil { + return xerrors.Errorf("failed to upload local code-server binary to remote server: %w", err) + } - // 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) + 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 - 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, - ) + 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 { @@ -117,13 +144,13 @@ func sshCode(host, dir string, o options) error { flog.Info("Tunneling remote port %v to %v", o.remotePort, o.bindAddr) - sshCmdStr = + sshCmdStr := fmt.Sprintf("ssh -tt -q -L %v:localhost:%v %v %v 'cd %v; %v --host 127.0.0.1 --allow-http --no-auth --port=%v'", o.bindAddr, o.remotePort, o.sshFlags, host, dir, codeServerPath, o.remotePort, ) // Starts code-server and forwards the remote port. - sshCmd = exec.Command("sh", "-l", "-c", sshCmdStr) + sshCmd := exec.Command("sh", "-l", "-c", sshCmdStr) sshCmd.Stdin = os.Stdin sshCmd.Stdout = os.Stdout sshCmd.Stderr = os.Stderr @@ -399,6 +426,20 @@ func checkSSHMaster(sshMasterCmd *exec.Cmd, sshFlags string, host string) error 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 := ensureFile(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 { @@ -517,6 +558,18 @@ func ensureDir(path string) error { return nil } +// ensureFile tries to stat the specified path and ensure it's a file. +func ensureFile(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 From c8e4017bc837a11f88a0cc3546dbcce01fbc15a4 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Fri, 6 Sep 2019 16:09:04 +0000 Subject: [PATCH 40/46] Rename --code-server-path to --upload-code-server --- main.go | 16 ++++++++-------- sshcode.go | 26 +++++++++++++------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/main.go b/main.go index f3a9b58..b355dc1 100644 --- a/main.go +++ b/main.go @@ -41,7 +41,7 @@ type rootCmd struct { noReuseConnection bool bindAddr string sshFlags string - codeServerPath string + uploadCodeServer string } func (c *rootCmd) Spec() cli.CommandSpec { @@ -59,7 +59,7 @@ func (c *rootCmd) RegisterFlags(fl *pflag.FlagSet) { 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.codeServerPath, "code-server-path", "", "custom code-server binary to upload") + fl.StringVar(&c.uploadCodeServer, "upload-code-server", "", "custom code-server binary to upload to the remote host") } func (c *rootCmd) Run(fl *pflag.FlagSet) { @@ -81,12 +81,12 @@ func (c *rootCmd) Run(fl *pflag.FlagSet) { } err := sshCode(host, dir, options{ - skipSync: c.skipSync, - sshFlags: c.sshFlags, - bindAddr: c.bindAddr, - syncBack: c.syncBack, - reuseConnection: !c.noReuseConnection, - codeServerPath: c.codeServerPath, + skipSync: c.skipSync, + sshFlags: c.sshFlags, + bindAddr: c.bindAddr, + syncBack: c.syncBack, + reuseConnection: !c.noReuseConnection, + uploadCodeServer: c.uploadCodeServer, }) if err != nil { diff --git a/sshcode.go b/sshcode.go index 2e47332..accdd0d 100644 --- a/sshcode.go +++ b/sshcode.go @@ -29,14 +29,14 @@ const ( ) type options struct { - skipSync bool - syncBack bool - noOpen bool - reuseConnection bool - bindAddr string - remotePort string - sshFlags string - codeServerPath string + skipSync bool + syncBack bool + noOpen bool + reuseConnection bool + bindAddr string + remotePort string + sshFlags string + uploadCodeServer string } func sshCode(host, dir string, o options) error { @@ -78,9 +78,9 @@ func sshCode(host, dir string, o options) error { } // Upload local code-server or download code-server from CI server. - if o.codeServerPath != "" { + if o.uploadCodeServer != "" { flog.Info("uploading local code-server binary...") - err = copyCodeServerBinary(o.sshFlags, host, o.codeServerPath, codeServerPath) + 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) } @@ -428,7 +428,7 @@ func checkSSHMaster(sshMasterCmd *exec.Cmd, sshFlags string, host string) error // copyCodeServerBinary copies a code-server binary from local to remote. func copyCodeServerBinary(sshFlags string, host string, localPath string, remotePath string) error { - if err := ensureFile(localPath); err != nil { + if err := validateIsFile(localPath); err != nil { return err } @@ -558,8 +558,8 @@ func ensureDir(path string) error { return nil } -// ensureFile tries to stat the specified path and ensure it's a file. -func ensureFile(path string) error { +// 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 From 7e6845d1bda59a90b09f90b8fc7815cdc23267c5 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 11 Sep 2019 10:38:11 -0500 Subject: [PATCH 41/46] Update deps --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e8637c0..e378ffe 100644 --- a/go.mod +++ b/go.mod @@ -12,5 +12,5 @@ require ( 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 df0d6a1..19ceba8 100644 --- a/go.sum +++ b/go.sum @@ -34,5 +34,5 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w 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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -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/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 6277c6bb0444e3ef9af40c5baad038e162087a3b Mon Sep 17 00:00:00 2001 From: Gwon Seonggwang Date: Sun, 22 Sep 2019 04:17:06 +0900 Subject: [PATCH 42/46] Update tarname and tar option for ci build Closes #142 --- ci/build.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/build.sh b/ci/build.sh index 1f14096..9e30b09 100755 --- a/ci/build.sh +++ b/ci/build.sh @@ -10,8 +10,8 @@ build(){ go build -ldflags "-X main.version=${tag}" -o $tmpdir/sshcode pushd $tmpdir - tarname=sshcode-$GOOS-$GOARCH.tar - tar -cf $tarname sshcode + tarname=sshcode-$GOOS-$GOARCH.tar.gz + tar -czf $tarname sshcode popd cp $tmpdir/$tarname bin rm -rf $tmpdir From fd95a4079501b8f9dc1080cd79541838d1c05e4c Mon Sep 17 00:00:00 2001 From: Brandon Callifornia Date: Fri, 25 Oct 2019 11:41:18 +0200 Subject: [PATCH 43/46] Fixed allow-http-warning and no auth --- sshcode.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sshcode.go b/sshcode.go index accdd0d..e769c4b 100644 --- a/sshcode.go +++ b/sshcode.go @@ -145,7 +145,7 @@ func sshCode(host, dir string, o options) error { flog.Info("Tunneling remote port %v to %v", o.remotePort, o.bindAddr) sshCmdStr := - fmt.Sprintf("ssh -tt -q -L %v:localhost:%v %v %v 'cd %v; %v --host 127.0.0.1 --allow-http --no-auth --port=%v'", + fmt.Sprintf("ssh -tt -q -L %v:localhost:%v %v %v 'cd %v; %v --host 127.0.0.1 --auth none --port=%v'", o.bindAddr, o.remotePort, o.sshFlags, host, dir, codeServerPath, o.remotePort, ) From 7ebef26504f6390830e436362b3eae72b48c41e2 Mon Sep 17 00:00:00 2001 From: hassieswift621 Date: Sat, 7 Dec 2019 20:02:02 +0000 Subject: [PATCH 44/46] Remove duplicated word from sync user settings error --- sshcode.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sshcode.go b/sshcode.go index e769c4b..8b239aa 100644 --- a/sshcode.go +++ b/sshcode.go @@ -212,7 +212,7 @@ func sshCode(host, dir string, o options) error { err = syncUserSettings(o.sshFlags, host, true) if err != nil { - return xerrors.Errorf("failed to sync user settings settings back: %w", err) + return xerrors.Errorf("failed to sync user settings back: %w", err) } return nil From 50e859cd1084379374420cb5263d053eae3b0254 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 14 Aug 2019 20:02:10 -0700 Subject: [PATCH 45/46] Add git bash and mingw support (#132) Supports git bash and mingw on Windows. Does not support cmd.exe. Signed-off-by: Dean Sheather Co-authored-by: Merith --- .gitignore | 1 + main.go | 6 +++++ settings.go | 4 ++++ sshcode.go | 63 ++++++++++++++++++++++++++++++++++++++++++++--------- 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index dc0daa9..94251e3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ vendor bin .vscode sshcode +sshcode.exe diff --git a/main.go b/main.go index b355dc1..674f983 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "fmt" "math/rand" "os" + "runtime" "strings" "time" @@ -80,6 +81,11 @@ func (c *rootCmd) Run(fl *pflag.FlagSet) { dir = "~" } + // Get linux relative path if on windows. + if runtime.GOOS == "windows" { + dir = gitbashWindowsDir(dir) + } + err := sshCode(host, dir, options{ skipSync: c.skipSync, sshFlags: c.sshFlags, diff --git a/settings.go b/settings.go index ad962a3..e88c260 100644 --- a/settings.go +++ b/settings.go @@ -24,6 +24,8 @@ func configDir() (string, error) { 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) } @@ -39,6 +41,8 @@ func extensionsDir() (string, error) { 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) } diff --git a/sshcode.go b/sshcode.go index 8b239aa..5021c09 100644 --- a/sshcode.go +++ b/sshcode.go @@ -10,6 +10,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" "strconv" "strings" "syscall" @@ -106,7 +107,6 @@ func sshCode(host, dir string, o options) error { // 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 @@ -145,10 +145,9 @@ func sshCode(host, dir string, o options) error { flog.Info("Tunneling remote port %v to %v", o.remotePort, o.bindAddr) sshCmdStr := - fmt.Sprintf("ssh -tt -q -L %v:localhost:%v %v %v 'cd %v; %v --host 127.0.0.1 --auth none --port=%v'", - o.bindAddr, o.remotePort, o.sshFlags, host, dir, codeServerPath, o.remotePort, + 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 @@ -266,9 +265,12 @@ func openBrowser(url string) { 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"): @@ -281,6 +283,8 @@ func openBrowser(url string) { 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 { @@ -335,6 +339,11 @@ func randomPort() (string, error) { // 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 { @@ -451,8 +460,10 @@ func syncUserSettings(sshFlags string, host string, back bool) error { return err } - const remoteSettingsDir = "~/.local/share/code-server/User/" - + var remoteSettingsDir = "~/.local/share/code-server/User/" + if runtime.GOOS == "windows" { + remoteSettingsDir = ".local/share/code-server/User/" + } var ( src = localConfDir + "/" dest = host + ":" + remoteSettingsDir @@ -477,7 +488,10 @@ func syncExtensions(sshFlags string, host string, back bool) error { return err } - const remoteExtensionsDir = "~/.local/share/code-server/extensions/" + var remoteExtensionsDir = "~/.local/share/code-server/extensions/" + if runtime.GOOS == "windows" { + remoteExtensionsDir = ".local/share/code-server/extensions/" + } var ( src = localExtensionsDir + "/" @@ -505,6 +519,7 @@ func rsync(src string, dest string, sshFlags string, excludePaths ...string) err // locally in order to properly delete an extension. "--delete", "--copy-unsafe-links", + "-zz", src, dest, )..., ) @@ -524,7 +539,7 @@ func downloadScript(codeServerPath string) string { [ "$(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 ~/.local/share/code-server %v +mkdir -p $HOME/.local/share/code-server %v cd %v curlflags="-o latest-linux" if [ -f latest-linux ]; then @@ -535,8 +550,8 @@ curl $curlflags https://codesrv-ci.cdr.sh/latest-linux ln latest-linux %v chmod +x %v`, codeServerPath, - filepath.Dir(codeServerPath), - filepath.Dir(codeServerPath), + filepath.ToSlash(filepath.Dir(codeServerPath)), + filepath.ToSlash(filepath.Dir(codeServerPath)), codeServerPath, codeServerPath, codeServerPath, @@ -548,6 +563,11 @@ chmod +x %v`, 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) } @@ -608,3 +628,26 @@ func parseGCPSSHCmd(instance string) (ip, sshFlags string, err error) { 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) +} From b52faf9528bdaa4cab8a20492065fed358b48b94 Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Mon, 10 Aug 2020 14:34:05 -0400 Subject: [PATCH 46/46] Add deprecation notice to README See #185 --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index dddd19e..3882f94 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # sshcode +**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)