--- title: "I paid for the whole vpn, so I'm damn well going to use the whole vpn" summary: "Or: hand roll a ngrok with protonvpn port forwarding for shenanigans" date: 2025-10-24T21:00:00-07:00 tags: ["sysadmin"] categories: ["Life of a sysadmin"] --- _Or: hand roll a ngrok with protonvpn port forwarding for shenanigans_ # Dumbness: port forward `sshd` Our general plan is this: Bring up a wireguard tunnel to protonvpn as usual. Bring up sshd as usual. Port forward some public port to `10.2.0.2:22`, with `natpmpc(1)`. The first two steps are just the standard issue procedure, I won't go over it here. I'll assume the wireguard config is placed at `/etc/wireguard/protonvpn.conf`, so bringing it up is simply `wg-quick up protonvpn`. Also remember to harden `sshd`, like disable password login and setup fail2ban. Otherwise you'll have a pretty bad day... First, the port forwarding logic. It's a simple bash script `/usr/local/bin/protonvpn-update-port-mapping`, adapted from [here](https://github.com/giu176/ProtonVPN-auto-NATPMP/blob/main/natpmpc_script.sh). ```bash #!/bin/bash TMPFILE=/tmp/natpmpc_output # We can assume the exit node IP doesn't change ip=$(curl -s --interface=protonvpn -4 icanhazip.com) echo "$ip" > "$RUNTIME_DIRECTORY/public-ip" ip6=$(curl -s --interface=protonvpn -6 icanhazip.com) echo "$ip6" > "$RUNTIME_DIRECTORY/public-ip6" while true; do natpmpc -a 1 22 udp 60 -g 10.2.0.1 > /dev/null \ && natpmpc -a 1 22 tcp 60 -g 10.2.0.1 > $TMPFILE \ || { echo -e "ERROR with natpmpc command \a" break } port=$(grep 'TCP' $TMPFILE | grep -o 'Mapped public port [0-9]*' | awk '{print $4}') rm $TMPFILE echo "$port" > "$RUNTIME_DIRECTORY/public-port" sleep 45 done ``` In the script provided in [protonvpn docs](https://protonvpn.com/support/port-forwarding-manual-setup), natpmpc is called with privateport = 0, which means use the same port as the public port, allocated by the gateway. Changing it to 22 makes it so that the gateway still chooses whatever it wants, but the privateport is always mapped to 22, on which sshd is listening. You'll notice we map UDP on line 12, in addition to TCP on line 13. Indeed it's not necessary for sshd, but it's useful for something like reverse proxying a wireguard mesh. I haven't done it, because tailscale and what not is vastly more convenient and versatile than this. I guess I will say the age-old punchline here: running wireguard on this is left as an exercise to the reader. Second, a systemd service `/etc/systemd/system/protonvpn-natpmp.service`. Note `wg-quick@.service` is a template provided in the most linux distros just runs wg-quick on the pprovided config. And setting `RuntimeDirectory` will create the `$RUNTIME_DIRECTORY` env var used by the script above. ```ini [Unit] Description=ProtonVPN NAT-PMP port forwarding update After=wg-quick@protonvpn.service Requires=wg-quick@protonvpn.service [Service] Type=exec ExecStart=/usr/local/bin/protonvpn-update-port-mapping RuntimeDirectory=protonvpn-natpmp # Harden, because why not ProtectSystem=strict PrivateTmp=true ProtectHome=true PrivateDevices=true ProtectKernelTunables=true ProtectControlGroups=true [Install] WantedBy=multi-user.target ``` Reload to pick up the new unit files. Enable and immediately start both services: ``` # systemctl daemon-reload # systemctl enable --now wg-quick@protonvpn.service protonvpn-natpmp.service ``` Read the public address under `/run/protonvpn`, now you should be able to ssh, from anywhere on the internet, to this machine. # Silliness? port forward HTTP It's basically the same process as for sshd. Make your HTTP server of choice, nginx or whatever, listen on 10.2.0.2:80 (or some other port of your choice). Maybe even setup DNS and a certificate for it. And then you can just access it on the public internet like any other server. That's it. Now, please don't do any nefarious things with this... That's how we lost port forwarding from mullvad. The curious reader who has not yet caught up on this matter may perform an internet search on their own behalf. # Automatically redirect to current port allocation Remembering that magic port number is pretty painful. Keeping it in links you save or send to friends is also pretty painful. Wouldn't it be nice to have some kind of short url service, that just redirects you to the current IP:port assigned, as of accessing?. For example, you can go to [https://sh.example.com/foo/bar], and be redirected to [https://funny-business.example.com:12345/foo/bar]. And when The python server that generates the redirects, and exposes an API for updating the mappings for our natpmp script to work with. ```python #!/usr/bin/python # example config file """ server-1 i.example.com server-2 ii.example.com this-is-a-joke foo.example.com:6969 """ import sys from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse, parse_qs config = {} config_path = sys.argv[1] PARENT_DOMAIN = "example.com" with open(config_path) as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue prefix, port = line.split(maxsplit=1) config[prefix] = port def update_config(prefix, port): config[prefix] = port with open(config_path, 'w') as f: for prefix, port in config.items(): f.write(f"{prefix} {port}\n") class RedirectHandler(BaseHTTPRequestHandler): def do_POST(self): p = urlparse(self.path) if p.path != "/-/config": return q = parse_qs(p.query) update_config(q['prefix'][0], int(q['port'][0])) self.send_response(200) self.end_headers() def do_GET(self): # self.path[0] is always '/', start at idnex 1 to ignore that split_pt = self.path.find('/', 1) if split_pt == -1: split_pt = len(self.path) prefix = self.path[1:split_pt] rest = self.path[split_pt:] if port := config[prefix]: self.send_response(302) self.send_header('Location', f"https://{prefix}.{PARENT_DOMAIN}:{port}{rest}") self.end_headers() return self.send_response(404) self.end_headers() HTTPServer(('127.0.233.80', 8046), RedirectHandler).serve_forever() ``` Run it somehow. Perhaps as a `Type=simple` systemd service. And nginx config fragment, which should be dropped inside the `http{}` block. A very standard reverse proxy setup. Unrestricted GET, and basic_auth protected POST (and everything else) for updating the redirect mappings. ```conf upstream sh { server 127.0.233.80:8046; keepalive 2; } server { listen 443 ssl; listen [::]:443 ssl; server_name sh.example.com; location / { limit_except GET { auth_basic "POST restricted"; auth_basic_user_file /etc/nginx/sh.example.com-htpasswd; } proxy_pass http://sh; } } ``` And update our port mapping script to send the newly obtained port to our redirects server. ```diff --- a/protonvpn-update-port-mapping.sh +++ b/protonvpn-update-port-mapping.sh @@ -8,6 +8,9 @@ ip6=$(curl -s --interface=protonvpn -6 icanhazip.com) echo "$ip6" > "$RUNTIME_DIRECTORY/public-ip6" +SERVICE_NAME=funny-business + +old_port=0 while true; do natpmpc -a 1 22 udp 60 -g 10.2.0.1 > /dev/null \ && natpmpc -a 1 22 tcp 60 -g 10.2.0.1 > $TMPFILE \ @@ -21,5 +24,14 @@ echo "$port" > "$RUNTIME_DIRECTORY/public-port" + if [[ $port != $old_port ]]; then + echo "Port changed from $old_port to $port" + # Valid DNS names and digits don't need any URL encoding to be standard compliant, so I'm using -d to be short + curl -X POST -u 'YOUR_USER:YOUR_PASSWORD' https://sh.example.com/-/config \ + -d "prefix=$SERVICE_NAME" \ + -d "port=$port" + old_port=$port + fi + sleep 45 done ``` Start everything, and now you can visit [https://sh.example.com/funny-business] for fun and profit.