How to use OpenVPN in GitHub Action workflows?
One of our clients requires a VPN connection to access their servers and we want to auto-deploy the app with a GitHub Action workflow. So let's dig in and fix it.
First some credits. Googling for “github actions vpn” gave golfzaptw/action-connect-ovpn
(November 2022 the repository has been removed), an open-source GitHub Action that can set up a VPN connection. That repository contained an issue with a 100% workflow YAML solution. That was my starting point. Many thanks to the involved devs!
I don’t use the action because the log output is not sent to stdout, which makes it hard to debug. And this package is a thin layer around OpenVPN, too thin in my opinion.
The solution
Why finish with the solution when you can also start with it? So here it is. Below I’ll explain every step.
# .github/vpn/client.ovpn
...
ca ca.crt
cert user.crt
key user.key
tls-auth tls.key
auth-user-pass secret.txt
...
# .github/workflows/your-workflow.yml
...
jobs:
deploy:
runs-on: ubuntu-20.04
steps:
- name: Install OpenVPN
run: |
sudo apt-get update
sudo apt-get --assume-yes --no-install-recommends install openvpn
- name: Setup VPN config
run: |
echo "${{ secrets.CA_CRT }}" > ca.crt
echo "${{ secrets.USER_CRT }}" > user.crt
echo "${{ secrets.USER_KEY }}" > user.key
echo "${{ secrets.SECRET_USERNAME_PASSWORD }}" > secret.txt
echo "${{ secrets.TLS_KEY }}" > tls.key
- name: Connect VPN
run: sudo openvpn --config ".github/vpn/config.ovpn" --log "vpn.log" --daemon
- name: Wait for a VPN connection
timeout-minutes: 1
run: until ping -c1 your-server-address; do sleep 2; done
# OR
run: until dig @your-dns-resolver your-server-address A +time=1; do sleep 2; done
- name: Deploy
run: some-command
- name: Kill VPN connection
if: always()
run: |
sudo chmod 777 vpn.log
sudo killall openvpn
- name: Upload VPN logs
uses: actions/upload-artifact@v2
if: always()
with:
name: VPN logs
path: vpn.log
...
Prepare the .ovpn file
It’s possible to use the .ovpn
file as is, but that means you commit credentials. That’s not secure. Adding the whole file as a secret is an option. I don’t prefer that because the file contains more configuration than just the credentials. I want to be able to see those details.
I choose to load the credentials from external files which are filled with secrets. To do that you have to replace some tags in the .ovpn
file. By replacing the tag you tell OpenVPN to read the value from a file. It’s not possible to use command flags, because your credentials could end up in the history of your shell.
Replace:
<ca>
...
</ca>
by ca ca.crt
and add secret CA_CRT
to your repository with the certificate as value (omit <ca>
and </ca>
).
The same with <cert>
, <key>
and <tls-auth>
. If one of them is missing just ignore it.
Replace
<cert>
...
</cert>
by cert user.crt
, with its content in secret USER_CRT
(omit <cert>
and </cert>
).
Replace
<key>
...
</key>
by key user.key
, with its content in secret USER_KEY
(omit <key>
and </key>
).
Replace
<tls-auth>
...
<tls-auth>
by tls-auth tls.key
, with its content in secret TLS_KEY
(omit <tls-auth>
and </tls-auth>
).
The last change, add secret.txt
as a parameter to auth-user-pass
:
auth-user-pass secret.txt
secret.txt
will contain the secret SECRET_USERNAME_PASSWORD
. That secret must contain the username and password of your VPN connection separated by a newline:
username
password
The workflow file
The first step is installing OpenVPN.
- name: Install OpenVPN
run: |
sudo apt-get update
sudo apt-get --assume-yes --no-install-recommends install openvpn
The second step is loading the secrets into files. You changed the .ovpn
file to load the values from those files. Omit the lines with files you didn’t reference.
- name: Setup VPN config
run: |
echo "${{ secrets.CA_CRT }}" > ca.crt
echo "${{ secrets.USER_CRT }}" > user.crt
echo "${{ secrets.USER_KEY }}" > user.key
echo "${{ secrets.SECRET_USERNAME_PASSWORD }}" > secret.txt
echo "${{ secrets.TLS_KEY }}" > tls.key
Now it’s time to start OpenVPN with the prepared .ovpn
file. By using the --daemon
flag OpenVPN is started as a background process. That makes it possible to run the deploy command, which uses the VPN connection. Because the process is daemonized the output is not sent to stdout, by adding the --log
flag the output is sent to a file. More on that in the last step.
- name: Connect VPN
run: sudo openvpn --config ".github/vpn/config.ovpn" --log "vpn.log" --daemon
Before the script can continue it must wait for the VPN connection to be established. For that, we use until
and ping
. until
is the opposite of while
. A very handy feature of Bash. When ping
fails, the script must sleep for 2 seconds and try again, until it is successful. And when it’s successful we know the VPN connection is established.
Replace your-server-address
by the hostname or IP address of the server you want to connect with. The timeout-minutes
property prevents the step from running longer than 2 minutes.
- name: Wait for a VPN connection
timeout-minutes: 2
run: until ping -c1 your-server-address; do sleep 2; done
When you’re dealing with a DNS resolver provided by the VPN server you can’t use ping
. It’s not possible to specify which DNS server ping
must use, with dig
it is. Replace your-server-address
by the hostname of the server and replace your-dns-resolver
by the IP or hostname of the DNS resolver. It will ensure dig
only succeeds when it can connect to the DNS resolver that provides the local IP address.
---
run: until dig @your-dns-resolver your-server-address A +time=1; do sleep 2; done
Now it’s your turn to use the VPN connection and deploy your code. Or whatever you want to do with it.
- name: Deploy
run: some-command
The workflow finishes with killing the VPN connection and making the log file available for step “Upload VPN logs”. Because OpenVPN is running as root, upload-artifacts is not allowed to read the log file. sudo chmod 777
gives the current user access to the log file.
Notice if: always()
, that line ensures the step is always executed, so you always have the log file available as a build artifact.
- name: Kill VPN connection
if: always()
run: |
sudo chmod 777 vpn.log
sudo killall openvpn
- name: Upload VPN logs
uses: actions/upload-artifact@v2
if: always()
with:
name: VPN logs
path: vpn.log
That’s it, happy deploying!