1414package cli
1515
1616import (
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.
3238This feature is meant to be used in SSH configuration files according to the AWS documentation
3339at https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-enable-ssh-connections.html
3440except 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
3642Where 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
4153var 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+ }
0 commit comments