├── .gitignore ├── .sail └── Dockerfile ├── .travis.yml ├── LICENSE ├── README.md ├── ci ├── build.sh ├── ensuremod.sh └── lint.sh ├── demo.gif ├── go.mod ├── go.sum ├── main.go ├── settings.go ├── sshcode.go └── sshcode_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | bin 3 | .vscode 4 | sshcode 5 | sshcode.exe 6 | -------------------------------------------------------------------------------- /.sail/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM codercom/ubuntu-dev-go 2 | 3 | # Go module tooling is completely broken. 4 | ENV GO111MODULE=off 5 | 6 | LABEL project_root "~/go/src/go.coder.com" 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: go 3 | go: 4 | - 1.12.x 5 | go_import_path: go.coder.com/retry 6 | env: 7 | - GO111MODULE=on 8 | script: 9 | - ./ci/ensuremod.sh 10 | - ./ci/lint.sh 11 | - go test -v ./... 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Coder Technologies, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sshcode 2 | 3 | **This project has been deprecated in favour of the [code-server install script](https://github.com/cdr/code-server#quick-install)** 4 | 5 | **See the discussion in [#185](https://github.com/cdr/sshcode/issues/185)** 6 | 7 | --- 8 | 9 | [!["Open Issues"](https://img.shields.io/github/issues-raw/cdr/sshcode.svg)](https://github.com/cdr/sshcode/issues) 10 | [!["Latest Release"](https://img.shields.io/github/release/cdr/sshcode.svg)](https://github.com/cdr/sshcode/releases/latest) 11 | [![MIT license](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/cdr/sshcode/blob/master/LICENSE) 12 | [![Discord](https://img.shields.io/discord/463752820026376202.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/zxSwN8Z) 13 | [![Build Status](https://travis-ci.org/cdr/sshcode.svg?branch=master)](https://travis-ci.org/cdr/sshcode) 14 | 15 | `sshcode` is a CLI to automatically install and run [code-server](https://github.com/cdr/code-server) over SSH. 16 | 17 | It uploads your extensions and settings automatically, so you can seamlessly use 18 | remote servers as [VS Code](https://code.visualstudio.com) hosts. 19 | 20 | If you have Chrome installed, it opens the browser in app mode. That means 21 | there's no keybind conflicts, address bar, or indication that you're coding within a browser. 22 | **It feels just like native VS Code.** 23 | 24 | ![Demo](/demo.gif) 25 | 26 | ## Install 27 | 28 | **Have Chrome installed for the best experience.** 29 | 30 | Install with `go`: 31 | 32 | ```bash 33 | go get -u go.coder.com/sshcode 34 | ``` 35 | 36 | Or, grab a [pre-built binary](https://github.com/cdr/sshcode/releases). 37 | 38 | ### OS Support 39 | 40 | We currently support: 41 | - Linux 42 | - MacOS 43 | - WSL 44 | 45 | For the remote server, we currently only support Linux `x86_64` (64-bit) 46 | servers with `glibc`. `musl` libc (which is most notably used by Alpine Linux) 47 | is currently not supported on the remote server: 48 | [#122](https://github.com/cdr/sshcode/issues/122). 49 | 50 | ## Usage 51 | 52 | ```bash 53 | sshcode kyle@dev.kwc.io 54 | # Starts code-server on dev.kwc.io and opens in a new browser window. 55 | ``` 56 | 57 | You can specify a remote directory as the second argument: 58 | 59 | ```bash 60 | sshcode kyle@dev.kwc.io "~/projects/sourcegraph" 61 | ``` 62 | 63 | ## Extensions & Settings Sync 64 | 65 | By default, `sshcode` will `rsync` your local VS Code settings and extensions 66 | to the remote server every time you connect. 67 | 68 | This operation may take a while on a slow connections, but will be fast 69 | on follow-up connections to the same server. 70 | 71 | To disable this feature entirely, pass the `--skipsync` flag. 72 | 73 | ### Custom settings directories 74 | 75 | If you're using an alternate release of VS Code such as VS Code Insiders, you 76 | must specify your settings directories through the `VSCODE_CONFIG_DIR` and 77 | `VSCODE_EXTENSIONS_DIR` environment variables. 78 | 79 | The following will make `sshcode` work with VS Code Insiders: 80 | 81 | **MacOS** 82 | 83 | ```bash 84 | export VSCODE_CONFIG_DIR="$HOME/Library/Application Support/Code - Insiders/User" 85 | export VSCODE_EXTENSIONS_DIR="$HOME/.vscode-insiders/extensions" 86 | ``` 87 | 88 | **Linux** 89 | 90 | ```bash 91 | export VSCODE_CONFIG_DIR="$HOME/.config/Code - Insiders/User" 92 | export VSCODE_EXTENSIONS_DIR="$HOME/.vscode-insiders/extensions" 93 | ``` 94 | 95 | ### Sync-back 96 | 97 | By default, VS Code changes on the remote server won't be synced back 98 | when the connection closes. To synchronize back to local when the connection ends, 99 | pass the `-b` flag. 100 | -------------------------------------------------------------------------------- /ci/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export GOARCH=amd64 3 | 4 | tag=$(git describe --tags) 5 | 6 | mkdir -p bin 7 | 8 | build(){ 9 | tmpdir=$(mktemp -d) 10 | go build -ldflags "-X main.version=${tag}" -o $tmpdir/sshcode 11 | 12 | pushd $tmpdir 13 | tarname=sshcode-$GOOS-$GOARCH.tar.gz 14 | tar -czf $tarname sshcode 15 | popd 16 | cp $tmpdir/$tarname bin 17 | rm -rf $tmpdir 18 | } 19 | 20 | GOOS=darwin build 21 | GOOS=linux build 22 | -------------------------------------------------------------------------------- /ci/ensuremod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # inspired by nhooyr's days as CI overlord 4 | 5 | set -eou pipefail 6 | 7 | function help() { 8 | echo 9 | echo "you may need to update go.mod/go.sum via:" 10 | echo "go list all > /dev/null" 11 | echo "go mod tidy" 12 | exit 1 13 | } 14 | 15 | go list -mod=readonly all > /dev/null 16 | 17 | go mod tidy 18 | 19 | if [[ $(git diff --name-only) != "" ]]; then 20 | git diff 21 | help 22 | fi 23 | -------------------------------------------------------------------------------- /ci/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Inspired by nhooyr's days as CI overlord. 4 | 5 | set -euo pipefail 6 | 7 | files=$(gofmt -l -s .) 8 | 9 | if [ ! -z "$files" ]; 10 | then 11 | echo "The following files need to be formatted:" 12 | echo "$files" 13 | echo "Please run 'gofmt -w -s .'" 14 | exit 1 15 | fi 16 | 17 | go vet -composites=false . 18 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/sshcode/b52faf9528bdaa4cab8a20492065fed358b48b94/demo.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.coder.com/sshcode 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 7 | github.com/pkg/errors v0.8.1 // indirect 8 | github.com/spf13/pflag v1.0.3 9 | github.com/stretchr/testify v1.3.0 10 | go.coder.com/cli v0.4.0 11 | go.coder.com/flog v0.0.0-20190129195112-eaed154a0db8 12 | go.coder.com/retry v0.0.0-20180926062817-cf12c95974ac 13 | golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd 14 | golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be // indirect 15 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 4 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 5 | github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= 6 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 7 | github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= 8 | github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 9 | github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98= 10 | github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= 11 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 12 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 13 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 17 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 18 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 19 | github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 20 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 21 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 22 | go.coder.com/cli v0.4.0 h1:PruDGwm/CPFndyK/eMowZG3vzg5CgohRWeXWCTr3zi8= 23 | go.coder.com/cli v0.4.0/go.mod h1:hRTOURCR3LJF1FRW9arecgrzX+AHG7mfYMwThPIgq+w= 24 | go.coder.com/flog v0.0.0-20190129195112-eaed154a0db8 h1:PtQ3moPi4EAz3cyQhkUs1IGIXa2QgJpP60yMjOdu0kk= 25 | go.coder.com/flog v0.0.0-20190129195112-eaed154a0db8/go.mod h1:83JsYgXYv0EOaXjIMnaZ1Fl6ddNB3fJnDZ/8845mUJ8= 26 | go.coder.com/retry v0.0.0-20180926062817-cf12c95974ac h1:ekdpsuykRy/E+SDq5BquFomNhRCk8OOyhtnACW9Bi50= 27 | go.coder.com/retry v0.0.0-20180926062817-cf12c95974ac/go.mod h1:h7MQcGZ698RYUan++Yu4aDcBvquTI2cSsup+GSy8D2Y= 28 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 29 | golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd h1:sMHc2rZHuzQmrbVoSpt9HgerkXPyIeCSO6k0zUMGfFk= 30 | golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 31 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 32 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 33 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 34 | golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be h1:mI+jhqkn68ybP0ORJqunXn+fq+Eeb4hHKqLQcFICjAc= 35 | golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 36 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 37 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= 38 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 39 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "os" 7 | "runtime" 8 | "strings" 9 | "time" 10 | 11 | "github.com/spf13/pflag" 12 | 13 | "go.coder.com/cli" 14 | "go.coder.com/flog" 15 | ) 16 | 17 | func init() { 18 | rand.Seed(time.Now().Unix()) 19 | } 20 | 21 | const helpTabWidth = 5 22 | 23 | var ( 24 | helpTab = strings.Repeat(" ", helpTabWidth) 25 | // version is overwritten by ci/build.sh. 26 | version string 27 | ) 28 | 29 | func main() { 30 | cli.RunRoot(&rootCmd{}) 31 | } 32 | 33 | var _ interface { 34 | cli.Command 35 | cli.FlaggedCommand 36 | } = new(rootCmd) 37 | 38 | type rootCmd struct { 39 | skipSync bool 40 | syncBack bool 41 | printVersion bool 42 | noReuseConnection bool 43 | bindAddr string 44 | sshFlags string 45 | uploadCodeServer string 46 | } 47 | 48 | func (c *rootCmd) Spec() cli.CommandSpec { 49 | return cli.CommandSpec{ 50 | Name: "sshcode", 51 | Usage: c.usage(), 52 | Desc: c.description(), 53 | } 54 | } 55 | 56 | func (c *rootCmd) RegisterFlags(fl *pflag.FlagSet) { 57 | fl.BoolVar(&c.skipSync, "skipsync", false, "skip syncing local settings and extensions to remote host") 58 | fl.BoolVar(&c.syncBack, "b", false, "sync extensions back on termination") 59 | fl.BoolVar(&c.printVersion, "version", false, "print version information and exit") 60 | fl.BoolVar(&c.noReuseConnection, "no-reuse-connection", false, "do not reuse SSH connection via control socket") 61 | fl.StringVar(&c.bindAddr, "bind", "", "local bind address for SSH tunnel, in [HOST][:PORT] syntax (default: 127.0.0.1)") 62 | fl.StringVar(&c.sshFlags, "ssh-flags", "", "custom SSH flags") 63 | fl.StringVar(&c.uploadCodeServer, "upload-code-server", "", "custom code-server binary to upload to the remote host") 64 | } 65 | 66 | func (c *rootCmd) Run(fl *pflag.FlagSet) { 67 | if c.printVersion { 68 | fmt.Printf("%v\n", version) 69 | os.Exit(0) 70 | } 71 | 72 | host := fl.Arg(0) 73 | if host == "" { 74 | // If no host is specified output the usage. 75 | fl.Usage() 76 | os.Exit(1) 77 | } 78 | 79 | dir := fl.Arg(1) 80 | if dir == "" { 81 | dir = "~" 82 | } 83 | 84 | // Get linux relative path if on windows. 85 | if runtime.GOOS == "windows" { 86 | dir = gitbashWindowsDir(dir) 87 | } 88 | 89 | err := sshCode(host, dir, options{ 90 | skipSync: c.skipSync, 91 | sshFlags: c.sshFlags, 92 | bindAddr: c.bindAddr, 93 | syncBack: c.syncBack, 94 | reuseConnection: !c.noReuseConnection, 95 | uploadCodeServer: c.uploadCodeServer, 96 | }) 97 | 98 | if err != nil { 99 | flog.Fatal("error: %v", err) 100 | } 101 | } 102 | 103 | func (c *rootCmd) usage() string { 104 | return "[FLAGS] HOST [DIR]" 105 | } 106 | 107 | func (c *rootCmd) description() string { 108 | return fmt.Sprintf(`Start VS Code via code-server over SSH. 109 | 110 | Environment variables: 111 | %v%v use special VS Code settings dir. 112 | %v%v use special VS Code extensions dir. 113 | 114 | More info: https://github.com/cdr/sshcode 115 | 116 | Arguments: 117 | %vHOST is passed into the ssh command. Valid formats are '' or 'gcp:'. 118 | %vDIR is optional.`, 119 | helpTab, vsCodeConfigDirEnv, 120 | helpTab, vsCodeExtensionsDirEnv, 121 | helpTab, 122 | helpTab, 123 | ) 124 | } 125 | -------------------------------------------------------------------------------- /settings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | 8 | "golang.org/x/xerrors" 9 | ) 10 | 11 | const ( 12 | vsCodeConfigDirEnv = "VSCODE_CONFIG_DIR" 13 | vsCodeExtensionsDirEnv = "VSCODE_EXTENSIONS_DIR" 14 | ) 15 | 16 | func configDir() (string, error) { 17 | if env, ok := os.LookupEnv(vsCodeConfigDirEnv); ok { 18 | return os.ExpandEnv(env), nil 19 | } 20 | 21 | var path string 22 | switch runtime.GOOS { 23 | case "linux": 24 | path = os.ExpandEnv("$HOME/.config/Code/User/") 25 | case "darwin": 26 | path = os.ExpandEnv("$HOME/Library/Application Support/Code/User/") 27 | case "windows": 28 | return os.ExpandEnv("/c/Users/$USERNAME/AppData/Roaming/Code/User"), nil 29 | default: 30 | return "", xerrors.Errorf("unsupported platform: %s", runtime.GOOS) 31 | } 32 | return filepath.Clean(path), nil 33 | } 34 | 35 | func extensionsDir() (string, error) { 36 | if env, ok := os.LookupEnv(vsCodeExtensionsDirEnv); ok { 37 | return os.ExpandEnv(env), nil 38 | } 39 | 40 | var path string 41 | switch runtime.GOOS { 42 | case "linux", "darwin": 43 | path = os.ExpandEnv("$HOME/.vscode/extensions/") 44 | case "windows": 45 | return os.ExpandEnv("/c/Users/$USERNAME/.vscode/extensions"), nil 46 | default: 47 | return "", xerrors.Errorf("unsupported platform: %s", runtime.GOOS) 48 | } 49 | return filepath.Clean(path), nil 50 | } 51 | -------------------------------------------------------------------------------- /sshcode.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "net" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "os/signal" 12 | "path/filepath" 13 | "runtime" 14 | "strconv" 15 | "strings" 16 | "syscall" 17 | "time" 18 | 19 | "github.com/pkg/browser" 20 | "go.coder.com/flog" 21 | "golang.org/x/xerrors" 22 | ) 23 | 24 | const codeServerPath = "~/.cache/sshcode/sshcode-server" 25 | 26 | const ( 27 | sshDirectory = "~/.ssh" 28 | sshDirectoryUnsafeModeMask = 0022 29 | sshControlPath = sshDirectory + "/control-%h-%p-%r" 30 | ) 31 | 32 | type options struct { 33 | skipSync bool 34 | syncBack bool 35 | noOpen bool 36 | reuseConnection bool 37 | bindAddr string 38 | remotePort string 39 | sshFlags string 40 | uploadCodeServer string 41 | } 42 | 43 | func sshCode(host, dir string, o options) error { 44 | host, extraSSHFlags, err := parseHost(host) 45 | if err != nil { 46 | return xerrors.Errorf("failed to parse host IP: %w", err) 47 | } 48 | if extraSSHFlags != "" { 49 | o.sshFlags = strings.Join([]string{extraSSHFlags, o.sshFlags}, " ") 50 | } 51 | 52 | o.bindAddr, err = parseBindAddr(o.bindAddr) 53 | if err != nil { 54 | return xerrors.Errorf("failed to parse bind address: %w", err) 55 | } 56 | 57 | if o.remotePort == "" { 58 | o.remotePort, err = randomPort() 59 | } 60 | if err != nil { 61 | return xerrors.Errorf("failed to find available remote port: %w", err) 62 | } 63 | 64 | // Check the SSH directory's permissions and warn the user if it is not safe. 65 | o.reuseConnection = checkSSHDirectory(sshDirectory, o.reuseConnection) 66 | 67 | // Start SSH master connection socket. This prevents multiple password prompts from appearing as authentication 68 | // only happens on the initial connection. 69 | if o.reuseConnection { 70 | flog.Info("starting SSH master connection...") 71 | newSSHFlags, cancel, err := startSSHMaster(o.sshFlags, sshControlPath, host) 72 | defer cancel() 73 | if err != nil { 74 | flog.Error("failed to start SSH master connection: %v", err) 75 | o.reuseConnection = false 76 | } else { 77 | o.sshFlags = newSSHFlags 78 | } 79 | } 80 | 81 | // Upload local code-server or download code-server from CI server. 82 | if o.uploadCodeServer != "" { 83 | flog.Info("uploading local code-server binary...") 84 | err = copyCodeServerBinary(o.sshFlags, host, o.uploadCodeServer, codeServerPath) 85 | if err != nil { 86 | return xerrors.Errorf("failed to upload local code-server binary to remote server: %w", err) 87 | } 88 | 89 | sshCmdStr := 90 | fmt.Sprintf("ssh %v %v 'chmod +x %v'", 91 | o.sshFlags, host, codeServerPath, 92 | ) 93 | 94 | sshCmd := exec.Command("sh", "-l", "-c", sshCmdStr) 95 | sshCmd.Stdout = os.Stdout 96 | sshCmd.Stderr = os.Stderr 97 | err = sshCmd.Run() 98 | if err != nil { 99 | return xerrors.Errorf("failed to make code-server binary executable:\n---ssh cmd---\n%s: %w", 100 | sshCmdStr, 101 | err, 102 | ) 103 | } 104 | } else { 105 | flog.Info("ensuring code-server is updated...") 106 | dlScript := downloadScript(codeServerPath) 107 | 108 | // Downloads the latest code-server and allows it to be executed. 109 | sshCmdStr := fmt.Sprintf("ssh %v %v '/usr/bin/env bash -l'", o.sshFlags, host) 110 | sshCmd := exec.Command("sh", "-l", "-c", sshCmdStr) 111 | sshCmd.Stdout = os.Stdout 112 | sshCmd.Stderr = os.Stderr 113 | sshCmd.Stdin = strings.NewReader(dlScript) 114 | err = sshCmd.Run() 115 | if err != nil { 116 | return xerrors.Errorf("failed to update code-server:\n---ssh cmd---\n%s"+ 117 | "\n---download script---\n%s: %w", 118 | sshCmdStr, 119 | dlScript, 120 | err, 121 | ) 122 | } 123 | } 124 | 125 | if !o.skipSync { 126 | start := time.Now() 127 | flog.Info("syncing settings") 128 | err = syncUserSettings(o.sshFlags, host, false) 129 | if err != nil { 130 | return xerrors.Errorf("failed to sync settings: %w", err) 131 | } 132 | 133 | flog.Info("synced settings in %s", time.Since(start)) 134 | 135 | flog.Info("syncing extensions") 136 | err = syncExtensions(o.sshFlags, host, false) 137 | if err != nil { 138 | return xerrors.Errorf("failed to sync extensions: %w", err) 139 | } 140 | flog.Info("synced extensions in %s", time.Since(start)) 141 | } 142 | 143 | flog.Info("starting code-server...") 144 | 145 | flog.Info("Tunneling remote port %v to %v", o.remotePort, o.bindAddr) 146 | 147 | sshCmdStr := 148 | fmt.Sprintf("ssh -tt -q -L %v:localhost:%v %v %v '%v %v --host 127.0.0.1 --auth none --port=%v'", 149 | o.bindAddr, o.remotePort, o.sshFlags, host, codeServerPath, dir, o.remotePort, 150 | ) 151 | // Starts code-server and forwards the remote port. 152 | sshCmd := exec.Command("sh", "-l", "-c", sshCmdStr) 153 | sshCmd.Stdin = os.Stdin 154 | sshCmd.Stdout = os.Stdout 155 | sshCmd.Stderr = os.Stderr 156 | err = sshCmd.Start() 157 | if err != nil { 158 | return xerrors.Errorf("failed to start code-server: %w", err) 159 | } 160 | 161 | url := fmt.Sprintf("http://%s", o.bindAddr) 162 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 163 | defer cancel() 164 | 165 | client := http.Client{ 166 | Timeout: time.Second * 3, 167 | } 168 | for { 169 | if ctx.Err() != nil { 170 | return xerrors.Errorf("code-server didn't start in time: %w", ctx.Err()) 171 | } 172 | // Waits for code-server to be available before opening the browser. 173 | resp, err := client.Get(url) 174 | if err != nil { 175 | continue 176 | } 177 | resp.Body.Close() 178 | break 179 | } 180 | 181 | ctx, cancel = context.WithCancel(context.Background()) 182 | 183 | if !o.noOpen { 184 | openBrowser(url) 185 | } 186 | 187 | go func() { 188 | defer cancel() 189 | sshCmd.Wait() 190 | }() 191 | 192 | c := make(chan os.Signal) 193 | signal.Notify(c, os.Interrupt) 194 | 195 | select { 196 | case <-ctx.Done(): 197 | case <-c: 198 | } 199 | 200 | flog.Info("shutting down") 201 | if !o.syncBack || o.skipSync { 202 | return nil 203 | } 204 | 205 | flog.Info("synchronizing VS Code back to local") 206 | 207 | err = syncExtensions(o.sshFlags, host, true) 208 | if err != nil { 209 | return xerrors.Errorf("failed to sync extensions back: %w", err) 210 | } 211 | 212 | err = syncUserSettings(o.sshFlags, host, true) 213 | if err != nil { 214 | return xerrors.Errorf("failed to sync user settings back: %w", err) 215 | } 216 | 217 | return nil 218 | } 219 | 220 | // expandPath returns an expanded version of path. 221 | func expandPath(path string) string { 222 | path = filepath.Clean(os.ExpandEnv(path)) 223 | 224 | // Replace tilde notation in path with the home directory. You can't replace the first instance of `~` in the 225 | // string with the homedir as having a tilde in the middle of a filename is valid. 226 | homedir := os.Getenv("HOME") 227 | if homedir != "" { 228 | if path == "~" { 229 | path = homedir 230 | } else if strings.HasPrefix(path, "~/") { 231 | path = filepath.Join(homedir, path[2:]) 232 | } 233 | } 234 | 235 | return filepath.Clean(path) 236 | } 237 | 238 | func parseBindAddr(bindAddr string) (string, error) { 239 | if !strings.Contains(bindAddr, ":") { 240 | bindAddr += ":" 241 | } 242 | 243 | host, port, err := net.SplitHostPort(bindAddr) 244 | if err != nil { 245 | return "", err 246 | } 247 | 248 | if host == "" { 249 | host = "127.0.0.1" 250 | } 251 | 252 | if port == "" { 253 | port, err = randomPort() 254 | } 255 | if err != nil { 256 | return "", err 257 | } 258 | 259 | return net.JoinHostPort(host, port), nil 260 | } 261 | 262 | func openBrowser(url string) { 263 | var openCmd *exec.Cmd 264 | 265 | const ( 266 | macPath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" 267 | wslPath = "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" 268 | winPath = "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe" 269 | ) 270 | 271 | switch { 272 | case commandExists("chrome"): 273 | openCmd = exec.Command("chrome", chromeOptions(url)...) 274 | case commandExists("google-chrome"): 275 | openCmd = exec.Command("google-chrome", chromeOptions(url)...) 276 | case commandExists("google-chrome-stable"): 277 | openCmd = exec.Command("google-chrome-stable", chromeOptions(url)...) 278 | case commandExists("chromium"): 279 | openCmd = exec.Command("chromium", chromeOptions(url)...) 280 | case commandExists("chromium-browser"): 281 | openCmd = exec.Command("chromium-browser", chromeOptions(url)...) 282 | case pathExists(macPath): 283 | openCmd = exec.Command(macPath, chromeOptions(url)...) 284 | case pathExists(wslPath): 285 | openCmd = exec.Command(wslPath, chromeOptions(url)...) 286 | case pathExists(winPath): 287 | openCmd = exec.Command(winPath, chromeOptions(url)...) 288 | default: 289 | err := browser.OpenURL(url) 290 | if err != nil { 291 | flog.Error("failed to open browser: %v", err) 292 | } 293 | return 294 | } 295 | 296 | // We do not use CombinedOutput because if there is no chrome instance, this will block 297 | // and become the parent process instead of using an existing chrome instance. 298 | err := openCmd.Start() 299 | if err != nil { 300 | flog.Error("failed to open browser: %v", err) 301 | } 302 | } 303 | 304 | func chromeOptions(url string) []string { 305 | return []string{"--app=" + url, "--disable-extensions", "--disable-plugins", "--incognito"} 306 | } 307 | 308 | // Checks if a command exists locally. 309 | func commandExists(name string) bool { 310 | _, err := exec.LookPath(name) 311 | return err == nil 312 | } 313 | 314 | func pathExists(name string) bool { 315 | _, err := os.Stat(name) 316 | return err == nil 317 | } 318 | 319 | // randomPort picks a random port to start code-server on. 320 | func randomPort() (string, error) { 321 | const ( 322 | minPort = 1024 323 | maxPort = 65535 324 | maxTries = 10 325 | ) 326 | for i := 0; i < maxTries; i++ { 327 | port := rand.Intn(maxPort-minPort+1) + minPort 328 | l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) 329 | if err == nil { 330 | _ = l.Close() 331 | return strconv.Itoa(port), nil 332 | } 333 | flog.Info("port taken: %d", port) 334 | } 335 | 336 | return "", xerrors.Errorf("max number of tries exceeded: %d", maxTries) 337 | } 338 | 339 | // checkSSHDirectory performs sanity and safety checks on sshDirectory, and 340 | // returns a new value for o.reuseConnection depending on the checks. 341 | func checkSSHDirectory(sshDirectory string, reuseConnection bool) bool { 342 | if runtime.GOOS == "windows" { 343 | flog.Info("OS is windows, disabling connection reuse feature") 344 | return false 345 | } 346 | 347 | sshDirectoryMode, err := os.Lstat(expandPath(sshDirectory)) 348 | if err != nil { 349 | if reuseConnection { 350 | flog.Info("failed to stat %v directory, disabling connection reuse feature: %v", sshDirectory, err) 351 | } 352 | reuseConnection = false 353 | } else { 354 | if !sshDirectoryMode.IsDir() { 355 | if reuseConnection { 356 | flog.Info("%v is not a directory, disabling connection reuse feature", sshDirectory) 357 | } else { 358 | flog.Info("warning: %v is not a directory", sshDirectory) 359 | } 360 | reuseConnection = false 361 | } 362 | if sshDirectoryMode.Mode().Perm()&sshDirectoryUnsafeModeMask != 0 { 363 | flog.Info("warning: the %v directory has unsafe permissions, they should only be writable by "+ 364 | "the owner (and files inside should be set to 0600)", sshDirectory) 365 | } 366 | } 367 | return reuseConnection 368 | } 369 | 370 | // startSSHMaster starts an SSH master connection and waits for it to be ready. 371 | // It returns a new set of SSH flags for child SSH processes to use. 372 | func startSSHMaster(sshFlags string, sshControlPath string, host string) (string, func(), error) { 373 | ctx, cancel := context.WithCancel(context.Background()) 374 | 375 | newSSHFlags := fmt.Sprintf(`%v -o "ControlPath=%v"`, sshFlags, sshControlPath) 376 | 377 | // -MN means "start a master socket and don't open a session, just connect". 378 | sshCmdStr := fmt.Sprintf(`exec ssh %v -MNq %v`, newSSHFlags, host) 379 | sshMasterCmd := exec.CommandContext(ctx, "sh", "-c", sshCmdStr) 380 | sshMasterCmd.Stdin = os.Stdin 381 | sshMasterCmd.Stderr = os.Stderr 382 | 383 | // Gracefully stop the SSH master. 384 | stopSSHMaster := func() { 385 | if sshMasterCmd.Process != nil { 386 | if sshMasterCmd.ProcessState != nil && sshMasterCmd.ProcessState.Exited() { 387 | return 388 | } 389 | err := sshMasterCmd.Process.Signal(syscall.SIGTERM) 390 | if err != nil { 391 | flog.Error("failed to send SIGTERM to SSH master process: %v", err) 392 | } 393 | } 394 | cancel() 395 | } 396 | 397 | // Start ssh master and wait. Waiting prevents the process from becoming a zombie process if it dies before 398 | // sshcode does, and allows sshMasterCmd.ProcessState to be populated. 399 | err := sshMasterCmd.Start() 400 | go sshMasterCmd.Wait() 401 | if err != nil { 402 | return "", stopSSHMaster, err 403 | } 404 | err = checkSSHMaster(sshMasterCmd, newSSHFlags, host) 405 | if err != nil { 406 | stopSSHMaster() 407 | return "", stopSSHMaster, xerrors.Errorf("SSH master wasn't ready on time: %w", err) 408 | } 409 | return newSSHFlags, stopSSHMaster, nil 410 | } 411 | 412 | // checkSSHMaster polls every second for 30 seconds to check if the SSH master 413 | // is ready. 414 | func checkSSHMaster(sshMasterCmd *exec.Cmd, sshFlags string, host string) error { 415 | var ( 416 | maxTries = 30 417 | sleepDur = time.Second 418 | err error 419 | ) 420 | for i := 0; i < maxTries; i++ { 421 | // Check if the master is running. 422 | if sshMasterCmd.Process == nil || (sshMasterCmd.ProcessState != nil && sshMasterCmd.ProcessState.Exited()) { 423 | return xerrors.Errorf("SSH master process is not running") 424 | } 425 | 426 | // Check if it's ready. 427 | sshCmdStr := fmt.Sprintf(`ssh %v -O check %v`, sshFlags, host) 428 | sshCmd := exec.Command("sh", "-c", sshCmdStr) 429 | err = sshCmd.Run() 430 | if err == nil { 431 | return nil 432 | } 433 | time.Sleep(sleepDur) 434 | } 435 | return xerrors.Errorf("max number of tries exceeded: %d", maxTries) 436 | } 437 | 438 | // copyCodeServerBinary copies a code-server binary from local to remote. 439 | func copyCodeServerBinary(sshFlags string, host string, localPath string, remotePath string) error { 440 | if err := validateIsFile(localPath); err != nil { 441 | return err 442 | } 443 | 444 | var ( 445 | src = localPath 446 | dest = host + ":" + remotePath 447 | ) 448 | 449 | return rsync(src, dest, sshFlags) 450 | } 451 | 452 | func syncUserSettings(sshFlags string, host string, back bool) error { 453 | localConfDir, err := configDir() 454 | if err != nil { 455 | return err 456 | } 457 | 458 | err = ensureDir(localConfDir) 459 | if err != nil { 460 | return err 461 | } 462 | 463 | var remoteSettingsDir = "~/.local/share/code-server/User/" 464 | if runtime.GOOS == "windows" { 465 | remoteSettingsDir = ".local/share/code-server/User/" 466 | } 467 | var ( 468 | src = localConfDir + "/" 469 | dest = host + ":" + remoteSettingsDir 470 | ) 471 | 472 | if back { 473 | dest, src = src, dest 474 | } 475 | 476 | // Append "/" to have rsync copy the contents of the dir. 477 | return rsync(src, dest, sshFlags, "workspaceStorage", "logs", "CachedData") 478 | } 479 | 480 | func syncExtensions(sshFlags string, host string, back bool) error { 481 | localExtensionsDir, err := extensionsDir() 482 | if err != nil { 483 | return err 484 | } 485 | 486 | err = ensureDir(localExtensionsDir) 487 | if err != nil { 488 | return err 489 | } 490 | 491 | var remoteExtensionsDir = "~/.local/share/code-server/extensions/" 492 | if runtime.GOOS == "windows" { 493 | remoteExtensionsDir = ".local/share/code-server/extensions/" 494 | } 495 | 496 | var ( 497 | src = localExtensionsDir + "/" 498 | dest = host + ":" + remoteExtensionsDir 499 | ) 500 | if back { 501 | dest, src = src, dest 502 | } 503 | 504 | return rsync(src, dest, sshFlags) 505 | } 506 | 507 | func rsync(src string, dest string, sshFlags string, excludePaths ...string) error { 508 | excludeFlags := make([]string, len(excludePaths)) 509 | for i, path := range excludePaths { 510 | excludeFlags[i] = "--exclude=" + path 511 | } 512 | 513 | cmd := exec.Command("rsync", append(excludeFlags, "-azvr", 514 | "-e", "ssh "+sshFlags, 515 | // Only update newer directories, and sync times 516 | // to keep things simple. 517 | "-u", "--times", 518 | // This is more unsafe, but it's obnoxious having to enter VS Code 519 | // locally in order to properly delete an extension. 520 | "--delete", 521 | "--copy-unsafe-links", 522 | "-zz", 523 | src, dest, 524 | )..., 525 | ) 526 | cmd.Stdout = os.Stdout 527 | cmd.Stderr = os.Stderr 528 | err := cmd.Run() 529 | if err != nil { 530 | return xerrors.Errorf("failed to rsync '%s' to '%s': %w", src, dest, err) 531 | } 532 | 533 | return nil 534 | } 535 | 536 | func downloadScript(codeServerPath string) string { 537 | return fmt.Sprintf( 538 | `set -euxo pipefail || exit 1 539 | 540 | [ "$(uname -m)" != "x86_64" ] && echo "Unsupported server architecture $(uname -m). code-server only has releases for x86_64 systems." && exit 1 541 | pkill -f %v || true 542 | mkdir -p $HOME/.local/share/code-server %v 543 | cd %v 544 | curlflags="-o latest-linux" 545 | if [ -f latest-linux ]; then 546 | curlflags="$curlflags -z latest-linux" 547 | fi 548 | curl $curlflags https://codesrv-ci.cdr.sh/latest-linux 549 | [ -f %v ] && rm %v 550 | ln latest-linux %v 551 | chmod +x %v`, 552 | codeServerPath, 553 | filepath.ToSlash(filepath.Dir(codeServerPath)), 554 | filepath.ToSlash(filepath.Dir(codeServerPath)), 555 | codeServerPath, 556 | codeServerPath, 557 | codeServerPath, 558 | codeServerPath, 559 | ) 560 | } 561 | 562 | // ensureDir creates a directory if it does not exist. 563 | func ensureDir(path string) error { 564 | _, err := os.Stat(path) 565 | if os.IsNotExist(err) { 566 | // This fixes a issue where Go reads `/c/` as `C:\c\` and creates 567 | // empty directories on the client that don't need to exist. 568 | if runtime.GOOS == "windows" && strings.HasPrefix(path, "/c/") { 569 | path = "C:" + path[2:] 570 | } 571 | err = os.MkdirAll(path, 0750) 572 | } 573 | 574 | if err != nil { 575 | return err 576 | } 577 | 578 | return nil 579 | } 580 | 581 | // validateIsFile tries to stat the specified path and ensure it's a file. 582 | func validateIsFile(path string) error { 583 | info, err := os.Stat(path) 584 | if err != nil { 585 | return err 586 | } 587 | if info.IsDir() { 588 | return xerrors.New("path is a directory") 589 | } 590 | return nil 591 | } 592 | 593 | // parseHost parses the host argument. If 'gcp:' is prefixed to the 594 | // host then a lookup is done using gcloud to determine the external IP and any 595 | // additional SSH arguments that should be used for ssh commands. Otherwise, host 596 | // is returned. 597 | func parseHost(host string) (parsedHost string, additionalFlags string, err error) { 598 | host = strings.TrimSpace(host) 599 | switch { 600 | case strings.HasPrefix(host, "gcp:"): 601 | instance := strings.TrimPrefix(host, "gcp:") 602 | return parseGCPSSHCmd(instance) 603 | default: 604 | return host, "", nil 605 | } 606 | } 607 | 608 | // parseGCPSSHCmd parses the IP address and flags used by 'gcloud' when 609 | // ssh'ing to an instance. 610 | func parseGCPSSHCmd(instance string) (ip, sshFlags string, err error) { 611 | dryRunCmd := fmt.Sprintf("gcloud compute ssh --dry-run %v", instance) 612 | 613 | out, err := exec.Command("sh", "-l", "-c", dryRunCmd).CombinedOutput() 614 | if err != nil { 615 | return "", "", xerrors.Errorf("%s: %w", out, err) 616 | } 617 | 618 | toks := strings.Split(string(out), " ") 619 | if len(toks) < 2 { 620 | return "", "", xerrors.Errorf("unexpected output for '%v' command, %s", dryRunCmd, out) 621 | } 622 | 623 | // Slice off the '/usr/bin/ssh' prefix and the '@' suffix. 624 | sshFlags = strings.Join(toks[1:len(toks)-1], " ") 625 | 626 | // E.g. foo@1.2.3.4. 627 | userIP := toks[len(toks)-1] 628 | 629 | return strings.TrimSpace(userIP), sshFlags, nil 630 | } 631 | 632 | // gitbashWindowsDir strips a the msys2 install directory from the beginning of 633 | // the path. On msys2, if a user provides `/workspace` sshcode will receive 634 | // `C:/msys64/workspace` which won't work on the remote host. 635 | func gitbashWindowsDir(dir string) string { 636 | 637 | // Don't bother figuring out path if it's relative to home dir. 638 | if strings.HasPrefix(dir, "~/") { 639 | if dir == "~" { 640 | return "~/" 641 | } 642 | return dir 643 | } 644 | 645 | mingwPrefix, err := exec.Command("sh", "-c", "{ cd / && pwd -W; }").Output() 646 | if err != nil { 647 | // Default to a sane location. 648 | mingwPrefix = []byte("C:/mingw64") 649 | } 650 | 651 | prefix := strings.TrimSuffix(string(mingwPrefix), "/\n") 652 | return strings.TrimPrefix(dir, prefix) 653 | } 654 | -------------------------------------------------------------------------------- /sshcode_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/http" 9 | "os/exec" 10 | "path/filepath" 11 | "strconv" 12 | "sync" 13 | "testing" 14 | "time" 15 | 16 | "github.com/stretchr/testify/require" 17 | "go.coder.com/retry" 18 | "golang.org/x/crypto/ssh" 19 | ) 20 | 21 | func TestSSHCode(t *testing.T) { 22 | sshPort, err := randomPort() 23 | require.NoError(t, err) 24 | 25 | // start up our jank ssh server 26 | defer trassh(t, sshPort).Close() 27 | 28 | localPort := randomPortExclude(t, sshPort) 29 | require.NotEmpty(t, localPort) 30 | 31 | remotePort := randomPortExclude(t, sshPort, localPort) 32 | require.NotEmpty(t, remotePort) 33 | 34 | var wg sync.WaitGroup 35 | wg.Add(1) 36 | go func() { 37 | defer wg.Done() 38 | err := sshCode("foo@127.0.0.1", "", options{ 39 | sshFlags: testSSHArgs(sshPort), 40 | bindAddr: net.JoinHostPort("127.0.0.1", localPort), 41 | remotePort: remotePort, 42 | noOpen: true, 43 | }) 44 | require.NoError(t, err) 45 | }() 46 | 47 | waitForSSHCode(t, localPort, time.Second*30) 48 | waitForSSHCode(t, remotePort, time.Second*30) 49 | 50 | // Typically we'd do an os.Stat call here but the os package doesn't expand '~' 51 | out, err := exec.Command("sh", "-l", "-c", "stat "+codeServerPath).CombinedOutput() 52 | require.NoError(t, err, "%s", out) 53 | 54 | out, err = exec.Command("pkill", filepath.Base(codeServerPath)).CombinedOutput() 55 | require.NoError(t, err, "%s", out) 56 | 57 | wg.Wait() 58 | } 59 | 60 | // trassh is an incomplete, local, insecure ssh server 61 | // used for the purpose of testing the implementation without 62 | // requiring the user to have their own remote server. 63 | func trassh(t *testing.T, port string) io.Closer { 64 | private, err := ssh.ParsePrivateKey([]byte(fakeRSAKey)) 65 | require.NoError(t, err) 66 | 67 | conf := &ssh.ServerConfig{ 68 | NoClientAuth: true, 69 | } 70 | 71 | conf.AddHostKey(private) 72 | 73 | listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", port)) 74 | require.NoError(t, err) 75 | 76 | go func() { 77 | for { 78 | func() { 79 | conn, err := listener.Accept() 80 | if err != nil { 81 | return 82 | } 83 | defer conn.Close() 84 | 85 | sshConn, chans, reqs, err := ssh.NewServerConn(conn, conf) 86 | require.NoError(t, err) 87 | 88 | go ssh.DiscardRequests(reqs) 89 | 90 | for c := range chans { 91 | switch c.ChannelType() { 92 | case "direct-tcpip": 93 | var req directTCPIPReq 94 | 95 | err := ssh.Unmarshal(c.ExtraData(), &req) 96 | if err != nil { 97 | t.Logf("failed to unmarshal tcpip data: %v", err) 98 | continue 99 | } 100 | 101 | ch, _, err := c.Accept() 102 | if err != nil { 103 | c.Reject(ssh.ConnectionFailed, fmt.Sprintf("unable to accept channel: %v", err)) 104 | continue 105 | } 106 | 107 | go handleDirectTCPIP(ch, &req, t) 108 | case "session": 109 | ch, inReqs, err := c.Accept() 110 | if err != nil { 111 | c.Reject(ssh.ConnectionFailed, fmt.Sprintf("unable to accept channel: %v", err)) 112 | continue 113 | } 114 | 115 | go handleSession(ch, inReqs, t) 116 | default: 117 | t.Logf("unsupported session type: %v\n", c.ChannelType()) 118 | c.Reject(ssh.UnknownChannelType, "unknown channel type") 119 | } 120 | } 121 | 122 | sshConn.Wait() 123 | }() 124 | } 125 | }() 126 | return listener 127 | } 128 | 129 | func handleDirectTCPIP(ch ssh.Channel, req *directTCPIPReq, t *testing.T) { 130 | defer ch.Close() 131 | 132 | dstAddr := net.JoinHostPort(req.Host, strconv.Itoa(int(req.Port))) 133 | 134 | conn, err := net.Dial("tcp", dstAddr) 135 | if err != nil { 136 | return 137 | } 138 | defer conn.Close() 139 | 140 | var wg sync.WaitGroup 141 | 142 | wg.Add(1) 143 | go func() { 144 | defer wg.Done() 145 | defer ch.Close() 146 | 147 | io.Copy(ch, conn) 148 | }() 149 | 150 | wg.Add(1) 151 | go func() { 152 | defer wg.Done() 153 | defer conn.Close() 154 | 155 | io.Copy(conn, ch) 156 | }() 157 | wg.Wait() 158 | } 159 | 160 | // execReq describes an exec payload. 161 | type execReq struct { 162 | Command string 163 | } 164 | 165 | // directTCPIPReq describes the extra data sent in a 166 | // direct-tcpip request containing the host/port for the ssh server. 167 | type directTCPIPReq struct { 168 | Host string 169 | Port uint32 170 | 171 | Orig string 172 | OrigPort uint32 173 | } 174 | 175 | // exitStatus describes an 'exit-status' message 176 | // returned after a request. 177 | type exitStatus struct { 178 | Status uint32 179 | } 180 | 181 | func handleSession(ch ssh.Channel, in <-chan *ssh.Request, t *testing.T) { 182 | defer ch.Close() 183 | 184 | for req := range in { 185 | if req.WantReply { 186 | req.Reply(true, nil) 187 | } 188 | 189 | // TODO support the rest of the types e.g. env, pty, etc. 190 | // Right now they aren't necessary for the tests. 191 | if req.Type != "exec" { 192 | t.Logf("Unsupported session type %v, only 'exec' is supported", req.Type) 193 | continue 194 | } 195 | 196 | var exReq execReq 197 | err := ssh.Unmarshal(req.Payload, &exReq) 198 | if err != nil { 199 | t.Logf("failed to unmarshal exec payload %s", req.Payload) 200 | return 201 | } 202 | 203 | cmd := exec.Command("sh", "-l", "-c", exReq.Command) 204 | 205 | stdin, err := cmd.StdinPipe() 206 | require.NoError(t, err) 207 | 208 | go func() { 209 | defer stdin.Close() 210 | io.Copy(stdin, ch) 211 | }() 212 | 213 | cmd.Stdout = ch 214 | cmd.Stderr = ch.Stderr() 215 | err = cmd.Run() 216 | 217 | var exit exitStatus 218 | if err != nil { 219 | exErr, ok := err.(*exec.ExitError) 220 | require.True(t, ok, "Not an exec.ExitError, was %T", err) 221 | 222 | exit.Status = uint32(exErr.ExitCode()) 223 | } 224 | 225 | _, err = ch.SendRequest("exit-status", false, ssh.Marshal(&exit)) 226 | if err != nil { 227 | t.Logf("unable to send status: %v", err) 228 | } 229 | break 230 | } 231 | } 232 | 233 | func waitForSSHCode(t *testing.T, port string, timeout time.Duration) { 234 | var ( 235 | url = fmt.Sprintf("http://localhost:%v/", port) 236 | client = &http.Client{ 237 | Timeout: time.Second, 238 | } 239 | ) 240 | 241 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 242 | defer cancel() 243 | 244 | backoff := &retry.Backoff{ 245 | Floor: time.Second, 246 | Ceil: time.Second, 247 | } 248 | 249 | for { 250 | resp, err := client.Get(url) 251 | if err == nil { 252 | require.Equal(t, http.StatusOK, resp.StatusCode) 253 | return 254 | } 255 | err = backoff.Wait(ctx) 256 | require.NoError(t, err) 257 | } 258 | } 259 | 260 | // fakeRSAKey isn't used for anything other than the trassh ssh 261 | // server. 262 | const fakeRSAKey = `-----BEGIN RSA PRIVATE KEY----- 263 | MIIEpQIBAAKCAQEAsbbGAxPQeqti2OgdzuMgJGBAwXe/bFhQTPuk0bIvavkZwX/a 264 | NhmXV0dhLino5KtjR8oEazLxOgnOkJ6mpwVEgUhNMZhD9jEHZ7at4DtBIwfxjHjv 265 | nF+kJAt4xX4AZYbwIfLN9TsDGGhv4wPlB7mbwv+lhmPK+HsLbajO4n69k3s0WW94 266 | LafJntx/98o9gL2R7hpbMxgUu8cSZjYakkRBQdab0xUuTiceq0HfAOBCQpEw0meF 267 | cmhMeeu7H5UwKGj573pBxON0G1SJgipkcs4TD2rZ9wjc29gDJjHjf3Ko/JzX1WFL 268 | db21fzqRGWelgCHCUsIvUBeExk4jM1d63JrmFQIDAQABAoIBAQCdc9OSjG6tEMYe 269 | aeFnGQK0V/dnskIOq1xSKK7J/7ZVb+iq8S0Tu67D7IEklos6dsMaqtkpZVQm2OOE 270 | bJw45MjiRn3mUAL+0EfAUzFQtw8qC3Kuw8N/55kVOnjBeba+PUTqvyZNfQBsErP3 271 | Dc9Q/dkMdtZf8HC3oMTqXqMWN7adQBQRBspUBkLQeSemYsUm2cc+YSnCwKel98uN 272 | EuDJaTZwutxTUF1FBoXlejYlVKcldk1w5HtKkjGdW+mbo2xUpu8W0620Rs/fXNpU 273 | +guAlpB1/Wx5foZqZx33Ul8HINfDre/uqHwCd+ucDIyV7TfIh9JV5w3iRLa0QCz0 274 | kFe/GsEtAoGBAODRa1GwfyK+gcgxF2qwfsxF3I+DQhqWFiCA0o5kO2fpiUR3rDQj 275 | XhBoPxr/qYBSBtGErHIiB7WFeQ6GjVTEgY/cEkIIh1tY95UWQ3/oIZWW498dQGRh 276 | SUGXm3lMrSsVCyXxNexSH5yTrRzyZ2u4mZupMeyACoGRGkNTVppOU4XbAoGBAMpc 277 | 1ifX3kr5m8CXa6mI+NWFAQlhW0Ak0hjhM/WDzMrSimYxLLSkaKyUSHnFP/8V4asA 278 | tV173lVut2Cjv5v5FcrOnI33Li2IcNlOzCRiLHzZ43HXckcoQDcU8iKTBq1a0Dx1 279 | eXr2rs+a/2pTy7IMsxyJVCSP6IDBI9+2iW+Cxh7PAoGBAMOa0hJAS02yjX7d367v 280 | I1OeETo4jQJOxa/ABfLoGJvfoJQWv5iZkRUbbpSSDytbsx0Gn3eqTiTMnbhar4sq 281 | ckP1yVj0zLhY3wkzVsVp9haOM3ODouvzjWZpf1d5tE2AwLNhfHZCOcjk4EEIU51w 282 | /w1ll89a1ElJM52SXA5jyd3zAoGBAKGtpKi2rvMGFKu+DxWnyu+FUXu2HhrUkEuy 283 | ejn5MMEHj+3v8gDtrnfcDT/FGclrKR7f9QeYtN1bFQYQLkGmtAOSKcC/MVTNwyPL 284 | 8gxLp7GkwDSvZq11ekDH6mE3SMluWhtD3Ggi+S4Db3f7NS6vONde3SxNEfz00v2l 285 | MI84U6Q/AoGAVTZGT5weqRTJSqnri6Noz+5j/73QMf/QiZDgHMMCF0giC2mxqOgR 286 | QF6+cxHQe0sbMQ/xJU5RYhgnqSa2TjLMju4N2nQ9i/HqI/3p0CPwjFsZWlXmWEK9 287 | 5kdld52W7Bu2vQuFbg2Oy7aPhnI+1CqlubOFRgMe4AJND2t9SMTV+rc= 288 | -----END RSA PRIVATE KEY----- 289 | ` 290 | 291 | func testSSHArgs(port string) string { 292 | return "-o StrictHostKeyChecking=no -p " + port 293 | } 294 | 295 | func randomPortExclude(t *testing.T, exludedPorts ...string) string { 296 | valid := func(port string) bool { 297 | for _, exPort := range exludedPorts { 298 | if exPort == port { 299 | return false 300 | } 301 | } 302 | return true 303 | } 304 | 305 | maxTries := 10 306 | for i := 0; i < maxTries; i++ { 307 | port, err := randomPort() 308 | require.NoError(t, err) 309 | 310 | if valid(port) { 311 | return port 312 | } 313 | } 314 | 315 | return "" 316 | } 317 | --------------------------------------------------------------------------------