Skip to content
This repository was archived by the owner on Jan 17, 2021. It is now read-only.

Add a simple bash test #61

Merged
merged 1 commit into from
Apr 30, 2019
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
dist: xenial
language: go

go:
- 1.12.x
go_import_path: go.coder.com/retry
env:
- GO111MODULE=on
script:
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
20 changes: 20 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
335 changes: 44 additions & 291 deletions main.go
Original file line number Diff line number Diff line change
@@ -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()
}
342 changes: 342 additions & 0 deletions sshcode.go
Original file line number Diff line number Diff line change
@@ -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
}
315 changes: 315 additions & 0 deletions sshcode_test.go
Original file line number Diff line number Diff line change
@@ -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 ""
}