How to Use Pulumi with AWS and Twingate

This guide provides step-by-step instructions on automating Twingate deployments with Pulumi on Amazon Web Services.

Prerequisites

This guide assumes the following (on top of prerequisites for all Pulumi guides):

  • You have an AWS account setup and an account with relevant access to create and delete resources
  • You are using an Operating System supporting Bash

Getting Started

First let’s setup a new folder for our Pulumi code to reside:

mkdir twingate_pulumi_aws_demo
cd twingate_pulumi_aws_demo

Next we can use a template to set up the environment:

pulumi new typescript

You are then prompted to enter some values, these can be whatever you want:

project name: (twingate_pulumi_aws_demo)
project description: (A minimal TypeScript Pulumi program) Using Pulumi to deploy Twingate on AWS
Created project 'twingate_pulumi_aws_demo'
Please enter your desired stack name.
To create a stack in an organization, use the format <org-name>/<stack-name> (e.g. `acmecorp/dev`).
stack name: (dev) demo

Once the project is setup, open the directory in your favorite code editor.

Authentication with AWS

To allow us to create resources in AWS, you must be authenticated. As with other cloud providers there are a few different ways to authenticate. For simplicity we will be storing our credentials as environment variables. This also ensures these values are kept local on your machine:

(The following commands can be used to store environment variables on OSX / Linux)

export AWS_ACCESS_KEY_ID=<YOUR_ACCESS_KEY_ID>
export AWS_SECRET_ACCESS_KEY=<YOUR_SECRET_ACCESS_KEY>
export AWS_REGION=<YOUR_AWS_REGION>

Setup & Configuration

Pulumi requires a Twingate API key along with the name of the Twingate tenant we will be configuring.

You can generate a new API key from your Twingate account following the instructions here.

The name of your Twingate tenant is the prefix on your URL, i.e. mycorp in mycorp.twingate.com.

Let’s store these two values in the Pulumi configuration:

pulumi config set twingate:apiToken YOUR_TOKEN --secret
pulumi config set twingate:network democompany

Generating a key pair and setting a public key

In order to connect to our test VM, we will be using an SSH key pair for authentication, below is an example of how to do this, for more information please see this link.

Let’s use the ssh-keygen command in a terminal session:

ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (/Users/username]/.ssh/id_rsa): /Users/username/.ssh/aws_id_rsa
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /Users/username/.ssh/aws_id_rsa
Your public key has been saved in /Users/username/.ssh/aws_id_rsa.pub
The key fingerprint is:
[Your unique fingerprint]
The key's randomart image is:
[Your unique image]

Next we want to store the public key as a Pulumi configuration value:

cat /Users/username/.ssh/aws_id_rsa.pub | pulumi config set publicKey

You can view and check the the Pulumi config values by running:

Pulumi config

Building our Configuration

One of the main reasons people prefer Pulumi over other IaC tools is Pulumi’s flexibility around the programming languages you can use. For this example we will be using TypeScript/JavaScript to configure our resources.

This guide assumes you have nodejs installed on your device, you can check this by running the following:

node -v

If you do not have node installed please refer to the installation instructions here.

Installing Node Modules

As we are using Typescript, Pulumi will use modules to build the infrastructure for both AWS and Twingate, therefore we need to install these modules.

npm install @pulumi/aws @twingate/pulumi-twingate

Now we have everything ready to start writing our code.

Writing the configuration

Open the index.ts file and import the modules we will be using:

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import { Connector } from "@pulumi/aws/mskconnect";
import * as twingate from "@twingate/pulumi-twingate";

First we will configure the public key to allow us to access the test server.

let config = new pulumi.Config();
let publicKey = config.require("publicKey");
const deployer = new aws.ec2.KeyPair("deployer", {
publicKey: publicKey,
});

Next we will create the networking components:

// VPC
const mainvpc = new aws.ec2.Vpc("mainVPC", {
cidrBlock: "10.0.0.0/16",
});
// Subnet
const mainSubnet = new aws.ec2.Subnet("mainSubnet", {
vpcId: mainvpc.id,
cidrBlock: "10.0.1.0/24",
tags: {
Name: "Main",
},
});
// Gateway
const gw = new aws.ec2.InternetGateway("mainGW", {
vpcId: mainvpc.id,
tags: {
Name: "main",
},
});
// Routing table
const mainRouteTable = new aws.ec2.RouteTable("mainRT", {
vpcId: mainvpc.id,
routes: [
{
cidrBlock: "0.0.0.0/0",
gatewayId: gw.id,
},
],
tags: {
Name: "example",
},
});
// Routing table association
const routeTableAssociation = new aws.ec2.RouteTableAssociation("mainRTA", {
subnetId: mainSubnet.id,
routeTableId: mainRouteTable.id,
});

Next we will setup the Twingate components:

// Twingate Setup
const tgawsNetwork = new twingate.TwingateRemoteNetwork("twingate-aws-demo", {
name: "twingate-aws-demo",
});
const tgawsConnector = new twingate.TwingateConnector("twingateConnector", { remoteNetworkId: tgawsNetwork.id });
const tgawsConnectorTokens = new twingate.TwingateConnectorTokens("twingateConnectorTokens", {
connectorId: tgawsConnector.id,
});
const tggroup = new twingate.TwingateGroup("twingateGroup", {
name: "aws demo group",
});

Then we build out the virtual machines; this will locate the most recent AMI for us to use:

const size = "t2.micro";
const ami = pulumi.output(
aws.ec2.getAmi({
filters: [
{
name: "name",
values: ["twingate/images/hvm-ssd/twingate-amd64-*"],
},
],
owners: ["617935088040"], // This owner ID is Amazon
mostRecent: true,
}),
);

We can now specify the startup script to install and setup Twingate on the Virtual Machine which will be hosting the Connector:

const user_data = pulumi.all([tgawsConnectorTokens.accessToken, tgawsConnectorTokens.refreshToken]).apply(
([accessToken, refreshToken]) => `#!/bin/bash
sudo mkdir -p /etc/twingate/
HOSTNAME_LOOKUP=$(curl http://169.254.169.254/latest/meta-data/local-hostname)
EGRESS_IP=$(curl https://checkip.amazonaws.com)
{
echo TWINGATE_URL="https://${twingate.config.network}.twingate.com"
echo TWINGATE_ACCESS_TOKEN="${accessToken}"
echo TWINGATE_REFRESH_TOKEN="${refreshToken}"
echo TWINGATE_LOG_ANALYTICS=v1
echo TWINGATE_LABEL_HOSTNAME=$HOSTNAME_LOOKUP
echo TWINGATE_LABEL_EGRESSIP=$EGRESS_IP
echo TWINGATE_LABEL_DEPLOYEDBY=tg-pulumi-aws-ec2
} > /etc/twingate/connector.conf
sudo systemctl enable --now twingate-connector
`,
);

We can now create the virtual machines:

const webserver = new aws.ec2.Instance("demo-server", {
instanceType: size,
associatePublicIpAddress: false,
keyName: deployer.id,
ami: ami.id,
subnetId: mainSubnet.id,
tags: {
Name: "Demo Server",
},
});
const tgserver = new aws.ec2.Instance("twingate-connector", {
instanceType: size,
associatePublicIpAddress: true,
ami: ami.id,
subnetId: mainSubnet.id,
userData: user_data,
tags: {
Name: "Twingate-Connector",
},
});

And finally the Twingate resource:

const tgresource = new twingate.TwingateResource("resource", {
name: "aws demo server",
address: webserver.privateIp,
remoteNetworkId: tgawsNetwork.id,
accessGroups: [
{
groupId: tggroup.id,
},
],
protocols: {
allowIcmp: true,
tcp: {
policy: "RESTRICTED",
ports: ["22", "80"],
},
udp: {
policy: "ALLOW_ALL",
},
},
});

The complete index.ts file should look like this:

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import { Connector } from "@pulumi/aws/mskconnect";
import * as twingate from "@twingate/pulumi-twingate";
let config = new pulumi.Config();
let publicKey = config.require("publicKey");
const deployer = new aws.ec2.KeyPair("deployer", {
publicKey: publicKey,
});
// VPC
const mainvpc = new aws.ec2.Vpc("mainVPC", {
cidrBlock: "10.0.0.0/16",
});
// Subnet
const mainSubnet = new aws.ec2.Subnet("mainSubnet", {
vpcId: mainvpc.id,
cidrBlock: "10.0.1.0/24",
tags: {
Name: "Main",
},
});
// Gateway
const gw = new aws.ec2.InternetGateway("mainGW", {
vpcId: mainvpc.id,
tags: {
Name: "main",
},
});
// Routing table
const mainRouteTable = new aws.ec2.RouteTable("mainRT", {
vpcId: mainvpc.id,
routes: [
{
cidrBlock: "0.0.0.0/0",
gatewayId: gw.id,
},
],
tags: {
Name: "example",
},
});
// Routing table assoc
const routeTableAssociation = new aws.ec2.RouteTableAssociation("mainRTA", {
subnetId: mainSubnet.id,
routeTableId: mainRouteTable.id,
});
// Twingate Setup
const tgawsNetwork = new twingate.TwingateRemoteNetwork("twingate-aws-demo", {
name: "twingate-aws-demo",
});
const tgawsConnector = new twingate.TwingateConnector("twingateConnector", { remoteNetworkId: tgawsNetwork.id });
const tgawsConnectorTokens = new twingate.TwingateConnectorTokens("twingateConnectorTokens", {
connectorId: tgawsConnector.id,
});
const tggroup = new twingate.TwingateGroup("twingateGroup", {
name: "aws demo group",
});
// Find latest AMI
const size = "t2.micro";
const ami = pulumi.output(
aws.ec2.getAmi({
filters: [
{
name: "name",
values: ["twingate/images/hvm-ssd/twingate-amd64-*"],
},
],
owners: ["617935088040"], // This owner ID is Amazon
mostRecent: true,
}),
);
// Startup script
const user_data = pulumi.all([tgawsConnectorTokens.accessToken, tgawsConnectorTokens.refreshToken]).apply(
([accessToken, refreshToken]) => `#!/bin/bash
sudo mkdir -p /etc/twingate/
HOSTNAME_LOOKUP=$(curl http://169.254.169.254/latest/meta-data/local-hostname)
EGRESS_IP=$(curl https://checkip.amazonaws.com)
{
echo TWINGATE_URL="https://${twingate.config.network}.twingate.com"
echo TWINGATE_ACCESS_TOKEN="${accessToken}"
echo TWINGATE_REFRESH_TOKEN="${refreshToken}"
echo TWINGATE_LOG_ANALYTICS=v1
echo TWINGATE_LABEL_HOSTNAME=$HOSTNAME_LOOKUP
echo TWINGATE_LABEL_EGRESSIP=$EGRESS_IP
echo TWINGATE_LABEL_DEPLOYEDBY=tg-pulumi-aws-ec2
} > /etc/twingate/connector.conf
sudo systemctl enable --now twingate-connector
`,
);
// Demo server
const demoserver = new aws.ec2.Instance("demo-server", {
instanceType: size,
associatePublicIpAddress: false,
keyName: deployer.id,
ami: ami.id,
subnetId: mainSubnet.id,
tags: {
Name: "Demo Server",
},
});
// Twingate Connector VM
const tgserver = new aws.ec2.Instance("twingate-connector", {
instanceType: size,
associatePublicIpAddress: true,
ami: ami.id,
subnetId: mainSubnet.id,
userData: user_data,
tags: {
Name: "Twingate-Connector",
},
});
// Twingate resource
const tgresource = new twingate.TwingateResource("resource", {
name: "aws demo server",
address: demoserver.privateIp,
remoteNetworkId: tgawsNetwork.id,
accessGroups: [
{
groupId: tggroup.id,
},
],
protocols: {
allowIcmp: true,
tcp: {
policy: "RESTRICTED",
ports: ["22", "80"],
},
udp: {
policy: "ALLOW_ALL",
},
},
});

Running and applying the configuration

You can run the following to check your Pulumi config:

pulumi preview

And once you are happy you can run:

pulumi up

Once you select YES you should see all the infrastructure being built!

You will see the resources in both Twingate and AWS being created. This will take a few minutes so now is a good time to reward yourself with a ☕️

The only thing left to do is give your Twingate user access to the new group that has been created.

Testing access

Then you can test you can reach the demo server over the private Twingate connection by browsing to the private server IP:

ssh -i ~/.ssh/aws_id_rsa ubuntu@10.0.1.164
The authenticity of host '10.0.1.264 (10.0.1.164)' can't be established.
ECDSA key fingerprint is SHA256:VFOTKrZvzwK5hgCwCZApGCDrrzFp3ISTaZoKaBG9218.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.0.1.214' (ECDSA) to the list of known hosts.

You should then see the VM logged in:

Welcome to Ubuntu 20.04.5 LTS (GNU/Linux 5.15.0-1019-aws x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage
System information as of Tue Sep 6 06:39:14 UTC 2022
System load: 0.0 Processes: 102
Usage of /: 19.6% of 7.57GB Users logged in: 0
Memory usage: 21% IPv4 address for ens5: 10.0.1.214
Swap usage: 0%
0 updates can be applied immediately.
The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.
ubuntu@ip-10-0-1-164:~$

We can now destroy the infrastructure by using the following command:

pulumi down

Last updated 3 months ago