STUNMESH-go Now Runs on Windows, on Android, and on an 8 Year Old MIPS Router - Date Huang's work<br>Contents
STUNMESH-go Now Runs on Windows, on Android, and on an 8 Year Old MIPS Router<br>A WireGuard mesh through NAT, with no coordination server, no relay, and no VPS<br>Date Huang included in Stunmesh Network<br>2026-08-01 2472 words<br>12 minutes
Contents
WireGuard is simple and fast because it does one job and stops there. Finding the other peer is not part of that job, and this is on purpose. WireGuard expects you to tell it where the peer is, and leaves the how to you. If both sides sit behind NAT, and neither has a fixed public address, nobody can answer that question, and the tunnel never starts. The usual answers are a VPS in the middle, a port forward on your router, or a service like Tailscale or NetBird that runs a coordination server for you.<br>STUNMESH-go takes a different path. It finds the public address of each node with STUN, encrypts that address, and puts it somewhere both sides can read. You run no server at all. This post explains how it works, and shows three new places it now runs: an 8 year old MIPS access point, Windows, and Android. The code is on GitHub, and the full documentation is at docs.stunmesh.dev.<br>The problem<br>A WireGuard peer needs an Endpoint, which is just an IP address and a UDP port. On a laptop at home, or on a router behind carrier NAT, that address is not yours. Your NAT gives you a temporary mapping, it can change at any time, and you cannot read it from inside the network.<br>So people solve it in one of these ways:<br>Port forwarding : you need control of the router, and a public IP. Many people have neither, because of CGNAT.<br>A VPS in the middle : it always works, but you pay for it, you maintain it, and all traffic goes through one machine.<br>A coordination server : Tailscale, NetBird, and headscale do this well. Something still has to run, and it has to know about every node.<br>All three need infrastructure. The question I wanted to answer is: do you need any of it?<br>How STUNMESH-go works<br>The idea is old and simple. It is the same hole punching that VoIP and games have used for years, applied to WireGuard.<br>Discover. STUNMESH sends a STUN request from the same UDP port that WireGuard listens on. It uses a raw socket with a BPF filter, so WireGuard keeps the socket and never notices. The STUN server replies with the public IP and port of your NAT mapping.<br>Publish. STUNMESH encrypts that endpoint with a Curve25519 sealed box, so only your peer can read it. Then it stores the result under a key made from a SHA-1 digest of the two public keys.<br>Establish. It reads the peer’s record from the same place, decrypts it, and sets it as the peer endpoint on the WireGuard interface.<br>Repeat. It refreshes on a timer, so the tunnel survives a NAT rebinding. It can also refresh when a ping to the peer stops working.<br>Both sides do this at the same time. After about two refresh intervals, the packets meet in the middle and the handshake completes.<br>The interesting part is step 2. STUNMESH does not care where the endpoint is stored. It only needs a place that both nodes can write to and read from. That place is a plugin.<br>The storage is something you already have<br>This is the design decision that removes the server. Instead of building a rendezvous service, STUNMESH borrows storage that already exists in your life.<br>Cloudflare DNS : the endpoint goes into a TXT record under a domain you own. If you have a domain, you already have this.<br>OpenDHT : a public distributed hash table. No account, no token, no quota, because nobody operates it. Savoir-faire Linux runs public proxies for Jami, and you can point STUNMESH at them, or run your own.<br>Your own script : an exec or shell plugin gets the encrypted blob on stdin and stores it however you like. A git repo, an S3 bucket, a Redis instance, a pastebin. The protocol is a few lines of text, so a shell script is enough.<br>The data in storage is a sealed box. The storage operator sees ciphertext and a hash, and learns nothing useful about your mesh.<br>A minimal two node config looks like this:
refresh_interval: "1m"<br>log:<br>level: "info"<br>interfaces:<br>wg0:<br>peers:<br>"PEER_B":<br>public_key: ""<br>plugin: dht<br>stun:<br>addresses: ["stun.l.google.com:19302"]<br>plugins:<br>dht:<br>type: builtin<br>name: opendht<br>endpoints:<br>- https://dhtproxy2.jami.net<br>- https://dhtproxy3.jami.net
Run the same thing on the other node with this node’s public key, wait about two minutes, and wg show reports a handshake.<br>New: an 8 year old MIPS access point<br>I wanted to know how small a device can be and still join the mesh. So I took an IgniteNet Spark SP-W2M-AC1200, an access point from around 2017:<br>SoCRealtek RTL8197F, MIPS 24Kc, little endianRAM128 MBFirmwareIgniteNet HeliOS 2.3.5, a vendor build based on OpenWrtKernelLinux 3.18.29That kernel is far too old to have the WireGuard module, and the vendor firmware has no package manager I want to trust. So the whole stack runs in userspace, as static Go...