Skip to content

Commit c61ef46

Browse files
authored
Integrate EC2 Instance Connect with SSM SSH functionality (#71)
Integrate EC2 instance connect with the ssm ssh functionality. This allows the public key for the session to be provisioned on the instance during the setup of the SSH session instead of requiring pre-existing SSH keys on the instance. * Update dependencies and use go 1.17 * Fix error when launching ssm plugin * Update ssm-session-client for bug fix with DNS target resolution
1 parent 18f9505 commit c61ef46

File tree

7 files changed

+237
-86
lines changed

7 files changed

+237
-86
lines changed

.circleci/config.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ version: 2.1
66
orbs:
77
go: circleci/[email protected]
88
aws-cli: circleci/[email protected]
9-
ruby: circleci/ruby@1.2.0
9+
ruby: circleci/ruby@1.4.0
1010
windows: circleci/[email protected]
1111

1212
jobs:
@@ -21,7 +21,7 @@ jobs:
2121
preflight:
2222
executor:
2323
name: go/default
24-
tag: '1.16'
24+
tag: '1.17'
2525

2626
steps:
2727
- checkout
@@ -36,7 +36,7 @@ jobs:
3636
build-darwin:
3737
executor:
3838
name: go/default
39-
tag: '1.16'
39+
tag: '1.17'
4040

4141
steps:
4242
- checkout
@@ -62,7 +62,7 @@ jobs:
6262
build-linux:
6363
executor:
6464
name: go/default
65-
tag: '1.16'
65+
tag: '1.17'
6666

6767
steps:
6868
- checkout
@@ -96,7 +96,7 @@ jobs:
9696
build-windows:
9797
executor:
9898
name: go/default
99-
tag: '1.16'
99+
tag: '1.17'
100100

101101
steps:
102102
- checkout
@@ -192,7 +192,7 @@ jobs:
192192
package-darwin:
193193
executor:
194194
name: go/default
195-
tag: '1.16'
195+
tag: '1.17'
196196
steps:
197197
- checkout
198198
- attach_workspace:
@@ -217,7 +217,7 @@ jobs:
217217
package-windows:
218218
executor:
219219
name: go/default
220-
tag: '1.16'
220+
tag: '1.17'
221221
steps:
222222
- checkout
223223
- attach_workspace:

cli/ssm_cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func execSsmPlugin(cfg aws.Config, in *ssm.StartSessionInput) error {
106106
}
107107

108108
var ep aws.Endpoint
109-
ep, err = cfg.EndpointResolverWithOptions.ResolveEndpoint(ssm.ServiceID, cfg.Region)
109+
ep, err = ssm.NewDefaultEndpointResolver().ResolveEndpoint(cfg.Region, ssm.EndpointResolverOptions{})
110110
if err != nil {
111111
return err
112112
}

cli/ssm_ssh_cmd.go

Lines changed: 104 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@
1414
package cli
1515

1616
import (
17+
"errors"
1718
"github.com/aws/aws-sdk-go-v2/aws"
19+
"github.com/aws/aws-sdk-go-v2/service/ec2instanceconnect"
1820
"github.com/aws/aws-sdk-go-v2/service/ssm"
21+
"github.com/kevinburke/ssh_config"
1922
"github.com/mmmorris1975/ssm-session-client/ssmclient"
2023
"github.com/urfave/cli/v2"
24+
"golang.org/x/crypto/ssh"
25+
"os"
26+
"path/filepath"
2127
"strconv"
2228
"strings"
2329
)
@@ -32,11 +38,17 @@ whose value is EC2 instance ID.
3238
This feature is meant to be used in SSH configuration files according to the AWS documentation
3339
at https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-enable-ssh-connections.html
3440
except that the ProxyCommand syntax changes to:
35-
ProxyCommand sh -c "aws-runas ssm ssh profile_name %h:%p"
41+
ProxyCommand aws-runas ssm ssh [--ec2ic] profile_name %r@%h:%p
3642
Where profile_name is the AWS configuration profile to use (you should also be able to use the
37-
AWS_PROFILE environment variable, in which case the profile_name could be omitted), and %h:%p
38-
are standard SSH configuration substitutions for the host and port number to connect with, and
39-
can be left as-is`
43+
AWS_PROFILE environment variable, in which case the profile_name could be omitted), and %r@%h:%p
44+
are standard SSH configuration substitutions for the remote user name, host and port number to connect with,
45+
and can be left as-is. If the optional --ec2ic argument is supplied, the public key is provisioned on the
46+
remote system using EC2 Instance Connect during the SSH session setup.`
47+
48+
var ec2InstanceConnectFlag = &cli.BoolFlag{
49+
Name: "ec2ic",
50+
Usage: "Send public key to instance using EC2 Instance Connect",
51+
}
4052

4153
var ssmSshCmd = &cli.Command{
4254
Name: "ssh",
@@ -45,40 +57,39 @@ var ssmSshCmd = &cli.Command{
4557
Description: ssmSshDesc,
4658
BashComplete: bashCompleteProfile,
4759

48-
Flags: []cli.Flag{ssmUsePluginFlag},
60+
Flags: []cli.Flag{ec2InstanceConnectFlag, ssmUsePluginFlag},
4961

5062
Action: func(ctx *cli.Context) error {
5163
target, c, err := doSsmSetup(ctx, 2)
5264
if err != nil {
5365
return err
5466
}
5567

56-
// default ssh port if we're passed a target which doesn't explicitly set one
57-
port := "22"
58-
59-
// "preprocess" target so it's acceptable to checkTarget()
60-
// Port number is expected to be the last element after splitting on ':' (except if using the
61-
// plain tag_key:tag_value format without a port), all other parts will be passed to checkTarget()
62-
parts := strings.Split(target, `:`)
63-
if len(parts) == 2 {
64-
// could be just tag_key:tag_value using default port, all other supported formats have
65-
// port as the final element. If Atoi can convert the string to a number, assume it's
66-
// supposed to be a port, otherwise we'll use the default
67-
if _, err = strconv.Atoi(parts[1]); err == nil {
68-
target = parts[0]
69-
port = parts[1]
70-
}
71-
} else if len(parts) > 2 {
72-
// cases where port is expected to be the final element of the target specification
73-
target = strings.Join(parts[:len(parts)-1], `:`)
74-
port = parts[len(parts)-1]
75-
}
68+
user, host, port := parseTargetSpec(target)
7669

77-
ec2Id, err := ssmclient.ResolveTarget(target, c.ConfigProvider())
70+
ec2Id, err := ssmclient.ResolveTarget(host, c.ConfigProvider())
7871
if err != nil {
7972
return err
8073
}
8174

75+
if ctx.Bool(ec2InstanceConnectFlag.Name) {
76+
var pubKey string
77+
if pubKey, err = getPubKey(host); err != nil {
78+
return err
79+
}
80+
81+
ec2ic := ec2instanceconnect.NewFromConfig(c.ConfigProvider())
82+
pubkeyIn := &ec2instanceconnect.SendSSHPublicKeyInput{
83+
InstanceId: &ec2Id,
84+
InstanceOSUser: &user,
85+
SSHPublicKey: &pubKey,
86+
}
87+
88+
if _, err = ec2ic.SendSSHPublicKey(ctx.Context, pubkeyIn); err != nil {
89+
return err
90+
}
91+
}
92+
8293
if ctx.Bool(ssmUsePluginFlag.Name) {
8394
params := map[string][]string{
8495
"portNumber": {port},
@@ -87,7 +98,7 @@ var ssmSshCmd = &cli.Command{
8798
in := &ssm.StartSessionInput{
8899
DocumentName: aws.String("AWS-StartSSHSession"),
89100
Parameters: params,
90-
Target: aws.String(ec2Id),
101+
Target: &ec2Id,
91102
}
92103
return execSsmPlugin(c.ConfigProvider(), in)
93104
}
@@ -100,3 +111,69 @@ var ssmSshCmd = &cli.Command{
100111
return ssmclient.SSHSession(c.ConfigProvider(), in)
101112
},
102113
}
114+
115+
func parseTargetSpec(target string) (string, string, string) {
116+
var user = "ec2-user"
117+
var port = "22"
118+
var host string
119+
120+
userHostPart := strings.Split(target, `@`)
121+
if len(userHostPart) > 1 {
122+
user = userHostPart[0]
123+
userHostPart = userHostPart[1:]
124+
}
125+
126+
// Format could be host:port or possibly tag_key:tag_value:port
127+
hostPortPart := strings.Split(userHostPart[0], `:`)
128+
if len(hostPortPart) == 1 {
129+
// bare host, use default port
130+
host = hostPortPart[0]
131+
} else {
132+
// Could be host:port, tag_key:tag_value with default port, or tag_key:tag_value:port
133+
// Use Atoi to see if the last element is a numeric string and assume that's a port number
134+
if i, err := strconv.Atoi(hostPortPart[len(hostPortPart)-1]); err == nil && i <= 65535 {
135+
host = strings.Join(hostPortPart[:len(hostPortPart)-1], `:`)
136+
port = hostPortPart[len(hostPortPart)-1]
137+
} else {
138+
host = strings.Join(hostPortPart, `:`)
139+
}
140+
}
141+
142+
return user, host, port
143+
}
144+
145+
func getPubKey(host string) (string, error) {
146+
var err error
147+
148+
for _, key := range ssh_config.GetAll(host, "IdentityFile") {
149+
if strings.HasPrefix(key, "~/") {
150+
dirname, _ := os.UserHomeDir()
151+
key = filepath.Join(dirname, key[2:])
152+
}
153+
154+
var bytes []byte
155+
bytes, err = os.ReadFile(key)
156+
if err != nil {
157+
if os.IsNotExist(err) {
158+
continue
159+
}
160+
return "", err
161+
}
162+
163+
var signer ssh.Signer
164+
signer, err = ssh.ParsePrivateKey(bytes)
165+
if err != nil {
166+
var protectedKeyErr *ssh.PassphraseMissingError
167+
if errors.As(err, &protectedKeyErr) {
168+
// FIXME handle a passphrase protected key ...
169+
// for now, just continue an hope there's an "unprotected" key in the list to try
170+
continue
171+
}
172+
return "", err
173+
}
174+
175+
return string(ssh.MarshalAuthorizedKey(signer.PublicKey())), nil
176+
}
177+
178+
return "", errors.New("public key not available")
179+
}

cli/ssm_ssh_cmd_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright (c) 2022 Michael Morris. All Rights Reserved.
3+
*
4+
* Licensed under the MIT license (the "License"). You may not use this file except in compliance
5+
* with the License. A copy of the License is located at
6+
*
7+
* https://github.com/mmmorris1975/aws-runas/blob/master/LICENSE
8+
*
9+
* or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
11+
* for the specific language governing permissions and limitations under the License.
12+
*/
13+
14+
package cli
15+
16+
import (
17+
"testing"
18+
)
19+
20+
func Test_parseTargetSpec(t *testing.T) {
21+
t.Run("user@instance:port", func(t *testing.T) {
22+
user, host, port := parseTargetSpec("user@instance:2222")
23+
if user != "user" || host != "instance" || port != "2222" {
24+
t.Error("bad user, host, or port returned")
25+
}
26+
})
27+
28+
t.Run("user@tag:port", func(t *testing.T) {
29+
user, host, port := parseTargetSpec("user@my_key:value:2222")
30+
if user != "user" || host != "my_key:value" || port != "2222" {
31+
t.Error("bad user, host, or port returned")
32+
}
33+
})
34+
35+
t.Run("user@instance", func(t *testing.T) {
36+
user, host, port := parseTargetSpec("user@instance")
37+
if user != "user" || host != "instance" || port != "22" {
38+
t.Error("bad user, host, or port returned")
39+
}
40+
})
41+
42+
t.Run("user@tag", func(t *testing.T) {
43+
user, host, port := parseTargetSpec("user@my_key:value")
44+
if user != "user" || host != "my_key:value" || port != "22" {
45+
t.Error("bad user, host, or port returned")
46+
}
47+
})
48+
49+
t.Run("instance:port", func(t *testing.T) {
50+
user, host, port := parseTargetSpec("instance:2222")
51+
if user != "ec2-user" || host != "instance" || port != "2222" {
52+
t.Error("bad user, host, or port returned")
53+
}
54+
})
55+
56+
t.Run("tag:port", func(t *testing.T) {
57+
user, host, port := parseTargetSpec("my_key:value:2222")
58+
if user != "ec2-user" || host != "my_key:value" || port != "2222" {
59+
t.Error("bad user, host, or port returned")
60+
}
61+
})
62+
}
63+
64+
func Test_getPubKey(t *testing.T) {
65+
t.Skip("not testable, can't specify custom config file location")
66+
}

docs/ssm.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,12 @@ for instructions on setting up the local and remote system to allow SSH connecti
115115

116116
This is a sample ssh config file entry to enable SSH connectivity to an SSM connected instance. The elements on the
117117
`Host` line can be modified to capture how you will be access the host, this example uses the EC2 instance ID. For the
118-
`ProxyCommand`, the `my_profile` element can be omitted if you will supply the profile name another way (likely via the
118+
`ProxyCommand`, the `profile_name` element can be omitted if you will supply the profile name another way (likely via the
119119
AWS_PROFILE environment variable).
120120

121121
```text
122122
Host i-* mi-*
123-
ProxyCommand sh -c "aws-runas ssm ssh my_profile %h:%p"
123+
ProxyCommand aws-runas ssm ssh profile_name %r@%h:%p
124124
125125
```
126126

@@ -143,13 +143,14 @@ DESCRIPTION:
143143
This feature is meant to be used in SSH configuration files according to the AWS documentation
144144
at https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-enable-ssh-connections.html
145145
except that the ProxyCommand syntax changes to:
146-
ProxyCommand sh -c "aws-runas ssm ssh profile_name %h:%p"
146+
ProxyCommand aws-runas ssm ssh [--ec2ic] profile_name %r@%h:%p
147147
Where profile_name is the AWS configuration profile to use (you should also be able to use the
148-
AWS_PROFILE environment variable, in which case the profile_name could be omitted), and %h:%p
149-
are standard SSH configuration substitutions for the host and port number to connect with, and
150-
can be left as-is
148+
AWS_PROFILE environment variable, in which case the profile_name could be omitted), and %r@%h:%p
149+
are standard SSH configuration substitutions for the remote user name, host and port number to connect with,
150+
and can be left as-is. If the optional --ec2ic argument is supplied, the public key is provisioned on the
151+
remote system using EC2 Instance Connect during the SSH session setup.
151152
152153
OPTIONS:
154+
--ec2ic Send public key to instance using EC2 Instance Connect (default: false)
153155
--plugin, -P Use the SSM session plugin instead of built-in code (default: false)
154-
--help, -h show help (default: false)
155156
```

go.mod

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,24 @@ go 1.16
44

55
require (
66
github.com/PuerkitoBio/goquery v1.8.0
7-
github.com/aws/aws-sdk-go-v2 v1.11.2
8-
github.com/aws/aws-sdk-go-v2/config v1.11.0
9-
github.com/aws/aws-sdk-go-v2/service/ecr v1.11.0
10-
github.com/aws/aws-sdk-go-v2/service/iam v1.12.0
11-
github.com/aws/aws-sdk-go-v2/service/ssm v1.17.0
12-
github.com/aws/aws-sdk-go-v2/service/sts v1.11.1
13-
github.com/aws/smithy-go v1.9.0
7+
github.com/aws/aws-sdk-go-v2 v1.13.0
8+
github.com/aws/aws-sdk-go-v2/config v1.11.1
9+
github.com/aws/aws-sdk-go-v2/service/ec2instanceconnect v1.11.0
10+
github.com/aws/aws-sdk-go-v2/service/ecr v1.11.1
11+
github.com/aws/aws-sdk-go-v2/service/iam v1.13.2
12+
github.com/aws/aws-sdk-go-v2/service/ssm v1.17.1
13+
github.com/aws/aws-sdk-go-v2/service/sts v1.12.0
14+
github.com/aws/smithy-go v1.10.0
1415
github.com/dustin/go-humanize v1.0.1-0.20210705192016-249ff6c91207
16+
github.com/kevinburke/ssh_config v1.1.1-0.20211102215853-c7f8dec5c76b
1517
github.com/mmmorris1975/simple-logger v0.5.1
16-
github.com/mmmorris1975/ssm-session-client v0.201.0
18+
github.com/mmmorris1975/ssm-session-client v0.202.1
1719
github.com/stretchr/testify v1.7.0 // indirect
1820
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635
1921
github.com/urfave/cli/v2 v2.3.1-0.20211106113742-12b7dfd08cb0
20-
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b
21-
golang.org/x/net v0.0.0-20211209124913-491a49abca63
22-
golang.org/x/sys v0.0.0-20211213223007-03aa0b5f6827
22+
golang.org/x/crypto v0.0.0-20220214200702-86341886e292
23+
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd
24+
golang.org/x/sys v0.0.0-20220209214540-3681064d5158
2325
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
2426
gopkg.in/ini.v1 v1.66.2
2527
)

0 commit comments

Comments
 (0)