├── .github └── main.workflow ├── .goreleaser.yml ├── .pullbot.yml ├── Dockerfile ├── README.md ├── cmd ├── connect.go ├── connect_test.go ├── install.go ├── match.go ├── root.go ├── setup.go ├── setup_test.go └── version.go ├── go.mod ├── go.sum ├── main.go └── pkg ├── ec2connect ├── authorize.go └── authorize_test.go └── sshconfig ├── effective_identity.go ├── effective_identity_test.go ├── expand.go ├── identity_agent.go ├── sshconfig.go └── sshconfig_test.go /.github/main.workflow: -------------------------------------------------------------------------------- 1 | workflow "CI" { 2 | on = "push" 3 | resolves = ["test"] 4 | } 5 | 6 | workflow "Release" { 7 | on = "push" 8 | resolves = ["goreleaser"] 9 | } 10 | 11 | action "is-tag" { 12 | uses = "actions/bin/filter@master" 13 | args = "tag" 14 | } 15 | 16 | action "not-tag" { 17 | uses = "actions/bin/filter@master" 18 | args = "not tag" 19 | } 20 | 21 | action "test" { 22 | uses = "./" 23 | args = "go test ./..." 24 | needs = ["not-tag"] 25 | env = { 26 | AWS_REGION = "ap-southeast-2" 27 | } 28 | secrets = [ 29 | "AWS_ACCESS_KEY_ID", 30 | "AWS_SECRET_ACCESS_KEY", 31 | "TEST_INSTANCE_ID" 32 | ] 33 | } 34 | 35 | action "goreleaser" { 36 | uses = "./" 37 | secrets = [ 38 | "GORELEASER_GITHUB_TOKEN" 39 | ] 40 | args = ["sh", "-c", "GITHUB_TOKEN=$GORELEASER_GITHUB_TOKEN goreleaser"] 41 | needs = ["is-tag"] 42 | } 43 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: [] 3 | builds: 4 | - env: 5 | - CGO_ENABLED=0 6 | goos: 7 | - darwin 8 | - linux 9 | - windows 10 | goarch: 11 | - amd64 12 | ldflags: 13 | - -s -w -X github.com/glassechidna/ec2connect/cmd.version={{.Version}} -X github.com/glassechidna/ec2connect/cmd.commit={{.ShortCommit}} -X github.com/glassechidna/ec2connect/cmd.date={{.Date}} 14 | archives: 15 | - replacements: 16 | darwin: Darwin 17 | linux: Linux 18 | windows: Windows 19 | 386: i386 20 | amd64: x86_64 21 | checksum: 22 | name_template: 'checksums.txt' 23 | snapshot: 24 | name_template: "{{ .Tag }}-next" 25 | changelog: 26 | sort: asc 27 | filters: 28 | exclude: 29 | - '^docs:' 30 | - '^test:' 31 | nfpm: 32 | vendor: ec2connect 33 | homepage: https://github.com/glassechidna/ec2connect 34 | maintainer: Aidan Steele 35 | description: ec2connect is a convenient SSH wrapper around EC2 instance connect 36 | formats: 37 | - deb 38 | brew: 39 | github: 40 | owner: glassechidna 41 | name: homebrew-taps 42 | commit_author: 43 | name: Aidan Steele 44 | email: aidan.steele@glassechidna.com.au 45 | homepage: https://github.com/glassechidna/ec2connect 46 | description: ec2connect is a convenient SSH wrapper around EC2 instance connect 47 | scoop: 48 | bucket: 49 | owner: glassechidna 50 | name: scoop-bucket 51 | commit_author: 52 | name: Aidan Steele 53 | email: aidan.steele@glassechidna.com.au 54 | homepage: https://github.com/glassechidna/ec2connect 55 | description: ec2connect is a convenient SSH wrapper around EC2 instance connect 56 | license: MIT -------------------------------------------------------------------------------- /.pullbot.yml: -------------------------------------------------------------------------------- 1 | checks: 2 | - type: golangci 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.12-alpine 2 | RUN apk add --update git upx openssh 3 | ADD https://github.com/goreleaser/goreleaser/releases/download/v0.106.0/goreleaser_Linux_x86_64.tar.gz . 4 | RUN tar -xvf goreleaser_Linux_x86_64.tar.gz && mv goreleaser /usr/bin 5 | 6 | ENV CGO_ENABLED=0 7 | 8 | WORKDIR /wd 9 | COPY go.mod go.sum ./ 10 | RUN go mod download 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `ec2connect` 2 | 3 | ![render1561718563616](https://user-images.githubusercontent.com/369053/60337186-ad908180-99e5-11e9-9db3-de8b0d353739.gif) 4 | 5 | In [June 2019][rel-notes], AWS released [EC2 Instance Connect][docs] - a way of 6 | authenticating SSH sessions using AWS IAM policies. This **massively** improves 7 | security by removing the need for sharing SSH private keys. It also improves 8 | reliability by removing the need for any workarounds to avoid sharing keys! 9 | 10 | AWS did release an [`mssh`][mssh] tool, but it's not as nice as it could be. 11 | `ec2connect` improves upon it: 12 | 13 | * Doesn't require Python to be installed. Single binary available for Mac, Linux 14 | and Windows. 15 | * Doesn't require a new command to be remembered - just `ssh ec2-user@host` as 16 | normal. 17 | * Integrates nicely with every other tool - any tool that relies on SSH (e.g. `git`) 18 | will work out of the box due to the above. 19 | 20 | ## Installation 21 | 22 | * Mac: `brew install glassechidna/taps/ec2connect` 23 | * Windows: `scoop bucket add glassechidna https://github.com/glassechidna/scoop-bucket.git; scoop install ec2connect` 24 | * Otherwise get the latest build from the [Releases][releases] tab. 25 | 26 | ## Usage 27 | 28 | On first time usage, run `ec2connect setup`. This sets up your SSH configuration 29 | to use `ec2connect` to connect to your instances. You only need to run this once. 30 | 31 | Now, connect to your instances using `ssh @`. For example: 32 | 33 | ``` 34 | # regular ssh connection 35 | ssh ec2-user@i-000abc124def 36 | 37 | # in a different region 38 | AWS_REGION=us-west-2 ssh ec2-user@i-000abc124def 39 | 40 | # with a profile 41 | AWS_PROFILE=mycompany ssh ec2-user@i-000abc124def 42 | 43 | # with port-forwarding. the possibilities are endless! 44 | ssh -L 2375:127.0.0.1:2375 ec2-user@i-000abc124def 45 | ``` 46 | 47 | ## Known issues 48 | 49 | Right now this tool only works with SSH public keys that are stored on disk or 50 | in an SSH agent. What that means in effect is that you can't pass in an identity 51 | using `ssh -i `. 52 | 53 | [rel-notes]: https://aws.amazon.com/about-aws/whats-new/2019/06/introducing-amazon-ec2-instance-connect/ 54 | [docs]: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Connect-using-EC2-Instance-Connect.html 55 | [mssh]: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-connect-set-up.html#ec2-instance-connect-install-eic-CLI 56 | [releases]: https://github.com/glassechidna/ec2connect/releases 57 | -------------------------------------------------------------------------------- /cmd/connect.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/awserr" 8 | "github.com/aws/aws-sdk-go/aws/credentials" 9 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 10 | "github.com/aws/aws-sdk-go/aws/request" 11 | "github.com/aws/aws-sdk-go/aws/session" 12 | "github.com/aws/aws-sdk-go/service/ec2" 13 | "github.com/aws/aws-sdk-go/service/ec2instanceconnect" 14 | "github.com/glassechidna/ec2connect/pkg/ec2connect" 15 | "github.com/glassechidna/ec2connect/pkg/sshconfig" 16 | "github.com/pkg/errors" 17 | "github.com/spf13/cobra" 18 | "io" 19 | "net" 20 | "os" 21 | "strings" 22 | ) 23 | 24 | func init() { 25 | cmd := &cobra.Command{ 26 | Use: "connect", 27 | Short: "SSH ProxyCommand implementation", 28 | Run: func(cmd *cobra.Command, args []string) { 29 | instanceId, _ := cmd.PersistentFlags().GetString("instance-id") 30 | region, _ := cmd.PersistentFlags().GetString("region") 31 | user, _ := cmd.PersistentFlags().GetString("user") 32 | port, _ := cmd.PersistentFlags().GetInt("port") 33 | err := connect(instanceId, region, user, port) 34 | if err != nil { 35 | panic(err) 36 | } 37 | }, 38 | } 39 | 40 | cmd.PersistentFlags().String("instance-id", "", "") 41 | cmd.PersistentFlags().String("region", "", "") 42 | cmd.PersistentFlags().String("user", "ec2-user", "") 43 | cmd.PersistentFlags().Int("port", 22, "") 44 | 45 | RootCmd.AddCommand(cmd) 46 | } 47 | 48 | func connect(instanceId, region, user string, port int) error { 49 | conf := sshconfig.DefaultSsh.Get(instanceId, user, port) 50 | pubKeyBytes := conf.EffectivePublicKey() 51 | 52 | info, err := authorize(instanceId, region, user, string(pubKeyBytes)) 53 | if err != nil { 54 | if awsErr, ok := errors.Cause(err).(awserr.Error); ok { 55 | if awsErr.Code() == credentials.ErrNoValidProvidersFoundInChain.Code() { 56 | _, _ = fmt.Fprintln(os.Stderr, ` 57 | No AWS credentials found. 58 | 59 | * You can specify one of the profiles from ~/.aws/config by setting the 60 | AWS_PROFILE environment variable. 61 | 62 | * You can set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and optionally 63 | AWS_SESSION_TOKEN.`) 64 | } else if strings.HasPrefix(awsErr.Code(), "InvalidInstanceID.") { 65 | _, _ = fmt.Fprintf(os.Stderr, ` 66 | No instance found with ID %s. Try specifying an explicit region using the 67 | AWS_REGION environment variable. 68 | 69 | `, instanceId) 70 | } else { 71 | return err 72 | } 73 | os.Exit(1) 74 | } else { 75 | return err 76 | } 77 | } 78 | 79 | return tunnel(fmt.Sprintf("%s:%d", info.Address, port)) 80 | } 81 | 82 | func authorize(instanceId, region, user, sshKey string) (*ec2connect.ConnectionInfo, error) { 83 | sess, err := session.NewSessionWithOptions(session.Options{ 84 | SharedConfigState: session.SharedConfigEnable, 85 | AssumeRoleTokenProvider: stscreds.StdinTokenProvider, 86 | Config: *aws.NewConfig().WithRegion(region), //.WithLogLevel(aws.LogDebugWithHTTPBody), 87 | }) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | sess.Handlers.Build.PushFront(request.MakeAddToUserAgentHandler("ec2connect", version)) 93 | 94 | auth := &ec2connect.Authorizer{Ec2Api: ec2.New(sess), ConnectApi: ec2instanceconnect.New(sess)} 95 | return auth.Authorize(context.Background(), instanceId, user, sshKey) 96 | } 97 | 98 | func tunnel(addr string) error { 99 | conn, err := net.Dial("tcp", addr) 100 | if err != nil { 101 | return errors.Wrapf(err, "establishing connection to %s", addr) 102 | } 103 | 104 | go func() { 105 | io.Copy(os.Stdout, conn) 106 | conn.Close() 107 | }() 108 | 109 | io.Copy(conn, os.Stdin) 110 | conn.Close() 111 | 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /cmd/connect_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "github.com/stretchr/testify/assert" 7 | "golang.org/x/crypto/ssh" 8 | "os" 9 | "testing" 10 | ) 11 | 12 | func TestAuthorize(t *testing.T) { 13 | instanceId := os.Getenv("TEST_INSTANCE_ID") 14 | 15 | if testing.Short() || instanceId == "" { 16 | t.SkipNow() 17 | } 18 | 19 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 20 | assert.NoError(t, err) 21 | 22 | signer, err := ssh.NewSignerFromKey(privateKey) 23 | assert.NoError(t, err) 24 | 25 | pubKey := ssh.MarshalAuthorizedKey(signer.PublicKey()) 26 | info, err := authorize(instanceId, "ap-southeast-2", "ec2-user", string(pubKey)) 27 | assert.NoError(t, err) 28 | 29 | assert.True(t, len(info.Address) > 0) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/install.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func init() { 8 | cmd := &cobra.Command{ 9 | Use: "install", 10 | Short: "Remotely install EC2 Instance Connect on EC2 instance", 11 | Long: ``, 12 | Run: func(cmd *cobra.Command, args []string) { 13 | }, 14 | } 15 | 16 | RootCmd.AddCommand(cmd) 17 | } 18 | 19 | func install() error { 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /cmd/match.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "os" 6 | "regexp" 7 | ) 8 | 9 | func init() { 10 | cmd := &cobra.Command{ 11 | Use: "match", 12 | Short: "Internal functionality", 13 | Run: func(cmd *cobra.Command, args []string) { 14 | host, _ := cmd.PersistentFlags().GetString("host") 15 | user, _ := cmd.PersistentFlags().GetString("user") 16 | matched := match(host, user) 17 | if !matched { 18 | os.Exit(1) 19 | } 20 | }, 21 | } 22 | 23 | cmd.PersistentFlags().String("host", "", "") 24 | cmd.PersistentFlags().String("user", "", "") 25 | RootCmd.AddCommand(cmd) 26 | } 27 | 28 | func match(host, user string) bool { 29 | re := regexp.MustCompile(`^i-[a-f0-9]+`) 30 | doesMatch := re.MatchString(host) 31 | 32 | //if doesMatch { 33 | // dir, _ := homedir.Expand("~/.ssh/ec2connect") 34 | // filePath := path.Join(dir, host) 35 | // ioutil.WriteFile(filePath, []byte(user), 0644) 36 | //} 37 | 38 | return doesMatch 39 | } 40 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Aidan Steele 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 | package cmd 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | 21 | "github.com/spf13/cobra" 22 | "github.com/spf13/viper" 23 | ) 24 | 25 | var RootCmd = &cobra.Command{ 26 | Use: "ec2connect", 27 | Short: "", 28 | Long: ``, 29 | // Uncomment the following line if your bare application 30 | // has an action associated with it: 31 | // Run: func(cmd *cobra.Command, args []string) { }, 32 | } 33 | 34 | // Execute adds all child commands to the root command sets flags appropriately. 35 | // This is called by main.main(). It only needs to happen once to the rootCmd. 36 | func Execute() { 37 | if err := RootCmd.Execute(); err != nil { 38 | fmt.Println(err) 39 | os.Exit(-1) 40 | } 41 | } 42 | 43 | func init() { 44 | cobra.OnInitialize(initConfig) 45 | viper.BindPFlags(RootCmd.PersistentFlags()) 46 | } 47 | 48 | // initConfig reads in config file and ENV variables if set. 49 | func initConfig() { 50 | viper.AddConfigPath(".") // adding home directory as first search path 51 | viper.AddConfigPath("$HOME") // adding home directory as first search path 52 | viper.AutomaticEnv() // read in environment variables that match 53 | 54 | // If a config file is found, read it in. 55 | if err := viper.ReadInConfig(); err == nil { 56 | fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /cmd/setup.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "encoding/pem" 9 | "fmt" 10 | "github.com/mitchellh/go-homedir" 11 | "github.com/pkg/errors" 12 | "github.com/spf13/cobra" 13 | "golang.org/x/crypto/ssh" 14 | "io/ioutil" 15 | "os" 16 | "os/exec" 17 | "path" 18 | "path/filepath" 19 | "strings" 20 | "text/template" 21 | ) 22 | 23 | func init() { 24 | cmd := &cobra.Command{ 25 | Use: "setup", 26 | Short: "First-time setup of ec2connect on your machine", 27 | Long: ` 28 | 'setup' will configure your ~/.ssh/config to use the 'ec2connect' helper tool 29 | whenever you ssh into an EC2 server. This setup only needs to be run once on 30 | your machine. 31 | `, 32 | Run: func(cmd *cobra.Command, args []string) { 33 | err := setup("~/.ssh/config", "~/.ssh/ec2connect") 34 | if err != nil { 35 | panic(err) 36 | } 37 | }, 38 | } 39 | 40 | RootCmd.AddCommand(cmd) 41 | } 42 | 43 | func setup(configPath, ec2connDir string) error { 44 | ec2connDir, err := homedir.Expand(ec2connDir) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | configPath, err = homedir.Expand(configPath) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | err = os.MkdirAll(ec2connDir, 0700) 55 | if err != nil { 56 | return errors.Wrapf(err, "creating directory at %s", ec2connDir) 57 | } 58 | 59 | privKey, pubKey, err := generateSshKeypair() 60 | if err != nil { 61 | return errors.Wrap(err, "generating new ssh key pair") 62 | } 63 | 64 | privKeyPath := path.Join(ec2connDir, "id_rsa") 65 | err = ioutil.WriteFile(privKeyPath, privKey, 0600) 66 | if err != nil { 67 | return errors.Wrap(err, "writing new ssh priv key to disk") 68 | } 69 | 70 | err = ioutil.WriteFile(path.Join(ec2connDir, "id_rsa.pub"), pubKey, 0644) 71 | if err != nil { 72 | return errors.Wrap(err, "writing new ssh pub key to disk") 73 | } 74 | 75 | snippet, err := sshConfigSnippet(privKeyPath) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | myConfPath := path.Join(ec2connDir, "ssh_config") 81 | err = ioutil.WriteFile(myConfPath, snippet, 0644) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | sshConfDir := path.Dir(configPath) 87 | relEc2ConfPath, err := filepath.Rel(sshConfDir, myConfPath) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | err = idempotentInsert(configPath, fmt.Sprintf("Include %s\n\n", relEc2ConfPath)) 93 | return errors.Wrapf(err, "appending config to %s", configPath) 94 | } 95 | 96 | func sshConfigSnippet(privKeyPath string) ([]byte, error) { 97 | cmdPath, err := exec.LookPath("ec2connect") 98 | if err != nil { 99 | return nil, errors.Wrap(err, "You have to first install ec2connect somewhere on your PATH") 100 | } 101 | 102 | tmpl, err := template.New("").Parse(` 103 | Match exec "{{ .CommandPath }} match --host %n --user %r" 104 | IdentityFile {{ .KeyPath }} 105 | ProxyCommand {{ .CommandPath }} connect --instance-id %h --user %r --port %p 106 | `) 107 | if err != nil { 108 | return nil, errors.Wrap(err, "parsing ssh config template") 109 | } 110 | 111 | b := &bytes.Buffer{} 112 | err = tmpl.Execute(b, map[string]string{ 113 | "KeyPath": privKeyPath, 114 | "CommandPath": cmdPath, 115 | }) 116 | if err != nil { 117 | return nil, errors.Wrap(err, "rendering ssh config template") 118 | } 119 | 120 | return b.Bytes(), nil 121 | } 122 | 123 | func idempotentInsert(path, content string) error { 124 | existing, err := ioutil.ReadFile(path) 125 | if err != nil && !os.IsNotExist(err) { 126 | return errors.Wrap(err, "reading existing file") 127 | } 128 | 129 | if strings.Contains(string(existing), content) { 130 | return nil 131 | } 132 | 133 | f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) 134 | if err != nil { 135 | return errors.Wrap(err, "opening file for appending") 136 | } 137 | defer f.Close() 138 | 139 | _, err = f.WriteString(content) 140 | _, err = f.Write(existing) 141 | return err 142 | } 143 | 144 | func generateSshKeypair() ([]byte, []byte, error) { 145 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 146 | if err != nil { 147 | return nil, nil, errors.Wrap(err, "generating new ssh private key") 148 | } 149 | 150 | buf := bytes.Buffer{} 151 | privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} 152 | err = pem.Encode(&buf, privateKeyPEM) 153 | if err != nil { 154 | return nil, nil, errors.Wrap(err, "pem-encoding new ssh private key") 155 | } 156 | 157 | signer, err := ssh.NewSignerFromKey(privateKey) 158 | if err != nil { 159 | return nil, nil, errors.Wrap(err, "creating signer from ssh priv key") 160 | } 161 | 162 | public := ssh.MarshalAuthorizedKey(signer.PublicKey()) 163 | return buf.Bytes(), public, nil 164 | } 165 | -------------------------------------------------------------------------------- /cmd/setup_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stretchr/testify/assert" 6 | "io/ioutil" 7 | "path" 8 | "testing" 9 | ) 10 | 11 | func TestSetup(t *testing.T) { 12 | t.Run("ssh_config doesn't exist", func(t *testing.T) { 13 | dir, err := ioutil.TempDir("", "ec2connect_tests") 14 | assert.NoError(t, err) 15 | 16 | fmt.Println(dir) 17 | 18 | sshConfPath := path.Join(dir, "ssh_config") 19 | ec2ConnDir := path.Join(dir, "ec2connect") 20 | err = setup(sshConfPath, ec2ConnDir) 21 | assert.NoError(t, err) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | // these are set by goreleaser 9 | var version, commit, date string 10 | 11 | func init() { 12 | cmd := &cobra.Command{ 13 | Use: "version", 14 | Short: "Information about this build of ec2connect", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | fmt.Printf(` 17 | Version: %s 18 | Commit: %s 19 | Date: %s 20 | `, version, commit, date) 21 | }, 22 | } 23 | 24 | RootCmd.AddCommand(cmd) 25 | } 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/glassechidna/ec2connect 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.20.11 7 | github.com/mitchellh/go-homedir v1.1.0 8 | github.com/pkg/errors v0.8.1 9 | github.com/spf13/cobra v0.0.5 10 | github.com/spf13/viper v1.3.2 11 | github.com/stretchr/objx v0.2.0 // indirect 12 | github.com/stretchr/testify v1.3.0 13 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 14 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 4 | github.com/aws/aws-sdk-go v1.20.11 h1:xDc2f/8KmwPW7WkuB0kDUCEP4jpx1PIMMMZkav6cbU4= 5 | github.com/aws/aws-sdk-go v1.20.11/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 6 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 7 | github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= 8 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 9 | github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 14 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 15 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 16 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 17 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 18 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 19 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= 20 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= 21 | github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= 22 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 23 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 24 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 25 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 26 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 27 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 28 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 29 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 30 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 31 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 32 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 33 | github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= 34 | github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= 35 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 36 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 37 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 38 | github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= 39 | github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= 40 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 41 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 42 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 43 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 44 | github.com/spf13/viper v1.3.2 h1:VUFqw5KcqRf7i70GOzW7N+Q7+gxVBkSSqiXB12+JQ4M= 45 | github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= 46 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 47 | github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= 48 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 49 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 50 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 51 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 52 | github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= 53 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 54 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= 55 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 56 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= 57 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 58 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= 59 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 60 | golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a h1:1n5lsVfiQW3yfsRGu98756EH1YthsFqr/5mxHduZW2A= 61 | golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 62 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= 63 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 64 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 65 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 66 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 67 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 69 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 70 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/glassechidna/ec2connect/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.RootCmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /pkg/ec2connect/authorize.go: -------------------------------------------------------------------------------- 1 | package ec2connect 2 | 3 | import ( 4 | "context" 5 | "github.com/aws/aws-sdk-go/service/ec2" 6 | "github.com/aws/aws-sdk-go/service/ec2/ec2iface" 7 | "github.com/aws/aws-sdk-go/service/ec2instanceconnect" 8 | "github.com/aws/aws-sdk-go/service/ec2instanceconnect/ec2instanceconnectiface" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type Authorizer struct { 13 | Ec2Api ec2iface.EC2API 14 | ConnectApi ec2instanceconnectiface.EC2InstanceConnectAPI 15 | } 16 | 17 | type ConnectionInfo struct { 18 | Address string 19 | RequestId string 20 | } 21 | 22 | func (c *Authorizer) Authorize(ctx context.Context, instanceId, user, sshKey string) (*ConnectionInfo, error) { 23 | r, err := c.Ec2Api.DescribeInstancesWithContext(ctx, &ec2.DescribeInstancesInput{InstanceIds: []*string{&instanceId}}) 24 | if err != nil { 25 | return nil, errors.Wrap(err, "describing instance") 26 | } 27 | 28 | if len(r.Reservations) == 0 || len(r.Reservations[0].Instances) == 0 { 29 | return nil, errors.Errorf("no instance with id %s", instanceId) 30 | } 31 | 32 | instance := r.Reservations[0].Instances[0] 33 | az := instance.Placement.AvailabilityZone 34 | 35 | r2, err := c.ConnectApi.SendSSHPublicKeyWithContext(ctx, &ec2instanceconnect.SendSSHPublicKeyInput{ 36 | AvailabilityZone: az, 37 | InstanceId: &instanceId, 38 | InstanceOSUser: &user, 39 | SSHPublicKey: &sshKey, 40 | }) 41 | if err != nil { 42 | return nil, errors.Wrap(err, "sending ssh key to instance") 43 | } 44 | 45 | if !*r2.Success { 46 | return nil, errors.Errorf("sending ssh key to instance") 47 | } 48 | 49 | ip := *instance.PrivateIpAddress 50 | if instance.PublicIpAddress != nil { // TODO: they might *want* the private ip 51 | ip = *instance.PublicIpAddress 52 | } 53 | 54 | return &ConnectionInfo{ 55 | Address: ip, 56 | RequestId: *r2.RequestId, 57 | }, nil 58 | } 59 | -------------------------------------------------------------------------------- /pkg/ec2connect/authorize_test.go: -------------------------------------------------------------------------------- 1 | package ec2connect 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/request" 8 | "github.com/aws/aws-sdk-go/service/ec2" 9 | "github.com/aws/aws-sdk-go/service/ec2/ec2iface" 10 | "github.com/aws/aws-sdk-go/service/ec2instanceconnect" 11 | "github.com/aws/aws-sdk-go/service/ec2instanceconnect/ec2instanceconnectiface" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/mock" 14 | "testing" 15 | ) 16 | 17 | func TestAuthorizer_Authorize(t *testing.T) { 18 | t.Run("err describing", func(t *testing.T) { 19 | connApi := &mockConnect{} 20 | ec2Api := &mockEc2{} 21 | ec2Api. 22 | On("DescribeInstancesWithContext", mock.Anything, mock.AnythingOfType("*ec2.DescribeInstancesInput"), mock.AnythingOfType("[]request.Option")). 23 | Return(nil, errors.New("err")) 24 | 25 | auth := &Authorizer{Ec2Api: ec2Api, ConnectApi: connApi} 26 | _, err := auth.Authorize(context.Background(), "i-012abc", "", "") 27 | assert.Error(t, err) 28 | }) 29 | 30 | t.Run("no instances", func(t *testing.T) { 31 | connApi := &mockConnect{} 32 | ec2Api := &mockEc2{} 33 | ec2Api. 34 | On("DescribeInstancesWithContext", mock.Anything, mock.AnythingOfType("*ec2.DescribeInstancesInput"), mock.AnythingOfType("[]request.Option")). 35 | Return(&ec2.DescribeInstancesOutput{}, nil) 36 | 37 | auth := &Authorizer{Ec2Api: ec2Api, ConnectApi: connApi} 38 | _, err := auth.Authorize(context.Background(), "i-012abc", "", "") 39 | assert.Error(t, err) 40 | }) 41 | 42 | t.Run("err sending ssh key", func(t *testing.T) { 43 | ec2Api := &mockEc2{} 44 | ec2Api. 45 | On("DescribeInstancesWithContext", mock.Anything, mock.AnythingOfType("*ec2.DescribeInstancesInput"), mock.AnythingOfType("[]request.Option")). 46 | Return(&ec2.DescribeInstancesOutput{ 47 | Reservations: []*ec2.Reservation{ 48 | { 49 | Instances: []*ec2.Instance{ 50 | { 51 | Placement: &ec2.Placement{ 52 | AvailabilityZone: aws.String("ap-southeast-2b"), 53 | }, 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, nil) 59 | 60 | connApi := &mockConnect{} 61 | connApi. 62 | On("SendSSHPublicKeyWithContext", mock.Anything, mock.AnythingOfType("*ec2instanceconnect.SendSSHPublicKeyInput"), mock.AnythingOfType("[]request.Option")). 63 | Return(nil, errors.New("err")) 64 | 65 | auth := &Authorizer{Ec2Api: ec2Api, ConnectApi: connApi} 66 | _, err := auth.Authorize(context.Background(), "i-012abc", "", "") 67 | assert.Error(t, err) 68 | }) 69 | 70 | t.Run("unsuccessful send ssh key", func(t *testing.T) { 71 | ec2Api := &mockEc2{} 72 | ec2Api. 73 | On("DescribeInstancesWithContext", mock.Anything, mock.AnythingOfType("*ec2.DescribeInstancesInput"), mock.AnythingOfType("[]request.Option")). 74 | Return(&ec2.DescribeInstancesOutput{ 75 | Reservations: []*ec2.Reservation{ 76 | { 77 | Instances: []*ec2.Instance{ 78 | { 79 | Placement: &ec2.Placement{ 80 | AvailabilityZone: aws.String("ap-southeast-2b"), 81 | }, 82 | }, 83 | }, 84 | }, 85 | }, 86 | }, nil) 87 | 88 | connApi := &mockConnect{} 89 | connApi. 90 | On("SendSSHPublicKeyWithContext", mock.Anything, mock.AnythingOfType("*ec2instanceconnect.SendSSHPublicKeyInput"), mock.AnythingOfType("[]request.Option")). 91 | Return(&ec2instanceconnect.SendSSHPublicKeyOutput{ 92 | Success: aws.Bool(false), 93 | }, nil) 94 | 95 | auth := &Authorizer{Ec2Api: ec2Api, ConnectApi: connApi} 96 | _, err := auth.Authorize(context.Background(), "i-012abc", "", "") 97 | assert.Error(t, err) 98 | }) 99 | } 100 | 101 | type mockEc2 struct { 102 | mock.Mock 103 | ec2iface.EC2API 104 | } 105 | 106 | func (m *mockEc2) DescribeInstancesWithContext(ctx aws.Context, input *ec2.DescribeInstancesInput, opts ...request.Option) (*ec2.DescribeInstancesOutput, error) { 107 | f := m.Called(ctx, input, opts) 108 | output, _ := f.Get(0).(*ec2.DescribeInstancesOutput) 109 | return output, f.Error(1) 110 | } 111 | 112 | type mockConnect struct { 113 | mock.Mock 114 | ec2instanceconnectiface.EC2InstanceConnectAPI 115 | } 116 | 117 | func (m *mockConnect) SendSSHPublicKeyWithContext(ctx aws.Context, input *ec2instanceconnect.SendSSHPublicKeyInput, opts ...request.Option) (*ec2instanceconnect.SendSSHPublicKeyOutput, error) { 118 | f := m.Called(ctx, input, opts) 119 | output, _ := f.Get(0).(*ec2instanceconnect.SendSSHPublicKeyOutput) 120 | return output, f.Error(1) 121 | } 122 | -------------------------------------------------------------------------------- /pkg/sshconfig/effective_identity.go: -------------------------------------------------------------------------------- 1 | package sshconfig 2 | 3 | import ( 4 | "github.com/mitchellh/go-homedir" 5 | "golang.org/x/crypto/ssh" 6 | "io/ioutil" 7 | ) 8 | 9 | func (c Config) EffectivePublicKey() []byte { 10 | for _, identityPath := range c["identityfile"] { 11 | identityPath, err := homedir.Expand(identityPath) 12 | if err != nil { 13 | continue 14 | } 15 | 16 | privBytes, err := ioutil.ReadFile(identityPath) 17 | if err != nil { 18 | continue 19 | } 20 | 21 | pubBytes, err := ioutil.ReadFile(identityPath + ".pub") 22 | if err == nil { 23 | return pubBytes 24 | } 25 | 26 | signer, err := ssh.ParsePrivateKey(privBytes) 27 | if err != nil { 28 | continue 29 | } 30 | 31 | return ssh.MarshalAuthorizedKey(signer.PublicKey()) 32 | } 33 | 34 | if c.Get("IdentitiesOnly") == "yes" { 35 | return nil 36 | } 37 | 38 | agent := c.IdentityAgent() 39 | if agent == nil { 40 | return nil 41 | } 42 | 43 | keys, err := agent.List() 44 | if err != nil || len(keys) == 0 { 45 | return nil 46 | } 47 | 48 | return ssh.MarshalAuthorizedKey(keys[0]) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/sshconfig/effective_identity_test.go: -------------------------------------------------------------------------------- 1 | package sshconfig 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "encoding/pem" 9 | "fmt" 10 | "github.com/stretchr/testify/assert" 11 | "golang.org/x/crypto/ssh" 12 | "io/ioutil" 13 | "os" 14 | "path" 15 | "testing" 16 | ) 17 | 18 | func TestConfig_EffectivePublicKey(t *testing.T) { 19 | t.Run("only choose pubkey when corresponding priv key exists", func(t *testing.T) { 20 | dir, err := ioutil.TempDir("", "sshconfig_tests") 21 | assert.NoError(t, err) 22 | defer os.RemoveAll(dir) 23 | 24 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 25 | assert.NoError(t, err) 26 | 27 | buf := bytes.Buffer{} 28 | privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} 29 | err = pem.Encode(&buf, privateKeyPEM) 30 | assert.NoError(t, err) 31 | 32 | signer, err := ssh.NewSignerFromKey(privateKey) 33 | assert.NoError(t, err) 34 | pubKey := ssh.MarshalAuthorizedKey(signer.PublicKey()) 35 | 36 | err = ioutil.WriteFile(path.Join(dir, "id_rsa.pub"), pubKey, 0644) 37 | assert.NoError(t, err) 38 | 39 | confPath := path.Join(dir, "ssh_config") 40 | err = ioutil.WriteFile(confPath, []byte(fmt.Sprintf(` 41 | IdentityFile %s 42 | IdentitiesOnly yes 43 | `, path.Join(dir, "id_rsa"))), 0644) 44 | assert.NoError(t, err) 45 | 46 | s := &Ssh{ConfigPath: confPath} 47 | c := s.Get("any", "user", 22) 48 | key := c.EffectivePublicKey() 49 | assert.Nil(t, key) 50 | }) 51 | } -------------------------------------------------------------------------------- /pkg/sshconfig/expand.go: -------------------------------------------------------------------------------- 1 | package sshconfig 2 | 3 | import ( 4 | "os" 5 | "os/user" 6 | "strings" 7 | ) 8 | 9 | func (c Config) Expand(input string) string { 10 | r := strings.ReplaceAll 11 | s := input 12 | 13 | u, _ := user.Current() 14 | host, _ := os.Hostname() 15 | remote := c.Get("HostName") 16 | 17 | s = r(s, "%C", "") // TODO 18 | s = r(s, "%d", u.HomeDir) 19 | s = r(s, "%h", remote) 20 | s = r(s, "%i", u.Uid) 21 | s = r(s, "%L", host) 22 | s = r(s, "%l", host) 23 | s = r(s, "%n", remote) 24 | s = r(s, "%p", c.Get("Port")) 25 | s = r(s, "%r", c.Get("User")) 26 | s = r(s, "%T", "NONE") 27 | s = r(s, "%u", u.Username) 28 | s = r(s, "%%", "%") 29 | 30 | return s 31 | } 32 | -------------------------------------------------------------------------------- /pkg/sshconfig/identity_agent.go: -------------------------------------------------------------------------------- 1 | package sshconfig 2 | 3 | import ( 4 | "golang.org/x/crypto/ssh/agent" 5 | "net" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | func (c Config) IdentityAgent() agent.Agent { 11 | conf := c.Get("IdentityAgent") 12 | if conf == "none" { 13 | return nil 14 | } 15 | 16 | if conf == "" || conf == "SSH_AUTH_SOCK" { 17 | return agentAtPath(os.Getenv("SSH_AUTH_SOCK")) 18 | } 19 | 20 | conf = c.Expand(conf) 21 | 22 | if strings.HasPrefix(conf, "$") { 23 | return agentAtPath(os.Getenv(conf[1:])) 24 | } 25 | 26 | return agentAtPath(conf) 27 | } 28 | 29 | func agentAtPath(path string) agent.Agent { 30 | conn, err := net.Dial("unix", path) 31 | if err != nil { 32 | return nil 33 | } 34 | 35 | return agent.NewClient(conn) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/sshconfig/sshconfig.go: -------------------------------------------------------------------------------- 1 | package sshconfig 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "os/exec" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | type Config map[string][]string 13 | 14 | func (s Config) Get(key string) string { 15 | key = strings.ToLower(key) 16 | vals := s[key] 17 | if len(vals) > 0 { 18 | return vals[0] 19 | } 20 | 21 | return "" 22 | } 23 | 24 | type Ssh struct { 25 | Executable string 26 | ConfigPath string 27 | } 28 | 29 | var DefaultSsh *Ssh 30 | 31 | func (s *Ssh) Get(host, user string, port int) Config { 32 | args := []string{"ssh"} 33 | 34 | if s != nil { 35 | if s.Executable != "" { 36 | args[0] = s.Executable 37 | } 38 | 39 | if s.ConfigPath != "" { 40 | args = append(args, "-F", s.ConfigPath) 41 | } 42 | } 43 | 44 | args = append(args, "-T", "-G", "-p", strconv.Itoa(port), fmt.Sprintf("%s@%s", user, host)) 45 | 46 | cmd := exec.Command(args[0], args[1:]...) 47 | out, err := cmd.CombinedOutput() 48 | if err != nil { 49 | panic(err) 50 | } 51 | 52 | conf := Config{} 53 | 54 | scanner := bufio.NewScanner(bytes.NewReader(out)) 55 | for scanner.Scan() { 56 | parts := strings.SplitN(scanner.Text(), " ", 2) 57 | key := parts[0] 58 | val := "" 59 | if len(parts) == 2 { 60 | val = parts[1] 61 | } 62 | 63 | 64 | conf[key] = append(conf[key], val) 65 | } 66 | 67 | return conf 68 | } 69 | -------------------------------------------------------------------------------- /pkg/sshconfig/sshconfig_test.go: -------------------------------------------------------------------------------- 1 | package sshconfig 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "io/ioutil" 7 | "os" 8 | "path" 9 | "testing" 10 | ) 11 | 12 | func TestSsh_Get(t *testing.T) { 13 | t.Run("default zero value", func(t *testing.T) { 14 | c := DefaultSsh.Get("host", "user", 22) 15 | assert.Equal(t, "host", c.Get("HostName")) 16 | }) 17 | 18 | t.Run("custom config", func(t *testing.T) { 19 | dir, err := ioutil.TempDir("", "sshconfig_tests") 20 | require.NoError(t, err) 21 | defer os.RemoveAll(dir) 22 | 23 | confPath := path.Join(dir, "config") 24 | err = ioutil.WriteFile(confPath, []byte(` 25 | Host myhost 26 | ProxyCommand mycommand %h %p 27 | `), 0600) 28 | assert.NoError(t, err) 29 | 30 | s := &Ssh{ConfigPath: confPath} 31 | c := s.Get("host", "user", 22) 32 | assert.Equal(t, "", c.Get("ProxyCommand")) 33 | 34 | c = s.Get("myhost", "user", 22) 35 | assert.Equal(t, "mycommand %h %p", c.Get("ProxyCommand")) 36 | }) 37 | } 38 | --------------------------------------------------------------------------------