Status: v1 (decisions locked 2026-07-28)
Author: port (with Claude)
Date: 2026-07-28
Repo: evolves from mullvad-wireproxy-setup
macOS has no good app-level split tunneling. If you want one browser to exit through a VPN while the rest of your machine (including other VPNs like Tailscale) stays untouched, your options today are bad:
- VPN apps (Mullvad, Proton) tunnel the whole machine; their split tunneling is absent or unreliable on macOS.
- Proxy extensions (FoxyProxy etc.) leak WebRTC, don't isolate cookies/ profile state, and need a proxy endpoint you still have to build.
- VMs / separate user accounts work but are heavyweight and awkward.
The working answer is userspace WireGuard (wireproxy) exposing a local SOCKS5 port, plus a dedicated Chromium profile pinned to that proxy with WebRTC locked down. Today that requires hand-assembling configs, launchd agents, and launcher scripts. This repo did it once, hardcoded, for one browser and one Mullvad device. When the device was rotated, fixing it required manual surgery.
A single-file CLI that manages N isolated browser instances, each routed through its own WireGuard tunnel:
perbrowser add work ~/Downloads/se-sto-wg-205.conf --browser brave
perbrowser add us-video ~/Downloads/us-nyc-wg-301.conf --browser chrome
perbrowser list
perbrowser update-config work ~/Downloads/new-device.conf
perbrowser doctor [name]
perbrowser rm work
Each instance is: a wireproxy daemon (launchd-managed, auto-restart) on its
own local port + a generated launcher app in ~/Applications that opens the
browser with an isolated profile hardwired to that proxy.
Provider-agnostic: any WireGuard .conf works (Mullvad, Proton, IVPN,
self-hosted). Not a Mullvad tool.
- Privacy-conscious developers: want a "VPN browser" next to a normal browser without tunneling their dev machine. (Primary; this is us.)
- Multi-region account users: one browser per region/identity, cookies and exit IP isolated together.
- Geo-testing: QA/growth people checking how a site behaves from different countries, side by side.
- One command turns a WireGuard conf + a browser choice into a working, Spotlight-launchable, auto-restarting VPN browser.
- Key rotation is one command (
update-config), not surgery. - No root, no kernel extensions, no interference with other VPNs.
- No leaks: DNS resolves through the tunnel, WebRTC pinned to proxied transports, profile fully separate from the user's normal browser.
- Auditable: plain POSIX shell, single file, no curl-to-bash of third-party code beyond the pinned, checksummed wireproxy release.
- Linux/Windows support (v2: systemd user units; Windows unplanned).
- Firefox (different mechanism: prefs, not flags; documented as out of scope).
- Fetching configs from provider APIs (user downloads the
.confthemselves). - GUI, menubar app.
- System-wide or per-app-other-than-browser tunneling.
- Validate the conf (has
PrivateKey,Endpoint; warn if world-readable). - Allocate the next free port starting at 1080; record in state.
- Install wireproxy if missing (pinned version, SHA256-verified, darwin_arm64 and darwin_amd64).
- Write
~/.config/perbrowser/<name>/wg.conf(chmod 600) andwireproxy.confpointing at it. - Write + bootstrap
~/Library/LaunchAgents/com.perbrowser.<name>.plist(KeepAlive, logs to the instance dir). - Generate launcher script +
~/Applications/<Name> (VPN).appwrapper:--user-data-dir=~/.config/perbrowser/<name>/profile,--proxy-server=socks5://127.0.0.1:<port>,--host-resolver-rules="MAP * ~NOTFOUND , EXCLUDE 127.0.0.1",--force-webrtc-ip-handling-policy=disable_non_proxied_udp. - Verify: curl through the proxy, print exit IP/country. Fail loudly if the tunnel doesn't come up.
Table: name, browser, port, launchd state, tunnel check (exit IP + country,
DEAD if the proxy times out, because a dead key looks exactly like today's bug).
Swap the conf, chmod 600, restart the agent (bootout + bootstrap, more
reliable than kickstart in practice), re-verify, offer to shred the source
file (it contains a private key).
Per instance: agent loaded? process running? port listening? exit IP sane? DNS through tunnel? Applies safe, reversible fixes itself (re-bootstrap an unloaded agent, restart a dead wireproxy, correct file permissions) and prints the exact command for anything destructive rather than running it. This command exists because today's failure mode ("handshake logs look fine, traffic is dead") was confusing.
Bootout + delete agent, app, instance dir. --keep-profile preserves
cookies/history.
~/.config/perbrowser/<name>/state (KEY=VALUE: port, browser, created).
Directory 700, confs 600. No central registry file. The directory listing
is the registry.
- One wireproxy process per instance. Simpler than multiplexing; failure isolation; launchd restarts each independently.
- launchd, not a daemon of our own. KeepAlive gives us supervision for
free;
launchctl printgives debuggability. - Chromium flags, not extensions. Flags can't be disabled by a site or forgotten by the user; the profile is born proxied.
- wireproxy pinned by checksum. It carries private keys; we verify the release artifact and never auto-update it silently.
- GitHub repo (MIT), rename
mullvad-wireproxy-setup→perbrowser. GitHub redirects the old URL; history and provenance preserved. Name verified available on GitHub and Homebrew (2026-07-28). - Homebrew tap (
brew install <user>/tap/perbrowser): the right channel for a security-adjacent tool; nocurl | shin the README as the primary path. - README hero: screenshot of two browser windows side by side showing two different countries on an IP-check site, above a 3-line quickstart.
- Migration: documented manual steps only (remove old agent, then
perbrowser add discord <conf> --browser brave). No migration code, because the old setup has a single user and the repo is private until M3; drop the docs section once that user has migrated.
- M1, Core CLI:
add,list,rmworking end to end on macOS ARM; migrate the existing discord-brave instance. (One sitting.) - M2, Lifecycle:
update-config,doctor, Intel mac support,--keep-profile, shellcheck clean, basic bats tests. - M3, Public: rename repo, README with screenshot demo, Homebrew tap, MIT license, CONTRIBUTING with the Firefox/Linux "help wanted" list.
| Risk | Impact | Mitigation |
|---|---|---|
| Chromium removes/changes proxy or WebRTC flags | Leak or breakage | doctor checks effective exit IP, not just config; pin flag docs in README |
| Users leave downloaded confs (private keys) in ~/Downloads | Key exposure | add/update-config offer to delete the source file every time |
| Dead key looks like "working" (handshakes log, no traffic) | Confusing failures | list/doctor always test real traffic through the proxy |
| Port collisions with other local services | Startup failure | Probe ports before assigning; port recorded in state |
| Name squatting / confusion with wireproxy itself | Adoption friction | Credit wireproxy prominently; we are a manager, not a fork |
add→ working VPN browser in under 60 seconds on a clean machine.update-configrecovers a rotated key in under 15 seconds.- Zero WebRTC/DNS leaks verified against browserleaks.com per release.
- External signal: first issue/PR from someone who isn't us.
- Name:
perbrowser. Verified available: no GitHub user/org, no repos with the name, not in Homebrew core. doctor: diagnose + safe auto-fixes. Reversible fixes run automatically; destructive ones are printed, not executed.- Migration: docs only. Single user, repo private pre-M3; remove the docs section after that user migrates.
- Repo: rename existing (keeps history + GitHub redirects).
- License: MIT.
What exists in this repo today (the hardcoded v0 that perbrowser generalizes):
install.sh: one-shot installer that downloads wireproxy v1.1.2 (SHA256-pinned, darwin_arm64), writes configs, creates launcher +Discord Brave.app, bootstraps the launchd agent. ~80% of whataddneeds, just parameterized by nothing.files/wireproxy.conf: points at~/.config/wireproxy/mullvad.conf, SOCKS5 on 127.0.0.1:1080.files/com.wireproxy.mullvad.plist: launchd agent, KeepAlive, logs to~/.config/wireproxy/wireproxy.log.files/discord-brave: Brave launcher with the three critical flags (proxy-server, host-resolver-rules, force-webrtc-ip-handling-policy).files/Info.plist: the .app wrapper manifest.
Live on this machine: agent com.wireproxy.mullvad running, config at
~/.config/wireproxy/mullvad.conf (Mullvad device "Square Bison",
se-sto-wg-205), verified working 2026-07-28. This becomes the manually
migrated discord instance once M1 ships.
Operational lessons from the 2026-07-28 key-rotation incident (bake into
doctor and update-config):
- A deleted/rotated Mullvad device still logs handshake responses; the
only reliable health check is real traffic through the SOCKS5 port
(
curl --proxy socks5h://127.0.0.1:<port> https://am.i.mullvad.net/json). launchctl kickstart -kcan fail with "Operation not permitted" on a loaded agent;bootout+bootstrapis the reliable restart path. Notebootoutalone leaves the agent unloaded. Always pair them.- Claude Code's sandbox cannot run
launchctlbootstrap/bootout/kickstart orpkill(including via the!prefix, which shares the sandbox). When implementing/testing, have the user run those specific commands in a real terminal, or expect permission errors.