Homelab VPN and AWS With Pulumi

Homelab VPN and AWS With Pulumi

· Read in about 13 min · (2587 words) ·

TL;DR

I used Pulumi, an open-source, Infrastructure as Code (IaC) focused tool, to build the AWS-side configurations for my Homelab’s VPN into my AWS VPC. Pulumi’s takes an IaC approach by ACTUALLY treating the IaC as “general purpose” code (JavaScript/TypeScript/GO/Python) as opposed to some form of an overlay language. The Pulumi code (program) is now stored in my GitHub repo where I can clone it down, iterate on it, use it in other CI/CD processes (Like CodeStream…hint towards a future blog post). This lets me treat my VPN configuration (at least in AWS) as a declarative object where I declare it what to be - and the platform is managing it for me.

You can see the full Pulumi program on my github here - https://github.com/codyde/pulumi-homelab-aws-vpn

Introduction

I just had a rough go at getting a VPN established between my Homelab and my AWS VPC. I destroyed and manually recreated the VPN constructs at least 20 times trying to tweak settings. It was infuriating. Most of the time I deleted more than what I had to; but at a certain point I was taking the “wrench to the engine” approach and just banging into things hoping it would work.

About ¾ of the way through banging wrenches on the AWS side I had an idea, why not use Pulumi to build an “Infrastructure as Code” model around the VPN configuration? With this in place - I could tweak my settings as needed, run a pulumi up and/or a pulumi update to iterate over my design. Once I had it in a known working state, I’d be able to commit that configuration into GitHub, and have there forever and always. I can also log into the Pulumi portal and observe my stack, and the associated objects - gathering any details I needed. Furthermore, I can continue to build onto this configuration - adding EC2, EKS, or whatever other AWS bells and whistles I wanted to. Sounds absolutely delightful; but what makes Pulumi that different from other tools in the same space? Quite a bit actually.

Infrastructure as Code for the Code Junkie

Now just to put this out there in case anyone gets any crazy ideas around Cody advocating for a different platform other than VMware’s Cloud Automation Services - I’m not. Our platform is designed around creating a self-service, governed, cloud agnostic management platform - which is a very different use case than what Pulumi targets. The key focus here is around solving problems; and solutions often are different based on the use case being targeted. I bias towards focus on platforms that solve problems as opposed to being married to specific products.

Pulumi has a really interesting take on Infrastructure as Code by actually treating it as real code - not an overlay language. This had me hooked because out of the gate when I look at a Pulumi “program” (the collection of classes, methods, interfaces and execution that results in a thing) I just get it. I’ve spent enough time writing more “normal” languages (JavaScript/Typescript/Python/GO) at this point that seeing Infrastructure as Code approached as a traditional native programming language just makes sense to me. Furthermore, they are open source, so I can dig into their code, contribute, and watch the platform and subsequent examples grow! This blog is specific to configuring my “stuff” for AWS - but it’s important to note that Pulumi supports a number of cloud providers and platforms. I’d recommend taking a look around their API documentation for more information!

Take the following example into account (which we’ll decompose further later…)

import * as aws from "@pulumi/aws";

const vpc = new aws.ec2.Vpc("AWSLab", {
    cidrBlock: '10.0.50.0/24',
    enableDnsHostnames: true,
})

export const vpcid = vpc.id

This blob of code is clear in what it’s trying to execute. We’re importing in the AWS module from Pulumi (installed as I would expect any package to be in NodeJS world), were declaring a read only variable called vpc which then translates into a new constructor object around aws.ec2.Vpc. If I head over to the Pulumi docs I can see the model data for the class Vpc object.

Using this documentation, we can see that the constructor object expects the name as a string, a set of args (arguments) from the VpcArgs interface, and then we can also fill in some CustomResourceOptions directly from Pulumi. In my case, i’ve fed in a new cidrBlock for my VPC as well as set it to enableDnsHostnames, to allow AWS to set hostnames for my EC2 workloads that deploy. Finally, I export the resulting id from that vpc object that’s created. This becomes more useful later in our program as we can use the resulting value to.

It’s a pretty slick approach overall - because now I can take this code and include it other applications I might be writing. Beyond that, Pulumi offers quite a few different Let’s take a look at how I used Pulumi to…

  • Build and configure my VPC for connectivity
  • Configure routing and security groups
  • Instantiate my VPN configurations

Building with Pulumi

Getting started with Pulumi is easy. On a Mac with homebrew installed we can simply run a brew install pulumi to install the tool. Pulumi has a great quickstart here that I’m not going to rewrite - it’s really great. Take a look there for how to get up and running.

With Pulumi installed, configure, and my AWS credentials in place - I’m ready to start building my Pulumi program.

Decomposing the items down that I need to do in order to have a functional VPC to setup my VPN, I need to do the following…

  • Create a VPC
  • Add a subnet to the VPC
  • Create an internet gateway so resources can get “out”
  • Establish a routing table, and associate that routing table to our previous resources
  • Create a security group that allows external access and inbound access on specific ports
  • Create my customer gateway so AWS understands “my” side of the connection (the WAN IP on my USG)
  • Create my VPN gateway within AWS and associate it to the VPC we created earlier
  • Create my VPN connection, tying all previous resources together
  • Setting static routes for the subnets in my LAN network that I want access to

I’ll start off with setting up a new Pulumi project

pulumi new

Which will result in the Pulumi “template” picker being displayed. We’ll explore some of this at another time - but lets select aws-javascript for now, and move through our selection prompts. For future reference, I chose us-west-1 for my Amazon region.

Upon completion, our directory will have been populated with a few items. What we care about most at this point is the index.js file. When we open this file, we can see a simple boilerplate of Pulumi code.

"use strict";
const pulumi = require("@pulumi/pulumi");
const aws = require("@pulumi/aws");
const awsx = require("@pulumi/awsx");

// Create an AWS resource (S3 Bucket)
const bucket = new aws.s3.Bucket("my-bucket");

// Export the name of the bucket
exports.bucketName = bucket.id;

If we do a pulumi up -y from here, it will go out and create an s3 bucket for us. We don’t want an S3 bucket. We want a VPC, and a few other things. Let’s switch this around!

Creating Our VPC

In order to get started creating my VPC, I need to delete the content under the final require statement (const awsx = require(“@pulumi/awsx”)) and replace it with something like the below. You can easily see what objects in the interface you would need to adjust to tune for an environment of your own.

const vpc = new aws.ec2.Vpc("homelab", {
    cidrBlock: '10.0.50.0/24',
    enableDnsHostnames: true,
})

const subnet = new aws.ec2.Subnet("hlsub", {
    cidrBlock: '10.0.50.0/27',
    vpcId: vpc.id,
    mapPublicIpOnLaunch: true,
})

In these blocks, we create our VPC, enabling DNS Hostnames as well as create a subnet inside our VPC that’s a /27. We attach this subnet to our VPC using the ID of the previously created object (creating a dependency which Pulumi will manage). Finally, we also enable the value to map public IPs at launch for an EC2 instances we want to run.

At this point if we run the following:

pulumi up -y

Our Pulumi program will execute and build out the first stages of our environment.

If I click the permalink at the bottom of the screen, the Pulumi console will launch and I can see all of my objects that Pulumi is managing the state of. We’ve got our first components of our environment setup, let’s get our communication squared away now!

Adding Our Internet Gateway and Routing for EC2 Instances

We can continue to iterate on this existing build and add new components to our Pulumi program in. Add the code I’ve provided below (modified for your environment) to your existing program…

const igw = new aws.ec2.InternetGateway("hligw",{
    vpcId: vpc.id
})

const rt = new aws.ec2.RouteTable("rt-external", {
    routes: [
        {cidrBlock: "0.0.0.0/0", gatewayId: igw.id}
    ],
    vpcId: vpc.id,
})

const routeTableAssociation = new aws.ec2.RouteTableAssociation("a", {
    routeTableId: rt.id,
    subnetId: subnet.id,
});

Save this file after adding this content. We have added an Internet Gateway and associated it to our previous VPC that we created. We’ve also created a routing table, and added our Internet Gateway to the default route, as well as bound the RouteTable to our VPC. Finally, we’ve created a Routing Table Association where we bind this routing table to the subnet we created earlier within our VPC. This ensures that any machines on this subnet will be able to get “out” to the internet.

Since we saved this file, lets try a new command!

pulumi update -y 

And observe how we iterate on our existing model, expanding it.

As we can see, Pulumi has observed the new declared state of our program and created the new objects. Our Internet Gateway, Route Table, and Route Table Association have been created.

Adding a Security Group

By default all communication with this VPN is currently disallowed. We can use Pulumi to create our security groups for this VPC/Subnet as well. I’ll add the below commands (modified for your environment) to the bottom of our program.

const group = new aws.ec2.SecurityGroup("general-group", {
    egress: [
        { protocol: "-1", fromPort:0, toPort: 0, cidrBlocks: ["0.0.0.0/0"]},
    ],
    ingress: [
        { protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
        { protocol: "tcp", fromPort: 22, toPort: 22, cidrBlocks: ["0.0.0.0/0"] }
    ],
    vpcId: vpc.id
});

This directive creates a new security group named general-group. It creates an egress (outbound) rule that allows all protocols, across all ports to go anywhere. We also create ingress (inbound) rules that allow tcp 80 (http) and 22 (ssh) inbound from any address. We really should lock this down a bit tighter - but this is just for demonstrations sake. Finally, we assign this security group to the VPC that we created at the very start.

Save our file, and execute a pulumi update -y again!

Our group is created and bound to our Amazon VPC. Awesome! Moving along now to finalizing our creation - the VPN constructs!

Creating Our VPN

Just like before we’re going to append the following block to our Pulumi program…

const vpn = new aws.ec2.CustomerGateway("cg-homelab", {
    bgpAsn: 65000,
    ipAddress: '67.123.32.123',
    type: 'ipsec.1',
})

const vpngw = new aws.ec2.VpnGateway("vgw-homelab", {
    vpcId: vpc.id
})

const homelabVpn = new aws.ec2.VpnConnection("vpn-homelab", {
    customerGatewayId: vpn.id,
    type: 'ipsec.1',
    vpnGatewayId: vpngw.id,
    staticRoutesOnly: true,
})

const lablan = new aws.ec2.VpnConnectionRoute("homelab-lab",{
    destinationCidrBlock: "192.168.1.0/24",
    vpnConnectionId: homelabVpn.id,
})

In this set of code, we create our Customer Gateway object as the vpn variable. We set a basic ASN for the Gateway as 65000 and we set our USG’s WAN ip address as the address. Type is set as ipsec.1. We create our VPN gateway, and bind it to our Amazon VPC. We then create our real VPN connection, which takes the 2 previous items and brings them together. We also specify that we are only going to use static routes in our environment. Finally, we add a static route for our homelabs LAN network, and then bind that object to our previous VPN connection.

Save our file and run our update, pulumi update -y

This execution will take a big longer than the others due to the VPN taking a bit of time to come up.

At this point our VPN is configured; however we have a bit of an issue… How do we get out the details we need in order to configure our VPN? At minimum, we need the Tunnel Address and Pre-Shared-Key. Looks like we have one final update to make!

Exporting Data

For our final update, we need to export out our values so we know what our connection details are for the VPN. There are a number of tricks to do this - but by far the easiest is just to export the values. We’ll append the following values to our program and save it for the final time!

exports.ipconn = homelabVpn.tunnel1Address
exports.psk = homelabVpn.tunnel1PresharedKey

Our final program should look like this…

"use strict";
const pulumi = require("@pulumi/pulumi");
const aws = require("@pulumi/aws");
const awsx = require("@pulumi/awsx");

const vpc = new aws.ec2.Vpc("homelab", {
    cidrBlock: '10.0.50.0/24',
    enableDnsHostnames: true,
})

const subnet = new aws.ec2.Subnet("hlsub", {
    cidrBlock: '10.0.50.0/27',
    vpcId: vpc.id,
    mapPublicIpOnLaunch: true,
})

const igw = new aws.ec2.InternetGateway("hligw",{
    vpcId: vpc.id
})

const rt = new aws.ec2.RouteTable("rt-external", {
    routes: [
        {cidrBlock: "0.0.0.0/0", gatewayId: igw.id}
    ],
    vpcId: vpc.id,
})

const routeTableAssociation = new aws.ec2.RouteTableAssociation("a", {
    routeTableId: rt.id,
    subnetId: subnet.id,
});

const group = new aws.ec2.SecurityGroup("webserver-secgrp", {
    egress: [
        { protocol: "-1", fromPort:0, toPort: 0, cidrBlocks: ["0.0.0.0/0"]},
    ],
    ingress: [
        { protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
        { protocol: "tcp", fromPort: 22, toPort: 22, cidrBlocks: ["0.0.0.0/0"] }
    ],
    vpcId: vpc.id
});

const vpn = new aws.ec2.CustomerGateway("cg-homelab", {
    bgpAsn: 65000,
    ipAddress: '67.181.97.174',
    type: 'ipsec.1',
})

const vpngw = new aws.ec2.VpnGateway("vgw-homelab", {
    vpcId: vpc.id
})

const homelabVpn = new aws.ec2.VpnConnection("vpn-homelab", {
    customerGatewayId: vpn.id,
    type: 'ipsec.1',
    vpnGatewayId: vpngw.id,
    staticRoutesOnly: true,
})

const lablan = new aws.ec2.VpnConnectionRoute("homelab-lab",{
    destinationCidrBlock: "192.168.1.0/24",
    vpnConnectionId: homelabVpn.id,
})

exports.ipconn = homelabVpn.tunnel1Address
exports.psk = homelabVpn.tunnel1PresharedKey

Run our pulumi update -y command to get our final update, which will return our connectivity details!

As you can see in the screenshot, we’re now returning the 2 necessary connectivity details for our environment, our Pre-Shared-Key and our Tunnel IP address.

We can use these with our VPN configuration inside of the Ubiquiti USG to create our VPN connection but that’s a blog for another time!

Conclusion

What most impressed me about Pulumi was how quick it was to get up and running, and how logical it was when I started writing my IaC in a “general purpose” language as opposed to some form of an overlay DSL. When I look through the lines of code that make up my VPN configuration now, I don’t imagine going in and tuning every one of these settings - I look at it as telling the platform what configuration I want - and bringing it “up”. When I need to make changes, I can simply update the file and run my update command; or wire it into a CI/CD process to automate.

In a “partner” blog to this one, I’ll show how to finish up your VPN configuration on the USG side! Stay tuned, and make sure to check out the github for this Pulumi program!

Resources

Throughout this blog, I was pretty deep into Pulumi’s API resource documentation which was incredibly helpful. Below is a list of the references I used: