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.
- start instancetagged openvpn
- waitpoll every 10 s
- swap IPin client.ovpn
- connectopenvpn3
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:
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:
$ vpnWaiting for the instance to start...Current instance state: pendingCurrent instance state: runningPublic IP Address: 13.250.xx.xx
(openvpn3 connects, and I do whatever I needed the VPN for)
$ vpnDid you forget to stop EC2? ooh the bill!!!Shutting down OpenVPN server...Waiting for the instance to stop...Current instance state: stoppedThe 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:
$HOME/.config/vpn/.vpnrc$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.
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.
Elastic IPAWS
A public IPv4 address you reserve and keep across an instance's restarts, billed by the hour whether or not anything is using it.