AWS EC2 Bastion Hosts

Introduction

In cloud infrastructure, security is essential, and a bastion host is key to keeping your private network safe. Acting as a secure gateway, it protects access to private resources. In this post, we’ll explore AWS EC2 bastion hosts, their role in network security, and the best ways to set them up for maximum security.

What a Bastion Host Is and How It Works

Simply put, a bastion host (also called a “jump box”) is a server specifically designed to provide secure, limited access to your private network. Instead of allowing direct access to your servers, which increases vulnerability, a bastion host acts as a go-between.

Think of a bastion host as a secure gateway or checkpoint into your private network. It's an EC2 instance that sits in a public subnet and acts as the only entry point to your private EC2 instances. When you need to access a private server, you first SSH into the bastion host, and from there, you can SSH into your private servers.

Imagine a bastion host as a secure entry point to your private network. It’s an EC2 instance in a public subnet that acts as the only door to reach your private servers. If you need to access a server in the private network, you start by connecting to the bastion host, and then from there, you can connect to the private servers.

The bastion host is set up with strict security rules, usually allowing only SSH access through port 22 and accepting connections only from approved IP addresses, this way your private servers stay protected from the public internet, but authorized admins can still access them securely.

From Concept to Execution

Throughout this post, I will guide you through the architecture outlined below in order to build a Bastion Host.

The idea is to provision two EC2 servers in a private subnet and another EC2 server in a public subnet to serve as a jump box. The jump box will allow the admin to securely access the private instances for configuration and other administrative tasks.

Provision Resources

I’ll use a CloudFormation template to set up the VPC, subnets, the internet gateway and route tables. For the EC2 instances, I’ll set them up through the AWS Console to make things easier to follow.

First, create a file named BastionHosts.yaml and start by adding the following code:

1AWSTemplateFormatVersion: '2010-09-09' 2Description: 'VPC with Public and Private Subnets' 3 4Parameters: 5 VpcCidr: 6 Type: String 7 Default: '10.0.0.0/16' 8 Description: CIDR block for the VPC 9 10 PublicSubnetCidr: 11 Type: String 12 Default: '10.0.1.0/24' 13 Description: CIDR block for Public Subnet 14 15 PrivateSubnetCidr: 16 Type: String 17 Default: '10.0.2.0/24' 18 Description: CIDR block for Private Subnet

This is the Parameters section of a CloudFormation template. It defines three parameters to configure the IP ranges for a VPC and its subnets:

  1. VpcCidr: Sets the IP range (CIDR block) for the entire VPC. Default Value: 10.0.0.0/16
  2. PublicSubnetCidr: Sets the IP range for the public subnet. Default Value: 10.0.1.0/24
  3. PrivateSubnetCidr: Sets the IP range for the private subnet. Default Value: 10.0.2.0/24

These parameters make the VPC’s IP ranges easy to customize without changing the template code directly.

Next, we need to add the Resources section, which will define the logical resources that will become physical resources when the template is deployed.

1Resources: 2 # VPC 3 MzVPC: 4 Type: AWS::EC2::VPC 5 Properties: 6 CidrBlock: !Ref VpcCidr 7 EnableDnsHostnames: true 8 EnableDnsSupport: true 9 Tags: 10 - Key: Name 11 Value: Mz VPC 12 13 # Internet Gateway 14 InternetGateway: 15 Type: AWS::EC2::InternetGateway 16 Properties: 17 Tags: 18 - Key: Name 19 Value: Mz IGW 20 21 AttachGateway: 22 Type: AWS::EC2::VPCGatewayAttachment 23 Properties: 24 VpcId: !Ref MzVPC 25 InternetGatewayId: !Ref InternetGateway

This section defines the resources that will be created when the CloudFormation template is deployed:

  1. MzVPC: Creates a VPC with the CIDR block provided by the VpcCidr parameter. It also enables DNS hostnames and support for DNS.
  2. InternetGateway: Creates an Internet Gateway (IGW) to provide internet access to the VPC.
  3. AttachGateway: Attaches the Internet Gateway to the VPC so that resources in the VPC can access the internet.

These resources define the basic network infrastructure: a VPC, internet access, and the necessary connection between them.

1# Public Subnet 2PublicSubnet: 3 Type: AWS::EC2::Subnet 4 Properties: 5 VpcId: !Ref MzVPC 6 CidrBlock: !Ref PublicSubnetCidr 7 AvailabilityZone: !Select [0, !GetAZs ''] 8 MapPublicIpOnLaunch: true 9 Tags: 10 - Key: Name 11 Value: Public Subnet 12 13# Private Subnet 14PrivateSubnet: 15 Type: AWS::EC2::Subnet 16 Properties: 17 VpcId: !Ref MzVPC 18 CidrBlock: !Ref PrivateSubnetCidr 19 AvailabilityZone: !Select [0, !GetAZs ''] 20 Tags: 21 - Key: Name 22 Value: Private Subnet

The code above defines the public subnet and private subnet that will be created in the VPC:

  1. PublicSubnet: Creates a public subnet within the VPC using the CIDR block provided by the PublicSubnetCidr parameter. It is placed in the first available availability zone and enables public IP addresses for instances launched in this subnet.
  2. PrivateSubnet: Creates a private subnet within the VPC using the CIDR block provided by the PrivateSubnetCidr parameter. It is also placed in the first available availability zone, but it does not automatically assign public IPs to instances launched in this subnet.

These subnets define the network layout: one for public-facing resources and one for private, internal resources.

1# Public Route Table 2PublicRouteTable: 3 Type: AWS::EC2::RouteTable 4 Properties: 5 VpcId: !Ref MzVPC 6 Tags: 7 - Key: Name 8 Value: Public Route Table 9 10PublicRoute: 11 Type: AWS::EC2::Route 12 DependsOn: AttachGateway 13 Properties: 14 RouteTableId: !Ref PublicRouteTable 15 DestinationCidrBlock: 0.0.0.0/0 16 GatewayId: !Ref InternetGateway 17 18PublicSubnetRouteTableAssociation: 19 Type: AWS::EC2::SubnetRouteTableAssociation 20 Properties: 21 SubnetId: !Ref PublicSubnet 22 RouteTableId: !Ref PublicRouteTable 23 24# Private Route Table 25PrivateRouteTable: 26 Type: AWS::EC2::RouteTable 27 Properties: 28 VpcId: !Ref MzVPC 29 Tags: 30 - Key: Name 31 Value: Private Route Table 32 33PrivateSubnetRouteTableAssociation: 34 Type: AWS::EC2::SubnetRouteTableAssociation 35 Properties: 36 SubnetId: !Ref PrivateSubnet 37 RouteTableId: !Ref PrivateRouteTable

The code above defines the route tables for both the public and private subnets, as well as the routes that connect them to the internet:

  1. PublicRouteTable: Creates a route table for the public subnet and associates it with the VPC.
  2. PublicRoute: Defines the route for the public subnet, allowing traffic to the internet via the Internet Gateway. The route is set to 0.0.0.0/0, meaning it allows all traffic to the internet.
  3. PublicSubnetRouteTableAssociation: Associates the public subnet with the public route table so that instances in the public subnet can route traffic to the internet.
  4. PrivateRouteTable: Creates a route table for the private subnet, again associating it with the VPC but without a direct route to the internet.
  5. PrivateSubnetRouteTableAssociation: Associates the private subnet with the private route table, ensuring instances in the private subnet route traffic only internally.

These route tables and associations ensure that the public subnet can access the internet while the private subnet remains isolated or has controlled access to the internet.

Now that our template is ready, let's deploy it using the CloudFormation service in the AWS Console UI. If you click the View in Infrastructure Composer button, you will see the infrastructure as code that we created, as represented in the image below

Now we're ready to proceed with deploying our template. Simply wait until the CloudFormation stack status updates to CREATE_COMPLETE.

Stack created successfully
Our physical resources are now ready

Now that our VPC architecture is ready, we can begin creating the EC2 instances to complete the Bastion Host setup.

Provision The Bastian Host

As mentioned in the previous section, I will use the Console UI to provision the EC2 instances. Go to the EC2 service, and click on Launch Instance to begin.

After entering the name and selecting the type for the EC2 instance, we need to specify a key pair to enable SSH access from our computer to the Bastion Host.

If you don't already have a key pair, simply click on the Create new key pair button

Under the network settings, we need to select the VPC we created and the public subnet, as the jump box needs to be located in the public subnet

The final step is to set the security group, we will create a new one and allow SSH connections from anywhere

Note: We can specify a specific CIDR IP block in the source of the security group for better security, but for the purposes of this post, we will allow access from anywhere.

After that, click on the Launch Instance button and wait for the EC2 instance to be provisioned. Once the instance is running, go to the Instance Details section to verify the Public IP other configurations.

SSH the Bastian Host

In my case, the public IP address for the Bastion Host is "35.91.217.143," and the key name is "MZ-Bastina-Host-Key.pem", so to establish an SSH connection, follow these steps:

  1. Open the terminal: Navigate to the folder where you downloaded the key pair.
  2. Change the permissions for the key pair: Type the following command in the terminal:
    chmod 400 "MZ-Bastina-Host-Key.pem"
  3. Copy the public IP address: Copy the public IP address of the Bastion Host.
  4. Run the SSH command: Run the following SSH command to connect:
    ssh -i .\MZ-Bastina-Host-Key.pem ec2-user@35.91.217.143
Successfully connected to the Jump Box 🥳

Provision The Private Instances

The steps to provision the private instances, Server 1 and Server 2, are the same as those for the Bastion Host instance. The only difference is in the Network settings, as these instances are private and need to be located in the private subnet without enabling the Auto-assign public IP option.

About the security group, it will be different because we need to allow SSH access only from the Jump Box. We can do this by changing the source type to custom, then selecting the security group of the Jump Box as the source.

Now that our three instances are running, it's time to test the connectivity from the Jump Box to the private instances, Server 1 and Server 2.

In order to do an SSH connection from our Bastion Host to the private servers, we first need to upload the key pairs of Server 1 and Server 2 to the Bastion Host and we can use the scp command.

Now, let's try to make the SSH connection from the jump box. For that, we need to use the private IPs of Server 1 (ip: 10.0.2.252) and Server 2 (ip: 10.0.2.164).

Successfully connected to the Server 1 🥳
Successfully connected to the Server 2 🥳

Conclusion

In summary, setting up a Bastion Host is a key step in building secure access points within your AWS environment. By carefully configuring networking, security groups, and SSH keys, we’ve established a secure gateway that allows controlled access to private instances. This setup enhances security by isolating access to sensitive resources, while also simplifying management and reducing potential vulnerabilities.