DEV Community

Cover image for How I build a free VPN over AWS CloudShell
JΓ©rΓ΄me GUYON for AWS Community Builders

Posted on • Originally published at builder.aws.com

How I build a free VPN over AWS CloudShell

From time to time when I'm travelling, mainly in Malaysia which has become kind of a second home πŸ‡²πŸ‡Ύ, I face geo-restrictions from CDNs or applications that prevent me from accessing what I need. A badly configured car rental website CDN, French government websites that block non-European IPs. This is very occasional usage, and I don't want to pay for yet another subscription and I also don't trust most VPN vendors with my traffic. As an IT professional, the question became: can I build or host my own for nearly $0?

That's when I started looking at AWS CloudShell. A free Linux shell in any AWS region, with outbound internet access. I recently published a blog article about the hidden CloudShell API, and the next logical step was obvious: what if I could route my laptop's traffic through it?

I'm calling it cloudshell-vpn and it's available on macOS. It routes all your traffic through any AWS region using CloudShell as an exit node. The total cost is $0 within the free tier's 100 GB/month outbound.

The CloudShell problem

CloudShell gives you a free Linux shell in any AWS region with outbound internet access. It can run OpenVPN. But it lives behind NAT with no inbound ports. You can't SSH into it, you can't expose a port, and you can't attach an Elastic IP. You can't run SSM StartPortForwardingSession either: CloudShell isn't a registered SSM managed instance. It only supports interactive shell sessions, not SSM documents like port forwarding.

So how do you establish a tunnel to something you can't reach?

The NAT hole punching solution

The approach borrows a technique from peer-to-peer networking. Both sides (your laptop and the CloudShell environment) discover their public IP and port using STUN, then simultaneously send UDP packets to each other. The NAT routers on both sides see outbound traffic and open pinholes. Once both sides have punched through, you have a bidirectional UDP path with no port forwarding required.

Inspired by Dan V.'s cloudshell-store project, I realized I could bypass SSM entirely and establish a direct UDP connection between the laptop and CloudShell using NAT hole punching.

Phase 1: Discovering your public face with STUN

Neither side knows its own public IP address and port. Your laptop knows it's 192.168.1.42:12345, but the outside world sees something like 85.123.45.67:54321, whatever your router's NAT assigned. You can't figure this out by looking at your own network interfaces.

STUN (Session Traversal Utilities for NAT) solves this. It's a simple protocol where you send a UDP packet to a public STUN server and it replies telling you the source IP and port it observed.

  Your laptop (192.168.1.42:12345)
       β”‚
       β”‚  UDP packet ──────────►  stun.l.google.com:19302
       β”‚                                β”‚
       β”‚                                β”‚ "I see you as 85.123.45.67:54321"
       β”‚                                β”‚
       β”‚  ◄── STUN Binding Response β”€β”€β”€β”€β”˜
       β”‚
  Now you know: your public endpoint is 85.123.45.67:54321
Enter fullscreen mode Exit fullscreen mode

Google runs free public STUN servers and the whole exchange is a single UDP round-trip. What is important: the UDP socket you used to contact the STUN server now has an open NAT mapping. Your router remembers "packets from 192.168.1.42:12345 go out as 85.123.45.67:54321" and that mapping is what we'll exploit next.

Both sides do this independently: the laptop discovers its public endpoint, and the agent running inside CloudShell discovers its own.

Phase 2: Exchanging endpoints through the SSM shell

The laptop knows its own public endpoint and the agent knows its own. But they need to know each other's, so we use the existing SSM shell session as the signaling channel.

The laptop already has a terminal session open to CloudShell (that's how we uploaded and started the agent). When the agent starts, it prints its public endpoint to stdout:

AGENT_READY:34.245.12.89:4433
Enter fullscreen mode Exit fullscreen mode

The laptop reads this from the SSM session output. The laptop's public endpoint was passed as a command-line argument when starting the agent. Both sides now know where to punch.

Phase 3: The hole punch, simultaneous UDP packets

NAT mappings are bidirectional once established. When your laptop sends a UDP packet to 34.245.12.89:4433, your router creates a mapping that says "if a packet comes back from 34.245.12.89:4433 to my port 54321, forward it to 192.168.1.42:12345". The same thing happens on CloudShell's side.

The problem is timing. If the laptop sends first, the packet arrives at CloudShell's NAT before CloudShell has sent anything, so CloudShell's NAT drops it (no mapping exists yet). The solution: both sides send packets simultaneously. Both NATs create their mappings at roughly the same time, and within a few hundred milliseconds, packets start flowing in both directions.

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Laptop  β”‚                                      β”‚CloudShellβ”‚
  β”‚  NAT     β”‚                                      β”‚  NAT     β”‚
  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜                                      β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
       β”‚                                                 β”‚
       │──── UDP "PUNCH" ───────────────────────────────►│ (dropped initially)
       β”‚                                                 β”‚
       β”‚ (dropped initially) ◄──────────── UDP "PUNCH" ──│
       β”‚                                                 β”‚
       │──── UDP "PUNCH" ───────────────────────────────►│ βœ“ NAT mapping exists!
       β”‚                                                 β”‚
       β”‚ βœ“ NAT mapping exists! ◄────────── UDP "PUNCH" ──│
       β”‚                                                 β”‚
       │◄═══════════════ UDP hole is open ══════════════►│
Enter fullscreen mode Exit fullscreen mode

In practice, both sides send a burst of PUNCH packets for a few seconds. After a few hundred milliseconds, both NATs have seen outbound traffic and created their mappings. The hole is punched.

Phase 4: Local UDP relay

The OpenVPN server starts on port 1194 inside CloudShell. A local UDP relay binds 127.0.0.1:1194 on your laptop and bridges packets to the punched hole. On the CloudShell side, an identical relay bridges between the external socket and the local port 1194.

The OpenVPN choice

I considered several options for the VPN tunnel, including macOS native APIs and WireGuard, but both require either a signed Network Extension or root privileges to create tun interfaces and modify routes. I chose OpenVPN because the free OpenVPN Connect client handles all privileged operations internally: it already has the required entitlements to create the tunnel interface and manage routes, so my tool doesn't need root. It just generates a .ovpn profile and imports it.

Quick start

The easiest way to run is with the launcher script. It checks prerequisites, manages a virtualenv, and starts the VPN:

export AWS_PROFILE=my-profile
./run.sh
Enter fullscreen mode Exit fullscreen mode

Et voilΓ ! Your traffic is now routed through the AWS region of your choice.

Things to know

  • NAT compatibility - UDP hole punching works with most residential and cloud NATs. It fails with symmetric NATs, which are common in strict corporate firewalls. If your office blocks it, try from home or a mobile hotspot.
  • CloudShell quota - 200 hours per region per month per account, enough for occasional use. You can request an increase through Service Quotas.
  • Session timeout - CloudShell auto-terminates after 12 hours of continuous use. The tool sends heartbeats every 5 minutes to prevent idle sleep (20 min timeout), but the 12-hour hard limit still applies.
  • Data transfer cost - Outbound bytes are billed at standard EC2 rates (~$0.09/GB after the first 100 GB/month free tier).
  • macOS only - For now.

If you need a real VPN, pay for a real VPN. If you need a free, disposable, region-selectable tunnel for 30 minutes of testing or to bypass a broken CDN geo-block while travelling, this is genuinely useful.

The source is at github.com/guyon-it-consulting/cloudshell-vpn. Give it a try and let me know what you think.

Credits

  • Inspired by Dan V.'s cloudshell-store project that demonstrated the CloudShell API use
  • Brice Dauzats: Thanks for the reviews and improvements
  • Kiro CLI and Kiro Crew: Assisted me in research and validating hypotheses throughout the project πŸ‘»πŸ‘»πŸ‘»

β€” Jerome

Top comments (0)