https://unsplash.com/photos/Zdf3zn5XXtU

Learn The Best Way How To Deploy and Connect To a Bastion Host in AWS EC2 With Terraform

introduction to Bastion host

Ever heard of the buzzword Bastion Host? What if I show you a secure and efficient way to access your instances in a private subnet? Follow me on this comprehensive guide that shows how to deploy and connect to a private AWS EC2 instance with Bastion hosts. Also, we will deploy every resource with Terraform. It is easier that way in my opinion.

What is Bastion Host?

A bastion host, often called a jump box or jump server, acts as a hardened gateway. It grants authorized individuals remote access to private instances within her AWS Virtual Private Cloud (VPC). It also acts as an extra layer of protection for your critical infrastructure from potential security threats.

Although deploying and connecting to a bastion host can seem daunting, do not fret, we’ll take advantage of Terraform’s capabilities to automate the setup and walk through each step of the process together.

Terraform is an infrastructure-as-code tool. It simplifies the provisioning and management of AWS resources for consistent, repeatable environments.

The objective

After completing this tutorial, you will know how to configure and deploy a bastion host. You will also know how to establish secure connections to your private instance from the Bastion host server. This is good if you have a team of Cloud Engineers that wants to SSH into a server. You can also use a Bastion host instead of a VPN to connect to your server.

If you are ready, join me, and let’s unlock the immense potential of deploying a bastion host with Terraform. See how to create a single EC2 instance here.

Create the Terraform provider resources
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
}

# Configure the AWS Provider
provider "aws" {
  region = "us-east-1"
  access_key = var.access_key
  secret_key = var.secret_key
}

# Create a VPC
resource "aws_vpc" "oxla-vpc" {
  cidr_block = "10.0.0.0/16"
}

As a best practice, it is good to pass sensitive details as variables in Terraform. Therefore, we stored our secret and access keys in AWS Secrets Manager. Then we will assign it using the auth.auto.tfvars. Create the file in the root of your Terraform directory and pass it to the secret manager’s reference.

vim auth.auto.tfvars.

Pass the config below and reference the aws secrets manager

access_key = your/secret/manager/reference
secret_key = your/secret/manager/reference

Now we need to assign the variables we referenced in the providers.tf file. So, go ahead and create a terraform variable.tf file. Paste the variable in the file. You also create variables for other resources to keep your terraform code clean.

variable "secret_key" {
    type = string
    default = ""
}

variable "access_key" {
  type = string
  default = ""
}

Create the 2 subnet resources. One private and one public and assign each different cidr_block. You can use the subnet calculator to get the particular subnet for you.

resource "aws_subnet" "oxla-public-subnet" {
  vpc_id     = aws_vpc.oxla-vpc.id
  cidr_block = "10.0.1.0/24"
  availability_zone = "us-east-1a"

  tags = {
    Name = "public-subnet"
  }
}

resource "aws_subnet" "oxla-private-subnet" {
  vpc_id     = aws_vpc.oxla-vpc.id
  cidr_block = "10.0.128.0/24"
  availability_zone = "us-east-1a"

  tags = {
    Name = "oxla-private-subnet"
  }
}

The next infrastructure for our Bastion system is to create two route tables. One will be associated with the public, and the other with the private subnet. The public route table will also be attached to an Internet Gateway.

resource "aws_route_table" "oxla-route-table" {
  vpc_id = aws_vpc.oxla-vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.oxla-internet-gateway.id
  }

  tags = {
    Name = "oxla-route-table"
  }
}

resource "aws_route_table_association" "oxla-route-table-association" {
  subnet_id      = aws_subnet.oxla-public-subnet.id
  route_table_id = aws_route_table.oxla-route-table.id
}

resource "aws_route_table" "oxla-private-route-table" {
  vpc_id = aws_vpc.oxla-vpc.id
  tags = {
    Name = "oxla-private-route-table"
  }
}

resource "aws_route_table_association" "oxla-private-route-table-association" {
  subnet_id      = aws_subnet.oxla-private-subnet.id
  route_table_id = aws_route_table.oxla-private-route-table.id
}

We will also create the Internet Gateway to route public traffic into our public subnet where will have to deploy a bastion host.

resource "aws_internet_gateway" "oxla-internet-gateway" {
  vpc_id = aws_vpc.oxla-vpc.id

  tags = {
    Name = "oxla-internet-gateway"
  }
}

Also, we will create 2 Security Groups for our bastion system. One of the security groups will be open to public traffic on ports 80, 443, and 22 for SSH. The second security group will only allow the traffic from the first security group.

resource "aws_security_group" "oxla-bastion-security-group" {
  name        = "bastion-allow-public-traffic"
  description = "Allow TLS inbound traffic"
  vpc_id      = aws_vpc.oxla-vpc.id

  ingress {
    description      = "Public-Traffic-TLS"
    from_port        = "443"
    to_port          = "443"
    protocol         = "tcp"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  ingress {
    description      = "Public-Traffic-TLS"
    from_port        = "22"
    to_port          = "22"
    protocol         = "tcp"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

    ingress {
    description      = "Public-Traffic-TLS"
    from_port        = "80"
    to_port          = "80"
    protocol         = "tcp"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  tags = {
    Name = "allow_tls"
  }
}

#The private security group open only to the public security group. Take note of how I referenced the security group in the ingress

resource "aws_security_group" "oxla-ec2-instance-security-group" {
  name        = "ec2-instance-security-group"
  description = "Allow TLS inbound traffic"
  vpc_id      = aws_vpc.oxla-vpc.id

  ingress {
    description      = "Public-Traffic-TLS"
    from_port        = "443"
    to_port          = "443"
    protocol         = "tcp"
    security_groups = [ aws_security_group.oxla-bastion-security-group.id]

  }

  ingress {
    description      = "Public-Traffic-TLS"
    from_port        = "22"
    to_port          = "22"
    protocol         = "tcp"
    security_groups = [ aws_security_group.oxla-bastion-security-group.id]
  }

   ingress {
    description      = "Public-Traffic-TLS"
    from_port        = "80"
    to_port          = "80"
    protocol         = "tcp"
    security_groups = [ aws_security_group.oxla-bastion-security-group.id]
  } 

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  tags = {
    Name = "allow-bastion-host"
  }
}

Now we are done with the first part of the setup, the second step to deploy the bastion host is to create the servers that will be involved.

deploy the aws private ec2 instance and the bastion host

At this stage, we will create two servers. One is our private server. The private server does not allow public traffic access to it. This is because it has no public IP address attached to it. This is the reason why we need to deploy the second server to serve as the bastion or jump host. The second server will allow public traffic because there is a public IP address attached to it. Also, we will only be able to connect to the private EC2 instance from the public instance using the private IP of the private instance.

In case that seems like a lot of information, Terraform simplifies it. See the configuration in the code block. So, create an instance.tf file and paste the code below inside.

resource "aws_instance" "oxla-ec2-instance" {
  ami                                  = data.aws_ami.ubuntu.id
  instance_type                        = "t2.micro"     #free tier eligible
  availability_zone                    = "us-east-1a"
  instance_initiated_shutdown_behavior = "terminate"
  #key_name                             = aws_key_pair.oxla-keypair.id
  monitoring                           = true
  subnet_id                            = aws_subnet.oxla-private-subnet.id
  tenancy                              = "default"
  ebs_optimized                        = false
  associate_public_ip_address          = false
  iam_instance_profile                 = aws_iam_instance_profile.oxla-instance-profile.id

  ebs_block_device {
    device_name = "/dev/sda1"
    volume_size = 20
    delete_on_termination = true
    volume_type = "gp2"
  }
  vpc_security_group_ids = [aws_security_group.oxla-ec2-instance-security-group.id]

  tags = {
    Name        = "oxla-private-server"
    Environment = "lab"
  }

}



#Create the bastion host server and enable the public IP address.

resource "aws_instance" "oxla-bastion-host" {
  ami                                  =  data.aws_ami.ubuntu.id
  instance_type                        = "t2.micro"     #free tier eligible
  availability_zone                    = "us-east-1a"
  instance_initiated_shutdown_behavior = "terminate"
  key_name                             = aws_key_pair.oxla-keypair.id
  monitoring                           = true
  subnet_id                            = aws_subnet.oxla-public-subnet.id
  tenancy                              = "default"
  ebs_optimized                        = false
  associate_public_ip_address          = true
  iam_instance_profile                 = aws_iam_instance_profile.oxla-instance-profile.id

  ebs_block_device {
    device_name = "/dev/sda1"
    volume_size = 20
    delete_on_termination = true
    volume_type = "gp2"
  }
  vpc_security_group_ids = [aws_security_group.oxla-ec2-instance-security-group.id]

  tags = {
    Name        = "oxla-server"
    Environment = "lab"
  }

}

We will also create the AMI resources inside the instance.tf file. Terraform will supply use the image to create the EC2 instance once we run it. Therefore, we do not need to manually copy one from our AWS console.

#The AMI will be used to create the EC2 instance without you having to check from the console manually.

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"] # Canonical
}
Create Keypair and IAM Instance Profile

We will also create the public key that the public server will use for SSH. I generated one on my local machine with the command ssh-keygen. Then I copied it into the terraform project root directory. Create these resources inside the instance.tf file.

resource "aws_key_pair" "oxla-keypair" {
  key_name   = "oxla-keypair"
  public_key = file("./oxla-demo-bastion-keys.pub")   #" "
}
Create iam role and instance profile

Now we need to create an IAM role for our instance. We will also use the data resources to get the template for the IAM role.

resource "aws_iam_role" "oxla-instance-role" {
  name               = "oxla_role"
  path               = "/"
  assume_role_policy = data.aws_iam_policy_document.oxla-assume-role.json
}

resource "aws_iam_instance_profile" "oxla-instance-profile" {
  name = "test_profile"
  role = aws_iam_role.oxla-instance-role.name
}

data "aws_iam_policy_document" "oxla-assume-role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

We will also create an IAM instance profile for our bastion host.

resource "aws_iam_instance_profile" "oxla-instance-profile" {
  name = "test_profile"
  role = aws_iam_role.oxla-instance-role.name
}

data "aws_iam_policy_document" "oxla-assume-role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

Once that is done, run the terraform init to initialize the terraform configuration. Thereafter, plan the resources to see how many terraform will create for you, and then apply the resources. Remember to enter YES at the prompt in the apply command.

terraform plan
terraform to plan bastion host deploy
terraform to plan bastion host deploy
terraform apply
terraform apply yes bastion host deploy
terraform apply for bastion host to  deploy
Connect To Private EC2 from Bastion Host

Now that you are done with creating the network infrastructure to connect to a private EC2 instance from a Bastion host, we will continue to use the private key of the Bastion host to connect to the private instance from our local machine. We will first need to add the SSH keys to our local machine’s host keys. That will make the keys available whenever we need to access it. We will use SSH Agent Forwarding to achieve this. SSH Agent is a certificate manager for SSH. It holds SSH in memory

Navigate to the key you generated in the early step of this tutorial, then add it to your local machine’s host keys.

#for mac client
ssh-add -K [private-key.ppk]

#for linux client
ssh-add -L [private-key.ppk]

#for windows, if you use the Windows Sublinux System (WSL).
ssh-add -L [private-key.ppk]   
#Then enable the Agent-Forwarding on the system

#If you use putty, mobaxterm or other windows ssh client,
#Simply enable the Agent Forwarding option of the client when adding the private keys to the client.

After you added the SSH key to the SSH-Agent, you can now connect to the Bastion host in the public subnet first. Run the SSH command to get into the server.

ssh ec2user@Public-IP-Adress

Once you established the connection for the Bastion host, you can then connect to the private instance from there. Use the user name and private IP address of the private EC2 instance to connect. You will not need to add the keys for the private IP anymore.

ssh ec2user@Private-IP-Address

And there you have it. You have connected to a private instance that you deploy from another public instance called the Bastion Host or Jump Host.

conclusion

In conclusion, deploying and connecting to a Bastion host in AWS EC2 with Terraform enhances security and streamlines remote access. Terraform simplifies the process, ensuring consistent environments. By following the steps provided, you can configure and deploy your own Bastion host, safeguarding sensitive data and systems. The Bastion host acts as an additional layer of defense, allowing secure access without compromising infrastructure integrity.

Embrace this opportunity to leverage the benefits of a Bastion host and Terraform in AWS EC2, empowering innovation and achieving business goals.


Posted

in

,

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *