Greetings! Today, we will be discussing the topic of bastion hosts. This post aims to enlighten you about their purpose in private subnets and guide you on how to implement them using AWS CDK and .NET. Our focus will be on the practical application of AWS CDK with .NET, providing a comprehensive explanation of all essential components needed to deploy a fully functional bastion host.
You can find all code here: https://github.com/erloon/bastion_host
What are we going to do and why?:
When designing your system, it’s crucial to pay attention to security. While I won’t delve into the ins and outs of good security practices, I’d like to focus on one common use case: how to provide secure access to our servers in private subnets without exposing them to the public internet. Within AWS, this can be achieved through at least three different methods:
- VPN: We can configure a VPN on the local environment and our VPC on AWS, enabling access to private subnets.
- AWS Direct Connect: This service allows us to set up a secure physical connection between our local network and AWS.
- Bastion host: We can configure a special EC2 instance that facilitates communication with our resources in a private subnet on AWS. In this post, I will focus on the more cost-effective and simpler-to-configure option: the bastion host. The diagram below provides an example architecture for our solution.
What will we need?
Before starting, ensure you have configured the necessary items from the list below:
- AWS CDK – https://docs.aws.amazon.com/cdk/v2/guide/work-with-cdk-csharp.html
- AWS CLI – https://aws.amazon.com/cli/
- Session Manager Plugin – https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html
- Node.js and npm
- .NET (which you should already have)
Walkthrough:
I will not delve into the details of AWS CDK with .NET. If you’re new to this topic, I highly recommend reading the AWS documentation. In short, it is a framework that allows us to create, deploy, and maintain resources in the AWS cloud, in this case via .NET.
Creating the application:
sealed class Program { public static void Main(string[] args) { var app = new App(); _ = new BastionApp(app, "bastion-app", new StageProps() { Env = new Amazon.CDK.Environment { Account = "027483264577", Region = "us-east-1", } }); app.Synth(); } }
The code above creates an environment called “bastion-host” using the class “BastionApp”, which is inherited from a stage class. This stage class is one of the main building components in AWS CDK, representing an environment in which we will deploy resources. It aids in organizing code and facilitates the management of code across different environments. For instance, we can create distinct stages for development, testing, and production environments. Within one stack, we can construct multiple stages.
public class BastionApp : Stage { public BastionApp(Construct scope, string id, IStageProps props = null) : base(scope, id, props) { var networkStack = new NetworkStack(scope, "network-stack", new StackProps { StackName = "network-stack" }); _ = new BastionStack(scope, "bastion-host-stack", networkStack.Vpc, new StackProps { StackName = "bastion-host-stack" }); } }
Above, we create two stacks. One defines the network we’ll use in this example. The second one is responsible for the rest of our infrastructure components. I will describe them in further detail using examples.
Network:
For our use case, we will need a VPC in which our components will be placed.
public class NetworkStack : Stack { public IVpc Vpc { get; } internal NetworkStack(Construct scope, string id, IStackProps props = null) : base(scope, id, props) { Vpc = new Vpc(this, "vpc", new VpcProps { IpAddresses = IpAddresses.Cidr("10.0.0.0/16"), NatGateways = 1, MaxAzs = 2, SubnetConfiguration = new[] { new SubnetConfiguration { Name = "public", CidrMask = 24, SubnetType = SubnetType.PUBLIC, }, new SubnetConfiguration { Name = "private", CidrMask = 24, SubnetType = SubnetType.PRIVATE_WITH_EGRESS, }, new SubnetConfiguration { Name = "isolated", CidrMask = 24, SubnetType = SubnetType.PRIVATE_ISOLATED, } }, }); Vpc.AddFlowLog("FlowLog"); } }
VPC:
- Cidr: It allocates IPs for a network. In this example, we will have fewer than 65k available IP addresses within a network.
- NatGateways: We add internet access for our components in private subnets. This is one-way access, from our network to the outside. There’s no connection from the internet to the private subnets.
- Subnets: We create three subnets. PUBLIC is reachable from the internet. PRIVATE_WITH_EGRESS is unreachable from the internet but communicates using a NatGateway. PRIVATE_ISOLATED is completely inaccessible from the internet and private network.
- Flow log: This is a network traffic monitoring mechanism in a VPC.
Having established the network, we can proceed to our stack with a bastion host.
Creating a bastion host:
public class BastionStack : Stack { internal BastionStack(Construct scope, string id, IVpc vpc, IStackProps props = null) : base(scope, id, props) { var bastionSecurityGroup = new SecurityGroup(this, "bastion-security-group", new SecurityGroupProps { Vpc = vpc, AllowAllOutbound = true, SecurityGroupName="bastion-security-group", Description = "security group for bastion host" }); bastionSecurityGroup.AddIngressRule(Peer.AnyIpv4(), Port.Tcp(22), "SSH access"); var bastion = new BastionHostLinux(this, "bastion-host", new BastionHostLinuxProps { Vpc = vpc, InstanceName = "bastion-hos", SecurityGroup = bastionSecurityGroup, InstanceType = new InstanceType("t2.micro"), SubnetSelection = new SubnetSelection { SubnetType = SubnetType.PRIVATE_WITH_EGRESS } }); var rdsSecurityGroup = new SecurityGroup(this, "rds-security-group", new SecurityGroupProps { Vpc = vpc, SecurityGroupName = "rds-security-group", Description = "Security group for accessing Aurora", AllowAllOutbound = false }); var userRoot = new Secret(this, "rds-credentials", new SecretProps() { RemovalPolicy = Amazon.CDK.RemovalPolicy.DESTROY, SecretName = "rds-root", GenerateSecretString = new SecretStringGenerator() { SecretStringTemplate = JsonSerializer.Serialize(new { username = "dbroot" }), ExcludePunctuation = true, IncludeSpace = false, ExcludeCharacters = "/!@#$%;^&*\\", GenerateStringKey = "password" }, }); var subnetGroup = new SubnetGroup(this, "subnet-group", new SubnetGroupProps { Vpc = vpc, Description = "subnet group for postgres rds", VpcSubnets = new SubnetSelection { SubnetType = SubnetType.PRIVATE_ISOLATED }, RemovalPolicy = RemovalPolicy.DESTROY }); var rdsCredentials = RdsCredentials.FromSecret(userRoot); var rds = new DatabaseCluster(this, "rds", new DatabaseClusterProps { Engine = DatabaseClusterEngine.AuroraPostgres(new AuroraPostgresClusterEngineProps { Version = AuroraPostgresEngineVersion.VER_15_2 }), ClusterIdentifier = "my-rds", SubnetGroup = subnetGroup, Instances = 1, InstanceProps = new Amazon.CDK.AWS.RDS.InstanceProps { Vpc = vpc, SecurityGroups = new ISecurityGroup[] { rdsSecurityGroup }, InstanceType = InstanceType.Of(InstanceClass.BURSTABLE3, InstanceSize.MEDIUM), VpcSubnets = new SubnetSelection { SubnetType = SubnetType.PRIVATE_ISOLATED } }, DefaultDatabaseName = "mydb", Credentials = rdsCredentials, RemovalPolicy = RemovalPolicy.DESTROY }); rds.Connections.AllowDefaultPortFrom(bastionSecurityGroup, "Allow access from bastion host"); rds.Connections.AllowTo(bastionSecurityGroup, Port.Tcp(5432), "Allow access from bastion host"); } }
This chunk of code might seem overwhelming, but let me explain what it does. To better understand, I’ll provide some context.
As I mentioned earlier, we create a bastion host when we want to communicate from the outside to our services within private subnets. In this example, I’ll use AWS RDS Postgres. We will place it within the isolated subnet, but thanks to the bastion host, we will be able to connect to the database from a local machine. This is probably one of the most common use cases that we encounter daily at work. Having given you a brief overview of what we’re going to do, I’ll describe what the above code does.
-
- We create a security group for the bastion host. Setting AllowAllOutbound to true means all outbound connections from services assigned to this group are permitted.
var bastionSecurityGroup = new SecurityGroup(this, "bastion-security-group", new SecurityGroupProps { Vpc = vpc, AllowAllOutbound = true, SecurityGroupName="bastion-security-group", Description = "security group for bastion host" });
- We add a rule that allows connecting to resources assigned to the security group using SSH from any IPv4 address. SSH uses port 22.
bastionSecurityGroup.AddIngressRule(Peer.AnyIpv4(), Port.Tcp(22), "SSH access");
- We create an EC2 instance for our bastion host. AWS CDK provides the very useful class BastionHostLinux, which includes everything necessary to create a bastion host. There’s no need to install an SSM agent on the instance or define the required policies. AWS CDK does all this for us. We assign our instance to the VPC we created beforehand, assign the security group, and place the EC2 instance within a private subnet.
var bastion = new BastionHostLinux(this, "bastion-host", new BastionHostLinuxProps { Vpc = vpc, InstanceName = "bastion-hos", SecurityGroup = bastionSecurityGroup, InstanceType = new InstanceType("t2.micro"), SubnetSelection = new SubnetSelection { SubnetType = SubnetType.PRIVATE_WITH_EGRESS } });
- To have something to connect to, we create a cluster and a database:
-
- Firstly, we need a user, so we create a secret. This secret will store the username, password, and other data. It’s important to correctly set which characters should be used to generate the password for this user using ExcludeCharacters, as not all characters are allowed. RemovalPolicy determines what to do if we delete the stack.
var userRoot = new Secret(this, "rds-credentials", new SecretProps() { RemovalPolicy = Amazon.CDK.RemovalPolicy.DESTROY, SecretName = "rds-root", GenerateSecretString = new SecretStringGenerator() { SecretStringTemplate = JsonSerializer.Serialize(new { username = "dbroot" }), ExcludePunctuation = true, IncludeSpace = false, ExcludeCharacters = "/!@#$%;^&*\\", GenerateStringKey = "password" }, });
This is what the created secret in AWS Secret Manager looks like.
{ "dbClusterIdentifier": "my-rds", "password": "wwXpk2k6c2m7TxBaikTZ4bOH71ZiYRjr", "dbname": "mydb", "engine": "postgres", "port": 5432, "host": "my-rds.cluster-cigpnv9v5zfk.us-east-1.rds.amazonaws.com", "username": "dbroot" }
- Firstly, we need a user, so we create a secret. This secret will store the username, password, and other data. It’s important to correctly set which characters should be used to generate the password for this user using ExcludeCharacters, as not all characters are allowed. RemovalPolicy determines what to do if we delete the stack.
-
- From the created secret, we now need to create RDS credentials, so AWS CDK will create a user database for us.
var rdsCredentials = RdsCredentials.FromSecret(userRoot);
- We define in which subnets we will place the RDS. In this case, we use PRIVATE_ISOLATED.
var subnetGroup = new SubnetGroup(this, "subnet-group", new SubnetGroupProps { Vpc = vpc, Description = "subnet group for postgres rds", VpcSubnets = new SubnetSelection { SubnetType = SubnetType.PRIVATE_ISOLATED }, RemovalPolicy = RemovalPolicy.DESTROY });
- We need a security group for our RDS. In this case, we set AllowAllOutbound to false.
var rdsSecurityGroup = new SecurityGroup(this, "rds-security-group", new SecurityGroupProps { Vpc = vpc, SecurityGroupName = "rds-security-group", Description = "Security group for accessing Aurora", AllowAllOutbound = false });
- We create a cluster and a database instance. We set the engine version. In our case, we use Aurora Postgres in version 15.2. We assign defined subnets via SubnetGroup. We determine the number of instances, assign the security group and the credentials that we created before.
var rds = new DatabaseCluster(this, "rds", new DatabaseClusterProps { Engine = DatabaseClusterEngine.AuroraPostgres(new AuroraPostgresClusterEngineProps { Version = AuroraPostgresEngineVersion.VER_15_2 }), ClusterIdentifier = "my-rds", SubnetGroup = subnetGroup, Instances = 1, InstanceProps = new Amazon.CDK.AWS.RDS.InstanceProps { Vpc = vpc, SecurityGroups = new ISecurityGroup[] { rdsSecurityGroup }, InstanceType = InstanceType.Of(InstanceClass.BURSTABLE3, InstanceSize.MEDIUM), VpcSubnets = new SubnetSelection { SubnetType = SubnetType.PRIVATE_ISOLATED } }, DefaultDatabaseName = "mydb", Credentials = rdsCredentials, RemovalPolicy = RemovalPolicy.DESTROY });
- We create a security group for the bastion host. Setting AllowAllOutbound to true means all outbound connections from services assigned to this group are permitted.
- An important part to note: now, when we already have an isolated DB, we must allow it to communicate with our bastion host. Communication must be bidirectional – to and from the DB.
rds.Connections.AllowDefaultPortFrom(bastionSecurityGroup, "Allow access from bastion host"); rds.Connections.AllowTo(bastionSecurityGroup, Port.Tcp(5432), "Allow access from bastion host");
That’s all the code we need. Now we have to deploy it.
Deployment:
Initially, we must bootstrap our AWS account. The CDK will create its own internal stack, which will enable it to create ours.
cdk bootstrap
The result should look like this:
Next, we can deploy our stacks. Remember that we have two stacks – the network and the bastion host – and we want to deploy both. So, use –all in the command.
cdk deploy --all
Here’s a part of the result you should receive. This is merely a part of the output for example. As you can see in the screenshot, many more resources are being created (16 in total).
Having already deployed the infrastructure in your AWS account, we can proceed to testing.
Testing:
To connect to our resources in private subnets, we will use the AWS SSM service. It will create a session to our EC2 with a bastion host. Remember that you must have the AWS SSM plugin installed locally and PATH configured in Environment Variables. In a new CMD window, we launch the tunnel to our machine
aws ssm start-session --region eu-west-1 --target i-010f60996be331899 --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters host="my-rdsinstance1.cigpnv9v5zfk.us-east-1.rds.amazonaws.com",portNumber="5432",localPortNumber="5432"
- —target – is the EC2 instance ID
- localPortNumber and portNumber are the ports we will be redirecting. These are set to 5432, which is the default PostgreSQL port.
- host – the RDS endpoint my-rdsinstance1.cigpnv9v5zfk.us-east-1.rds.amazonaws.com
To connect to the database, we use DBreaveror any tool that you prefer. In the host field, we put localhost, as the traffic will be redirected from our local machine to AWS. Also, we use the port we previously defined, 5432. In the field of username and password, we should use the ones from AWS Secret Manager.
Conclusion:
As we’ve seen, it’s relatively easy to set up a secure connection to services in a private subnet using a bastion host and AWS CDK with .NET. This is only one way of achieving a secure connection to your services. Depending on your specific use case and requirements, other methods like VPN or Direct Connect could be more suitable. It’s crucial to consider the best approach for your infrastructure setup and to maintain good security practices.
The bastion host, AWS CDK, and .NET combination makes it easier to manage, deploy, and maintain our AWS resources. Furthermore, it gives us a way to ensure the security of our resources without needing to expose them to the public internet. This allows us to protect our infrastructure while still being able to work efficiently and effectively.
Thank you for reading, and I hope you found this guide useful! If you have any questions or would like further clarification on any points, feel free to leave a comment below.