A transparent UDP proxy for FlaschenTaschen displays behind a network that blocks device-to-device traffic (client isolation, NAT, or both). Common at venues like OpenSauce, where the WiFi won't let attendees reach the FT host directly.
The goal: attendees keep using the existing FT protocol (UDP to port 1337, PPM frames). No new client software, no browser app, no protocol changes. They just send to a public IP instead of a local one.
On top of the proxy this guide adds an optional geolocation gate (you must be at the grounds to get 30 minutes of access) and abuse controls (a kill switch and per-IP blocking) for whoever is running the display.
The display sits behind a network that only allows outbound connections. So the booth-side box dials out to a public host and brings up a WireGuard tunnel. The public host then forwards incoming UDP/1337 straight down that tunnel to the real FT host. WireGuard carries genuine UDP end to end, so nothing about the protocol changes.
attendee --UDP/1337--> PublicIP (VPS or box with a routable address)
| UDP forward (nftables DNAT, gated by allow-set)
v
wg0 (10.10.0.1) <==WireGuard==> wg0 (10.10.0.2) booth Pi
|
v
FT host UDP/1337
The booth Pi initiates the handshake, so inbound blocking at the venue doesn't matter.
PersistentKeepalive keeps the venue's NAT mapping open so the tunnel stays reachable
from the public side for the whole show.
- A public host with a routable IP. A cheap VPS is fine; so is any box you can give a public address. One public IP per FT display (see multi-FT section below).
- A booth-side Linux box on the same LAN as the FT host. A Raspberry Pi is ideal. It can be the FT host itself or a separate machine.
- WireGuard on both (
apt install wireguardon Debian/Raspberry Pi OS). - The venue must allow outbound UDP to your WireGuard port. Test this first; see Troubleshooting for fallbacks if it doesn't.
- For the geolocation gate: Python 3 (standard library only) and a way to serve the page over HTTPS (the Geolocation API requires a secure context). Caddy is the simplest; see that section.
On each machine:
wg genkey | tee privatekey | wg pubkey > publickeyYou'll have four keys total: a private/public pair on the public host and another on the booth Pi. Each config uses its own private key and the other machine's public key.
Install the WireGuard config at /etc/wireguard/wg0.conf:
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = <PUBLIC_HOST_PRIVATE_KEY>
[Peer]
# booth Pi
PublicKey = <PI_PUBLIC_KEY>
AllowedIPs = 10.10.0.2/32Bring it up:
sudo systemctl enable --now wg-quick@wg0Now set up forwarding. Use nftables DNAT rather than socat: it runs in the kernel,
has no per-client process to leak, and holds up under a crowd. This ruleset is written
to work with the geolocation gate and abuse controls below: DNAT only fires for source
IPs in an allowed set, and blocked sources are dropped first.
# enable forwarding
sudo sysctl -w net.ipv4.ip_forward=1
sudo nft add table ip ft
# allow-set: source IPs, each self-expiring after its timeout
sudo nft add set ip ft allowed '{ type ipv4_addr ; flags timeout ; }'
# blocklist: source IPs to drop, also self-expiring
sudo nft add set ip ft blocked '{ type ipv4_addr ; flags timeout ; }'
sudo nft add chain ip ft prerouting '{ type nat hook prerouting priority -100 ; }'
# drop blocked sources first
sudo nft insert rule ip ft prerouting ip saddr @blocked drop
# only DNAT if the source IP is currently in the allow-set
sudo nft add rule ip ft prerouting udp dport 1337 ip saddr @allowed dnat to 10.10.0.2:1337
sudo nft add chain ip ft postrouting '{ type nat hook postrouting priority 100 ; }'
sudo nft add rule ip ft postrouting ip daddr 10.10.0.2 masqueradeThe masquerade rule makes return traffic come back through the public host, keeping
the tunnel path symmetric.
If you don't want the geo gate at all and just want an open public proxy, replace the gated DNAT rule with an unconditional one and skip the sets:
sudo nft add rule ip ft prerouting udp dport 1337 dnat to 10.10.0.2:1337To make forwarding persistent across reboots, set net.ipv4.ip_forward=1 in
/etc/sysctl.conf and save the ruleset with sudo nft list ruleset > /etc/nftables.conf
(loaded by the nftables service). Note that set contents (granted/blocked IPs) are
runtime state and are not meant to persist; that's fine, they're all short-lived.
For a throwaway test with no gating, one line of socat does the forwarding without nftables:
socat UDP4-LISTEN:1337,reuseaddr,fork UDP4:10.10.0.2:1337Fine for the bench. The fork spawns a process per source and UDP sources never
"close", so don't run this at a busy show.
Install at /etc/wireguard/wg0.conf:
[Interface]
Address = 10.10.0.2/24
PrivateKey = <PI_PRIVATE_KEY>
[Peer]
# public host
PublicKey = <PUBLIC_HOST_PUBLIC_KEY>
Endpoint = <PUBLIC_IP>:51820
AllowedIPs = 10.10.0.1/32
PersistentKeepalive = 25Bring it up:
sudo systemctl enable --now wg-quick@wg0PersistentKeepalive = 25 is what keeps the outbound-initiated tunnel alive through
the venue's NAT idle timeout. Don't omit it.
If the FT host is a different machine from the Pi, replace 10.10.0.2:1337 in the
public-host DNAT rule with the FT host's LAN IP, and make sure the Pi forwards to it
(enable ip_forward and add a masquerade toward the LAN). If the Pi is the FT host,
the config above works as-is.
Confirm the handshake:
sudo wg showYou want a recent latest handshake and traffic counting up under transfer.
If you're using the gated ruleset, add your test machine's IP to the allow-set first (the gate normally does this for you):
sudo nft add element ip ft allowed '{ <YOUR_SOURCE_IP> timeout 30m }'Then send a frame at the public IP using any normal FT client, e.g. from the FlaschenTaschen repo:
./send-image -h <PUBLIC_IP> -l 3 some-image.pngor the classic one-liner:
echo "P6 1 1 255 $(printf '\xff\x00\x00')" | socat - UDP:<PUBLIC_IP>:1337If the display lights up, the path works. You can prove the whole thing on your bench before the show by using any second box with a routable address as the stand-in "public host."
One public IP and one tunnel per display. Give each tunnel its own interface, subnet, and WireGuard port on the public host, and scope each DNAT rule to the matching public IP:
PublicIP-A:1337 -> wg0 (10.10.0.0/24) -> FT #1 at 10.10.0.2:1337
PublicIP-B:1337 -> wg1 (10.11.0.0/24) -> FT #2 at 10.11.0.2:1337
wg1 on the public host mirrors wg0 with a different Address, ListenPort
(e.g. 51821), and peer. The DNAT rule keys on the destination public IP:
sudo nft add rule ip ft prerouting ip daddr <PublicIP-B> udp dport 1337 ip saddr @allowed dnat to 10.11.0.2:1337
sudo nft add rule ip ft postrouting ip daddr 10.11.0.2 masqueradeBringing up FT #2 is then just: new keypair, new wgN.conf on each side, one DNAT
rule, one masquerade rule. (A single shared allowed/blocked set across displays is
usually what you want; use per-display sets only if access should differ per display.)
Require attendees to be at the grounds before they get access. A small web page asks
the browser for its coordinates, POSTs them to the public host, and if they're within
a radius of the venue the server adds the caller's source IP to the nftables allowed
set with a 30-minute timeout. Expiry is automatic; there's no database and no cleanup.
Be clear-eyed about what this is. Browser Geolocation is self-reported. The page
asks the browser for coordinates and a determined person can POST fake coordinates with
curl. This is a friction gate that scopes honest attendees to the grounds and keeps
idle internet randos out, not real enforcement. For a weekend art display that's the
right amount of effort. True enforcement would require gating on something the client
can't forge (your own SSID, a rotating QR token), which is a heavier system.
The IP must match. The IP added to the allow-set is the one the attendee's UDP
packets arrive from, so they must load the page from the same device and network they
send UDP from (their phone/laptop on the venue WiFi). Tell attendees to be on the venue
WiFi, not cellular, both because carrier-grade NAT shares one IP across many users and
because that's the network you want them on anyway. If the app is behind a reverse
proxy, it must read X-Forwarded-For; if directly exposed, it uses the socket peer.
Single Python file, standard library only, no framework, no dependencies. It also carries the abuse-control endpoints described in the next section.
#!/usr/bin/env python3
import os, json, math, subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# San Mateo Event Center (OpenSauce). Verify against where attendees actually stand.
CENTER_LAT, CENTER_LON = 37.5528, -122.3038
RADIUS_M = 2000 # generous; covers the grounds and parking
TIMEOUT = "30m"
BEHIND_PROXY = False # True if a reverse proxy sets X-Forwarded-For
FLAG = "/opt/ftgate/CLOSED"
ADMIN_TOKEN = os.environ.get("FTGATE_ADMIN_TOKEN", "") # set in the unit file
def nft(*args):
subprocess.run(["sudo", "/usr/sbin/nft", *args], check=True, capture_output=True)
def haversine(a1, o1, a2, o2):
R = 6371000
p1, p2 = math.radians(a1), math.radians(a2)
dp, do = math.radians(a2 - a1), math.radians(o2 - o1)
h = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(do/2)**2
return 2 * R * math.asin(math.sqrt(h))
PAGE = """<!doctype html><meta name=viewport content="width=device-width,initial-scale=1">
<title>FlaschenTaschen access</title>
<body style="font-family:system-ui;max-width:32rem;margin:3rem auto;padding:0 1rem">
<h1>Light up the FlaschenTaschen</h1>
<p>You need to be at the OpenSauce grounds. Tap the button and allow location.</p>
<button onclick="go()" style="font-size:1.2rem;padding:.6rem 1rem">Grant me 30 minutes</button>
<p id=out></p>
<script>
function go(){
const out=document.getElementById('out');
out.textContent='Getting location...';
navigator.geolocation.getCurrentPosition(async p=>{
const r=await fetch('/grant',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({lat:p.coords.latitude,lon:p.coords.longitude})});
out.textContent=(await r.json()).message;
}, e=>{out.textContent='Location denied or unavailable: '+e.message;},
{enableHighAccuracy:true,timeout:10000});
}
</script>
"""
ADMIN_PAGE = """<!doctype html><meta name=viewport content="width=device-width,initial-scale=1">
<title>FT operator</title>
<body style="font-family:system-ui;max-width:32rem;margin:2rem auto;padding:0 1rem">
<h1>FT operator</h1>
<p><button onclick=go('/admin/panic')>PANIC: drop all + close</button>
<button onclick=go('/admin/reopen')>Reopen</button></p>
<p><input id=ip placeholder="1.2.3.4">
<button onclick=block()>Block IP 12h</button>
<button onclick=go('/admin/clients','GET')>Show clients</button></p>
<pre id=out></pre>
<script>
let tok=localStorage.ftTok||(localStorage.ftTok=prompt('admin token'));
async function go(path,method='POST',body){
const r=await fetch(path,{method,headers:{'X-Admin-Token':tok,'content-type':'application/json'},
body:body?JSON.stringify(body):undefined});
document.getElementById('out').textContent=await r.text();
}
function block(){go('/admin/block','POST',{ip:document.getElementById('ip').value});}
</script>
"""
class H(BaseHTTPRequestHandler):
def client_ip(self):
if BEHIND_PROXY:
xff = self.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
return self.client_address[0]
def authed(self):
return bool(ADMIN_TOKEN) and self.headers.get("X-Admin-Token") == ADMIN_TOKEN
def html(self, body):
b = body.encode()
self.send_response(200)
self.send_header("content-type", "text/html; charset=utf-8")
self.send_header("content-length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def json(self, code, obj):
b = json.dumps(obj).encode()
self.send_response(code)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def body_json(self):
n = int(self.headers.get("content-length", 0))
return json.loads(self.rfile.read(n)) if n else {}
def do_GET(self):
if self.path == "/":
return self.html(PAGE)
if self.path == "/admin":
return self.html(ADMIN_PAGE)
if self.path == "/admin/clients":
if not self.authed(): return self.json(403, {"message": "no"})
out = subprocess.run(["sudo", "/usr/sbin/nft", "list", "set", "ip", "ft", "allowed"],
capture_output=True, text=True)
return self.json(200, {"message": out.stdout})
self.send_error(404)
def do_POST(self):
if self.path == "/grant":
if os.path.exists(FLAG):
return self.json(503, {"message": "Access is temporarily closed. Check with an organizer."})
try:
d = self.body_json()
lat, lon = float(d["lat"]), float(d["lon"])
except Exception:
return self.json(400, {"message": "Bad request."})
dist = haversine(lat, lon, CENTER_LAT, CENTER_LON)
if dist > RADIUS_M:
return self.json(403, {"message":
f"You appear to be {int(dist)} m away. You must be at the grounds."})
ip = self.client_ip()
try:
nft("add", "element", "ip", "ft", "allowed", "{ %s timeout %s }" % (ip, TIMEOUT))
except subprocess.CalledProcessError:
return self.json(500, {"message": "Could not grant access. Find an organizer."})
return self.json(200, {"message":
f"You're in for 30 minutes. Send FT frames to this server's IP on UDP 1337. (Your IP: {ip})"})
# --- operator endpoints ---
if self.path == "/admin/panic":
if not self.authed(): return self.json(403, {"message": "no"})
nft("flush", "set", "ip", "ft", "allowed")
open(FLAG, "w").close()
return self.json(200, {"message": "All third-party clients dropped. Gate closed."})
if self.path == "/admin/reopen":
if not self.authed(): return self.json(403, {"message": "no"})
try: os.remove(FLAG)
except FileNotFoundError: pass
return self.json(200, {"message": "Gate reopened."})
if self.path == "/admin/block":
if not self.authed(): return self.json(403, {"message": "no"})
ip = self.body_json().get("ip", "").strip()
if not ip: return self.json(400, {"message": "no ip"})
nft("add", "element", "ip", "ft", "blocked", "{ %s timeout 12h }" % ip)
try: nft("delete", "element", "ip", "ft", "allowed", "{ %s }" % ip)
except subprocess.CalledProcessError: pass
return self.json(200, {"message": f"Blocked {ip} for 12h."})
if self.path == "/admin/unblock":
if not self.authed(): return self.json(403, {"message": "no"})
ip = self.body_json().get("ip", "").strip()
try: nft("delete", "element", "ip", "ft", "blocked", "{ %s }" % ip)
except subprocess.CalledProcessError: pass
return self.json(200, {"message": f"Unblocked {ip}."})
self.send_error(404)
def log_message(self, *a): # quiet
pass
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 8000), H).serve_forever()The app runs as an unprivileged webgate user and is allowed to run only the specific
nft element/set commands, never a shell:
webgate ALL=(root) NOPASSWD: /usr/sbin/nft add element ip ft allowed *, \
/usr/sbin/nft add element ip ft blocked *, \
/usr/sbin/nft delete element ip ft allowed *, \
/usr/sbin/nft delete element ip ft blocked *, \
/usr/sbin/nft flush set ip ft allowed, \
/usr/sbin/nft list set ip ft allowed
sudo useradd -r -s /usr/sbin/nologin webgate
sudo mkdir -p /opt/ftgate && sudo cp ftgate.py /opt/ftgate/
sudo tee /etc/systemd/system/ftgate.service >/dev/null <<'EOF'
[Unit]
Description=FT geolocation access gate
After=network.target
[Service]
ExecStart=/usr/bin/python3 /opt/ftgate/ftgate.py
User=webgate
Environment=FTGATE_ADMIN_TOKEN=change-me-to-something-long
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable --now ftgatenavigator.geolocation only works in a secure context, so plain http:// will
silently fail to prompt except on localhost. The simplest fix is Caddy, which fetches
and renews a real certificate automatically. Point a domain at the public host and use:
# /etc/caddy/Caddyfile
ft.example.org {
reverse_proxy 127.0.0.1:8000
}
Set BEHIND_PROXY = True in the app so it reads the real client IP from
X-Forwarded-For. Attendees then open https://ft.example.org/, and a QR code at the
booth pointing there is the natural way to hand it out. Operators open
https://ft.example.org/admin.
For whoever is running the display. Two distinct levers, both usable from a phone at
the booth via the /admin page (or curl).
Kill switch (stop all third-party clients now). Flushing the allow-set drops every
current client instantly, and a CLOSED flag file stops the gate from handing out new
grants until you reopen. POST /admin/panic does both; POST /admin/reopen clears the
flag. Equivalent by hand:
sudo nft flush set ip ft allowed # drop everyone now
sudo touch /opt/ftgate/CLOSED # refuse new grants
sudo rm /opt/ftgate/CLOSED # reopenTargeted block (ban one abuser, keep everyone else running). A blocked IP is
dropped before the allow-set check, so it can't re-grant itself via the page. Bans
self-expire (12h here). POST /admin/block {"ip":"..."} blocks and also removes any
active grant. Equivalent by hand:
sudo nft add element ip ft blocked '{ 203.0.113.7 timeout 12h }'
sudo nft delete element ip ft allowed '{ 203.0.113.7 }' 2>/dev/null || trueFinding the abuser. List current grantees with GET /admin/clients (or
sudo nft list set ip ft allowed) to see active IPs and remaining time. To attribute
abuse to a specific source, log accepted frames and watch rates live:
sudo nft add rule ip ft prerouting udp dport 1337 ip saddr @allowed \
log prefix "ft-frame " counter dnat to 10.10.0.2:1337
sudo journalctl -kf | grep ft-frameThe source flooding the log is your abuser. Add limit rate to the log statement if it
gets noisy. (Insert this logging variant in place of the plain gated DNAT rule if you
want it on from the start.)
Keep your own control immune to the kill switch. If your own presenter machine goes
through the same public IP and gate, panic kills it too. Give trusted sources a path
that doesn't depend on the allow-set: either a DNAT rule matching your known source IPs
placed before the gated rule, or, cleaner, reach the FT host directly over the
WireGuard tunnel and reserve the public IP purely for the public. Then panic only ever
affects strangers while you hunt the abuser.
No handshake. The venue is probably blocking outbound UDP to your WireGuard port. Two fallbacks, in order of preference:
- Move
ListenPortto 443. WireGuard is still UDP, but UDP/443 is what QUIC uses and often passes where 51820 doesn't. Change it on the public host and update the Pi'sEndpoint. - Only TCP/443 survives? Wrap the WireGuard tunnel in
wstunnelorudp2raw. This adds a shim on the tunnel leg only; attendees and the FT protocol stay pure UDP.
Handshake works, display doesn't light up. Check net.ipv4.ip_forward=1 on the
public host, confirm the DNAT target matches the FT host's tunnel/LAN IP, verify the
masquerade rule is present (without it, replies don't return through the tunnel), and
if using the gate, confirm your source IP is actually in the allowed set
(sudo nft list set ip ft allowed).
Geo page never asks for location. It's being served over http://. Geolocation
needs HTTPS; put it behind Caddy or another TLS terminator.
Granted but still can't send. The IP you were granted may differ from the IP your UDP packets leave from (common on cellular / carrier-grade NAT). Get on the venue WiFi and re-grant.
Tunnel dies after a while. Confirm PersistentKeepalive = 25 is set on the booth Pi.
The FT protocol has no authentication, so all access control lives at the proxy. The
geolocation gate is friction, not enforcement (see that section). The admin token is a
single shared secret passed in a header; keep the /admin page off search engines and
prefer HTTPS so the token isn't sent in the clear. Bans and grants are deliberately
short-lived so state can't accumulate or be forgotten after the show.