├── LICENSE ├── README.md ├── aws.go ├── cmd.go ├── digital_ocean.go ├── example.gif └── main.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2019 Dani Hodovic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # generate-ssh-configs 2 | 3 |

4 | 5 | ## Description 6 | 7 | generate-ssh-configs reads cloud providers API and generates ssh config files 8 | for you. This is especially useful when dealing with tens or hundreds of 9 | servers. 10 | 11 | The program writes to stdout. Using shell redirection we can write persistent 12 | config files and include them using the ssh `Include` directive. 13 | 14 | ## Examples 15 | 16 | ### Prerequisites 17 | **Install generate-ssh-configs** 18 | ``` 19 | go get github.com/danihodovic/generate-ssh-configs 20 | ``` 21 | 22 | #### Ensure your ssh config includes all the config files in the ssh directory. 23 | ``` 24 | cat ~/.ssh/.config 25 | # ...at the bottom of the file... 26 | Include ~/.ssh/config-* 27 | ``` 28 | 29 | #### Ensure your AWS credentials have been configured if using AWS 30 | 31 | See https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html 32 | 33 | #### Ensure `$DIGITAL_OCEAN_TOKEN` is set if using DigitalOcean 34 | 35 | See https://www.digitalocean.com/docs/api/create-personal-access-token/ 36 | 37 | ### Generate ssh configs for all AWS instances 38 | Uses the current AWS region (`AWS_DEFAULT_REGION`) and generates all 39 | configs using the EC2 API. 40 | ``` 41 | generate-ssh-configs aws --prefix myservers --user myuser > ~/.ssh/config-myservers 42 | ``` 43 | 44 | ### AWS multi-region, multi-environment setup 45 | Using multiple regions, environments and jumphosts for each region and 46 | environment. This works if all of your environments are contained in a single 47 | AWS account and separated by VPC and tags. 48 | ``` 49 | # Generate configs for dev,test,prod in eu-west-1 50 | AWS_DEFAULT_REGION=eu-west-1 generate-ssh-configs aws \ 51 | --prefix myorg-dev-eu-west-1 \ 52 | --filters 'Name=tag:Environment,Values=dev' 53 | --jumphost jumphost --user dani \ 54 | > ~/.ssh/config-myorg-dev-eu-west-1 55 | 56 | AWS_DEFAULT_REGION=eu-west-1 generate-ssh-configs aws \ 57 | --prefix myorg-prod-eu-west-1 \ 58 | --filters 'Name=tag:Environment,Values=prod' \ 59 | --jumphost jumphost \ 60 | --user dani \ 61 | > ~/.ssh/config-myorg-prod-eu-west-1 62 | 63 | 64 | # Generate configs for dev,test,prod in ap-south 1 65 | AWS_DEFAULT_REGION=ap-south-1 generate-ssh-configs aws \ 66 | --prefix myorg-dev-ap-south-1 \ 67 | --filters 'Name=tag:Environment,Values=dev' \ 68 | --jumphost jumphost \ 69 | --user dani \ 70 | > ~/.ssh/config-myorg-dev-ap-south-1 71 | 72 | AWS_DEFAULT_REGION=ap-south-1 generate-ssh-configs aws \ 73 | --prefix myorg-prod-ap-south-1 \ 74 | --filters 'Name=tag:Environment,Values=prod' \ 75 | --jumphost jumphost \ 76 | --user dani \ 77 | > ~/.ssh/config-myorg-prod-ap-south-1 78 | ``` 79 | 80 | ## Usage with [FZF](https://github.com/junegunn/fzf) 81 | SSH configs work beautifully with FZF since the servers are essentially a list. 82 | Using some bash magic we can quickly to select the server we want to ssh to. 83 | 84 | Here is an example of using fzf and zsh to quickly select a server. Pressing 85 | Ctrl+s in a terminal launches fzf-ssh. Place the script in your `~/.zshrc` 86 | ``` 87 | stty stop undef 88 | function fzf-ssh { 89 | all_matches=$(grep -P -r "Host\s+\w+" ~/.ssh/ | grep -v '\*') 90 | only_host_parts=$(echo "$all_matches" | awk '{print $NF}') 91 | selection=$(echo "$only_host_parts" | fzf) 92 | echo $selection 93 | 94 | if [ ! -z $selection ]; then 95 | BUFFER="ssh $selection" 96 | zle accept-line 97 | fi 98 | zle reset-prompt 99 | } 100 | zle -N fzf-ssh 101 | bindkey "^s" fzf-ssh 102 | ``` 103 | 104 | ## Features 105 | - **AWS** 106 | - Uses name tags to identify instances. 107 | - Works with jumphosts or bastion hosts. 108 | - Uses the public IP if 109 | - the instance is in a public subnet 110 | - the security group allows ingress port 22 from the public internet 111 | - the security group allows ingress port 22 from subnet provided via `--subnet` flag 112 | - Otherwise it uses the private IP and routes through the jumphost if one is 113 | configured. 114 | - **DigitalOcean** 115 | -------------------------------------------------------------------------------- /aws.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // TODO: If all ports are open, use public IP 4 | 5 | import ( 6 | "fmt" 7 | "html/template" 8 | "os" 9 | "strings" 10 | 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/aws/aws-sdk-go/aws/session" 13 | "github.com/aws/aws-sdk-go/service/ec2" 14 | ) 15 | 16 | const tmplAWS = ` 17 | {{ range $_, $instance := $.Instances -}} 18 | Host {{ $instance.Domain }} 19 | HostName {{ $instance.IP }} 20 | {{ if $instance.ProxyJump -}} 21 | ProxyJump {{ $instance.ProxyJump }} 22 | {{ end }} 23 | {{ end -}} 24 | 25 | Host {{ $.Prefix }}* 26 | {{ if $.User -}} 27 | User {{ $.User }} 28 | {{ end -}} 29 | IdentityFile {{ $.IdentityFile }} 30 | ` 31 | 32 | func getName(inst *ec2.Instance) string { 33 | 34 | // Try to find the domain tag. For instances where this doesn't 35 | // exist, fall back to the IP 36 | for _, tag := range inst.Tags { 37 | if *tag.Key == "Name" { 38 | return strings.Replace(*tag.Value, " ", "-", -1) 39 | } 40 | } 41 | 42 | if inst.PublicIpAddress != nil { 43 | return *inst.PublicIpAddress 44 | } 45 | 46 | return *inst.PrivateIpAddress 47 | } 48 | 49 | func getTag(inst *ec2.Instance, tagName string) string { 50 | for _, tag := range inst.Tags { 51 | if *tag.Key == tagName { 52 | return *tag.Value 53 | } 54 | } 55 | 56 | return "" 57 | } 58 | 59 | func parseFilter(filterStr string) *ec2.Filter { 60 | nameValueParts := strings.Split(filterStr, ",") 61 | if len(nameValueParts) != 2 { 62 | 63 | } 64 | 65 | nameParts := strings.Split(nameValueParts[0], "=") 66 | if len(nameParts) != 2 { 67 | fmt.Println("nameParts must equal to 2") 68 | os.Exit(1) 69 | } 70 | if nameParts[0] != "Name" { 71 | fmt.Println("nameParts[0] must be 'Name'") 72 | os.Exit(1) 73 | } 74 | 75 | valueParts := strings.Split(nameValueParts[1], "=") 76 | if len(valueParts) != 2 { 77 | fmt.Println("valueParts must equal to 2") 78 | os.Exit(1) 79 | } 80 | if valueParts[0] != "Values" { 81 | fmt.Println("valueParts[0] must be 'Values'") 82 | os.Exit(1) 83 | } 84 | 85 | filter := &ec2.Filter{ 86 | Name: aws.String(nameParts[1]), 87 | Values: []*string{aws.String(valueParts[1])}, 88 | } 89 | return filter 90 | // 'Name=ip-permission.from-port,Values=22' 'Name=ip-permission.to-port,Values=22' 91 | } 92 | 93 | func findRouteTableForInstance(instance *ec2.Instance, routeTables *ec2.DescribeRouteTablesOutput) *ec2.RouteTable { 94 | var tablesForVpc []*ec2.RouteTable 95 | for _, table := range routeTables.RouteTables { 96 | if *instance.VpcId == *table.VpcId { 97 | tablesForVpc = append(tablesForVpc, table) 98 | } 99 | } 100 | 101 | var mainTable *ec2.RouteTable 102 | for _, table := range tablesForVpc { 103 | 104 | // Try to find an explicitly associated instance 105 | for _, assoc := range table.Associations { 106 | if assoc.SubnetId != nil && *assoc.SubnetId == *instance.SubnetId { 107 | return table 108 | } 109 | if *assoc.Main { 110 | mainTable = table 111 | } 112 | } 113 | } 114 | 115 | // Fall back to default route table 116 | return mainTable 117 | } 118 | 119 | func instanceIsPublic(instance *ec2.Instance, routeTables *ec2.DescribeRouteTablesOutput) bool { 120 | routeTable := findRouteTableForInstance(instance, routeTables) 121 | for _, route := range routeTable.Routes { 122 | if route.GatewayId != nil && 123 | strings.HasPrefix(*route.GatewayId, "igw-") && 124 | *route.DestinationCidrBlock == "0.0.0.0/0" { 125 | return true 126 | } 127 | } 128 | return false 129 | } 130 | 131 | func isInstanceInPublicSubnet(ec2Client *ec2.EC2, inst *ec2.Instance) bool { 132 | routeTableRes, err := ec2Client.DescribeRouteTables(&ec2.DescribeRouteTablesInput{ 133 | Filters: []*ec2.Filter{ 134 | &ec2.Filter{ 135 | Name: aws.String("association.subnet-id"), 136 | Values: []*string{inst.SubnetId}, 137 | }, 138 | }, 139 | }) 140 | if err != nil { 141 | panic(err) 142 | } 143 | 144 | // uses default route table 145 | if len(routeTableRes.RouteTables) == 0 { 146 | routeTableRes, err = ec2Client.DescribeRouteTables(&ec2.DescribeRouteTablesInput{ 147 | Filters: []*ec2.Filter{ 148 | &ec2.Filter{ 149 | Name: aws.String("association.main"), 150 | Values: []*string{aws.String("true")}, 151 | }, 152 | }, 153 | }) 154 | if err != nil { 155 | panic(err) 156 | } 157 | } 158 | 159 | for _, routeTable := range routeTableRes.RouteTables { 160 | for _, r := range routeTable.Routes { 161 | if r.GatewayId != nil && strings.HasPrefix(*r.GatewayId, "igw") { 162 | return true 163 | } 164 | } 165 | } 166 | 167 | return false 168 | } 169 | 170 | func isPortOpen(ec2Client *ec2.EC2, inst *ec2.Instance) bool { 171 | securityGroupIds := []*string{} 172 | for _, group := range inst.SecurityGroups { 173 | securityGroupIds = append(securityGroupIds, group.GroupId) 174 | } 175 | securityGroupOutput, err := ec2Client.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{ 176 | GroupIds: securityGroupIds, 177 | }) 178 | if err != nil { 179 | panic(err) 180 | } 181 | 182 | publicSubnets := []string{"0.0.0.0/0"} 183 | if openSubnet != "" { 184 | publicSubnets = append(publicSubnets, openSubnet) 185 | } 186 | 187 | for _, securityGroup := range securityGroupOutput.SecurityGroups { 188 | for _, permission := range securityGroup.IpPermissions { 189 | protocolOk := (*permission.IpProtocol == "tcp" || *permission.IpProtocol == "-1") 190 | publicIpRanges := usesPublicIpRanges(permission.IpRanges, publicSubnets) 191 | 192 | if permission.FromPort == nil && permission.ToPort == nil && 193 | publicIpRanges && 194 | protocolOk { 195 | return true 196 | } 197 | 198 | if permission.FromPort != nil && *permission.FromPort <= 22 && 199 | permission.ToPort != nil && *permission.ToPort >= 22 && 200 | publicIpRanges && 201 | protocolOk { 202 | return true 203 | 204 | } 205 | } 206 | } 207 | 208 | return false 209 | } 210 | 211 | func usesPublicIpRanges(ipRanges []*ec2.IpRange, subnets []string) bool { 212 | for _, r := range ipRanges { 213 | for _, s := range subnets { 214 | if *r.CidrIp == s { 215 | return true 216 | } 217 | } 218 | } 219 | 220 | return false 221 | } 222 | 223 | func findJumpHost(instances []*ec2.Instance, jumphostTagName string) *ec2.Instance { 224 | jumphosts := []*ec2.Instance{} 225 | for _, instance := range instances { 226 | name := getName(instance) 227 | if strings.Contains(name, jumphostTagName) { 228 | jumphosts = append(jumphosts, instance) 229 | } 230 | } 231 | 232 | if len(jumphosts) != 1 { 233 | fmt.Println("Did not find exactly 1 jumphost, found", len(jumphosts)) 234 | os.Exit(1) 235 | } 236 | 237 | return jumphosts[0] 238 | } 239 | 240 | func generateAWS(prefix string) { 241 | sess := session.Must(session.NewSessionWithOptions(session.Options{ 242 | SharedConfigState: session.SharedConfigEnable, 243 | })) 244 | 245 | ec2Client := ec2.New(sess) 246 | 247 | ec2Filters := []*ec2.Filter{ 248 | &ec2.Filter{ 249 | Name: aws.String("instance-state-name"), 250 | Values: []*string{ 251 | aws.String("running"), 252 | }, 253 | }, 254 | } 255 | 256 | if len(filters) > 0 { 257 | ec2Filters = append(ec2Filters, parseFilter(filters)) 258 | } 259 | 260 | describeInstancesOutput, err := ec2Client.DescribeInstances(&ec2.DescribeInstancesInput{ 261 | Filters: ec2Filters, 262 | }) 263 | checkErr(err) 264 | 265 | instances := []*ec2.Instance{} 266 | for _, r := range describeInstancesOutput.Reservations { 267 | for _, instance := range r.Instances { 268 | instances = append(instances, instance) 269 | } 270 | } 271 | 272 | templateData := struct { 273 | Prefix string 274 | User string 275 | IdentityFile string 276 | Instances []map[string]string 277 | }{ 278 | Prefix: prefix, 279 | User: sshUser, 280 | IdentityFile: identityFile, 281 | Instances: []map[string]string{}, 282 | } 283 | 284 | var jumphostHostname string 285 | if jumphost != "" { 286 | jumphostInstance := findJumpHost(instances, jumphost) 287 | jumphostName := getName(jumphostInstance) 288 | jumphostHostname = prefix + "-" + jumphostName 289 | } 290 | 291 | for _, instance := range instances { 292 | isInPublicSubnet := isInstanceInPublicSubnet(ec2Client, instance) 293 | isPortOpen := isPortOpen(ec2Client, instance) 294 | name := getName(instance) 295 | hostName := prefix + name 296 | 297 | var tmplData map[string]string 298 | 299 | if isInPublicSubnet && isPortOpen { 300 | tmplData = map[string]string{ 301 | "Domain": hostName, 302 | "IP": *instance.PublicIpAddress, 303 | } 304 | } else if jumphost != "" { 305 | tmplData = map[string]string{ 306 | "Domain": hostName, 307 | "IP": *instance.PrivateIpAddress, 308 | "ProxyJump": jumphostHostname, 309 | } 310 | } 311 | 312 | if _, ok := tmplData["IP"]; ok { 313 | templateData.Instances = append(templateData.Instances, tmplData) 314 | } 315 | } 316 | 317 | t := template.Must(template.New("tmpl").Parse(tmplAWS)) 318 | t.DefinedTemplates() 319 | err = t.Execute(os.Stdout, templateData) 320 | checkErr(err) 321 | } 322 | -------------------------------------------------------------------------------- /cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/user" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var prefix string 10 | var sshUser string 11 | var identityFile string 12 | var jumphost string 13 | var filters string 14 | var openSubnet string 15 | 16 | var rootCmd = &cobra.Command{ 17 | Use: "generate-ssh-configs", 18 | Run: func(cmd *cobra.Command, args []string) { 19 | err := cmd.Help() 20 | checkErr(err) 21 | }, 22 | } 23 | 24 | var awsCmd = &cobra.Command{ 25 | Use: "aws", 26 | Run: func(cmd *cobra.Command, args []string) { 27 | if prefix != "" { 28 | prefix = prefix + "-" 29 | } 30 | generateAWS(prefix) 31 | }, 32 | } 33 | 34 | var digitalOceanCmd = &cobra.Command{ 35 | Use: "digital-ocean", 36 | Run: func(cmd *cobra.Command, args []string) { 37 | if prefix != "" { 38 | prefix = prefix + "-" 39 | } 40 | generateDigitalOcean(prefix) 41 | }, 42 | } 43 | 44 | func requirePrefix(cmd *cobra.Command) { 45 | cmd.Flags().StringVar( 46 | &prefix, 47 | "prefix", 48 | "", 49 | "The prefix thats used in the ssh file for this group", 50 | ) 51 | } 52 | 53 | func userFlag(cmd *cobra.Command) { 54 | cmd.Flags().StringVar( 55 | &sshUser, 56 | "user", 57 | "", 58 | "The ssh user", 59 | ) 60 | } 61 | 62 | func identityFileFlag(cmd *cobra.Command) { 63 | usr, err := user.Current() 64 | if err != nil { 65 | panic(err) 66 | } 67 | defaultIdentityFile := usr.HomeDir + "/.ssh/id_rsa" 68 | cmd.Flags().StringVar( 69 | &identityFile, 70 | "identityFile", 71 | defaultIdentityFile, 72 | "", 73 | ) 74 | } 75 | 76 | func cmdInit() { 77 | requirePrefix(awsCmd) 78 | requirePrefix(digitalOceanCmd) 79 | 80 | userFlag(awsCmd) 81 | userFlag(digitalOceanCmd) 82 | identityFileFlag(awsCmd) 83 | awsCmd.Flags().StringVar( 84 | &jumphost, 85 | "jumphost", 86 | "", 87 | "The jumphost", 88 | ) 89 | awsCmd.Flags().StringVar( 90 | &filters, 91 | "filters", 92 | "", 93 | "AWS instance filters", 94 | ) 95 | awsCmd.Flags().StringVar( 96 | &openSubnet, 97 | "subnet", 98 | "", 99 | "Additional open subnet", 100 | ) 101 | 102 | rootCmd.AddCommand(awsCmd) 103 | rootCmd.AddCommand(digitalOceanCmd) 104 | } 105 | -------------------------------------------------------------------------------- /digital_ocean.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "os" 8 | 9 | "github.com/digitalocean/godo" 10 | "golang.org/x/oauth2" 11 | ) 12 | 13 | const tmpl = ` 14 | {{ range $_, $d := .Instances -}} 15 | Host {{ $.Prefix }}{{ $d.Name }} 16 | HostName {{ ( index $d.Networks.V4 0).IPAddress }} 17 | {{ end}} 18 | 19 | Host {{ .Prefix }}* 20 | User {{ .User }} 21 | ` 22 | 23 | func generateDigitalOcean(prefix string) { 24 | tokenSource := &TokenSource{} 25 | oauthClient := oauth2.NewClient(context.Background(), tokenSource) 26 | client := godo.NewClient(oauthClient) 27 | 28 | droplets, _, err := client.Droplets.List(context.TODO(), &godo.ListOptions{PerPage: 200}) 29 | checkErr(err) 30 | 31 | templateData := struct { 32 | Prefix string 33 | User string 34 | Instances []godo.Droplet 35 | }{ 36 | Prefix: prefix, 37 | User: sshUser, 38 | Instances: droplets, 39 | } 40 | 41 | t := template.Must(template.New("tmpl").Parse(tmpl)) 42 | err = t.Execute(os.Stdout, templateData) 43 | checkErr(err) 44 | } 45 | 46 | type TokenSource struct{} 47 | 48 | func (t *TokenSource) Token() (*oauth2.Token, error) { 49 | envVar := "DIGITAL_OCEAN_TOKEN" 50 | 51 | envToken := os.Getenv(envVar) 52 | if envToken == "" { 53 | fmt.Printf("Please provide a personal access token as %s\n", envVar) 54 | os.Exit(1) 55 | } 56 | 57 | token := &oauth2.Token{ 58 | AccessToken: envToken, 59 | } 60 | return token, nil 61 | } 62 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danihodovic/generate-ssh-configs/841a1e5a7ebf47a6029b2f878efd8bede597fb08/example.gif -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func checkErr(err error) { 9 | if err != nil { 10 | panic(err) 11 | } 12 | } 13 | 14 | func main() { 15 | cmdInit() 16 | err := rootCmd.Execute() 17 | if err != nil { 18 | fmt.Println(err) 19 | os.Exit(1) 20 | } 21 | } 22 | --------------------------------------------------------------------------------