Connecting to a private Windows EC2 instance without exposing RDP to the internet

26 minute read

The problem statement

Let’s say you have a (Windows or Linux) EC2 instance in a private subnet and want to access it interactively. There are several ways to do this:

You could use a bastion host in your public subnet, harden it and limit access to a certain IP range, and then tunnel your SSH or RDP (or any other TCP) traffic through this host using SSH.

Alternatively, you could set up a VPN server through which to connect to your instance.

Another option for RDP could be an RDP gateway (to not expose RDP directly).

Or you could just care a little less and put your instance in a public subnet, make it directly addressable, lock down source IPs with a security group, and live with the risk of not having an additional layer in front.

Any other idea?

AWS Systems Manager Session Manager

There’s actually another relatively straightforward way to connect to your instance – AWS managed and without the requirement to open ports to any of your resources, manage SSH keys or maintain your own bastion hosts.

With AWS Systems Manager Session Manager, you (or any IAM user with permission) can open a session to your private instance either from the AWS console in the browser or through your local machine. You can even get a connection history and shell logs of established sessions and do your user management as you are already used to in AWS IAM.

If the last paragraph sounded like an advertisement, it wasn’t supposed to :-) I’m generally a fan of using established and vendor-independent tools and procedures; so if you already have everything set up and an established secure workflow with a VPN or bastion host, it may not be too interesting for you. If your resources are primarily on AWS, however, it is still a useful service to know about.

Since I just had this use case for a Windows server to which I also needed GUI access, I wrote down my steps. Let’s see how we can practically set up this scenario for RDP.

What this tutorial is about

In the following steps we will create a new VPC with a public (i.e. with a route to an internet gateway) and a private subnet.

For internet access, we will give the private subnet a route to a NAT gateway residing in the public subnet. Then we will continue by creating an instance which can assume a role for Session Manager Management.

And finally, we will create a port-forwarding session and access our private instance through a local RDP client.

It’s also possible to do this without internet access by using VPC endpoints; I think the EC2 instance only needs to have egress port 443 allowed to the SSM gateway endpoints. You can see the Session Manager Agent on the EC2 instance establishing these connections.

If you just want to see how to connect, jump to the end of the article.

Creating VPC, subnets, gateways, routes

Let’s create a VPC with a small IP range (adjust as you like), giving us space for about 250 hosts (not counting network, a couple of AWS internal reserved hosts, and broadcast). More than enough as we will actually only have one instance and NAT gateway for this demo setup.

aws ec2 create-vpc --cidr-block 10.0.0.0/24  # from now on: vpc-1234

Next, we create an internet gateway and attach it to our VPC:

aws ec2 create-internet-gateway  # from now on: igw-1234
aws ec2 attach-internet-gateway --internet-gateway-id igw-1234 --vpc-id vpc-1234

Let’s continue by creating two subnets. One public (to which we connect the internet gateway) and one private (where our Windows server will reside). Again, I’ll just split the /24 network into two parts, but you might want something else.

aws ec2 create-subnet --vpc-id vpc-1234 --cidr-block 10.0.0.0/25  # from now on: subnet-1234 (will become public)
aws ec2 create-subnet --vpc-id vpc-1234 --cidr-block 10.0.0.128/25  # from now on: subnet-5678 (will stay private)

To give the private subnet access to the internet, we’ll add a NAT gateway and assign it an IP address:

aws ec2 allocate-address --domain vpc --network-border-group eu-central-1  # from now: eipalloc-1234
aws ec2 create-nat-gateway --subnet-id subnet-1234 --allocation-id eipalloc-1234  # from now on: nat-1234

And finally, we create the route table and routes.

First, for the public subnet:

aws ec2 create-route-table --vpc-id vpc-1234  # from now on: rtb-1234
aws ec2 associate-route-table --route-table-id rtb-1234 --subnet-id subnet-1234
aws ec2 create-route --route-table-id rtb-1234 --destination-cidr-block "0.0.0.0/0" --gateway-id igw-1234

And then for the private subnet:

aws ec2 create-route-table --vpc-id vpc-1234 # from now on: rtb-5678
aws ec2 associate-route-table --route-table-id rtb-5678 --subnet-id subnet-5678
aws ec2 create-route --route-table-id rtb-5678 --destination-cidr-block "0.0.0.0/0" --nat-gateway-id nat-1234

Note that the route tables will additionally always get a default route for destination 10.0.0.0/24 to target local on creation.

Setting up instance profile and creating Windows server in private subnet

We start out by creating a role with an EC2 trust relationship, such that instances can assume this role.

# EC2AssumeRoleSTS.json
{
  "Version": "2012-10-17",
  "Statement": {
    "Effect": "Allow",
    "Principal": {
      "Service": "ec2.amazonaws.com"
    },
    "Action": "sts:AssumeRole"
  }
}

aws iam create-role --role-name SSMManagedEC2 --assume-role-policy-document file://EC2AssumeRoleSTS.json

The first relevant piece for this whole Session Manager scenario is to add the (AWS managed) policy AmazonSSMManagedInstanceCore (arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore) to our role, which enables the AWS Systems Manager core functionality.

aws iam attach-role-policy --role-name SSMManagedEC2 --policy arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

And since we want to use the role for the EC2 instance, we create an instance profile and then attach the role to it:

aws iam create-instance-profile --instance-profile-name SSMEC2
aws iam add-role-to-instance-profile --instance-profile-name SSMEC2 --role-name SSMManagedEC2

Finally, we can create our instance in the private subnet and specify the instance type, profile and image (AMI). I’m using an AMI for a Windows Server 2022 in eu-central-1 region. You could additionaly also specify a key-pair, but don’t have to as you can later access the instance from the browser via the AWS console (Instance -> Connect -> Session Manager) and create your user/change password from there (you’ll be dropped into a PowerShell session as a privileged ssm-user).

aws ec2 run-instances --instance-type t2.medium --subnet-id subnet-5678 --image-id ami-0ced908879ca69797 --iam-instance-profile Arn=arn:aws:iam::<your-account-id>:instance-profile/SSMEC2  # from now on: i-1234

Please note that you have to install the System Manager Agent on the instance yourself, if you’re not using an AWS provided AMI. For the one mentoned above, no extra steps are required.

Installing Session Manager plugin, starting port-forwarding session and connecting via RDP

If you can live with working in a browser, there’s not much else to do. Just hit the “Connect” button in the AWS console’s EC2 dashboard, select Session Manager, and your session will start (either a PowerShell session as the privileged ssm-user, or an RDP session via “Fleet Manager”).

If you want access from your local terminal or your local RDP client (or direct access to any other network application on the instance, really), you can use the port-forwarding feature.

To be able to start a session from your local machine you first have to install the Session Manager plugin from AWS (next to the awscli). If you use brew (on macOS), you can brew install --cask session-manager-plugin. For a manual install or a different OS see the install guide.

Now we’re finally ready to connect. Let’s open up an RDP client and get our Windows user/password ready (decrypt the randomly generated one either via your key-pair or create a new user/password via the Session Manager in the AWS console, as mentioned above).

We can then use the ssm command to start our session. And just like we would do with SSH, we can specify a port mapping. In the following I map the remote RDP port 3389 to an arbitrary local port:

aws ssm start-session --target i-1234 --document-name AWS-StartPortForwardingSession --parameters '{"portNumber":["3389"], "localPortNumber": ["33089"]}'

With our local RDP client, we can now connect to localhost:33089, authenticate with the Windows credentials and get a regular RDP experience.

If you work with SSH, you can also use the Session Manager as a proxy for your SSH session (document-name AWS-StartSSHSession).

Like to comment? Feel free to send me an email or reach out on Twitter.

Did this or another article help you? If you like and can afford it, you can buy me a coffee (3 EUR) ☕️ to support me in writing more posts. In case you would like to contribute more or I helped you directly via email or coding/troubleshooting session, you can opt to give a higher amount through the following links or adjust the quantity: 50 EUR, 100 EUR, 500 EUR. All links redirect to Stripe.