1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
|
---
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_
_Updated 2025-11-08_: added addendum section on using wireguard in a network namespace.
# 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 `[email protected]` 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
[email protected]
[email protected]
[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 [email protected] 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.
# Addendum: split tunnelling with netns
For those who are unfamilar: split tunnelling is to selectively route _some_ traffic through the VPN but not others, usually discriminated by originating application.
Traditionally, you would do this with `ip rule` on linux. Or you'd go really heavyweight, and just slap docker on the problem, by putting both the VPN connection (wireguard) and the application into containers.
But it's also possible to just use a network namespace. Docker is basically just this, but also namespacing pid/uts/time/mnt/user at the same time.
To actually do this, you basically have two choices:
- setup the interface manually with `ip(1)`
- use a heavyweight champion like [wg-netns](https://github.com/dadevel/wg-netns).
with the upshot of both being incompatible with `wg-quick(1)` config files. VPN providers almost always provide wg-quick config generators. Having to manually reformat that is pretty tedious.
So this is the part I present my own cobbled together shell script `wg-reallyquick` that emulates a subset of wg-quick behavior while adding it to a netns:
https://gist.github.com/rtk0c/ae7e9aa29fa1a83ba02c7768f871b11c
Enjoy.
P.S. usage in the gist comments
P.P.S. I know that name sucks. But it's also kind of funny at the same time, so I kept it.
## Commentary on specific use cases
BitTorrent client is the best use case I found. It has all these properties at the same time:
1. torrent clients often don't expose enough network configurables
2. you really want a kill stich
3. only interacts with the internet (i.e. not with other programs on the system).
As soon as any of these properties go away, netns (manually) can seem like it's not worth the effort anymore.
Every time the isolated application needs to communicate with something else on your system, e.g. HTTP reverse proxy, you start to need many `systemd-socket-proxyd`, or upgrade to `veth(4)` interfaces. In my opinion, at this point you might as well just use containerization.
If the software allows you to bind to a specific interface, or even better, set fwmark on packets it creates, you can just use regular `ip rule` based routing. It's so much simpler to setup and easier to understand.
But also, with multiple applications all needing to be split tunneled, `ip rule` based routing can become pretty convoluted, so in that case, netns can seem appealing again. So it's all situational.
## Some extra friendly links
The grandma of all such efforts is ostensibly [Routing & Network Namespace Integration](https://www.wireguard.com/netns/) from the official wireguard website (note: the author did not fact check this statement), which explains the concept and general procedure better than I can, so go read it.
This [blog](https://blog.thea.codes/nordvpn-wireguard-namespaces/) is a nicely written walkthrough on the particular thing you may be wanting to do, that is, sailing in the high seas. It also has [a section](https://blog.thea.codes/nordvpn-wireguard-namespaces/#accessing-transmission-from-the-host) on how to expose the web UI of some application running inside a netns using `systemd-socket-proxyd`.
Alternatively, you can use good old `socat` [to proxy TCP traffic](https://unix.stackexchange.com/a/298409).
Lastly, for diving even deeper, [this blog](https://7bits.nl/journal/posts/what-does-ip-netns-add-actually-do/) disects the source code to show how the `ip netns add` family of command works under the hood, using syscalls. Sorry it doesn't go into kernel code.
|