├── .gitignore ├── LICENSE ├── README.md ├── cfmanifest ├── manifest.go ├── manifest_test.go ├── ssh_manifest.go ├── ssh_manifest_test.go └── suite_test.go ├── fixtures ├── app │ ├── Staticfile │ ├── index.html │ └── manifest.yml ├── fixture.go ├── manifest-minimal.yml ├── manifest-oneapp-without-root-node.yml ├── manifest-oneapp.yml └── manifest-twoapps.yml ├── main.go └── suite_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | # bin 27 | cf-pankcake* 28 | 29 | # Sublime 30 | broker.sublime-project 31 | broker.sublime-workspace 32 | 33 | # src 34 | apps 35 | 36 | # gin 37 | gin-bin 38 | 39 | cf-ssh.yml 40 | out 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Dr Nic Williams 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cf-ssh 2 | ====== 3 | 4 | **DEPRECATED:** Cloud Foundry CLI now has `cf ssh` built-in. 5 | 6 | SSH into a running container for your Cloud Foundry application, run one-off tasks, debug your app, and more. 7 | 8 | Initial implementation requires the application to have a `manifest.yml`. 9 | 10 | Also, `cf-ssh` requires that you run the command from within the project source folder. It performs a `cf push` to create a new application based on the same source code/path, buildpack, and variables. Once CF Runtime supports copy app bits [#78847148](https://www.pivotaltracker.com/story/show/78847148), then `cf-ssh` will be upgraded to use app bit copying, and not require local access to project app bits. 11 | 12 | It is desired that `cf-ssh` works correctly from all platforms that support the `cf` CLI. 13 | 14 | Windows is a target platform but has not yet been tested. Please give feedback in the [Issues](https://github.com/cloudfoundry-community/cf-ssh/issues). 15 | 16 | Requirements 17 | ------------ 18 | 19 | This tool requires the following CLIs to be installed 20 | 21 | - `cf` ([download](https://github.com/cloudfoundry/cli/releases)) 22 | - `ssh` (pre-installed on all *nix; [download](http://www.mls-software.com/opensshd.html) for Windows) 23 | 24 | It is assumed that in using `cf-ssh` you have already successfully targeted a Cloud Foundry API, and have pushed an application (successfully or not). 25 | 26 | This tool also currently requires outbound internet access to the http://tmate.io/ proxies. In future, to avoid the requirement of public Internet access, it would be great to package up the tmate server as a BOSH release and deploy it into the same infrastructure as the Cloud Foundry deployment. 27 | 28 | ### Why require `ssh` CLI? 29 | 30 | This project is written in the Go programming language, and there is a candidate library [go.crypto](https://godoc.org/code.google.com/p/go.crypto/ssh#Session.RequestPty) that could have natively supported an interactive SSH session. Unfortunately, the SSL supports a subset of ciphers that don't seem to work with tmate.io proxies [[stackoverflow](http://stackoverflow.com/questions/18998473/failed-to-dial-handshake-failed-ssh-no-common-algorithms-error-in-ssh-client/19002265#19002265)] 31 | 32 | Using the `go.crypto` library I was getting the following error. In future, perhaps either tmate.io or go.crypto will change to support each other. 33 | 34 | ``` 35 | unable to connect: ssh: handshake failed: ssh: no common algorithms 36 | ``` 37 | 38 | Installation 39 | ------------ 40 | 41 | Download a [pre-compiled release](https://github.com/cloudfoundry-community/cf-ssh/releases) for your platform. Place it in your `$PATH` or `%PATH%` and rename to `cf-ssh` (or `cf-ssh.exe` for Windows). 42 | 43 | Alternately, if you have Go setup you can build it from source: 44 | 45 | ``` 46 | go get github.com/cloudfoundry-community/cf-ssh 47 | ``` 48 | 49 | Usage 50 | ----- 51 | 52 | ``` 53 | cd path/to/app 54 | cf-ssh -f manifest.yml 55 | ``` 56 | 57 | Publish releases 58 | ---------------- 59 | 60 | To generate the pre-compiled executables for the target platforms, using [gox](https://github.com/mitchellh/gox): 61 | 62 | ``` 63 | gox -output "out/{{.Dir}}_{{.OS}}_{{.Arch}}" -osarch "darwin/amd64 linux/amd64 windows/amd64 windows/386" ./... 64 | ``` 65 | 66 | They are now in the `out` folder: 67 | 68 | ``` 69 | -rwxr-xr-x 1 drnic staff 4.0M Oct 25 23:05 cf-ssh_darwin_amd64 70 | -rwxr-xr-x 1 drnic staff 4.0M Oct 25 23:05 cf-ssh_linux_amd64 71 | -rwxr-xr-x 1 drnic staff 3.4M Oct 25 23:05 cf-ssh_windows_386.exe 72 | -rwxr-xr-x 1 drnic staff 4.2M Oct 25 23:05 cf-ssh_windows_amd64.exe 73 | ``` 74 | 75 | ```bash 76 | VERSION=v0.1.0 77 | github-release release -u cloudfoundry-community -r cf-ssh -t $VERSION --name "cf-ssh $VERSION" --description 'SSH into a running container for your Cloud Foundry application, run one-off tasks, debug your app, and more.' 78 | 79 | for arch in darwin_amd64 linux_amd64 windows_amd64 windows_386; do 80 | github-release upload -u cloudfoundry-community -r cf-ssh -t $VERSION --name cf-ssh_$arch --file out/cf-ssh_$arch* 81 | done 82 | ``` 83 | -------------------------------------------------------------------------------- /cfmanifest/manifest.go: -------------------------------------------------------------------------------- 1 | package cfmanifest 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | 7 | "launchpad.net/goyaml" 8 | ) 9 | 10 | // Manifest models a manifest.yml 11 | // See http://docs.cloudfoundry.org/devguide/deploy-apps/manifest.html 12 | type Manifest map[string]interface{} 13 | 14 | // NewManifest creates a Manifest 15 | func NewManifest() (manifest *Manifest) { 16 | return &Manifest{} 17 | } 18 | 19 | // NewManifestFromPath creates a Manifest from a manifest.yml file 20 | func NewManifestFromPath(manifestPath string) (manifest *Manifest, err error) { 21 | manifestData := make(map[string]interface{}) 22 | manifest = &Manifest{} 23 | 24 | file, err := os.Open(manifestPath) 25 | if err != nil { 26 | return 27 | } 28 | yml, err := ioutil.ReadAll(file) 29 | if err != nil { 30 | return 31 | } 32 | err = goyaml.Unmarshal(yml, manifestData) 33 | 34 | if manifestData["applications"] == nil { 35 | copyAppSettingsToApplicationsArray(manifestData) 36 | } 37 | 38 | // cant do manifest = &Manifest(manifestData) for unknown reason 39 | temp := Manifest(manifestData) 40 | manifest = &temp 41 | 42 | return 43 | } 44 | 45 | // Applications returns the full list of applications 46 | func (manifest Manifest) Applications() (apps []interface{}) { 47 | if manifest["applications"] == nil { 48 | return []interface{}{} 49 | } 50 | return manifest["applications"].([]interface{}) 51 | } 52 | 53 | // FirstApplication returns the first application in the manifest 54 | func (manifest Manifest) FirstApplication() map[interface{}]interface{} { 55 | app := manifest.Applications()[0] 56 | return app.(map[interface{}]interface{}) 57 | } 58 | 59 | // ApplicationName returns the "name" of the first application in the manifest 60 | func (manifest Manifest) ApplicationName() string { 61 | app := manifest.FirstApplication() 62 | return app["name"].(string) 63 | } 64 | 65 | // AddApplication adds a default manifestApp 66 | func (manifest Manifest) AddApplication(appName string) (app map[interface{}]interface{}) { 67 | app = map[interface{}]interface{}{"name": appName} 68 | apps := manifest.Applications() 69 | apps = append(apps, app) 70 | manifest["applications"] = apps 71 | return 72 | } 73 | 74 | // RemoveAllButFirstApplication removes all applications but the first 75 | func (manifest Manifest) RemoveAllButFirstApplication() { 76 | firstApp := manifest.Applications()[0] 77 | apps := []interface{}{firstApp} 78 | manifest["applications"] = apps 79 | return 80 | } 81 | 82 | // Save the Manifest to a file in YAML format 83 | func (manifest Manifest) Save(path string) (err error) { 84 | data, err := goyaml.Marshal(manifest) 85 | if err != nil { 86 | return 87 | } 88 | ioutil.WriteFile(path, data, 0644) 89 | return 90 | } 91 | 92 | func copyAppSettingsToApplicationsArray(manifestData map[string]interface{}) { 93 | appData := make(map[interface{}]interface{}) 94 | for key := range manifestData { 95 | appData[key] = manifestData[key] 96 | delete(manifestData, key) 97 | } 98 | manifestData["applications"] = []interface{}{appData} 99 | } 100 | -------------------------------------------------------------------------------- /cfmanifest/manifest_test.go: -------------------------------------------------------------------------------- 1 | package cfmanifest_test 2 | 3 | import ( 4 | "github.com/cloudfoundry-community/cf-ssh/cfmanifest" 5 | "github.com/cloudfoundry-community/cf-ssh/fixtures" 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("cfmanifest", func() { 11 | Describe("NewManifestFromPath", func() { 12 | testWithFixture := func(path string) { 13 | path, err := fixtures.FixturePath(path) 14 | Expect(err).NotTo(HaveOccurred()) 15 | manifest, err := cfmanifest.NewManifestFromPath(path) 16 | Expect(err).NotTo(HaveOccurred()) 17 | Expect(len(manifest.Applications())).To(Equal(1)) 18 | app := manifest.FirstApplication() 19 | Expect(app["name"]).To(Equal("oneapp")) 20 | } 21 | 22 | Context("with 'applications' root key", func() { 23 | It("loads manifest", func() { 24 | testWithFixture("manifest-oneapp.yml") 25 | }) 26 | }) 27 | 28 | Context("without 'applications' root key", func() { 29 | It("loads manifest", func() { 30 | testWithFixture("manifest-oneapp-without-root-node.yml") 31 | }) 32 | }) 33 | }) 34 | 35 | Describe("AddApplication", func() { 36 | It("adds first app", func() { 37 | manifest := cfmanifest.NewManifest() 38 | Expect(len(manifest.Applications())).To(Equal(0)) 39 | manifest.AddApplication("first") 40 | Expect(len(manifest.Applications())).To(Equal(1)) 41 | first := manifest.FirstApplication() 42 | Expect(first["name"]).To(Equal("first")) 43 | }) 44 | 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /cfmanifest/ssh_manifest.go: -------------------------------------------------------------------------------- 1 | package cfmanifest 2 | 3 | // NewSSHManifest prepares for a new cf-ssh.yml 4 | func NewSSHManifest(appName string) (manifest *Manifest) { 5 | manifest = NewManifest() 6 | cfssh := manifest.AddApplication(appName) 7 | cfssh["command"] = "curl -s https://raw.githubusercontent.com/danhigham/cf-console/master/install.sh > /tmp/install.sh && bash /tmp/install.sh && sleep 3600" 8 | cfssh["no-route"] = true 9 | cfssh["instances"] = 1 10 | return 11 | } 12 | 13 | // NewSSHManifestFromManifestPath prepares for a new cf-ssh.yml based on existing manifest.yml 14 | func NewSSHManifestFromManifestPath(manifestPath string) (manifest *Manifest, err error) { 15 | manifest, err = NewManifestFromPath(manifestPath) 16 | if err != nil { 17 | return 18 | } 19 | cfssh := manifest.FirstApplication() 20 | name := cfssh["name"].(string) 21 | cfssh["name"] = name + "-ssh" 22 | cfssh["command"] = "curl -s https://raw.githubusercontent.com/danhigham/cf-console/master/install.sh > /tmp/install.sh && bash /tmp/install.sh && sleep 3600" 23 | cfssh["no-route"] = true 24 | cfssh["instances"] = 1 25 | 26 | manifest.RemoveAllButFirstApplication() 27 | return 28 | } 29 | -------------------------------------------------------------------------------- /cfmanifest/ssh_manifest_test.go: -------------------------------------------------------------------------------- 1 | package cfmanifest_test 2 | 3 | import ( 4 | "github.com/cloudfoundry-community/cf-ssh/cfmanifest" 5 | "github.com/cloudfoundry-community/cf-ssh/fixtures" 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("cfmanifest", func() { 11 | Describe("NewSSHManifestFromManifestPath", func() { 12 | It("keeps the one app in manifest", func() { 13 | path, err := fixtures.FixturePath("manifest-oneapp.yml") 14 | Expect(err).NotTo(HaveOccurred()) 15 | 16 | manifest, err := cfmanifest.NewSSHManifestFromManifestPath(path) 17 | Expect(err).NotTo(HaveOccurred()) 18 | Expect(len(manifest.Applications())).To(Equal(1)) 19 | app := manifest.FirstApplication() 20 | Expect(app["name"]).To(Equal("oneapp-ssh")) 21 | Expect(app["command"]).To(Equal("curl http://tmate-bootstrap.cfapps.io | sh")) 22 | Expect(app["no-route"]).To(Equal(true)) 23 | Expect(app["instances"]).To(Equal(1)) 24 | }) 25 | 26 | It("keeps the first app in manifest", func() { 27 | path, err := fixtures.FixturePath("manifest-twoapps.yml") 28 | Expect(err).NotTo(HaveOccurred()) 29 | 30 | manifest, err := cfmanifest.NewSSHManifestFromManifestPath(path) 31 | Expect(err).NotTo(HaveOccurred()) 32 | Expect(len(manifest.Applications())).To(Equal(1)) 33 | app := manifest.FirstApplication() 34 | Expect(app["name"]).To(Equal("first-ssh")) 35 | Expect(app["command"]).To(Equal("curl http://tmate-bootstrap.cfapps.io | sh")) 36 | Expect(app["no-route"]).To(Equal(true)) 37 | Expect(app["instances"]).To(Equal(1)) 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /cfmanifest/suite_test.go: -------------------------------------------------------------------------------- 1 | package cfmanifest_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestApi(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "cfssh/cfmanifest suite") 13 | } 14 | -------------------------------------------------------------------------------- /fixtures/app/Staticfile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudfoundry-community-attic/cf-ssh/7410d83473377d876247cfeee29750e7f364af77/fixtures/app/Staticfile -------------------------------------------------------------------------------- /fixtures/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hello World 4 | 5 | 6 | Hello World 7 | 8 | 9 | -------------------------------------------------------------------------------- /fixtures/app/manifest.yml: -------------------------------------------------------------------------------- 1 | applications: 2 | - name: hello-world 3 | -------------------------------------------------------------------------------- /fixtures/fixture.go: -------------------------------------------------------------------------------- 1 | package fixtures 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // LoadFixture loads one of the fixtures in this folder 10 | func LoadFixture(name string) (contents []byte, err error) { 11 | path, err := FixturePath(name) 12 | if err != nil { 13 | return 14 | } 15 | file, err := os.Open(path) 16 | if err != nil { 17 | return 18 | } 19 | return ioutil.ReadAll(file) 20 | } 21 | 22 | // FixturePath returns the absolute path to a fixture file 23 | func FixturePath(name string) (string, error) { 24 | return filepath.Abs(filepath.Join("../fixtures", name)) 25 | } 26 | -------------------------------------------------------------------------------- /fixtures/manifest-minimal.yml: -------------------------------------------------------------------------------- 1 | applications: 2 | - name: minimal 3 | -------------------------------------------------------------------------------- /fixtures/manifest-oneapp-without-root-node.yml: -------------------------------------------------------------------------------- 1 | name: oneapp 2 | buildpack: ruby-buildpack 3 | path: ./myapp-webapp 4 | memory: 256M 5 | instances: 5 6 | services: 7 | - mypostgres 8 | - myredis 9 | env: 10 | DEBUG: true 11 | -------------------------------------------------------------------------------- /fixtures/manifest-oneapp.yml: -------------------------------------------------------------------------------- 1 | applications: 2 | - name: oneapp 3 | buildpack: ruby-buildpack 4 | path: ./myapp-webapp 5 | memory: 256M 6 | instances: 5 7 | services: 8 | - mypostgres 9 | - myredis 10 | env: 11 | DEBUG: true 12 | -------------------------------------------------------------------------------- /fixtures/manifest-twoapps.yml: -------------------------------------------------------------------------------- 1 | applications: 2 | - name: first 3 | buildpack: ruby-buildpack 4 | path: ./first-webapp 5 | memory: 256M 6 | services: 7 | - mypostgres 8 | - myredis 9 | env: 10 | DEBUG: true 11 | - name: second 12 | buildpack: go-buildpack 13 | path: ./second-webapp 14 | memory: 256M 15 | services: 16 | - mypostgres 17 | - myredis 18 | env: 19 | DEBUG: true 20 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "regexp" 11 | "time" 12 | 13 | "github.com/cloudfoundry-community/cf-ssh/cfmanifest" 14 | "github.com/codegangsta/cli" 15 | ) 16 | 17 | func cmdSSH(c *cli.Context) { 18 | // TODO: confirm that `cf` and `ssh` are in path 19 | // TODO: Windows: cf.exe and ssh.exe? 20 | manifestPath, err := filepath.Abs(c.String("manifest")) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | var manifest *cfmanifest.Manifest 25 | if _, err := os.Stat(manifestPath); os.IsNotExist(err) { 26 | log.Fatal("USAGE: cf-ssh -f manifest.yml") 27 | 28 | // appName := c.Args().First() 29 | // 30 | // if appName == "" { 31 | // log.Fatal("USAGE: cf-ssh [APPNAME] [-f manifest.yml]") 32 | // } 33 | // manifest = cfmanifest.NewSSHManifest(appName) 34 | } else { 35 | manifest, err = cfmanifest.NewSSHManifestFromManifestPath(manifestPath) 36 | if err != nil { 37 | log.Fatalf("Manifest %s exists but failed to load: %s", manifestPath, err) 38 | } 39 | } 40 | 41 | cfSSHYAML, err := filepath.Abs("cf-ssh.yml") 42 | if err != nil { 43 | log.Fatalf("Could not create absolute file path: %s", err) 44 | } 45 | 46 | manifest.Save(cfSSHYAML) 47 | sshAppname := manifest.ApplicationName() 48 | fmt.Printf("Deploying SSH container '%s'...\n", sshAppname) 49 | 50 | // TODO: extract the `cf push` & log scraping 51 | cmd := exec.Command("cf", "push", "-f", cfSSHYAML) 52 | // TODO: defer cf delete 53 | err = cmd.Run() 54 | if err != nil { 55 | log.Fatalf("Failed to run SSH container: %s", err) 56 | } 57 | 58 | var sshUser, sshHost string 59 | fmt.Print("Initiating tmate connection...") 60 | time.Sleep(1 * time.Second) 61 | for counter := 0; counter < 10; counter++ { 62 | time.Sleep(1 * time.Second) 63 | 64 | // repeat following until it succeeds or times out 65 | // ssh_host=$(cf logs $ssh_appname --recent | grep tmate.io | tail -n1 | awk '{print $NF }') 66 | cmd = exec.Command("cf", "logs", sshAppname, "--recent") 67 | var out bytes.Buffer 68 | cmd.Stdout = &out 69 | 70 | err = cmd.Run() 71 | if err != nil { 72 | log.Fatalf("Failed to get recent logs: %s", err) 73 | } 74 | logs := out.String() 75 | sshHostLine, err := regexp.CompilePOSIX("=====> (.*)@(.*)$") 76 | if err != nil { 77 | log.Fatalf("Invalid POSIX regular expression: %s", err) 78 | } 79 | sshHostMatches := sshHostLine.FindAllStringSubmatch(logs, -1) 80 | if sshHostMatches != nil { 81 | sshHostMatch := sshHostMatches[len(sshHostMatches)-1] 82 | sshUser = sshHostMatch[1] 83 | sshHost = sshHostMatch[2] 84 | break 85 | } else { 86 | fmt.Print(".") 87 | } 88 | 89 | } 90 | if sshUser == "" { 91 | fmt.Print("timed out\n") 92 | } 93 | 94 | fmt.Print("success\n") 95 | cmd = exec.Command("ssh", "-t", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost)) 96 | cmd.Stdin = os.Stdin 97 | cmd.Stdout = os.Stdout 98 | cmd.Stderr = os.Stderr 99 | cmd.Run() 100 | 101 | // Either: 102 | // cf delete $ssh_appname -f 103 | // cf stop $ssh_appname 104 | 105 | } 106 | 107 | func main() { 108 | app := cli.NewApp() 109 | app.Name = "cf-ssh" 110 | app.Flags = []cli.Flag{ 111 | cli.StringFlag{ 112 | Name: "manifest, f", 113 | Value: "manifest.yml", 114 | Usage: "Path to application manifest", 115 | }, 116 | } 117 | 118 | app.Usage = "SSH into a Cloud Foundry app container" 119 | app.Action = cmdSSH 120 | 121 | app.Run(os.Args) 122 | } 123 | -------------------------------------------------------------------------------- /suite_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestCFSSH(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "cfssh suite") 13 | } 14 | --------------------------------------------------------------------------------