Skip to content

Commit

Permalink
Merge pull request #1 from stknohg/feature-eice
Browse files Browse the repository at this point in the history
Add ec2rdp eice command.
  • Loading branch information
stknohg authored Jun 18, 2023
2 parents f19ef5a + 2399bcc commit b4910fd
Show file tree
Hide file tree
Showing 10 changed files with 524 additions and 72 deletions.
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@ This tool assists you to easily connet to your EC2 instances with Remote Desktop

## Prerequisites

* Windows, (Experimental) macOS
* [Session Manager plugin](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html)
* Windows, (Experimental) macOS
* [Parallels Client](https://www.parallels.com/products/ras/capabilities/rdp-client/) 19+ is needed on macOS
* (Optional) [AWS CLI](https://aws.amazon.com/cli/) 2.12.0+
* Required when using `ec2rdp eice` command
* (Optional) [Session Manager plugin](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html)
* Required when using `ec2rdp ssm` command

### Required IAM actions

* `ec2:DescribeInstances`
* `ec2:GetPasswordData`
* `ec2:DescribeInstanceConnectEndpoints`
* `ssm:DescribeInstanceInformation`
* `ssm:StartSession`
* `ssm:TerminateSession`
* `ec2-instance-connect:OpenTunnel`

## How to install

Expand Down Expand Up @@ -62,6 +67,31 @@ PS C:\> $env:AWS_PROFILE='your_profile'
PS C:\> ec2rdp ssm -i i-01234567890abcdef -p C:\project\example.pem
```

### ec2rdp eice

Connect to EC2 instance with Remote Desktop Client via EC2 Instance Connect Endpoint.

```powershell
ec2rdp eice -i 'EC2 instance ID' -p 'Path to private key file (.pem)'
```

You can also use `--endpointid`(`-e`) flag to specify EC2 Instance Connect Endpoint ID.

```powershell
ec2rdp eice -e 'Endpoint ID' -i 'EC2 instance ID' -p 'Path to private key file (.pem)'
```

#### example

```powershell
# Connect to EC2 via endpoint in the same VPC
PS C:\> $env:AWS_PROFILE='your_profile'
PS C:\> ec2rdp eice -i i-01234567890abcdef -p C:\project\example.pem
# Connect to EC2 via spcecified endpoint
PS C:\> ec2rdp eice -e eice-xxxxxxxxxx -i i-01234567890abcdef -p C:\project\example.pem
```

### Customization

You can use `--profile`, `--region` parameters.
Expand Down
199 changes: 199 additions & 0 deletions cmd/eice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package cmd

import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"

"github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/hashicorp/go-version"
"github.com/spf13/cobra"
"github.com/stknohg/ec2rdp/internal/aws"
"github.com/stknohg/ec2rdp/internal/aws/ec2"
"github.com/stknohg/ec2rdp/internal/aws/ec2instanceconnect"
"github.com/stknohg/ec2rdp/internal/connector"
)

var (
eiceEndpointId string
)

// eiceCmd represents the ssm command
var eiceCmd = &cobra.Command{
Use: "eice",
Short: "Connect to EC2 instance via EC2 Instance Connect Endpoint",
Long: `Connect to EC2 instance via EC2 Instance Connect Endpoint`,
Args: func(cmd *cobra.Command, args []string) error {
if installed, err := isAWSCLIInstalled(); !installed {
return err
}
if cpPemFile == "" && !cpUserPassword {
return errors.New("--pemfile or --password flag is requied")
}
if cpPemFile != "" {
err := validatePemFile(cpPemFile)
if err != nil {
return err
}
}
err := validatePort(cpPort)
if err != nil {
return err
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return invokeEICECommand(cmd, args)
},
}

func init() {
rootCmd.AddCommand(eiceCmd)
eiceCmd.Flags().StringVarP(&cpInstanceId, "instance", "i", "", "EC2 Instance ID")
eiceCmd.Flags().StringVarP(&cpPemFile, "pemfile", "p", "", ".pem file path")
eiceCmd.Flags().IntVar(&cpPort, "port", 3389, "RDP port no")
eiceCmd.Flags().StringVar(&cpUserName, "user", "Administrator", "RDP username")
eiceCmd.Flags().BoolVarP(&cpUserPassword, "password", "P", false, "RDP passowrd")
eiceCmd.Flags().StringVar(&cpProfileName, "profile", "", "AWS profile name")
eiceCmd.Flags().StringVar(&cpRegionName, "region", "", "AWS region name")
eiceCmd.Flags().StringVarP(&eiceEndpointId, "endpointid", "e", "", "EC2 Instance Connect Endpoint ID")
//
eiceCmd.MarkFlagRequired("instance")
eiceCmd.MarkFlagFilename("pemfile", "pem")
eiceCmd.MarkFlagsMutuallyExclusive("pemfile", "password")
// custom completion
eiceCmd.RegisterFlagCompletionFunc("region", invokeRegionCompletion)
}

func invokeEICECommand(cmd *cobra.Command, args []string) error {
// check if connector application installed
connector := connector.DefaultConnector{}
_, err := connector.IsInstalled()
if err != nil {
return err
}

// get aws config
cfg := aws.GetConfig(cpProfileName, cpRegionName)
ec2api := ec2.NewAPI(cfg)
ctx := context.Background()

// check instance exists
_, err = ec2.IsInstanceExist(ec2api, ctx, cpInstanceId)
if err != nil {
return err
}

// get instance metadata information
metadata, err := ec2.GetInstanceMetadataForEICE(ec2api, ctx, cpInstanceId)
if err != nil {
return err
}
if metadata.State.Name != types.InstanceStateNameRunning {
return fmt.Errorf("instance %v is %v (status code=%d)", cpInstanceId, metadata.State.Name, *metadata.State.Code)
}

// get EC2 Insntance Endpoint information
var fetchResult *ec2.EICEndpointMetadata
if eiceEndpointId != "" {
fetchResult, err = ec2.FetchEICEndpointById(ec2api, ctx, eiceEndpointId)
if err != nil {
return err
}
} else {
fetchResult, err = ec2.FetchEICEndpointByVpc(ec2api, ctx, metadata.VpcId)
if err != nil {
return err
}
}
if eiceEndpointId == "" {
fmt.Printf("Detect EC2 Connect Endpoint %v\n", fetchResult.EndpointId)
}
// get administrator password
var password string
if !cpUserPassword {
password, err = ec2.GetAdministratorPassword(ec2api, ctx, cpInstanceId, cpPemFile)
if err != nil {
return err
}
if password == "" {
return fmt.Errorf("EC2 PasswordData is empty. Use --password flag instead")
}
fmt.Println("Administrator password acquisition completed")
} else {
password = readPrompt("Enter password:")
}

// get hostname and local port
var localHostName = "localhost"
localPort, err := getLocalRDPPort(localHostName, 33389)
if err != nil {
return err
}

// Open WebSocket tunnel with AWS CLI
wspid, err := ec2instanceconnect.OpenTunnel(cfg, ctx, fetchResult.EndpointId, fetchResult.DnsName, metadata.PrivateIpAddress, localPort, cpPort)
if err != nil {
return err
}
fmt.Printf("Opening WebSocket tunnel (pid=%v)\n", wspid)
for i := 1; ; i++ {
if isPortOpen(localHostName, localPort) {
break
}
time.Sleep(500 * time.Millisecond)
if i >= 10 {
return fmt.Errorf("%v port %v is not open", localHostName, localPort)
}
}
fmt.Printf("Start listening %v:%v\n", localHostName, localPort)

// connect
connector.HostName = localHostName
connector.Port = localPort
connector.UserName = cpUserName
connector.PlainPassword = password
connector.WaitFor = true // always true
return connectEICEInstance(&connector, wspid)
}

func isAWSCLIInstalled() (bool, error) {
_, err := exec.LookPath("aws")
if err != nil {
return false, errors.New("AWS CLI is not found")
}
output, err := exec.Command("aws", "--version").Output()
if err != nil {
return false, errors.New("failed to get AWS CLI version")
}
cliVersion, err := version.NewVersion(strings.Split(strings.Split(string(output), " ")[0], "/")[1])
if err != nil {
return false, errors.New("failed to get AWS CLI version")
}
constraint, _ := version.NewConstraint(">=2.12.0")
result := constraint.Check(cliVersion)
if !result {
return false, fmt.Errorf("AWS CLI 2.12.0 later is required (current version=%s)", cliVersion)
}
return true, nil
}

func connectEICEInstance(con connector.Connector, wspid int) error {
err := con.PreConnect()
if err != nil {
return err
}
err = con.Connect()
if err != nil {
return err
}
defer func() {
con.PostConnect()
fmt.Printf("Close WebSocket tunnel (pid=%v)\n", wspid)
ec2instanceconnect.CloseTunnel(wspid)
}()
return nil
}
17 changes: 2 additions & 15 deletions cmd/ssm.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ import (
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"strconv"
"time"

"github.com/spf13/cobra"
Expand All @@ -20,8 +18,8 @@ import (
// ssmCmd represents the ssm command
var ssmCmd = &cobra.Command{
Use: "ssm",
Short: "Connect to EC2 Instance via SSM Session Manager",
Long: `Connect to EC2 Instance via SSM Session Manager`,
Short: "Connect to EC2 instance via SSM Session Manager",
Long: `Connect to EC2 instance via SSM Session Manager`,
Args: func(cmd *cobra.Command, args []string) error {
if installed, err := isSessionManagerPluginInstalled(); !installed {
return err
Expand Down Expand Up @@ -156,17 +154,6 @@ Please refer to SessionManager Documentation here: https://docs.aws.amazon.com/s
return true, nil
}

func getLocalRDPPort(localHost string, startPort int) (int, error) {
for i := startPort; i <= 65535; i++ {
listener, err := net.Listen("tcp", net.JoinHostPort(localHost, strconv.Itoa(i)))
if err == nil {
defer listener.Close()
return i, nil
}
}
return 65535, fmt.Errorf("failed to find local proxy port")
}

func connectSSMInstance(con connector.Connector, ret *ssm.StartSSMSessionPluginResult) error {
err := con.PreConnect()
if err != nil {
Expand Down
11 changes: 11 additions & 0 deletions cmd/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ func isPortOpen(hostName string, port int) bool {
return true
}

func getLocalRDPPort(localHost string, startPort int) (int, error) {
for i := startPort; i <= 65535; i++ {
listener, err := net.Listen("tcp", net.JoinHostPort(localHost, strconv.Itoa(i)))
if err == nil {
defer listener.Close()
return i, nil
}
}
return 65535, fmt.Errorf("failed to find local proxy port")
}

func invokeRegionCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// generate from : aws ec2 describe-regions --all-regions --query "sort_by(Regions,&RegionName)[].RegionName" --output json
regions := []string{
Expand Down
4 changes: 2 additions & 2 deletions cmd/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import (
"github.com/spf13/cobra"
)

var version string = "0.0.0"
var cmdVersion string = "0.0.0"

// versionCmd represents the version command
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show version",
Long: `Show version`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(version)
fmt.Println(cmdVersion)
},
}

Expand Down
33 changes: 17 additions & 16 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,30 @@ module github.com/stknohg/ec2rdp
go 1.20

require (
github.com/aws/aws-sdk-go-v2 v1.18.0
github.com/aws/aws-sdk-go-v2/config v1.18.25
github.com/aws/aws-sdk-go-v2/credentials v1.13.24
github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0
github.com/aws/aws-sdk-go-v2/service/ssm v1.36.4
github.com/aws/aws-sdk-go-v2 v1.18.1
github.com/aws/aws-sdk-go-v2/config v1.18.27
github.com/aws/aws-sdk-go-v2/credentials v1.13.26
github.com/aws/aws-sdk-go-v2/service/ec2 v1.100.1
github.com/aws/aws-sdk-go-v2/service/ssm v1.36.6
github.com/aws/smithy-go v1.13.5
github.com/danieljoos/wincred v1.2.0
github.com/hashicorp/go-version v1.6.0
github.com/spf13/cobra v1.7.0
)

require (
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.10 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.19.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.35 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.28 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.19.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/term v0.8.0
golang.org/x/text v0.9.0
golang.org/x/sys v0.9.0 // indirect
golang.org/x/term v0.9.0
golang.org/x/text v0.10.0
)
Loading

0 comments on commit b4910fd

Please sign in to comment.