back
7 min read

A VPN that turns itself off

I wanted a VPN. I did not want to pay for a VPN. I had AWS free tier. This is the story of the worst-named, most-useful shell script I have ever written.

#aws#bash#vpn#weekend-project

I needed a VPN for the most boring reason a person can need a VPN: a service I use is geo-restricted to a region I do not live in, and the official recommendation from that service is “use a VPN”. I bounced through three or four commercial VPNs across a couple of months, ran into the same set of small frictions (subscriptions, capped bandwidth, suspiciously-cheerful privacy claims), and at some point thought fine, I’ll do it myself.

The plan was: spin up the cheapest EC2 instance AWS will rent me, put OpenVPN on it, connect my laptop to it. The plan worked. The plan also gave me an AWS bill that started growing while I slept, because of course an EC2 instance does not stop running just because I stopped using the VPN.

The fix was a shell script. I called it vpn. What I actually want to talk about is the thirty-second-long shape of “small problem, smaller solution.”

The actual problem

I had been leaving the EC2 instance running. That was the bug. Free tier covers a t2.micro for 750 hours per month, which is one continuously-running instance. The instant I left a second t2.micro running for any reason, or kept the VPN one alive past the free-tier window, I started paying for compute I wasn’t using.

The “correct” answer is to stop the instance when you’re not connected and start it when you are. That is two AWS CLI commands and a vague intention to remember. The slightly-less-correct-but-actually-going-to-happen answer is to make connecting and disconnecting from the VPN also start and stop the instance, with no separate step to remember.

So: vpn should be one command. It should leave the instance off by default. It should bring the instance up, wait for it to be reachable, connect, do the VPN thing, and on disconnect stop the instance again. If I never type vpn, the instance never costs me anything.

What the script does

There is one file. It is a bash script. It has been the same shape since the second commit.

When you run it, it asks the AWS API for an EC2 instance tagged Name=openvpn, reads its current state, and branches:

Here is the trick that took me longest to get right. EC2 gives you a new public IP every time the instance starts (unless you pay for an Elastic IP, which I refuse to do for a personal VPN). The OpenVPN client config file (client.ovpn) embeds the server’s address as a literal line: remote 1.2.3.4 1194 udp. If you start the instance and that line is stale, the client just times out trying to reach yesterday’s IP.

So after the instance is up, the script reads the current public IP from the AWS API, edits the client.ovpn file in place to point at it, and then hands the file to openvpn3 to actually connect. When openvpn3 exits, the script stops the EC2 instance again. One command does the whole arc.

The trace is roughly:

$ vpn
Starting OpenVPN server...
Waiting for instance to be running...
Instance is running with public IP: 13.250.xx.xx
Updating OpenVPN client config...
Connecting to VPN...

# ... I do whatever I needed the VPN for ...
# ... I hit Ctrl+C ...

Disconnecting from VPN...
Shutting down OpenVPN server...
Done.

There is nothing clever in there. The cleverness is that I do not have to remember to run a separate “stop the instance” command, so I no longer get to be the kind of person who pays AWS to babysit an idle t2.micro.

Config in a .vpnrc

The first version of the script had my AWS region, instance name, and OpenVPN username/password hardcoded in the file. That was fine when I was the only user, but I wanted to put it on GitHub without committing my credentials, and I also wanted to be able to use a different region from a different machine (laptop in one country, desktop in another).

The fix is the same fix every Unix tool has used for forty years. The script looks for a .vpnrc file in two places, in order:

  1. $HOME/.config/vpn/.vpnrc (preferred, follows the XDG-ish convention)
  2. $HOME/.vpnrc (fallback, for people who do not care about XDG)

If it finds one, it sources it. If it doesn’t, it falls back to defaults baked into the script and warns loudly. The .vpnrc is a regular shell file, so it can be chmod 600’d, gitignored, kept in a personal dotfiles repo if you want, and edited with whatever editor you already have open. No new config format to learn, no parser to write.

INSTANCE_NAME="openvpn"
USERNAME="openvpn"
PASSWORD="your_password"
REGION="ap-southeast-1"
PROFILE_NAME="client.ovpn"

The .vpnrc convention is the whole reason this script is shippable. Without it, every fork would need its own edit. With it, you git clone, drop your .vpnrc in ~/.config/vpn/, symlink the script onto your $PATH, and you are done.

Things I learned by doing this very small thing

Three, and only three.

The AWS CLI is the API. You do not need a SDK, you do not need Terraform, you do not need an account abstraction layer. aws ec2 describe-instances --filters "Name=tag:Name,Values=$INSTANCE_NAME" is a shell command. It returns JSON. You pipe it through --query (which is JMESPath built into the CLI) and you get back exactly the field you want. For a script this small, anything heavier than the CLI is overkill.

set -e and set -u would have caught me three times. I did not add them. The script grew defensively (every variable is checked for emptiness, every command has an explicit error path) instead. That is the wrong answer. Defensive code does the same work as set -euo pipefail, badly, and it makes the script twice as long. If I rewrote this today I would put strict-mode at the top and delete half my error checks.

Hardcoded defaults that look wrong on purpose are useful. The fallback defaults in the script include PASSWORD=my_password. That is intentionally embarrassing. If you forget to set up your .vpnrc and the script tries to connect, OpenVPN will reject the password and the script will fail loudly. You will not be silently connected with someone else’s defaults, because there are no real defaults; the defaults are tripwires.

What this script is not

It is not a VPN provider. It is a way to operate a VPN you already own. The OpenVPN server itself comes from the free OpenVPN Access Server AMI on the AWS Marketplace. Set up the AMI once, tag the instance openvpn, generate the client .ovpn profile, drop the script alongside it, and from then on the script is your front door.

It is not for power users. There is no daemon mode, no systemd integration, no auto-reconnect on flaky wifi. It is a single-user, single-instance, “connect now, disconnect when I’m done” tool. If your needs grew past that, you would write something different, and the kindest thing the script does for you is to not pretend otherwise.

It is not free. AWS bills for the EC2 hours, the data transfer out, and the EBS volume the instance lives on. With the free tier, an t2.micro, and modest usage, it has cost me approximately nothing per month for a year. Without the free tier, it would cost a few dollars a month, which is still cheaper than the cheapest commercial VPN, and the bandwidth is whatever your EC2 region can give you.

Why I am bothering to write about it

Because the script is the smallest useful thing I have written in a long time, and I keep coming back to it. Every laptop I set up, every fresh machine, I drop this script onto it and have a working VPN in under five minutes. It has approximately ten paragraphs of code. It uses no library that wasn’t already on the machine. It will keep working as long as aws ec2 start-instances and openvpn3 session-start keep working, which is to say, indefinitely.

A lot of what we do as engineers is build large systems. Some of what we do is recognise the moments when the right answer is a hundred lines of bash, and have the discipline to ship that and walk away.

The repo is at github.com/jaeaeich/vpn. It’s GPL-3.0. If you decide to use it, please make sure you actually understand which AWS region you are renting an instance in, because nothing in the script will protect you from the geography of your own free tier.