├── .gitignore ├── Makefile ├── README.md ├── arguments_parser.go ├── aws.go ├── cloud-ssh.yaml.example ├── config.go ├── diginal_ocean.go ├── install.sh └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | cloud-ssh.yaml 3 | 4 | Dockerfile 5 | 6 | *.gz 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: build-macosx build-x86 build-x64 2 | 3 | build-x64: 4 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build 5 | tar -czf cloud_ssh_x64.tar.gz cloud-ssh 6 | rm cloud-ssh 7 | 8 | build-x86: 9 | GOOS=linux GOARCH=386 CGO_ENABLED=0 go build 10 | tar -czf cloud_ssh_x86.tar.gz cloud-ssh 11 | rm cloud-ssh 12 | 13 | build-macosx: 14 | GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build 15 | tar -czf cloud_ssh_macosx.tar.gz cloud-ssh 16 | rm cloud-ssh -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cloud-ssh 2 | ========= 3 | 4 | Cloud enhanced SSH client replacement with host auto-completion. 5 | 6 | In times of digital clouds, servers come and go, and you barely remember its names and addresses. This tiny tool provide fuzzy search (yeah like SublimeText) for your instances list, based on tags, security groups and names. 7 | 8 | Official site: https://buger.github.io/cloud-ssh/ 9 | 10 | ## Installation 11 | Automatic installation. Just run following code in your terminal 12 | ``` 13 | bash <(curl -# https://buger.github.io/cloud-ssh/install.sh) 14 | ``` 15 | 16 | Manual installation - download and unpack latest release: http://github.com/buger/cloud-ssh/releases 17 | 18 | ## Examples 19 | 20 | ``` 21 | sh-3.2$ # Lets say i want connect to server called stage-matching 22 | sh-3.2$ ./cloud-ssh leon@stama 23 | Found config: /Users/buger/.ssh/cloud-ssh.yaml 24 | Found clound instance: 25 | Cloud: granify_ec2 Matched by: aws:autoscaling:groupName=stage-matching Addr: ec2-50-200-40-200.compute-1.amazonaws.com 26 | 27 | Welcome to Ubuntu 12.04 LTS (GNU/Linux 3.2.0-25-virtual x86_64) 28 | ``` 29 | 30 | If there are more then 1 server matching your query, it will ask you to choose one: 31 | ``` 32 | sh-3.2$ # I want to check one of my CouchBase servers 33 | sh-3.2$ ./cloud-ssh ubuntu@couch 34 | Found config: /Users/buger/.ssh/cloud-ssh.yaml 35 | Found multiple instances: 36 | 1) Cloud: granify_ec2 Matched by: Name=couchbase-02 Addr: ec2-50-200-40-201.compute-1.amazonaws.com 37 | 2) Cloud: granify_ec2 Matched by: Name=couchbase-03 Addr: ec2-50-200-40-202.compute-1.amazonaws.com 38 | 3) Cloud: granify_ec2 Matched by: Name=couchbase-04 Addr: ec2-50-200-40-203.compute-1.amazonaws.com 39 | 4) Cloud: granify_ec2 Matched by: Name=couchbase-01 Addr: ec2-50-200-40-204.compute-1.amazonaws.com 40 | 5) Cloud: granify_ec2 Matched by: Name=couchbase-05 Addr: ec2-50-200-40-205.compute-1.amazonaws.com 41 | Choose instance: 1 42 | Welcome to Ubuntu 12.04.4 LTS (GNU/Linux 3.2.0-58-virtual x86_64) 43 | ``` 44 | 45 | Nice, right? More over, cloud-ssh can act as full ssh replacement, since it just forward all calls to ssh command. 46 | 47 | ## Configuration 48 | 49 | By default it checks your environment for AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables. If you want advanced configuration you can create `cloud-ssh.yaml` in one of this directories: ./ (current), ~/.ssh/, /etc/ 50 | 51 | Note that you can define multiple clouds, per provider, if you have multi-datacenter setup or just different clients. Cloud name will be included into search term, so you can filter by it too! 52 | 53 | Right now only 2 data cloud providers supported: Amazon EC2 and DigitalOcean. 54 | 55 | Example configuration: 56 | ``` 57 | gran_ec2: # cloud name, used when searching 58 | provider: aws 59 | region: us-east-1 60 | access_key: AAAAAAAAAAAAAAAAA 61 | secret_key: BBBBBBBBBBBBBBBBBBBBBBBBB 62 | default_user: ubuntu 63 | gran_digital: 64 | provider: digital_ocean 65 | client_id: 111111111111111111 66 | api_key: 22222222222222222 67 | ``` 68 | 69 | ## Faq 70 | 71 | ### Can it be drop-in replacement for SSH? 72 | Totally yes! Actually it just SSH wrapper, so all SSH command options will work as before. 73 | 74 | 75 | ## Contributing 76 | 77 | 1. Fork it 78 | 2. Create your feature branch (git checkout -b my-new-feature) 79 | 3. Commit your changes (git commit -am 'Added some feature') 80 | 4. Push to the branch (git push origin my-new-feature) 81 | 5. Create new Pull Request 82 | -------------------------------------------------------------------------------- /arguments_parser.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func splitHostname(str string) (user string, hostname string) { 8 | if arr := strings.Split(str, "@"); len(arr) > 1 { 9 | return arr[0], arr[1] 10 | } else { 11 | return "", str 12 | } 13 | } 14 | 15 | func joinHostname(user string, hostname string) string { 16 | if user != "" { 17 | return user + "@" + hostname 18 | } else { 19 | return hostname 20 | } 21 | } 22 | 23 | // Go though arguments, and find one with host 24 | func getTargetHostname(args []string) (user string, hostname string, arg_idx int) { 25 | for idx, arg := range args { 26 | if !strings.HasPrefix(arg, "-") { 27 | if idx == 0 { 28 | hostname = arg 29 | arg_idx = idx 30 | break 31 | } else { 32 | if !strings.HasPrefix(args[idx-1], "-") { 33 | hostname = arg 34 | arg_idx = idx 35 | break 36 | } 37 | } 38 | } 39 | } 40 | 41 | user, hostname = splitHostname(hostname) 42 | 43 | return 44 | } 45 | -------------------------------------------------------------------------------- /aws.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "gopkg.in/amz.v2/aws" 5 | "gopkg.in/amz.v2/ec2" 6 | "log" 7 | ) 8 | 9 | func getEC2Instances(config map[string]string) (instances Instances) { 10 | instances = make(Instances) 11 | 12 | if _, ok := config["access_key"]; !ok { 13 | log.Fatal("Missing access_key for ", config["name"], " AWS cloud") 14 | } 15 | 16 | if _, ok := config["secret_key"]; !ok { 17 | log.Fatal("Missing secret_key for ", config["name"], " AWS cloud") 18 | } 19 | 20 | if _, ok := config["region"]; !ok { 21 | config["region"] = "us-east-1" 22 | } 23 | 24 | if _, ok := config["output_format"]; !ok { 25 | config["output_format"] = "Cloud: {cloud} \tMatched by: {tag_name} = {tag_value} \tAddr: {addr}" 26 | } 27 | 28 | auth := aws.Auth{AccessKey: config["access_key"], SecretKey: config["secret_key"]} 29 | 30 | e := ec2.New(auth, aws.Regions[config["region"]]) 31 | resp, err := e.Instances(nil, nil) 32 | 33 | if err != nil { 34 | log.Println(err) 35 | return 36 | } 37 | 38 | for _, res := range resp.Reservations { 39 | for _, inst := range res.Instances { 40 | 41 | if inst.DNSName != "" { 42 | var tags []Tag 43 | 44 | for _, tag := range inst.Tags { 45 | tags = append(tags, Tag{tag.Key, tag.Value}) 46 | } 47 | 48 | for _, sg := range inst.SecurityGroups { 49 | tags = append(tags, Tag{"Security group", sg.Name}) 50 | } 51 | ci := config["connection_interface"] 52 | if ci == "private_ip" { 53 | instances[inst.PrivateIPAddress] = tags 54 | } else if ci == "public_ip" { 55 | instances[inst.IPAddress] = tags 56 | } else if ci == "private_dns" { 57 | instances[inst.PrivateDNSName] = tags 58 | } else { 59 | instances[inst.DNSName] = tags 60 | } 61 | } 62 | } 63 | } 64 | 65 | return 66 | } 67 | -------------------------------------------------------------------------------- /cloud-ssh.yaml.example: -------------------------------------------------------------------------------- 1 | # cloud-ssh support multiple clouds at once 2 | # 3 | # gran_ec2: 4 | # provider: aws 5 | # region: us-east-1 6 | # access_key: 7 | # secret_key: 8 | # default_user: leon # you can specify default user for every cloud 9 | # gran_digital: 10 | # provider: digital_ocean 11 | # client_id: 12 | # api_key: 13 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/yaml.v1" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "runtime" 10 | ) 11 | 12 | type Config map[string]StrMap 13 | type StrMap map[string]string 14 | 15 | func userHomeDir() string { 16 | if runtime.GOOS == "windows" { 17 | home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") 18 | if home == "" { 19 | home = os.Getenv("USERPROFILE") 20 | } 21 | return home 22 | } 23 | return os.Getenv("HOME") 24 | } 25 | 26 | func readConfig() (config Config) { 27 | config = make(Config) 28 | 29 | prefferedPaths := []string{ 30 | "./cloud-ssh.yaml", 31 | userHomeDir() + "/.ssh/cloud-ssh.yaml", 32 | "/etc/cloud-ssh.yaml", 33 | } 34 | 35 | var content []byte 36 | 37 | for _, path := range prefferedPaths { 38 | if _, err := os.Stat(path); err == nil { 39 | fmt.Println("Found config:", path) 40 | content, err = ioutil.ReadFile(path) 41 | 42 | if err != nil { 43 | log.Fatal("Error while reading config: ", err) 44 | } 45 | 46 | break 47 | } 48 | } 49 | 50 | if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" { 51 | config["default"] = make(StrMap) 52 | config["default"]["access_key"] = os.Getenv("AWS_ACCESS_KEY_ID") 53 | config["default"]["secret_key"] = os.Getenv("AWS_SECRET_ACCESS_KEY") 54 | config["default"]["region"] = os.Getenv("AWS_REGION") 55 | config["default"]["provider"] = "aws" 56 | } 57 | 58 | if len(content) == 0 { 59 | if len(config) == 0 { 60 | fmt.Println("Can't find any configuration or ENV variables. Check http://github.com/buger/cloud-ssh for documentation.") 61 | } 62 | return 63 | } else if err := yaml.Unmarshal(content, &config); err != nil { 64 | log.Fatal(err) 65 | } 66 | 67 | return 68 | } 69 | -------------------------------------------------------------------------------- /diginal_ocean.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/jmoiron/jsonq" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | func getDigitalOceanInstances(config map[string]string) (instances Instances) { 11 | instances = make(Instances) 12 | 13 | if _, ok := config["client_id"]; !ok { 14 | log.Fatal("Missing client_id for ", config["name"], " DigitalOcean cloud") 15 | } 16 | 17 | if _, ok := config["api_key"]; !ok { 18 | log.Fatal("Missing api_key for ", config["name"], " DigitalOcean cloud") 19 | } 20 | 21 | resp, err := http.Get("https://api.digitalocean.com/droplets/?client_id=" + config["client_id"] + "&api_key=" + config["api_key"]) 22 | 23 | if err != nil { 24 | log.Println("DigitalOcean API error:", err) 25 | return 26 | } 27 | 28 | defer resp.Body.Close() 29 | 30 | data := map[string]interface{}{} 31 | dec := json.NewDecoder(resp.Body) 32 | dec.Decode(&data) 33 | jq := jsonq.NewQuery(data) 34 | 35 | status, err := jq.String("status") 36 | 37 | if status == "ERROR" { 38 | err_msg, _ := jq.String("error_message") 39 | 40 | log.Println("DigitalOcean API error: ", err_msg) 41 | return 42 | } 43 | 44 | droplets, err := jq.ArrayOfObjects("droplets") 45 | 46 | if err != nil { 47 | log.Println(err) 48 | return 49 | } 50 | 51 | for _, droplet := range droplets { 52 | instances[droplet["ip_address"].(string)] = []Tag{ 53 | Tag{"Name", droplet["name"].(string)}, 54 | } 55 | } 56 | 57 | return 58 | } 59 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | shopt -s extglob 4 | set -o errtrace 5 | set -o errexit 6 | 7 | readonly PROGNAME=$(basename $0) 8 | readonly ARGS="$@" 9 | readonly VERSION="0.4" 10 | 11 | log() { printf "%b\n" "$*"; } 12 | fail() { log "\nERROR: $*\n" ; exit 1 ; } 13 | 14 | install_initialization(){ 15 | if [ "$(uname)" == "Darwin" ]; then 16 | URL="https://github.com/buger/cloud-ssh/releases/download/$VERSION/cloud_ssh_macosx.tar.gz" 17 | elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then 18 | URL="https://github.com/buger/cloud-ssh/releases/download/$VERSION/cloud_ssh_x86.tar.gz" 19 | elif [ "$(expr substr $(uname -s) 1 10)" == "MINGW32_NT" ]; then 20 | fail 'Installation script for Windows platform not yet supported. But you still can manually download binaries.' 21 | fi 22 | } 23 | 24 | download_binary(){ 25 | TEMPFILE=$(mktemp /tmp/cloud-ssh.XXXXXX) 26 | 27 | echo "Downloading binary from Github: $1" 28 | 29 | if curl --fail -L "$1" --progress-bar > $TEMPFILE 30 | then 31 | echo "Unpacking" 32 | else 33 | fail "Failed to download binary from Github. Try later, or install manually." 34 | fi 35 | } 36 | 37 | unpack_binary(){ 38 | tar -xzf $TEMPFILE -C $1 39 | rm "$TEMPFILE" 40 | } 41 | 42 | install_example_configuration(){ 43 | EXAMPLE_CONFIG="https://raw.githubusercontent.com/buger/cloud-ssh/master/cloud-ssh.yaml.example" 44 | CONFIG_PATH=~/.ssh/cloud-ssh.yaml 45 | 46 | if [ ! -f $CONFIG_PATH ]; then 47 | curl --fail -L -# "$EXAMPLE_CONFIG" > $CONFIG_PATH 48 | echo "Configuration file located at $CONFIG_PATH" 49 | fi 50 | } 51 | 52 | install(){ 53 | DEFAULT_PATH="/usr/local/bin" 54 | read -p "Choose path to install cloud-ssh binary? [Default: $DEFAULT_PATH ] " path 55 | path=${path:-$DEFAULT_PATH} 56 | 57 | download_binary $URL 58 | unpack_binary $path 59 | install_example_configuration 60 | 61 | echo "cloud-ssh succesfully installed" 62 | } 63 | 64 | main() { 65 | install_initialization 66 | install $ARGS 67 | } 68 | main 69 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "regexp" 9 | "sort" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | ) 14 | 15 | type Tag struct { 16 | Name, Value string 17 | } 18 | 19 | type Instances map[string][]Tag 20 | type CloudInstances map[string]Instances 21 | 22 | func getInstances(config Config) (clouds CloudInstances) { 23 | clouds = make(CloudInstances) 24 | 25 | var wg sync.WaitGroup 26 | var mux sync.RWMutex 27 | 28 | for name, cfg := range config { 29 | for k, v := range cfg { 30 | if k == "provider" { 31 | switch v { 32 | case "aws": 33 | wg.Add(1) 34 | go func(name string, cfg StrMap) { 35 | mux.Lock() 36 | clouds[name] = getEC2Instances(cfg) 37 | mux.Unlock() 38 | wg.Done() 39 | }(name, cfg) 40 | case "digital_ocean": 41 | wg.Add(1) 42 | go func(name string, cfg StrMap) { 43 | mux.Lock() 44 | clouds[name] = getDigitalOceanInstances(cfg) 45 | mux.Unlock() 46 | wg.Done() 47 | }(name, cfg) 48 | default: 49 | log.Println("Unknown provider: ", v) 50 | } 51 | } 52 | } 53 | } 54 | 55 | wg.Wait() 56 | 57 | return 58 | } 59 | 60 | type SortByTagValue []StrMap 61 | 62 | func (a SortByTagValue) Len() int { return len(a) } 63 | func (a SortByTagValue) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 64 | func (a SortByTagValue) Less(i, j int) bool { return a[i]["tag_value"] < a[j]["tag_value"] } 65 | 66 | func getMatchedInstances(clouds CloudInstances, filter string) (matched []StrMap) { 67 | // Fuzzy matching, like SublimeText 68 | filter = strings.Join(strings.Split(filter, ""), ".*?") 69 | 70 | rHost := regexp.MustCompile(filter) 71 | 72 | for cloud, instances := range clouds { 73 | for addr, tags := range instances { 74 | for _, tag := range tags { 75 | if rHost.MatchString(cloud + tag.Value) { 76 | matched = append(matched, StrMap{ 77 | "cloud": cloud, 78 | "addr": addr, 79 | "tag_name": tag.Name, 80 | "tag_value": tag.Value, 81 | "instance_name": getInstanceName(tags), 82 | }) 83 | 84 | break 85 | } 86 | } 87 | } 88 | } 89 | 90 | sort.Sort(SortByTagValue(matched)) 91 | 92 | return 93 | } 94 | 95 | func formatMatchedInstance(inst StrMap, output string) string { 96 | c := strings.SplitAfter(output, "{") 97 | for i := 1; i < len(c); i++ { 98 | s := strings.SplitN(c[i], "}", 2) 99 | c[i] = getStringValue(inst, s[0]) 100 | output = strings.Replace(output, "{" + s[0] + "}", c[i], -1) 101 | } 102 | return output 103 | } 104 | 105 | func getStringValue(inst StrMap, s string) string{ 106 | if len(inst[s]) > 0 { 107 | return inst[s] 108 | } 109 | return "{" + s + "}" 110 | } 111 | 112 | func getInstanceName(tags []Tag) string { 113 | for _, tag := range tags { 114 | if tag.Name == "Name" { return tag.Value } 115 | } 116 | return "" 117 | } 118 | 119 | func main() { 120 | config := readConfig() 121 | instances := getInstances(config) 122 | 123 | args := os.Args[1:len(os.Args)] 124 | 125 | user, hostname, arg_idx := getTargetHostname(args) 126 | 127 | match := getMatchedInstances(instances, hostname) 128 | 129 | var matched_instance map[string]string 130 | 131 | if len(match) == 0 { 132 | fmt.Println("Can't find cloud instance, trying to connect anyway") 133 | } else if len(match) == 1 { 134 | matched_instance = match[0] 135 | } else { 136 | for i, host := range match { 137 | fmt.Println(strconv.Itoa(i+1)+") ", formatMatchedInstance(host, config[host["cloud"]]["output_format"])) 138 | } 139 | fmt.Print("Choose instance: ") 140 | 141 | var i int 142 | _, err := fmt.Scanf("%d", &i) 143 | 144 | if err != nil || i > len(match)+1 { 145 | log.Fatal("Wrong index") 146 | } 147 | 148 | matched_instance = match[i-1] 149 | } 150 | 151 | if matched_instance != nil { 152 | hostname = matched_instance["addr"] 153 | default_user := config[matched_instance["cloud"]]["default_user"] 154 | 155 | if len(user) == 0 && len(default_user) > 0 { 156 | user = default_user 157 | } 158 | 159 | fmt.Println("Connecting to instance:") 160 | fmt.Println(formatMatchedInstance(matched_instance, config[matched_instance["cloud"]]["output_format"])) 161 | } 162 | 163 | if len(args) == 0 { 164 | args = append(args, joinHostname(user, hostname)) 165 | } else { 166 | args[arg_idx] = joinHostname(user, hostname) 167 | } 168 | 169 | cmd := exec.Command("ssh", args...) 170 | cmd.Stdin = os.Stdin 171 | cmd.Stdout = os.Stdout 172 | cmd.Stderr = os.Stderr 173 | 174 | cmd.Run() 175 | } 176 | --------------------------------------------------------------------------------