├── 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 |
--------------------------------------------------------------------------------