21 June 2021: Updated to resolve bug where wrong private key was passed to client_certs. This was exposed by service change.
There is a good guide to generating the necessary certificates and manually editing the openvpn
config you can download from the portal in the official docs.
Being a sucker for punishment I wondered if I could automate the process (mainly because I always forget the openssl commands) and secondly it was to be run by someone else (not me) so I wanted it to be as simple as possible.
Warning: Please take time to understand the limitations of this certificate generation before production use (TF State file containing CA private keys) and review the limitations in the TLS provider for Terraform.
So how would this work? First choice is Terraform for the automation. Luckily that has a provider for generating certs! So the flow goes like this:
1. Create the Root CA
2. Use that to generate a client cert
3. Output the client cert for use in the openvpn config file
4. Inject the CA into the Azure VPN configuration and create it
5. Run a script to fetch the Azure VPN OpenVPN configuration file (as this contains the Pre-shared key we don’t set) then inject the client cert we outputted from the Terraform.
6. Connect
One gotcha – The TLS provider only outputs standard pems. Azure VPN requires the CA to be in a specific format outputted by the following command.
openssl x509 -in caCert.pem -outform der | base64 -w0 > caCert.der
As a result there is a null_resource
and local_file
resource to handle this translation.
So all you should need is Terraform, Bash, OpenSSL and Azure CLI – not perfect but it’s the best I could do! (This is currently a working draft – please use with caution).
resource "random_string" "random" { | |
length = 8 | |
special = false | |
upper = false | |
number = false | |
} | |
resource "azurerm_public_ip" "vpn_ip" { | |
name = "vpn-ip" | |
location = var.region | |
resource_group_name = var.resource_group_name | |
domain_name_label = random_string.random.result | |
allocation_method = "Dynamic" | |
tags = var.tags | |
} | |
resource "tls_private_key" "example" { | |
algorithm = "RSA" | |
rsa_bits = "2048" | |
} | |
# Create the root certificate | |
resource "tls_self_signed_cert" "ca" { | |
key_algorithm = tls_private_key.example.algorithm | |
private_key_pem = tls_private_key.example.private_key_pem | |
# Certificate expires after 1 year | |
validity_period_hours = 8766 | |
# Generate a new certificate if Terraform is run within three | |
# hours of the certificate's expiration time. | |
early_renewal_hours = 200 | |
# Allow to be used as a CA | |
is_ca_certificate = true | |
allowed_uses = [ | |
"key_encipherment", | |
"digital_signature", | |
"server_auth", | |
"client_auth", | |
"cert_signing" | |
] | |
dns_names = [ azurerm_public_ip.vpn_ip.domain_name_label ] | |
subject { | |
common_name = "CAOpenVPN" | |
organization = "dev env" | |
} | |
} | |
resource "local_file" "ca_pem" { | |
filename = "caCert.pem" | |
content = tls_self_signed_cert.ca.cert_pem | |
} | |
resource "null_resource" "cert_encode" { | |
provisioner "local-exec" { | |
# Bootstrap script called with private_ip of each node in the clutser | |
command = "openssl x509 -in caCert.pem -outform der | base64 -w0 > caCert.der" | |
} | |
depends_on = [ local_file.ca_pem ] | |
} | |
data "local_file" "ca_der" { | |
filename = "caCert.der" | |
depends_on = [ | |
null_resource.cert_encode | |
] | |
} | |
resource "tls_private_key" "client_cert" { | |
algorithm = "RSA" | |
rsa_bits = "2048" | |
} | |
resource "tls_cert_request" "client_cert" { | |
key_algorithm = tls_private_key.client_cert.algorithm | |
private_key_pem = tls_private_key.client_cert.private_key_pem | |
# dns_names = [ azurerm_public_ip.vpn_ip.domain_name_label ] | |
subject { | |
common_name = "ClientOpenVPN" | |
organization = "dev env" | |
} | |
} | |
resource "tls_locally_signed_cert" "client_cert" { | |
cert_request_pem = tls_cert_request.client_cert.cert_request_pem | |
ca_key_algorithm = tls_private_key.example.algorithm | |
ca_private_key_pem = tls_private_key.example.private_key_pem | |
ca_cert_pem = tls_self_signed_cert.ca.cert_pem | |
validity_period_hours = 43800 | |
allowed_uses = [ | |
"key_encipherment", | |
"digital_signature", | |
"server_auth", | |
"key_encipherment", | |
"client_auth", | |
] | |
} | |
resource "azurerm_virtual_network_gateway" "vpn-gateway" { | |
name = "vpn-gateway" | |
location = var.region | |
resource_group_name = var.resource_group_name | |
type = "Vpn" | |
active_active = false | |
enable_bgp = false | |
sku = "VpnGw1" | |
ip_configuration { | |
name = "vnetGatewayConfig" | |
public_ip_address_id = azurerm_public_ip.vpn_ip.id | |
private_ip_address_allocation = "Dynamic" | |
subnet_id = azurerm_subnet.yoursubnethere.id | |
} | |
vpn_client_configuration { | |
address_space = ["10.1.0.0/16"] | |
vpn_client_protocols = ["OpenVPN"] | |
root_certificate { | |
name = "terraformselfsignedder" | |
public_cert_data = data.local_file.ca_der.content | |
} | |
} | |
} | |
output "client_cert" { | |
value = tls_locally_signed_cert.client_cert.cert_pem | |
} | |
output "client_key" { | |
value = tls_private_key.client_cert.private_key_pem | |
} | |
output "vpn_id" { | |
value = azurerm_virtual_network_gateway.vpn-gateway.id | |
} |
#!/bin/bash | |
set -e | |
# Get vars from TF State | |
VPN_ID=`terraform output vpn_id` | |
VPN_CLIENT_CERT=`terraform output client_cert` | |
VPN_CLIENT_KEY=`terraform output client_key` | |
# Replace newlines with \n so sed doesn't break | |
VPN_CLIENT_CERT="${VPN_CLIENT_CERT//$'\n'/\\n}" | |
VPN_CLIENT_KEY="${VPN_CLIENT_KEY//$'\n'/\\n}" | |
CONFIG_URL=`az network vnet-gateway vpn-client generate –ids $VPN_ID -o tsv` | |
wget $CONFIG_URL -O "vpnconfig.zip" | |
# Ignore complaint about backslash in filepaths | |
unzip -o "vpnconfig.zip" -d "./vpnconftemp"|| true | |
OPENVPN_CONFIG_FILE="./vpnconftemp/OpenVPN/vpnconfig.ovpn" | |
echo "Updating file $OPENVPN_CONFIG_FILE" | |
sed -i "s~\$CLIENTCERTIFICATE~$VPN_CLIENT_CERT~" $OPENVPN_CONFIG_FILE | |
sed -i "s~\$PRIVATEKEY~$VPN_CLIENT_KEY~g" $OPENVPN_CONFIG_FILE | |
cp $OPENVPN_CONFIG_FILE openvpn.ovpn | |
rm -r ./vpnconftemp | |
rm vpnconfig.zip |