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 "" +}