diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5657f6e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +vendor \ No newline at end of file diff --git a/.sail/Dockerfile b/.sail/Dockerfile new file mode 100644 index 0000000..2e5b81d --- /dev/null +++ b/.sail/Dockerfile @@ -0,0 +1,3 @@ +FROM codercom/ubuntu-dev-go + +LABEL project_root "~/go/src/go.coder.com" diff --git a/README.md b/README.md new file mode 100644 index 0000000..bba642f --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# sshcode + +`sshcode` is a CLI to automatically install and run [code-server](https://github.com/codercom/code-server) over SSH. + +![Demo](/demo.gif) + +## Install + +Chrome is recommended. + +```bash +go get go.coder.com/sshcode +``` + +## Usage + +```bash +sshcode kyle@dev.kwc.io +# Starts code-server on dev.kwc.io and opens in a new browser window. +``` \ No newline at end of file diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..5c24246 Binary files /dev/null and b/demo.gif differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8dcfcb5 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module go.coder.com/sshcode + +go 1.12 + +require ( + go.coder.com/flog v0.0.0-20190129195112-eaed154a0db8 + golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bc35627 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +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= +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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..447e4d1 --- /dev/null +++ b/main.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "strconv" + "time" + + "go.coder.com/flog" +) + +func main() { + flag.Usage = func() { + fmt.Printf(`Usage: %v HOST [SSH ARGS...] + +Start code-server over SSH. +More info: https://github.com/codercom/sshcode +`, os.Args[0]) + } + + flag.Parse() + host := flag.Arg(0) + + if host == "" { + // If no host is specified output the usage. + flag.Usage() + os.Exit(1) + } + + flog.Info("ensuring code-server is updated...") + + // Downloads the latest code-server and allows it to be executed. + sshCmd := exec.Command("ssh", + "-tt", + host, + `/bin/bash -c 'set -euxo pipefail || exit 1 +mkdir -p ~/bin +wget -q https://codesrv-ci.cdr.sh/latest-linux -O ~/bin/code-server +chmod +x ~/bin/code-server +'`, + ) + output, err := sshCmd.CombinedOutput() + if err != nil { + flog.Fatal("failed to update code-server: %v: %s", err, output) + } + + flog.Info("starting code-server...") + localPort, err := scanAvailablePort() + if err != nil { + flog.Fatal("failed to scan available port: %v", err) + } + + // Starts code-server and forwards the remote port. + sshCmd = exec.Command("ssh", + "-tt", + "-q", + "-L", + localPort+":localhost:"+localPort, + host, + "~/bin/code-server --host 127.0.0.1 --allow-http --no-auth --port="+localPort, + ) + 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(), 3*time.Second) + defer cancel() + for { + if ctx.Err() != nil { + flog.Fatal("code-server didn't start in time %v", ctx.Err()) + } + // Waits for code-server to be available before opening the browser. + r, _ := http.NewRequest("GET", url, nil) + r = r.WithContext(ctx) + resp, err := http.DefaultClient.Do(r) + if err != nil { + continue + } + resp.Body.Close() + break + } + + openBrowser(url) + sshCmd.Wait() +} + +func openBrowser(url string) { + var openCmd *exec.Cmd + if commandExists("google-chrome") { + openCmd = exec.Command("google-chrome", "--app="+url, "--disable-extensions", "--disable-plugins") + } else if commandExists("firefox") { + openCmd = exec.Command("firefox", "--url="+url, "-safe-mode") + } else { + flog.Info("unable to find a browser to open: sshcode only supports firefox and chrome") + + return + } + + err := openCmd.Start() + if err != nil { + flog.Fatal("failed to open browser: %v", err) + } +} + +// Checks if a command exists locally. +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// scanAvailablePort scans 1024-4096 until an available port is found. +func scanAvailablePort() (string, error) { + for port := 1024; port < 4096; port++ { + l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + // If we have an error the port is taken. + port++ + continue + } + _ = l.Close() + + return strconv.Itoa(port), nil + } + + return "", errors.New("no ports available") +}