jaeaeich
writing

A VPN I only pay for while I'm using it

, 6 min read

  • #aws
  • #bash
  • #vpn
  • #weekend-project
contents
  1. The actual problem
  2. What the script does
  3. Config in a .vpnrc
  4. What a very small script taught me
  5. What it isn’t
  6. Why write about it at all

I needed a VPN for the most boring reason there is: a service I use is geo-restricted to a region I don’t live in, and its official advice is “use a VPN”. I went through three or four commercial VPNs over a couple of months, hit the same small annoyances every time (subscriptions, capped bandwidth, suspiciously cheerful privacy promises), and eventually thought: fine, I’ll do it myself.

The plan was to rent the cheapest EC2 instance AWS offers, put OpenVPN on it and connect my laptop, and it worked, right up to the point where I noticed an AWS bill that had grown while I slept, because an EC2 instance doesn’t stop running just because I stopped using the VPN.

The fix turned out to be a 140-line bash script called vpn, and this post is about that script and about how small the right fix for a small problem is allowed to be.

The actual problem

The whole bug was that I kept leaving the instance running.

The textbook answer is to stop the instance when you’re done and start it again when you need it, which amounts to two AWS CLI commands and a vague intention to remember them, and I have a poor track record with vague intentions. The answer that actually works is to make starting and stopping the instance part of using the VPN, so that there is nothing separate to remember.

That meant vpn had to be a single command with the instance off by default: run it once and it starts the instance, waits for it and connects, run it again and it shuts the instance down. If I never type vpn, the instance costs me nothing but its disk.

What the script does

It’s one bash file that has kept the same shape since the first afternoon. When you run it, it asks AWS for the instance tagged Name=openvpn, reads its state and branches:

  • Stopped: start it, poll every ten seconds until AWS says it’s running, then connect.
  • Running: print Did you forget to stop EC2? ooh the bill!!! and shut it down.

That second message prints every time I stop the VPN, including the times I didn’t forget, and I have never changed it. It’s still sitting on jaeaeich/vpnvpn.sh:79, mostly because it keeps me honest.

The part that took longest to get right was the IP address, because EC2 gives an instance a new public IP every time it starts unless you pay for an Elastic IP, which I refuse to do for a personal VPN. The OpenVPN client profile (client.ovpn) has the server’s address written into it as a plain line, remote 1.2.3.4 1194 udp, so if you start the instance with a stale line the client sits there timing out against yesterday’s IP.

Once the instance is up, then, the script asks AWS for the new IP, finds the old one in the profile, swaps them and hands the profile to openvpn3, which is really the heart of the whole thing:

vpn.sh
INSTANCE_PUBLIC_IP=$(aws ec2 describe-instances \
--region "$REGION" \
--instance-ids "$INSTANCE_ID" \
--query "Reservations[].Instances[].PublicIpAddress" \
--output text)
PREV_IP=$(grep remote "$PATH_TO_OVPN_CLIENT_CONFIG" | head -n 1 | awk '{print $2}')
sed -i "s/$PREV_IP/$INSTANCE_PUBLIC_IP/g" "$PATH_TO_OVPN_CLIENT_CONFIG"
printf "$USERNAME\n$PASSWORD\n" | openvpn3 session-start --config "$PATH_TO_OVPN_CLIENT_CONFIG"

A day with it looks roughly like this:

a day with vpn
$ vpn
Waiting for the instance to start...
Current instance state: pending
Current instance state: running
Public IP Address: 13.250.xx.xx
(openvpn3 connects, and I do whatever I needed the VPN for)
$ vpn
Did you forget to stop EC2? ooh the bill!!!
Shutting down OpenVPN server...
Waiting for the instance to stop...
Current instance state: stopped
The instance has been successfully stopped.

It is simple on purpose, since the whole trick is that stopping is the same word as starting, and that alone means I no longer get to be the person paying AWS to babysit an idle t2.micro.

Config in a .vpnrc

The first version had my region, instance name, and OpenVPN username and password hardcoded, which was fine while I was the only user but became a problem once I wanted to put it on GitHub without also committing my password.

The fix is the one Unix tools have used for decades, an rc file, and the script looks for it in two places, in order:

  1. $HOME/.config/vpn/.vpnrc
  2. $HOME/.vpnrc

If it finds one it sources it, and if not it says so and falls back to the defaults baked into the script. Because the .vpnrc is just a shell file, you can chmod 600 it, keep it in your dotfiles and edit it with whatever editor is already open, without any new config format or parser to go with it.

~/.config/vpn/.vpnrc
INSTANCE_NAME="openvpn"
USERNAME="openvpn"
PASSWORD="your_password"
REGION="ap-southeast-1"
PROFILE_NAME="client.ovpn"

That file is what makes the script shareable: clone the repo, drop your .vpnrc into ~/.config/vpn/, symlink the script onto your $PATH, and you’re done.

What a very small script taught me

The AWS CLI is the API. You don’t need an SDK or Terraform for something like this, because aws ec2 describe-instances --filters "Name=tag:Name,Values=$INSTANCE_NAME" is already a shell command that returns JSON, and --query (JMESPath, built into the CLI) pulls out exactly the field you want. For a script this size anything heavier would be overkill.

I should have used set -euo pipefail. I didn’t, and instead the script grew a set of hand-written checks: a helper that bails if a value is empty or None, a check that the profile file exists, and so on. That is backwards, since strict mode does most of that work for free and the hand-rolled version only makes the script longer. If I rewrote it today, strict mode would go at the top and half the checks would go away.

Defaults that look wrong on purpose are useful. The fallback password in the script is literally my_password, and that is deliberate: if you forget to set up your .vpnrc, OpenVPN rejects the login and you find out immediately, which makes them less defaults than tripwires.

What it isn’t

It isn’t a VPN provider so much as a way to operate a VPN you already have. The server is the OpenVPN Access Server AMI from the AWS Marketplace; set that up once, tag the instance openvpn, download the client profile and put it next to the script, and from then on vpn is your front door.

It is also deliberately small, with no daemon, no systemd unit and no reconnecting on flaky wifi, just one user and one instance that you connect to now and stop when you’re done. If you need more than that you want a different tool, and the script doesn’t pretend otherwise.

It isn’t free either, since AWS bills for instance hours, data transfer out and the EBS volume. On the free tier, with a t2.micro and light use, it costs close to nothing, and off the free tier it’s a few dollars a month, which is still less than most commercial VPNs and comes with whatever bandwidth your region gives you.

Why write about it at all

Because it’s a clean example of something I care about, which is solving the problem you actually have at the size it actually is. It needs bash, the AWS CLI and openvpn3 and nothing else, and it will keep working for as long as AWS keeps start-instances around.

A lot of engineering is building big systems, but some of it is noticing when the right answer is 140 lines of bash and having the discipline to ship that and walk away.

The code is at jaeaeich/vpn under GPL-3.0. If you use it, double-check which AWS region you’re renting that instance in, because nothing in the script will protect you from the geography of your own free tier.