How to run OpenVPN from Terraform Code
by Mateusz Ślaski, Sales Support Engineer, CloudFerro
The following article covers the subject of creating an OpenVPN instance allowing secure access to the OpenStack network through a VPN tunnel.
We will build step-by-step code containing templates for:
- network for our environment
- necessary security group with rules
- virtual machine instance with automatically configured VPN
- dedicated Object Storage for VPN configuration persistence
Instructions and the way of executing are defined in a way that allows you to learn, by the way, some Terraform and OpenStack functions such as:
- splitting TF code into multiple files
- using TF Workspaces
- using TF templates
- launching instances configured with Cloud-Init as TF and OpenStack "user-data"
Prerequisites / Preparation
Before you start, please read the documents:
- "Generating and Authorizing Terraform using a Keycloak User on CREODIAS" https://creodias.docs.cloudferro.com/en/latest/openstackdev/Generating-and-authorizing-Terraform-using-Keycloak-user-on-Creodias.html
- "How to Generate or Use Application Credentials via CLI on CREODIAS": https://creodias.docs.cloudferro.com/en/latest/cloud/How-to-generate-or-use-Application-Credentials-via-CLI-on-Creodias.html We will use them to authenticate the Terraform OpenStack provider.
- Additionally, you may review:
- Official Terraform documentation: https://developer.hashicorp.com/terraform
- Terraform OpenStack Provider documentation: https://registry.terraform.io/providers/terraform-provider-openstack/openstack/latest/docs
You may also, if necessary, refresh some details about the manual management of: projects, key-pairs, networks, and security groups:
- https://creodias.docs.cloudferro.com/en/latest/networking/Generating-a-SSH-keypair-in-Linux-on-Creodias.html
- https://creodias.docs.cloudferro.com/en/latest/cloud/How-to-create-key-pair-in-OpenStack-Dashboard-on-Creodias.html
- https://creodias.docs.cloudferro.com/en/latest/networking/How-to-Import-SSH-Public-Key-to-OpenStack-Horizon-on-Creodias.html
- https://creodias.docs.cloudferro.com/en/latest/cloud/How-to-use-Security-Groups-in-Horizon-on-Creodias.html
- https://creodias.docs.cloudferro.com/en/latest/networking/How-to-create-a-network-with-router-in-Horizon-Dashboard-on-Creodias.html
Step 1 - Select or create project
You may use the default project in your tenant (usually named "cloud_aaaaa_bb") or create a new one by following the document mentioned below.
Step 2 - Install Terraform
There are various ways to install Terraform, some of which are described in the documentation mentioned in the "Preparation" chapter.
If you are using Ubuntu 22.04 LTS or newer and you do not need the latest Terraform release (for the Terraform OpenStack provider, it is not necessary), the easiest way is to use Snap.
First, install Snap
sudo apt install snapd
Then install Terraform
sudo snap install terraform --classic
Step 3 - Allowing access to project from Terraform
Now create Application Credentials.
Please follow the mentioned document: "How to Generate or Use Application Credentials via CLI on CREODIAS": https://creodias.docs.cloudferro.com/en/latest/cloud/How-to-generate-or-use-Application-Credentials-via-CLI-on-Creodias.html
When you have them ready, save them in a secure location (i.e., password manager) and fill in the variables in the "my_first_vpn.tfvars" file.
Step 4 - Prepare configuration files
As Terraform operates on the entire directory and automatically merges all "*.tf" files into one codebase, we may split our Terraform code into a few files to manage the code more easily.
- main.tf
- variables.tf
- resources.tf
Additionally, we need two other files:
- open_vpn_user_data.yaml
- my_first_vpn.tfvars
File 1 - main.tf
In this file, we keep the main definitions for Terraform and the OpenStack provider.
terraform {
required_version = ">= 0.14.0"
required_providers {
openstack = {
source = "terraform-provider-openstack/openstack"
version = "~> 1.51.1"
}
}
}
provider "openstack" {
auth_url = var.auth_url
region = var.region
user_name = "${var.os_user_name}"
application_credential_id = "${var.os_application_credential_id}"
application_credential_secret = "${var.os_application_credential_secret}"
}
data "openstack_networking_router_v2" "external_router" {
name = "${var.tenant_project_name}"
}
File 2 - variables.tf
In this file, we will keep variable definitions.
# Section provideing data necessary to connect and authenticate to OpenStack
variable os_user_name {
type = string
}
variable tenant_project_name {
type = string
}
variable os_application_credential_id {
type = string
}
variable os_application_credential_secret {
type = string
}
variable "auth_url" {
type = string
default = "https://keystone.cloudferro.com:5000"
}
variable "region" {
type = string
validation {
condition = contains(["WAW3-1", "WAW3-2", "FRA1", "FRA1-2", "WAW4-1"], var.region)
error_message = "Proper region names are: WAW3-1, WAW3-2, FRA1, FRA1-2, WAW4-1"
}
}
#Our friendly name for entire environment.
variable "env_id" {
type = string
}
# Key-pair created in previous steps
variable env_keypair {
type = string
}
variable internal_network {
type = string
default = "192.168.11.0"
validation {
condition = can(regex("^(10\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|192\\.168\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))$", var.internal_network))
error_message = "Provide proper network address for class 10.a.b.c or 192.168.a.b"
}
}
variable internal_netmask {
type = string
default = "/24"
validation {
condition = can(regex("^\\/(1[6-9]|2[0-4])$", var.internal_netmask))
error_message = "Please use mask size from /16 to /24."
}
}
variable external_network {
type = string
default = "10.8.0.0"
validation {
condition = can(regex("^(10\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|192\\.168\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))$", var.external_network))
error_message = "Provide proper network address for class 10.a.b.c or 192.168.a.b"
}
}
variable "vpn_image" {
type = string
default = "Ubuntu 22.04 LTS"
}
variable "vpn_version" {
type = string
}
variable "vpn_flavor" {
type = string
default = "eo2a.xlarge"
}
variable cert_country {
type = string
}
variable cert_province {
type = string
}
variable cert_city {
type = string
}
variable cert_org {
type = string
}
variable cert_email {
type = string
}
variable cert_orgunit {
type = string
}
File 3 - resources.tf
This is the most significant file where definitions of all entities and resources are stored.
resource "random_password" "password" {
length = 24
special = true
min_upper = 8
min_lower = 8
min_numeric = 6
min_special = 2
override_special = "-"
keepers = {
tenant = var.tenant_project_name
}
}
resource "openstack_identity_ec2_credential_v3" "object_storage_ec2_key" {
region = var.region
}
resource "openstack_objectstorage_container_v1" "backup_repo" {
name = "${var.env_id}-vpnaac-backup"
region = var.region
}
resource "openstack_networking_secgroup_v2" "sg_openvpn" {
name = "${var.env_id}-sg-openvpn"
description = "OpenVPN UDP port"
}
resource "openstack_networking_secgroup_rule_v2" "sg_openvpn_rule_1" {
direction = "ingress"
ethertype = "IPv4"
protocol = "udp"
port_range_min = 1194
port_range_max = 1194
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.sg_openvpn.id
}
data "openstack_networking_router_v2" "project-external-router" {
name = "${var.tenant_project_name}"
}
resource "openstack_networking_network_v2" "env_net" {
name = "${var.env_id}-net"
}
resource "openstack_networking_subnet_v2" "env_net_subnet" {
name = "${var.env_id}-net-subnet"
network_id = openstack_networking_network_v2.env_net.id
cidr = "${var.internal_network}${var.internal_netmask}"
gateway_ip = cidrhost("${var.internal_network}${var.internal_netmask}", 1)
ip_version = 4
enable_dhcp = true
}
resource "openstack_networking_router_interface_v2" "router_interface_external" {
router_id = data.openstack_networking_router_v2.external_router.id
subnet_id = openstack_networking_subnet_v2.env_net_subnet.id
}
resource "openstack_networking_floatingip_v2" "vpn_public_ip" {
pool = "external"
}
resource "openstack_compute_instance_v2" "vpn_server" {
name = "${var.env_id}-vpn-server"
image_name = "Ubuntu 22.04 LTS"
flavor_name = var.vpn_flavor
security_groups = [
"default",
"allow_ping_ssh_icmp_rdp",
openstack_networking_secgroup_v2.sg_openvpn.name
]
key_pair = var.env_keypair
depends_on = [
openstack_networking_subnet_v2.env_net_subnet
]
user_data = "${templatefile("./vpn_user_data.yaml",
{
env_id = "${var.env_id}"
region_name = "${var.region}"
archive_url = "${join("", ["https://s3.", lower(var.region), ".cloudferro.com"])}"
archive_name = "${openstack_objectstorage_container_v1.backup_repo.name}"
archive_access = "${openstack_identity_ec2_credential_v3.object_storage_ec2_key.access}"
archive_secret = "${openstack_identity_ec2_credential_v3.object_storage_ec2_key.secret}"
vpn_version = "${var.vpn_version}"
vpn_net_external = "${var.external_network}"
vpn_net_internal = "${var.internal_network}"
vpn_net_internal_mask = "${cidrnetmask("${var.internal_network}${var.internal_netmask}")}"
vpn_public_ip = "${openstack_networking_floatingip_v2.vpn_public_ip.address}"
cert_pass = "${random_password.password.result}"
cert_country = "${var.cert_country}"
cert_province = "${var.cert_province}"
cert_city = "${var.cert_city}"
cert_org = "${var.cert_org}"
cert_email = "${var.cert_email}"
cert_orgunit = "${var.cert_orgunit}"
}
)}"
network {
uuid = openstack_networking_network_v2.env_net.id
fixed_ip_v4 = cidrhost("${var.internal_network}${var.internal_netmask}", 3)
}
}
resource "openstack_compute_floatingip_associate_v2" "vpn_ip_associate" {
floating_ip = openstack_networking_floatingip_v2.vpn_public_ip.address
instance_id = openstack_compute_instance_v2.vpn_server.id
}
File 4 - vpn_user_data.yaml
This is a template of user-data that would be injected into our VPN instance. This file contains configuration and package installation directives and a script responsible for VPN configuration.
#cloud-config
package_update: true
package_upgrade: true
packages:
- openssh-server
- openvpn
- easy-rsa
- iptables
write_files:
- path: /run/scripts/prepare_vpn
permissions: '0700'
content: |
#!/bin/bash
echo "${archive_access}:${archive_secret}" > /home/eouser/.passwd-s3fs-archive
chmod 600 /home/eouser/.passwd-s3fs-archive
REPO_NAME=${archive_name}
if ! [[ -z "$REPO_NAME" ]]
then
mkdir /mnt/archive
echo "/usr/local/bin/s3fs#$REPO_NAME /mnt/archive fuse passwd_file=/home/eouser/.passwd-s3fs-repo,_netdev,allow_other,use_path_request_style,uid=0,umask=0000,mp_umask=0000,gid=0,url=${archive_url},endpoint=default 0 0" >> /etc/fstab
mount /mnt/repo
fi
ENV_ID="${env_id}"
CLIENT_NAME="client-$ENV_ID"
VPN_BACKUP=/mnt/archive/openvpn-backup-$ENV_ID.tar
VPN_VERSION=`cat /mnt/archive/$ENV_ID-vpn-version`
if [[ -f $VPN_BACKUP ]] && [[ "$VPN_VERSION" = "${vpn_version}" ]]
then
tar xf $VPN_BACKUP -C /etc openvpn
tar xf $VPN_BACKUP -C /home/eouser $CLIENT_NAME.ovpn
else
# ---- Server cerificates preparation
make-cadir /etc/openvpn/$ENV_ID-easy-rsa
cd /etc/openvpn/$ENV_ID-easy-rsa
echo "set_var EASYRSA_REQ_COUNTRY \"${cert_country}\"" >> ./vars
echo "set_var EASYRSA_REQ_PROVINCE \"${cert_province}\"" >> ./vars
echo "set_var EASYRSA_REQ_CITY \"${cert_city}\"" >> ./vars
echo "set_var EASYRSA_REQ_ORG \"${cert_org}\"" >> ./vars
echo "set_var EASYRSA_REQ_EMAIL \"${cert_email}\"" >> ./vars
echo "set_var EASYRSA_REQ_OU \"${cert_orgunit}\"" >> ./vars
./easyrsa init-pki
CERT_PASS="${cert_pass}"
(echo "$CERT_PASS"; echo "$CERT_PASS"; echo "$ENV_ID-vpn") | ./easyrsa build-ca
SRV_NAME="server-$ENV_ID"
(echo $SRV_NAME) | ./easyrsa gen-req $SRV_NAME nopass
./easyrsa gen-dh
# (echo "yes"; echo "$CERT_PASS"; echo "$CERT_PASS") | ./easyrsa sign-req server $SRV_NAME
(echo "yes"; echo "$CERT_PASS") | ./easyrsa sign-req server $SRV_NAME
cp /etc/openvpn/$ENV_ID-easy-rsa/pki/dh.pem /etc/openvpn/
cp /etc/openvpn/$ENV_ID-easy-rsa/pki/ca.crt /etc/openvpn/
cp /etc/openvpn/$ENV_ID-easy-rsa/pki/issued/$SRV_NAME.crt /etc/openvpn/
cp /etc/openvpn/$ENV_ID-easy-rsa/pki/private/$SRV_NAME.key /etc/openvpn/
# ---- Client certificates
(echo $CLIENT_NAME) | ./easyrsa gen-req $CLIENT_NAME nopass
# (echo "yes"; echo "$CERT_PASS"; echo "$CERT_PASS") | ./easyrsa sign-req client $CLIENT_NAME
(echo "yes"; echo "$CERT_PASS") | ./easyrsa sign-req client $CLIENT_NAME
CA_CLIENT_CONTENT=`cat /etc/openvpn/$ENV_ID-easy-rsa/pki/ca.crt`
CRT_CLIENT_CONTENT=`cat /etc/openvpn/$ENV_ID-easy-rsa/pki/issued/$CLIENT_NAME.crt`
KEY_CLIENT_CONTENT=`cat /etc/openvpn/$ENV_ID-easy-rsa/pki/private/$CLIENT_NAME.key`
cd /etc/openvpn/
openvpn --genkey secret ta.key
TA_CLIENT_CONTENT=`cat /etc/openvpn/ta.key`
# ---- Server configuration
TMP_SRV_CONF="/home/eouser/$SRV_NAME.conf"
SRV_CONF="/etc/openvpn/$SRV_NAME.conf"
cat <<EOF > $TMP_SRV_CONF
port 1194
dev tun
ca ca.crt
cert $SRV_NAME.crt
key $SRV_NAME.key
dh dh.pem
server ${vpn_net_external} 255.255.255.0
push "route ${vpn_net_internal} ${vpn_net_internal_mask}"
ifconfig-pool-persist /var/log/openvpn/ipp.txt
duplicate-cn
keepalive 10 120
tls-auth ta.key 0
cipher AES-256-CBC
persist-key
persist-tun
status /var/log/openvpn/openvpn-status.log
verb 3
explicit-exit-notify 1
EOF
cp $TMP_SRV_CONF $SRV_CONF
rm $TMP_SRV_CONF
# --- Client config generation ---
CLIENT_CONF="/home/eouser/$CLIENT_NAME.ovpn"
cat <<EOF > $CLIENT_CONF
client
dev tun
proto udp
remote ${vpn_public_ip} 1194
resolv-retry infinite
nobind
persist-key
persist-tun
remote-cert-tls server
tls-auth ta.key 1
key-direction 1
cipher AES-256-CBC
verb 3
<ca>
$CA_CLIENT_CONTENT
</ca>
<cert>
$CRT_CLIENT_CONTENT
</cert>
<key>
$KEY_CLIENT_CONTENT
</key>
<tls-auth>
$TA_CLIENT_CONTENT
</tls-auth>
EOF
chown eouser.eouser $CLIENT_CONF
# Backup config to archive
tar cf $VPN_BACKUP -C /etc openvpn
tar rf $VPN_BACKUP -C /home/eouser $CLIENT_NAME.ovpn
echo ${vpn_version} > /mnt/archive/$ENV_ID-vpn-version
fi
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
sysctl -p
cat <<EOF > /etc/systemd/system/iptables_nat.service
[Unit]
Requires=network-online.target
[Service]
Type=simple
ExecStart=iptables -t nat -A POSTROUTING -s ${vpn_net_external}/24 -o eth0 -j MASQUERADE
[Install]
WantedBy=multi-user.target
EOF
systemctl enable iptables_nat.service
systemctl start iptables_nat.service
systemctl start openvpn@$SRV_NAME
systemctl enable openvpn@$SRV_NAME
date > /home/eouser/server_ready.txt
runcmd:
- ["/bin/bash", "/run/scripts/prepare_vpn"]
File 5 - my_first_vpn.tfvars
In this file we will provide values of Terraform variables
- os_user_name - Enter your username used to authenticate in CREODIAS here.
- tenant_project_name - Name of the project selected or created in step 1.
- os_application_credential_id
- os_application_credential_secret
- region - CloudFerro Cloud region name. Allowed values are: WAW3-1, WAW3-2, FRA1-2, WAW4-1.
- env_id - Name that will prefix all resources created in OpenStack.
- vpn_keypair - Keypair available in OpenStack. You will use it to log in via SSH to the VPN machine to get the Client configuration file.
- network - Network class for our environment. Any of 10.a.b.c or 192.168.b.c.
- netmask - Network mask. Allowed values: /24, /16.
- vpn_flavor - VM flavor for our VPN.
- vpn_version - It may be any string. If this value doesn't change, then recreating the VPN instance will download the backup configuration from the object archive, and users may still connect with the VPN client config file. However, if you change this variable and reapply Terraform on your environment, the VPN configuration will be recreated, and a new VPN client configuration file has to be delivered to users.
Some of the included data, such as credentials, are sensitive. So if you save this in a Git repository, it is strongly recommended to add the file pattern "*.tfvars" to ".gitignore".
You may also add to this file the variable "external_network" however.
Do not forget to fill or update variable values in the content below.
os_user_name = "user@domain"
tenant_project_name = "cloud_aaaaa_b"
os_application_credential_id = "enter_ac_id_here"
os_application_credential_secret = "enter_ac_secret_here"
region = ""
env_id = ""
env_keypair = ""
internal_network = "192.168.1.0"
internal_netmask = "/24"
external_network = "10.8.0.0"
vpn_flavor = "eo2a.large"
vpn_version = "1"
cert_country = ""
cert_city = ""
cert_province = ""
cert_org = ""
cert_orgunit = ""
cert_email = ""
Files listed above except "my_first_vpn.tfvars" will be available in the CloudFerro GitHub repository.
Step 5 - Activate Terraform workspace
A very useful Terraform functionality is workspaces. Using workspaces, you may manage multiple environments with the same code.
Create and enter a directory for our project by executing commands:
mkdir tf_vpn cd tf_vpn
To initialize Terraform, execute:
terraform init
Then, check workspaces:
terraform workspace list
As an output of the command above, you should see output like this:
* default
As we want to use a dedicated workspace for our environment, we must create it. To do this, please execute the command:
terraform workspace new my_first_vpn
Terraform will create a new workspace and switch to it.
Step 6 - Validate configuration
To ensure the prepared configuration is valid, do two things.
First, execute the command:
terraform validate
Then execute Terraform plan:
terraform plan -var-file=my_first_vpn.tfvars
You should get as an output a list of messages describing resources that would be created.
Step 7 - Provisioning of resources
To provision all resources, execute the command:
terraform apply -var-file=my_first_vpn.tfvars
As with the plan command, you should get as an output a list of messages describing resources that would be created, but now finished with a question if you want to apply changes.
You must answer with the full word "yes".
You will see a sequence of messages about the status of provisioning.
Please remember that when the above sequence successfully finishes, the VPN is still not ready!
A script configuring the VPN is still running on the VPN server.
The process of automatically signing certificates with the easy-rsa package may take several minutes.
We recommend waiting about 5 minutes.
Step 8 - Obtaining VPN Client Configuration File
First, you have to find the public IP address of the created VPN server.
You may check it in the OpenStack Horizon GUI or use the OpenStack CLI command `openstack server list`. But if Terraform is at hand, then use it and execute: terraform state show openstack_networking_floatingip_v2.vpn_public_ip
In the command output, look for the row with "address".
Test the connection to the VPN server by executing the command:
ssh -i .ssh/PRV_KEY eouser@VPN_SRV_IP
You will be asked to confirm the server fingerprint. Type "yes".
When you successfully get a connection, check if the VPN was automatically configured.
Execute the command:
ls -l
You should see two files:
- client-ENV_ID.ovpn - Contains client configuration with all necessary keys and certificates.
- server_ready.txt - Contains the date and time when the configuration script finished its work.
Logout by typing:
exit
And copy the configuration file to your computer by executing:
scp -i .ssh/PRV_KEY eouser@VPN_SRV_IP:/home/eouser/client-ENV_ID.ovpn
Step 9 - Configure OpenVPN Client to Test Connection
Create an Ubuntu instance in another project, or in another region.
Associate an FIP to it.
Log in with SSH.
And install OpenVPN:
sudo apt install openvpn
Exit this instance and copy the client configuration file by executing:
scp -i .ssh/PRIV_KEY client-ENV_ID.ovpn eouser@CLIENT_IP:/home/eouser
Log in to this instance again and execute:
sudo cp client-vpnaas.ovpn /etc/openvpn/client.conf cd /etc/openvpn sudo openvpn --config client.conf
You should see a sequence of log messages finished with "Initialization Sequence Completed". If you connect to this machine with another terminal and execute:
ip a
You should see 3 network interfaces:
- lo
- eth0
- tun0
Return to terminal where test session of OpenVPN was started. Stop it by pressing "Ctr+C".
To make VPN connection persistent execute:
sudo systemctl enable openvpn@client
sudo systemctl start openvpn@client
That is all. From this moment, you may access any resources within the created network via the VPN tunnel.
Some remarks and possible improvements:
- Obtained client file may be used also on Windows computers. To connect them. Download community OpenVPN client from https://openvpn.net/community-downloads/ and import this file.
- Proposed configuration allow to connect separate computers to single VPN server. If you need bridge connection network to network, then please check OpenVPN documentation to tune this solution.
- All client computers connected use the same certificates and keys to connect. If you need user based access end client identification than also please tune it to your needs.