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:
Generate the server certificate:
❯ ./easyrsa gen-req SERVERNAME nopass ❯ ./easyrsa sign-req server SERVERNAME
Generate a client certificate:
❯ ./easyrsa gen-req CLIENTNAME nopass ❯ ./easyrsa sign-req client CLIENTNAME
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
- Populate
/etc/openvpn/pki/private/my.domain.com.key
. - Populate
/etc/openvpn/pki/issued/my.domain.com.crt
. - Populate
/etc/openvpn/pki/ca.crt
. - Generate
/etc/openvpn/pki/dh.pem
usingdocker run --rm kylemanna/openvpn sh -c 'openssl dhparam -out dh-long.pem -2 2048 && cat dh-long.pem'
. - Generate
/etc/openvpn/pki/ta.key
usingdocker 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