VPN

Draft

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>

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 for 10.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

Wireguard Website

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>" \

June 2, 2025