jaeaeich

vpn: a VPN I only pay for while I’m using it

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 3 or 4 commercial VPNs over a couple of months, hit the same small annoyances every time, mostly subscriptions and capped bandwidth with a side of 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. 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 ended up being a 140-line bash script called vpn, which is what the rest of this post is about.

The 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 2 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 10 seconds until AWS says it’s running, then connect.
  • Running: print Did you forget to stop EC2? ooh the bill!!! and shut it down.
  1. start instancetagged openvpn
  2. waitpoll every 10 s
  3. swap IPin client.ovpn
  4. connectopenvpn3
What vpn does when it finds the instance stopped; run it again and it stops the instance instead.

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

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
  • hands the profile to openvpn3

These few lines are most of what the script exists to do:

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.

Because stopping is the same command as starting, I can’t forget it, and AWS no longer gets paid 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 2 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.

Choices I’d keep, and one I wouldn’t

AWS CLI instead of an SDK. 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.

Strict mode. I should have used set -euo pipefail and didn’t, so 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 by itself 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.

The fallback password. The one 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 straight away instead of an hour later, wondering why nothing connects.

Setup, limits and cost

The script only starts and stops a VPN you’ve already set up, and the setup is a one-time job: launch the OpenVPN Access Server image from the AWS Marketplace, 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, one user and one instance with nothing running in the background to reconnect you on flaky wifi, so if you need more than that you’ll want a different tool.

It isn’t free either, since AWS bills for:

  • instance hours
  • the public IP while the instance is up
  • data going out
  • the instance’s disk

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 it stayed small

I still like it mostly because it stayed at 140 lines of bash, which I’d have happily buried under Terraform and a config format if I’d started with a bigger plan. It needs nothing beyond the AWS CLI and openvpn3, so it should keep working for as long as AWS keeps start-instances around.

The code is at jaeaeich/vpn under GPL-3.0. If you use it, double-check which AWS region you launch the instance in, since that’s where your traffic will look like it’s coming from.

  • #aws
  • #bash
  • #vpn
  • #weekend-project
esc