Table of Contents
OpenVPN: Azure VPN GW
Root CA
Ensure the root certificate has CA:TRUE
and keyCertSign
.
Create an extensions file called root_ca.cnf
:
[ req ]
default_bits = 4096
prompt = no
default_md = sha256
x509_extensions = v3_ca
distinguished_name = dn
[ dn ]
CN = P2S-Root-CA
[ v3_ca ]
basicConstraints = critical,CA:TRUE
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
subjectKeyIdentifier = hash
Generate the root CA key and cert.
Generate private key:
openssl genrsa -out rootCA.key 4096
Generate self-signed root CA certificate with CA extensions:
openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 3650 \
-out rootCA.crt -config root_ca.cnf
Now rootCA.crt
is a proper CA certificate capable of signing client certs.
Extract the base64 string for Terraform:
awk 'BEGIN{p=0} /BEGIN CERTIFICATE/{p=1;next} /END CERTIFICATE/{p=0} p' rootCA.crt | tr -d '\n'
This one-line string is what you paste into root_cert_pem_base64
.
Terraform
Create subnet for VPN GW:
resource "azurerm_subnet" "subnet_gw_dev" {
name = "GatewaySubnet"
resource_group_name = data.azurerm_resource_group.main_rg.name
virtual_network_name = azurerm_virtual_network.product_os_dev.name
address_prefixes = ["10.50.254.0/24"]
}
Create public IP address resource:
resource "azurerm_public_ip" "gw_pip_dev" {
name = "dev-gw-pip"
location = data.azurerm_resource_group.main_rg.location
resource_group_name = data.azurerm_resource_group.main_rg.name
allocation_method = "Static"
sku = "Standard"
}
Create VPN clients CIDR pool:
variable "vpn_client_pool_dev" {
type = list(string)
default = ["172.16.201.0/24"]
description = "Address pool assigned to dev VPN clients."
}
You will need root certificate to provision VPN GW (see Root CA part upper):
variable "root_cert_pem_base64_dev" {
type = string
description = "Base64 of the DER/base64 content of the root certificate (strip PEM headers)."
sensitive = true
}
Create VPN Gateway:
resource "azurerm_virtual_network_gateway" "vpngw" {
name = "dev-vpngw"
location = data.azurerm_resource_group.main_rg.location
resource_group_name = data.azurerm_resource_group.main_rg.name
type = "Vpn"
vpn_type = "RouteBased"
active_active = false
enable_bgp = false
sku = "VpnGw1"
ip_configuration {
name = "vnetGatewayConfig"
public_ip_address_id = azurerm_public_ip.gw_pip_dev.id
private_ip_address_allocation = "Dynamic"
subnet_id = azurerm_subnet.subnet_gw_dev.id
}
vpn_client_configuration {
address_space = var.vpn_client_pool_dev
vpn_client_protocols = ["OpenVPN"]
root_certificate {
name = "p2s-root"
public_cert_data = var.root_cert_pem_base64_dev
}
}
}
Outputs:
output "vpn_gateway_public_ip" {
description = "Public IP of the VPN Gateway to verify connectivity."
value = azurerm_public_ip.gw_pip.ip_address
}
Client Configuration
Make another config file client_cert.cnf
:
[ req ]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = v3_req
[ dn ]
CN = user1
[ v3_req ]
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
Generate VPN user private key:
openssl genrsa -out user1.key 2048
Create CSR with extensions:
openssl req -new -key user1.key -out user1.csr -config client_cert.cnf
Sign with root CA:
openssl x509 -req -in user1.csr -CA rootCA.crt -CAkey rootCA.key \
-CAcreateserial -out user1.crt -days 825 -sha256 \
-extensions v3_req -extfile client_cert.cnf
Export to PFX for VPN client:
openssl pkcs12 -export -inkey user1.key -in user1.crt -out user1.pfx -passout pass:""
Connection
Install OpenVPN:
sudo apt update
sudo apt install openvpn -y
Download Azure VPN client profile:
az network vnet-gateway vpn-client generate \
--resource-group <RG_NAME> \
--name <VPN_GATEWAY_NAME> \
--processor-architecture Amd64 \
--authentication-method EapTls
This returns a JSON with a URL to download a ZIP file (Generic/vpnclientconfiguration.zip).
Inside you’ll find:
- Azure gateway root cert: Generic/VpnServerRoot.cer
- OpenVPN client config: Generic/openvpn.ovpn
Prepare your client certificate. You already created a userX.pfx
. You can use original key
and crt
files or convert pfx
it to PEM format for OpenVPN.
Extract private key:
openssl pkcs12 -in user1.pfx -nocerts -out user1.key -nodes
Extract client certificate:
openssl pkcs12 -in user1.pfx -clcerts -nokeys -out user1.crt
Now you have user1.key
and user1.crt
.
Merge certs into the OpenVPN profile. Edit openvpn.ovpn
and add these lines or replace placeholders if present:
<cert>
-----BEGIN CERTIFICATE-----
... contents of user1.crt ...
-----END CERTIFICATE-----
</cert>
<key>
-----BEGIN PRIVATE KEY-----
... contents of user1.key ...
-----END PRIVATE KEY-----
</key>
If Azure’s .ovpn
references external files (cert, key, ca), you can either keep them separate or inline them like above. The important part is that the client cert + key are included.
Run OpenVPN client
sudo openvpn --config openvpn.ovpn
If everything is correct, you should see logs like:
Initialization Sequence Completed
At that point, you’ll have a tunnel interface tun0
.
Verify VPN connection and test DNS resolution of AKS private endpoint (if deployed):
ip addr show tun0
nslookup <aks_private_fqdn>
Azure Privatelink DNS
By default all VNETs are using 168.63.129.16
DNS resolver. This resolver works only for VNET scoped resources. You must create private DNS resolver.
resource "azurerm_private_dns_resolver" "product_env_pdr" {
name = "product-env-private-dns-resolver"
resource_group_name = data.azurerm_resource_group.main_rg.name
location = data.azurerm_resource_group.main_rg.location
virtual_network_id = azurerm_virtual_network.product_env.id
}
resource "azurerm_private_dns_resolver_inbound_endpoint" "product_env_pdrie" {
name = "product-env-pdrie"
private_dns_resolver_id = azurerm_private_dns_resolver.product_env_pdr.id
location = azurerm_private_dns_resolver.product_env_pdr.location
ip_configurations {
private_ip_allocation_method = "Static"
subnet_id = azurerm_subnet.dns_resolver_env.id
private_ip_address = "10.xxx.xxx.xxx"
}
tags = {
environment = "env"
}
}
Now you have to directly set this 10.xxx.xxx.xxx
IP address in .ovpn
client configuration:
dhcp-option DNS 10.xxx.xxx.xxx
Or you can switch your VNET to use custom DNS server.
SystemD DNS Resolution
By default, OpenVPN on Linux does not push DNS server settings into systemd-resolved
or /etc/resolv.conf
, so your Debian box keeps using your LAN DNS (192.168.0.1 or something else). That resolver doesn’t know about *.privatelink.*.azmk8s.io
, so lookups fail.
Using Debian 12 you should install:
sudo apt install openvpn-systemd-resolved
Restart your DNS resolver service:
sudo systemctl restart systemd-resolved.service
Check your /etc/resolv.conf
file:
nameserver 127.0.0.53
options edns0 trust-ad
search .
Add DNS up and down scripts to your VPN client configuration file:
# DNS
script-security 2
up /etc/openvpn/update-systemd-resolved
down /etc/openvpn/update-systemd-resolved
down-pre
Establish connection and check tun0
DNS servers:
resolvectl status tun0
Now your VPN client will take DNS settings from server.
Split Tunneling
To ensure your Internet traffic is not going through the VPN gateway - see PUSH_REPLY
message in connection log:
2025-09-02 18:02:25 PUSH: Received control message: 'PUSH_REPLY,route 10.1.0.0 255.255.0.0,route-gateway 172.16.201.1,topology subnet,ifconfig 172.16.201.2 255.255.255.0,dhcp-option DNS 10.1.6.10,cipher AES-256-GCM'
Breakdown:
route 10.1.0.0 255.255.0.0
: Only traffic for10.1.0.0/16
(the Azure VNet) will go through the VPN tunnel.route-gateway 172.16.201.1
: That’s the virtual gateway inside the tunnel.- No
redirect-gateway
option present. The server is not telling you to send all internet traffic (0.0.0.0/0) into the VPN. dhcp-option DNS 10.1.6.10
: You should use the DNS server 10.1.6.10 while connected.
If you want to make sure it always stays this way, add this line to your client .ovpn
file:
pull-filter ignore "redirect-gateway"
Enjoy using Azure VPN Gateway!
Revoking a Certificate Pipeline
First you need to calculate thumbprint using .crt
file:
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage:
$0 -i <cert-file> [-p pfx-password]
Supported input types: .pfx/.p12, .pem, .cer/.crt
Outputs: thumbprint (uppercase hex, no spaces)
EOF
exit 2
}
while getopts "i:p:" opt; do
case $opt in
i) IN="$OPTARG" ;;
p) PFX_PASS="$OPTARG" ;;
*) usage ;;
esac
done
if [[ -z "${IN:-}" ]]; then usage; fi
if [[ ! -f "$IN" ]]; then echo "File not found: $IN" >&2; exit 3; fi
# Extract certificate in PEM form then compute SHA1 fingerprint
temp=$(mktemp)
trap 'rm -f "$temp"' EXIT
ext="${IN##*.}"
case "$ext" in
pfx|p12)
if [[ -z "${PFX_PASS:-}" ]]; then
echo "Missing pfx password. Provide with -p." >&2
exit 4
fi
openssl pkcs12 -in "$IN" -nokeys -passin pass:"$PFX_PASS" -out "$temp" 2>/dev/null
;;
pem|cer|crt)
# convert to PEM if necessary
openssl x509 -in "$IN" -out "$temp" 2>/dev/null || cp "$IN" "$temp"
;;
*)
# try to parse as PEM
openssl x509 -in "$IN" -out "$temp" 2>/dev/null || { echo "Unsupported cert format: $ext" >&2; exit 5; }
;;
esac
# compute fingerprint (SHA1), remove colons, uppercase
fingerprint=$(openssl x509 -in "$temp" -noout -fingerprint -sha1 \
| awk -F= '{print $2}' \
| tr -d ':' \
| tr '[:lower:]' '[:upper:]')
# Remove possible whitespace / non-hex characters
echo "$fingerprint" | tr -d '[:space:]'
Now commit this thumbprint to Azure VPN Gateway using Terraform:
vpn_client_configuration {
address_space = var.vpn_client_pool_prod
vpn_client_protocols = ["OpenVPN"]
vpn_auth_types = ["Certificate"]
root_certificate {
name = "p2s-root"
public_cert_data = var.root_cert_pem_base64_prod
}
revoked_certificate {
name = "testuser-and-revocation-cause-comment"
thumbprint = "A054860C0D8B61A8DACE0E6CCF53A80D9EB23754"
}
}
Save all VPN clients
.crt
files or thumbprints somewhere. Without thumbprints you can't revoke client access.
Ensure
rootCA.key
remains confidential. Prevent unauthorized access, avoid duplication, and never commit it to a Git repository.
Identify TUN interfaces
Lists open files associated with the TUN network device, requiring superuser privileges:
sudo lsof /dev/net/tun
Output:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
openvpn 312525 root 4u CHR 10,200 0t72 399 /dev/net/tun
openvpn 323573 root 4u CHR 10,200 0t83 399 /dev/net/tun
Displays detailed information about the processes with PIDs 312525 and 323573.
ps -fp 312525 323573
Result:
UID PID PPID C STIME TTY STAT TIME CMD
root 312525 312524 0 13:06 pts/9 S+ 0:02 openvpn --config ./client1.ovpn
root 323573 323572 0 15:04 pts/11 S+ 0:00 openvpn --config ./client2.ovpn
Wireguard
Server
Install Kernel module (wireguard.ko), tools wg
and wg-quick
:
sudo apt update
sudo apt install wireguard
Generate server keys:
cd /etc/wireguard
umask 077
wg genkey | tee server_privatekey | wg pubkey > server_publickey
Create wireguard server config:
[Interface]
PrivateKey = <private key content>
Address = 172.16.0.1/24
ListenPort = 51820
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE;
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE;
To enable IP forwarding uncomment or add this line in /etc/sysctl.conf
:
net.ipv4.ip_forward=1
Apply IP forwarding and start the wireguard server:
sudo sysctl -p
sudo systemctl enable wg-quick@wg0
sudo systemctl start wg-quick@wg0
Firewall configuration:
sudo ufw allow 51820/udp
Peer
To create a user (peer) connection in WireGuard, you need to configure both the server and the client (user). Below is a complete and clear step-by-step guide for adding a new WireGuard peer (user) connection.
On admin Linux VM:
wg genkey | tee user1_privatekey | wg pubkey > user1_publickey
Create user's wireguard configuration:
[Interface]
PrivateKey = <contents of user1_privatekey>
Address = <peer-ip>
DNS = <dns-server-ip>
[Peer]
PublicKey = <server-public-key>
Endpoint = <server-ip>:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
Also you can set MTU=1200
for using VPN in the L2TP ISP networks.
The peer-ip
should be set with mask /32
. The dns-server-ip
is internal infrastructure DNS server to resolve domain names for services. For example in Azure it will be 168.63.129.16
or it can be your custom infrastructure DNS resolver.
It is better to set AllowedIPs parameter to certain IP ranges you really want to access:
172.16.1.0/24, 10.12.1.0/24, 10.12.2.0/24, 10.12.3.0/24, 10.12.4.0/24, 168.63.129.0/24
Add peer to the server side configuration file /etc/wireguard/wg0.conf
:
[Peer] # username
PublicKey = <contents of user1_publickey>
AllowedIPs = <peer-ip>
sudo systemctl restart wg-quick@wg0
You can monitor the connection on the server:
sudo wg show
NetworkManager
nmcli connection add type wireguard \
con-name wg0 ifname wg0 autoconnect yes \
wireguard.private-key <CLIENT_PRIVATE_KEY>
FortiGate
Peer
sudo apt install network-manager-fortisslvpn
nmcli connection add type vpn con-name <CONNECTION-NAME> \
vpn-type fortisslvpn \
-- \
vpn.data "gateway=<GATEWAY-IP-ADDRESS>:<PORT>,user=<USERNAME>" \
vpn.secrets "password=<PASSWORD>" \
ipv4.never-default yes \
connection.autoconnect no
Debug:
sudo journalctl -u NetworkManager -f
If your gateway certificate validation failed - add trusted-cert
digest parameter:
vpn.data "gateway=<GATEWAY-IP-ADDRESS>:<PORT>,user=<USERNAME>,trusted-cert=<CERT-DIGEST>" \