How to run OpenVPN from Terraform Code

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:

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.

https://creodias.docs.cloudferro.com/en/latest/openstackcli/How-To-Create-and-Configure-New-Project-on-Creodias-Cloud.html

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.