Configuring A Secured Primary Network Infrastructure on AWS

Gene Kuo
9 min readOct 21, 2021

--

Photo by Fabio Bracht on Unsplash

This article is a guideline for configuring a primary network infrastructure on AWS to securely control network access to networking resources in AWS. We will use several AWS networking services and provide Terraform resource blocks to help us understand how to set up a primary network infrastruture in an Infrastrucure as Code (IaC) way.

Primary Network Infrastructure

The primary network infrastructure on AWS we are going to talk about is a common network configuration that can be set up to be extended further to add application-related infrastructure on top of it to fulfill specific application or system requirements. This infrastructure is shown as follows. We will talk about the guidelines of configuring its major components in the following sections.

Virtual Private Cloud (VPC)

A VPC is a logically isolated virtual network on AWS cloud where we can launch AWS resources there. A VPC resides within an AWS region.

We will need to specify a Classless Inter-Domain Routing (CIDR) block to create our VPC. This is the range of IP addresses that will be available for use in the network. In terms of IPv4 addresses, the CIDR block can be represented such as 10.0.0.0/24 which contains 256 addresses. Since AWS reserves 5 IP addresses within each subnet, there can be a maximum of 251 IP addresses available for use in a VPC with a single subnet.

We don’t use the default VPC created automatically by AWS. The default VPC includes public subnets and an internet gateway and will not be considered secure when we create AWS resources in it like EC2 instances.

resource "aws_vpc" "main" {
cidr_block = var.cidr_block
tags = {
Name = var.vpc_name
}
}

Subnets

Subnets contain the partial range of the IP addresses in the VPC. A subnet resides within a specific availability zone and belonged to a VPC. We can only launch EC2 instances in a subnet.

We will create two public subnets (var.public_subnets_count = 2)and two private subnets (var.private_subnets_count = 2) in different availability zones for high availability. Each subnet has its own CIDR block that is a subset of the VPC CIDR block.

// 2 Public Subnets
resource "aws_subnet" "public_subnets" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index * 2 + 1}.0/24"
availability_zone = element(var.availability_zones, count.index)
map_public_ip_on_launch = true
count = var.public_subnets_count tags = {
Name = "public_10.0.${count.index * 2 + 1}.0_${element(var.availability_zones, count.index)}"
}
}
// 2 Private Subnets
resource "aws_subnet" "private_subnets" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index * 2}.0/24"
availability_zone = element(var.availability_zones, count.index)
map_public_ip_on_launch = false
count = var.private_subnets_count tags = {
Name = "private_10.0.${count.index * 2}.0_${element(var.availability_zones, count.index)}"
}
}

Internet Gateway (IGW)

The previous section for creating public and private subnets by default makes those subnets private that there is no connection between a subnet and the public internet. We will need to configure Internet Gateway and Route Tables to make subnets public.

Internet Gateway is associated with the VPC and is used to provide internet connectivity to EC2 instances within the public subnet. It maps an instance’s private IP address with an associated public or Elastic IP address and then routes traffic outside the subnet to the internet.

To connect an instance in our VPC to the public internet, we will need to specify how to route traffic to the public internet in a Route Table. We will later define a public route table with a specific route to route traffic from public subnets to the IGW.

resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "igw_${var.vpc_name}"
}
}

Public Route Table

Route Tables define a set of rules called routes about how traffic is routed throughout our VPC. Each route consists of a destination and a target. If traffic is being sent to an IP address within the destination CIDR block, then the traffic is directed to the route’s target.

When we create a VPC, a route table is automatically created and attached to the VPC. It is called the main route table with a single route that has the destination CIDR block as that of the VPC, and the target is local. This allows resources in the VPC to communicate with each other.

Instead of modifying the main route table, we will create a new route table with a destination of 0.0.0.0/0 and target internet gateway id, and associate it with our public subnets. When we create a new route table, the local route will automatically be created as well.

resource "aws_route_table" "public_rt" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "public_rt_${var.vpc_name}"
}
}
resource "aws_route_table_association" "public" {
count = var.public_subnets_count
subnet_id = element(aws_subnet.public_subnets.*.id, count.index)
route_table_id = aws_route_table.public_rt.id
}

NAT Gateway

A NAT Gateway allows our instances in private subnets to send traffic out of our VPC to the public internet, but not in the other direction.

The NAT Gateway will be created in a public subnet and will forward the outbound traffic to the VPC’s internet gateway and then out to the public internet, but not allow inbound traffic from the public internet to reach the private subnets. After the NAT gateway is configured, we will need to add a route to the private route table to point to the NAT Gateway.

// Static IP for Nat Gateway
resource "aws_eip" "nat" {
vpc = true
tags = {
Name = "eip-nat_${var.vpc_name}"
}
}
// Nat Gateway
resource "aws_nat_gateway" "nat" {
allocation_id = aws_eip.nat.id
subnet_id = element(aws_subnet.public_subnets.*.id, 0)
tags = {
Name = "nat_${var.vpc_name}"
}
}

Private Route Table

If traffic in private subnets is routed through the NAT gateway, it will not be publicly accessible, in contrast to the traffic in public subnets is routed to the internet gateway and is publicly accessible.

So we need to create another route table consisting of a route to connect our private subnets to the NAT gateway. The route in the route table has a destination of 0.0.0.0/0 and a target of our NAT Gateway and associated with our private subnets.

// Private Route Table
resource "aws_route_table" "private_rt" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat.id
}
tags = {
Name = "private_rt_${var.vpc_name}"
}
}
// Associate private subnets to private route table
resource "aws_route_table_association" "private" {
count = var.private_subnets_count
subnet_id = element(aws_subnet.private_subnets.*.id, count.index)
route_table_id = aws_route_table.private_rt.id
}

Security Groups

So far the instances in public subnets can be exposed to the internet through internet gateway, and the instances in private subnets can reach the internet but we cannot SSH into the instances in private subnets because of NAT gateway. We will need to create a bastion host instance in our public subnets that allow us to SSH into the instances in the private subnets from the bastion host. Before that, we will talk about security groups.

Security groups act as virtual firewalls for our EC2 instances, controlling both inbound and outbound traffic at the instance level. Each inbound rule consists of a source, protocol, and port range. Each outbound rule consists of destination, protocol, and port range. An example of an outbound rule might be destination: 0.0.0.0/0, protocol number: TCP(6), and port range: 443. This rule specifies that outbound traffic from the instance, destined for any IP address, using TCP protocol, on port 443 is allowed.

For the bastion host, we will create a security group with an inbound rule as follows to allow SSH access to the bastion host, associate this security group with the bastion host that will be created later. This inbound rule (ingress) will allow traffic on port 22 from anywhere. The CIDR blocks can also be limited to your own public IP address or network address to enhance security.

resource "aws_security_group" "bastion_host" {
name = "bastion_sg_${var.vpc_name}"
description = "Allow SSH from anywhere"
vpc_id = aws_vpc.main.id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "bastion_sg_${var.vpc_name}"
}
}

Bastion Host

A bastion host instance will be created in our public subnet and will route SSH traffic from our local network over EC2 instances in our private subnets by setting up a secure SSH tunnel.

In addition to an Amazon 2 Linux machine image we choose for our bastion host, we need to attach an SSH key pair to be able to access SSH to the bastion host with the private key. The public key should be put in the file specified in file(var.public_key) from our variable file.

We then need to provide a layer of security for our instance by associating the bastion host with the security group created previously.

And the bastion host will be created within our public subnet.

data "aws_ami" "bastion" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-ebs"]
}
}
// Key Pair
resource "aws_key_pair" "main" {
key_name = "main"
public_key = file(var.public_key)
}
resource "aws_instance" "bastion" {
ami = data.aws_ami.bastion.id
instance_type = var.bastion_instance_type
key_name = aws_key_pair.main.id
vpc_security_group_ids = [aws_security_group.bastion_host.id]
subnet_id = element(aws_subnet.public_subnets, 0).id
associate_public_ip_address = true
tags = {
Name = "bastion"
}
}

We can then use the Terraform outputs.tf to output the IP address of the bastion host in the terminal when we run terraform apply as follows.

output "bastion" {
value = aws_instance.bastion.public_ip
}

After terraform apply completes, we can set up an SSH tunnel to access private instances from our local machines.

ssh -L [LOCAL_IP:]LOCAL_PORT:DESTINATION_PRIVATE_IP:DESTINATION_PORT ec2-user@BASTION_IP

In addition, when we create EC2 instances within the private subnets to be accessible from the bastion host in the public subnet, we will create a security group and associate it to these instances as follows.

resource "aws_security_group" "server_sg" {
name = "server_sg"
description = "Allow traffic on port 8080 and enable SSH"
vpc_id = aws_vpc.main.id
ingress {
from_port = "22"
to_port = "22"
protocol = "tcp"
security_groups = [aws_security_group.bastion_host.id]
}
ingress {
from_port = "8080"
to_port = "8080"
protocol = "tcp"
cidr_blocks = [var.cidr_block]
}
egress {
from_port = "0"
to_port = "0"
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "server_sg"
}
}

This security group allows SSH from the bastion host only and inbound traffic on port 8080 from VPC CIDR blocks.

Application Related Infrastructure

After the primary network infrastructure is configured, it can be used to securely put other AWS resources onto this infrastructure to fulfill specific application requirements.

Examples can be the following:

  • Creating EC2 instances for various applications or databases within public or private subnets, and configuring security groups for instances to communicate each other or from other subnets.
  • Creating Elastic Load Balancer (ELB) in front of application EC2 instances within public or private subnets to distribute traffic. We can configure HTTPS listeners to use SSL protocol to establish secure connection from outside, and set up health checks on ELB to stop sending traffic to unhealthy EC2 instances. We can also get an SSL certificate with AWS Certificate Manager (ACM) and use it to configure HTTPS listener.
  • Creating an A record in the Route 53 service to point to the load balancer full qualified domain name (FQDN). This allow us to access our application with a friendly domain name.
  • Creating Auto Scaling Group (ASG) for a pool of same EC2 instances to prevent a single point of failure in cases of instance crashes or overloaded. We will need to first create launch configuration with security groups configured. We can leverage user-data to execute specific tasks at boot time, such as installation, updating patches, and starting services in the instance. We can then create ASG based on the launch configuration.
  • To be able to scale the number of instances within ASG dynamically, we can define scaling policies based on metrics such as CPU utilization. For example, we can define an AWS CloudWatch metric alarm based on CPU utilization, to trigger a scale-out or scale-in alarm event to add or remove instances if the specified thresholds is reached.

One example primary and application infrastructure might look like the following:

Summary

We have describe guidelines for setting up a secured primary network infrastructure by leveraging various AWS resources such as VPC, subnets, internet and NAT gateway, security groups, EC2, and so on. It will be the basis for application-specific infrastruce to extend on.

Infrastructure as code is an approach to defining infrastructure and network resources through declarative code. We use Terrform resource blocks to help us understand how to configure components of our infrastructure. The code can be reused and version controlled to avoid configuration drifts.

Network access control list (Network ACL) can be used in addition to security groups to provide defense in depth. Network ACLs act as a firewall for associated subnets, controlling both inbound and outbound traffic at the subnet level.

There are more advanced network and security related services we are not covering here, such as VPC Peering for communicating multiple VPCs, Site-to-Site VPN for connecting VPCs to private networks, Network ACL for preventing DoS attacks, VPC enpoints for connecting to AWS services, and AWS Web Application Firewall (WAF) for preventing common web application attacks.

--

--

Gene Kuo
Gene Kuo

Written by Gene Kuo

Solutions Architect, AWS CSAA/CDA: microservices, kubernetes, algorithms, Java, Rust, Golang, React, JavaScript…

No responses yet