Pessoa: leak-proof browser isolation with network namespaces and WireGuard
May 29, 202611 min read
I manage online accounts for several clients, and some of those accounts care a lot about where the traffic comes from. The expected IP is the client's, not mine. Cookie-level isolation — containers, separate browser profiles, multi-account containers — doesn't solve this: the cookies are separated but the network path still leaks back to my home connection. The fix has to live at the network layer, not the application layer.
Pessoa is the tool I built for that. It gives each client an isolated Firefox whose traffic physically cannot leave except through that client's WireGuard tunnel. Not "configured to use a proxy" — there is no other route out of the box it runs in. The repo is github.com/milojarow/pessoa, AGPL-3.0.
Let me be honest about what it is before the impressive part. Pessoa is a single-operator localhost tool, not a polished SaaS. It binds 0.0.0.0:8000. It needs root, the WireGuard kernel module, and a broad passwordless sudo rule so the web process can run ip netns and wg without prompting. You would not expose this to the internet. It's a personal workflow layer that automates a pile of ip netns commands I was otherwise typing by hand. The name comes from Fernando Pessoa, who kept dozens of complete literary identities — heteronyms — in parallel, each with its own biography and voice. Same idea: many separate personas, each watertight.
The leak problem, precisely
A WireGuard config has an Endpoint — the public address of the VPN server — that your machine reaches over plain UDP. That UDP socket has to originate from a network stack that can route to the public internet. So you can't just drop a process into a fully empty namespace and hand it a WireGuard config: the handshake packets would have nowhere to go.
But you also can't leave the interface on the host, because then anything could route around it. You need a box where the only exit is the tunnel, while the tunnel's own underlying socket still reaches the endpoint.
The resolution is a sequence that looks backwards the first time you see it:
- Create the WireGuard interface on the host, where the UDP socket can complete the handshake to the endpoint.
- Move the live interface into a fresh network namespace with
ip link set wg-{slug} netns pessoa-{slug}. - Inside the namespace, set the interface's address, bring up
lo, and add a default route through the WireGuard interface and nothing else. - Launch Firefox with
ip netns exec, so the browser is born inside that namespace.
The WireGuard socket keeps talking to the endpoint from the host's perspective (that's a property of how wireguard interfaces work once created), but Firefox, living in the namespace, sees exactly one route: the tunnel. There is no eth0 in there, no fallback, no DNS leak — /etc/netns/pessoa-{slug}/resolv.conf overrides resolution inside the namespace too. If the tunnel is down, the browser has no connectivity. That's the property I want: failure is closed, not open.
The control plane (gray, dashed) is the panel shelling out to sudo ip netns, wg, and firefox. The data plane (blue) is the only path the client's traffic can take. Nothing crosses between namespaces.
The startup sequence in real commands
This is the heart of it, lightly trimmed from app/local_client.py. Every call goes through sudo --non-interactive — if the sudoers rule is missing, every one of these fails silently and the UI just spins.
# 1. fresh namespace
sudo ip netns add pessoa-acme
# 2. WireGuard interface created on the HOST (so its UDP socket can reach the endpoint)
sudo ip link add wg-acme type wireguard
sudo wg setconf wg-acme /tmp/pessoa-acme-wg.conf
# 3. move the live interface into the namespace
sudo ip link set wg-acme netns pessoa-acme
# 4. configure inside the namespace: address, loopback, and a single default route
sudo ip netns exec pessoa-acme ip link set lo up
sudo ip netns exec pessoa-acme ip addr add 10.8.0.4/32 dev wg-acme
sudo ip netns exec pessoa-acme ip link set wg-acme up
sudo ip netns exec pessoa-acme ip route add default dev wg-acme
# 5. trigger the handshake by sending one packet through the tunnel
sudo ip netns exec pessoa-acme ping -c1 -W5 1.1.1.1
A few details that took iterations to get right:
- The config is split before it's applied. A
wg-quick.confmixes WireGuard-native fields (PrivateKey,Peer,Endpoint) withwg-quickconveniences (Address,DNS,MTU,PostUp).wg setconfonly understands the native ones. So the parser stripsAddress/DNS/MTUout, applies them withipcommands inside the namespace, and silently dropsPostUp/PostDown/Tableentirely — those scripts assumewg-quick's world and would fight the namespace. - DNS is namespaced too. Writing
/etc/netns/pessoa-acme/resolv.confmakes resolution inside the namespace use the tunnel's DNS, closing the classic VPN DNS leak. - The interface name budget is brutal. Linux caps interface names at 15 characters. The prefix
wg-eats three, so the client slug has to be 12 characters or fewer. The code raises before doing anything if the name would overflow. - Cleanup is transactional. If any step throws, the
exceptblock tears down the namespace, the interface, and the/etc/netnsdirectory, then re-raises. A half-built namespace is worse than none.
Here's a redacted shape of the config Pessoa ingests — never a real key:
[Interface]
PrivateKey = <REDACTED — 44-char base64>
Address = 10.8.0.4/32
DNS = 10.8.0.1
MTU = 1420
[Peer]
PublicKey = <REDACTED>
PresharedKey = <REDACTED>
Endpoint = vpn.example.net:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
AllowedIPs = 0.0.0.0/0 is what makes the tunnel a default route for everything, which is exactly what we want inside a namespace with no other exit.
Status is derived, never stored
The thing I'm quietly proud of: Pessoa keeps no state about whether a tunnel is up. There's no database row that says "acme is connected," no flag that can drift out of sync with reality. Every status is computed on read from live kernel state — specifically from the peer's last handshake timestamp.
When the UI asks for status, the panel runs wg show <iface> dump inside the namespace and reads the peer line. The fifth tab-separated field is the Unix timestamp of the latest handshake; fields six and seven are received and transmitted bytes. From those three numbers and whether the namespace exists at all, the entire state machine falls out:
The subtle state is Idle. A naive reading would call any handshake older than 180 seconds dead. But WireGuard is lazy — it only renegotiates when there's traffic to send. A tunnel that carried bytes and then went quiet has a "stale" handshake and is perfectly fine; it'll re-handshake the instant Firefox makes a request. So the rule is: stale handshake with prior transfer is Idle (healthy, dormant), stale without any bytes ever is genuinely broken — Error. Encoding that distinction is the difference between a status light that cries wolf and one you actually trust.
Because there's nothing stored, there's nothing to reconcile, nothing to migrate, no cache to invalidate. Delete the namespace out from under the panel and the next status read just says Stopped. The kernel is the database.
Why HTMX, no JSON API
The whole frontend is server-rendered HTML fragments swapped in over the wire. There is no client-side JSON API, no React, no build step on the frontend at all — just FastAPI returning Jinja2 partials and HTMX gluing them into the page. Click Start VPN and the button posts to a route that does the namespace dance and returns the updated client card as HTML, which HTMX drops into place.
For a tool whose entire job is "render the current state of some namespaces and let me poke them," this is the right amount of machinery and not one piece more. The polling is adaptive: the page refreshes the client list every 10 seconds normally, and accelerates to every 2 seconds while any client is Starting, so a freshly-launched tunnel flips to Active almost as soon as the handshake lands, without hammering the box the rest of the time. No WebSocket, no SSE, no state-management library. The server already knows the truth; HTMX just keeps the page honest about it.
What it is and isn't
It is: a leak-proof, per-client browser isolation tool that I actually use, where the network guarantee is structural rather than configured, and where the status you see is the literal state of the kernel.
It isn't: hardened, multi-user, or safe to expose. The passwordless sudo surface alone means anyone who reaches :8000 can run ip netns and wg as root through it. That's an acceptable trade for a tool that lives on localhost on a machine only I touch, and an unacceptable one for anything else. I'd rather ship something honest and narrow than pretend the localhost tool is a product.
The interesting engineering was never the web panel. It was the realization that you create the interface where it can reach the network, then move it to where it can't reach anything else — and that the cleanest status system is the one that stores nothing at all.