12 min read

Configuring a secure OpenVPN 2.4 server with Docker

UPDATE (2017-12-31): added instructions for running tls-crypt alongside tls-auth.

UPDATE (2017-12-31): added instructions on how to dynamically switch between LZO and LZ4 (v2) depending on the OpenVPN client version (2.3 vs 2.4).

UPDATE (2017-12-02): disabled the block-outside-dns push directive as it is specific to Windows clients.

UPDATE (2017-11-21): expanded instructions on running a PKI outside the running server container and added some comments from @OpenVPN.


I’ve been looking to switch to OpenVPN 2.4 for quite some time now but I knew I would want to explore all the new features it comes with. I have a fairly large VPN client user base for a typical family, but luckily for me they either run macOS or iOS, so it is fairly easy to guarantee that configuration changes won’t cause connection issues when deploying them. I finally had the time to complete this upgrade today.

I am a long time user of the kylemanna/openvpn Docker image and recently it started tracking alpine:latest which means it now offers support for OpenVPN 2.4 out of the box. You don’t have to use Docker to take advantage of this walkthrough, but if you do, then it assumes you have basic knowledge of how it works. At the end of the article there is a reference configuration in case you want to deploy OpenVPN standalone.

OpenVPN 2.4 is full of great changes, but the ones I am more particularly excited about are:

  • Seamless client IP/port floating
  • Data channel cipher negotiation
  • AEAD (GCM) data channel cipher support
  • ECDH key exchange (more on this later)
  • Improved Certificate Revocation List (CRL) processing
  • LZ4 Compression and pushable compression
  • Control channel encryption (–tls-crypt)

Configuring OpenVPN with Docker

The kylemanna/openvpn repository docs are actually very good so I will focus this post on the configuration side.

First, create a data volume container to host your configuration files (I opted for the name openvpn but if you want to use the bundled systemd script you should prefix the name with ovpn-data-, i.e. ovpn-data-<name>) or use a volume mount with a host directory.

❯ docker volume create --name openvpn

If you’d like to edit the container data directly, you can mount it on a ephemeral container to make it accessible:

❯ docker run -v openvpn:/etc/openvpn --rm -it kylemanna/openvpn sh

Generate the server configuration (see the Hardening section below for more details and guidance):

❯ docker run -v openvpn:/etc/openvpn --rm kylemanna/openvpn \
  ovpn_genconfig \
    -u udp://my.domain.com \
    -T 'TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384:TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384:TLS-ECDHE-ECDSA-WITH-AES-256-CBC-SHA384:TLS-DHE-RSA-WITH-AES-256-GCM-SHA384:TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256:TLS-DHE-RSA-WITH-CAMELLIA-256-CBC-SHA256' \
    -C 'AES-256-CBC' \
    -a 'SHA256' \
    -e 'tls-version-min 1.2' \
    -e 'script-security 2' \
    -e 'client-connect /etc/openvpn/compress.sh' \
    -c \
    -b \
    -e 'management 0.0.0.0 5555' \
    -p 'route 192.168.1.0 255.255.255.192' \
    -p 'route 192.168.10.0 255.255.255.0' \
    -p 'dhcp-option DOMAIN home' \
    -n '192.168.1.1'

Note: you can remove the last four lines/arguments (routes, dhcp-option and 192.168.1.1) as those are specific to my network setup. Additionally, the management config allows you to retrieve statistics about the connected clients and may be useful if you use a companion app like OpenVPN Monitor. If you’re not using one, you can remove it too.

Managing your own certificates using a separate volume container

EasyRSA is a simple yet powerful certificate manager. However, one of the most common mistakes is leaving its working material, like the CA private key, on a publicly available server. While it’s usually in an encrypted form, bruteforce attacks on private keys are not unlikely and they have the advantage of going unnoticed for a long time (tip: use DataDog to monitor usage metrics!).

Taking this into consideration, let’s completely remove this problem by generating a second volume to store this information:

❯ docker volume create --name openvpn-pki

Since kylemanna/openvpn requires /etc/openvpn/ovpn_env.sh to exist, let’s generate a dummy one but point it to the correct domain as it will be used for the server certificate common name (CN):

❯ docker run -v openvpn-pki:/etc/openvpn --rm -it kylemanna/openvpn ovpn_genconfig -u udp://my.domain.com

Now, initialize the pki:

❯ docker run -v openvpn-pki:/etc/openvpn --rm -it kylemanna/openvpn ovpn_initpki

Generate a client certificate:

❯ docker run -v openvpn-pki:/etc/openvpn --rm -it kylemanna/openvpn easyrsa build-client-full CLIENTNAME nopass

Bundle the OpenVPN config file into an exportable format that can now be distributed to the client:

❯ docker run -v openvpn-pki:/etc/openvpn --rm -it kylemanna/openvpn ovpn_getclient CLIENTNAME > CLIENTNAME.ovpn

So what do you need from this data volume container in order to run the server separately from the PKI?

  • /etc/openvpn/pki/ca.crt
  • /etc/openvpn/pki/crl.pem (ovpn_run will copy it to /etc/openvpn/ on boot)
  • /etc/openvpn/pki/dh.pem
  • /etc/openvpn/pki/issued/my.domain.com.crt
  • /etc/openvpn/pki/private/my.domain.com.key
  • /etc/openvpn/pki/ta.key

Copy these files from the openvpn-pki volume container to the openvpn volume destination:

❯ docker run -v openvpn-pki:/etc/openvpn --rm kylemanna/openvpn cat /etc/openvpn/pki/crl.pem
❯ docker run -v openvpn:/etc/openvpn --rm kylemanna/openvpn mkdir /etc/openvpn/pki/{issued,private}
❯ for file in /etc/openvpn/pki/crl.pem /etc/openvpn/pki/ca.crt /etc/openvpn/pki/dh.pem /etc/openvpn/pki/issued/my.domain.com.crt /etc/openvpn/pki/private/my.domain.com.key /etc/openvpn/pki/ta.key
do
  docker run -v openvpn-pki:/etc/openvpn --rm kylemanna/openvpn cat "$file" > $(basename "$file")
  docker run -v openvpn:/etc/openvpn --rm -i kylemanna/openvpn sh -c "cat > $file && chmod 600 $file" < $(basename "$file")
  docker run -v openvpn:/etc/openvpn --rm -i kylemanna/openvpn sh -c "chmod 600 $file"
done

Running the server

Run the OpenVPN server in UDP mode mounting the openvpn volume which does not contain the CA private key:

❯ docker run -d -v openvpn:/etc/openvpn \
  -p 1194:1194/udp \
  --name openvpn-udp \
  --cap-add=NET_ADMIN \
  --device=/dev/net/tun \
  --restart=always \
  kylemanna/openvpn

To create a TCP fallback, just set the --proto tcp flag and point it to the same configuration:

❯ docker run -d -v openvpn:/etc/openvpn \
  -p 1194:1194/tcp \
  --name openvpn-tcp \
  --cap-add=NET_ADMIN \
  --device=/dev/net/tun \
  --restart=always \
  kylemanna/openvpn \
  ovpn_run --proto tcp

Internally, the server always listens on port 1194 (UDP or TCP, depending on whether --proto tcp is set or not), so it’s up to the Docker network mapping layer to decide on which port you’d like to run it in. I run both on 1194 (UDP + TCP) and then use port forwarding on my UniFi Security Gateway (USG) to do the NAT mapping. Port 1193 (UDP) on Internet goes to port 1194 UDP and port 443 (TCP) goes to port 1194 TCP, which is harder for firewalls to filter because HTTPS typically uses this port for communication.

Hardening

Introduction

A number of settings can be tweaked to harden OpenVPN’s security. This is a non-exclusive list of ways to harden OpenVPN on different levels, with a focus on OpenVPN 2.4.

Auth (-a / –auth)

The default data channel packet authentication, if enabled, is sha1, so opting for a stronger sha256 is recommended.

If an AEAD cipher mode such as GCM is chosen, the specified auth algorithm is ignored for the data channel and the authentication method of the AEAD cipher is used instead. This does not apply to the TLS Auth digest.

With AEAD, the data channel packet authentication and decryption happens in a single operation (hence why --auth is ignored), while other ciphers require two operations, which affects performance. In addition to this, GCM packets are slightly smaller than other the other ciphers, so combined with the decreased number of cryptographic operations (from two to one) plus packet size, gives better throughput.

TLS Cipher (-T / –tls-cipher)

Since OpenVPN 2.4+, the number of TLS ciphers is extremely limited to the most secure ones. If no TLS ciphers are set, the default value will be DEFAULT:!EXP:!LOW:!MEDIUM:!kDH:!kECDH:!DSS:!PSK:!SRP:!kRSA (see notes below to find out to which TLS ciphers this string translates to).

However, if you’d like to be even stricter, you can reduce that list further. The Diffie-Hellman Ephemeral (DHE) TLS ciphers ensure Perfect Forwarded Secrecy. They are available for clients running OpenVPN 2.3.3+. The faster EC alternatives (ECDHE) are preferred.

Note that for TLS authentication the ECDSA cipher suite will not work if you are using an RSA certificate.

Since OpenVPN limits the number of ciphers one can list (maximum of 256 characters per line), my preferred choices are:

  • TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384
  • TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384
  • TLS-ECDHE-ECDSA-WITH-AES-256-CBC-SHA384
  • TLS-DHE-RSA-WITH-AES-256-GCM-SHA384
  • TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256
  • TLS-DHE-RSA-WITH-CAMELLIA-256-CBC-SHA256

The ChaCha20-Poly1305 version is there for future support on iOS, should Apple introduce it.

As of September 2017, the OpenVPN Connect application for iOS supports the top performer TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384. If disabled on the server (don’t do this!) it will fallback to the non-EC version TLS-DHE-RSA-WITH-AES-256-GCM-SHA384.

If you have to support older clients, you may have to keep the default cipher list by removing the -T parameter when generating the config.

Data Channel Cipher (-C / –cipher)

The default data channel packet encryption is BF-CBC, which is not recommended anymore.

As of OpenVPN 2.4+, cipher negotiation (NCP) can override the cipher specified by --cipher, negotiating between the newer AES-256-GCM (if the client is also on OpenVPN 2.4+) and AES-256-CBC.

It’s important to set the default cipher to AES-256-CBC because if you connect an OpenVPN 2.3 client, since NCP is not available, it will fallback to BF-CBC if this config isn’t set.

As of September 2017, the OpenVPN Connect application for iOS supports NCP which means it will use AES-256-GCM with an OpenVPN 2.4+ server.

Client Certificates X.509 Key Size

A key size of 2048-bit has been used. It should be enough for most use cases, but you can opt for Elliptic Curves.

TLS Auth (–tls-auth)

TLS Auth, an additional layer of HMAC authentication on top of the TLS control channel to mitigate DoS attacks and attacks on the TLS stack, has been used. This is a shared pre-generated static key.

TLS Crypt (–tls-crypt)

TLS Crypt, the newer version of TLS Auth which not only authenticates but also encrypts the TLS control channel, is available on OpenVPN 2.4+. It provides more obfuscation since it makes it harder to identify OpenVPN traffic, as well as additional privacy by hiding the certificate used for the TLS connection.

As of September 2017, the OpenVPN Connect application for iOS does not yet support tls-crypt, so until it does I’ve decided to keep the TLS Auth version. It’s currently under development [1] on the OpenVPN 3 Core library which is the foundational work that the OpenVPN Connect products build on.

The latest OpenVPN Connect application (net.openvpn.connect.ios_1.2.3-0), currently in closed beta testing, bundles support for TLS Crypt. As it is a non-negotiable protocol, it is not possible to conditionally enable it for different clients. However, you can run multiple instances with and without support for it.

You will need to manually remove the tls-auth and key-direction directives from the OpenVPN Server configuration since for now they are enabled by default when using the kylemanna/openvpn image and OpenVPN does not accept command line overrides of values already set in the configuration file.

Running the same configuration with the overrides set.

TLS Auth in UDP mode:

❯ docker run -d -v openvpn:/etc/openvpn \
  -p 1194:1194/udp \
  --name openvpn-udp \
  --cap-add=NET_ADMIN \
  --device=/dev/net/tun \
  --restart=always \
  kylemanna/openvpn \
  ovpn_run --tls-auth /etc/openvpn/pki/ta.key 0

TLS Crypt in UDP mode:

❯ docker run -d -v openvpn:/etc/openvpn \
  -p 1195:1194/udp \
  --name openvpn-udp-tls-crypt \
  --cap-add=NET_ADMIN \
  --device=/dev/net/tun \
  --restart=always \
  kylemanna/openvpn \
  ovpn_run --tls-crypt /etc/openvpn/pki/ta.key

TLS Auth in TCP mode:

❯ docker run -d -v openvpn:/etc/openvpn \
  -p 1194:1194/tcp \
  --name openvpn-tcp \
  --cap-add=NET_ADMIN \
  --device=/dev/net/tun \
  --restart=always \
  kylemanna/openvpn \
  ovpn_run --proto tcp --tls-auth /etc/openvpn/pki/ta.key 0

TLS Crypt in TCP mode:

❯ docker run -d -v openvpn:/etc/openvpn \
  -p 1195:1194/tcp \
  --name openvpn-tcp-tls-crypt \
  --cap-add=NET_ADMIN \
  --device=/dev/net/tun \
  --restart=always \
  kylemanna/openvpn \
  ovpn_run --proto tcp --tls-crypt /etc/openvpn/pki/ta.key

Just make sure to connect to the right ports when using either TLS Auth or TLS Crypt to avoid connection errors.

TLS Minimum Version

If you don’t need to support clients pre OpenVPN 2.3.3, then you can set the minimum TLS version to 1.2 for increased security.

Dynamically enabling LZO/LZ4v2 compression for 2.3 and 2.4 clients

OpenVPN 2.4+ supports the faster LZ4 (v2) compression algorithm in addition to the older LZO. Ideally, an OpenVPN 2.4 client would signal its interest in using LZ4 and an OpenVPN 2.3 client would remain with LZO. However, I was unable to get both algorithms to work simultaneously.

As of September 2017, the OpenVPN Connect application for iOS does not yet support LZ4 compression.

There’s also a newer version of LZ4 named lz4-v2 which is even better, but currently neither the OpenVPN Connect application for iOS nor Viscosity support it.

Turns out that the compress directive is pushable and OpenVPN already offers dynamic configuration for each client that connects to the server. By conditionally enabling LZO/LZ4v2, it is now possible to offer a solution that allows both 2.3 and 2.4 clients to benefit from the best of both worlds. I have adapted the original concept from this thread.

The generated config already points a connect script to /etc/openvpn/compress.sh. Inside this script, paste the following content to conditionally push the right compression directive:

#!/bin/sh

env | grep IV_

if [ "${IV_LZ4:-0}" -eq 1 ]
then
  echo "Enabling LZ4 compression for client $common_name"
  echo "compress lz4-v2" >> $1
  echo "push \"compress lz4-v2\"" >> $1
else
  echo "Enabling LZO compression for client $common_name"
  echo "comp-lzo" >> $1
  echo "push \"comp-lzo\"" >> $1
fi

This will append the echo’ed commands to a temporary config file which the OpenVPN Server reads from. It also print the Enabling… message to its log output so it is easier for debugging which algorithm was chosen for each client.

Confirmed working on the latest OpenVPN Connect application (net.openvpn.connect.ios_1.1.1-212) for iOS and Viscosity for macOS (1.7.6).

Tips

  • Translate TLS cipher string into actual ciphers:

    docker run --rm kylemanna/openvpn openssl ciphers -v 'DEFAULT:!EXP:!LOW:!MEDIUM:!kDH:!kECDH:!DSS:!PSK:!SRP:!kRSA'
    
  • Show available TLS ciphers:

    docker run --rm kylemanna/openvpn openvpn --show-tls
    
  • Show available encryption ciphers:

    docker run --rm kylemanna/openvpn openvpn --show-ciphers
    

Manually create your own certificates using an existing EasyRSA installation

If you already manage a CA using EasyRSA, then you instead of creating a new one and using the kylemanna/openvpn container helpers, you may generate the certificates manually:

  1. Generate the server certificate:

    ❯ ./easyrsa gen-req SERVERNAME nopass
    ❯ ./easyrsa sign-req server SERVERNAME
    
  2. Generate a client certificate:

    ❯ ./easyrsa gen-req CLIENTNAME nopass
    ❯ ./easyrsa sign-req client CLIENTNAME
    
  3. Generate the Certificate Revocation List (CRL)

    ❯ ./easyrsa gen-crl
    

Then, make sure to edit the content of the following by mounting the data volume container:

❯ docker run -v openvpn:/etc/openvpn --rm -it kylemanna/openvpn bash
  1. Populate /etc/openvpn/pki/private/my.domain.com.key.
  2. Populate /etc/openvpn/pki/issued/my.domain.com.crt.
  3. Populate /etc/openvpn/pki/ca.crt.
  4. Generate /etc/openvpn/pki/dh.pem using docker run --rm kylemanna/openvpn sh -c 'openssl dhparam -out dh-long.pem -2 2048 && cat dh-long.pem'.
  5. Generate /etc/openvpn/pki/ta.key using docker run --rm kylemanna/openvpn openvpn --genkey --secret /dev/stdout.

OpenVPN Generated Config Reference

The generated config file (/etc/openvpn/openvpn.conf):

server 192.168.255.0 255.255.255.0
verb 3
key /etc/openvpn/pki/private/my.domain.com.key
ca /etc/openvpn/pki/ca.crt
cert /etc/openvpn/pki/issued/my.domain.com.crt
dh /etc/openvpn/pki/dh.pem
tls-auth /etc/openvpn/pki/ta.key
key-direction 0
keepalive 10 60
persist-key
persist-tun

proto udp
# Rely on Docker to do port mapping, internally always 1194
port 1194
dev tun0
status /tmp/openvpn-status.log

user nobody
group nogroup
tls-cipher TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384:TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384:TLS-ECDHE-ECDSA-WITH-AES-256-CBC-SHA384:TLS-DHE-RSA-WITH-AES-256-GCM-SHA384:TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256:TLS-DHE-RSA-WITH-CAMELLIA-256-CBC-SHA256
cipher AES-256-CBC
auth SHA256
client-to-client

### Route Configurations Below
route 192.168.254.0 255.255.255.0

### Push Configurations Below
push "dhcp-option DNS 192.168.1.1"
push "route 192.168.1.0 255.255.255.192"
push "route 192.168.10.0 255.255.255.0"
push "dhcp-option DOMAIN home"

### Extra Configurations Below
tls-version-min 1.2
script-security 2
client-connect /etc/openvpn/compress.sh
management 0.0.0.0 5555

[1] https://twitter.com/OpenVPN/status/911358857443766272