Skip to content

Latest commit

 

History

History
216 lines (176 loc) · 10.1 KB

File metadata and controls

216 lines (176 loc) · 10.1 KB

PRD: perbrowser, one VPN per browser

Status: v1 (decisions locked 2026-07-28) Author: port (with Claude) Date: 2026-07-28 Repo: evolves from mullvad-wireproxy-setup

1. Problem

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.

2. Solution

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.

3. Users

  1. Privacy-conscious developers: want a "VPN browser" next to a normal browser without tunneling their dev machine. (Primary; this is us.)
  2. Multi-region account users: one browser per region/identity, cookies and exit IP isolated together.
  3. Geo-testing: QA/growth people checking how a site behaves from different countries, side by side.

4. Goals / Non-goals

Goals (v1)

  • 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.

Non-goals (v1)

  • 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 .conf themselves).
  • GUI, menubar app.
  • System-wide or per-app-other-than-browser tunneling.

5. CLI specification

perbrowser add <name> <wg.conf> [--browser brave|chrome|edge|chromium]

  1. Validate the conf (has PrivateKey, Endpoint; warn if world-readable).
  2. Allocate the next free port starting at 1080; record in state.
  3. Install wireproxy if missing (pinned version, SHA256-verified, darwin_arm64 and darwin_amd64).
  4. Write ~/.config/perbrowser/<name>/wg.conf (chmod 600) and wireproxy.conf pointing at it.
  5. Write + bootstrap ~/Library/LaunchAgents/com.perbrowser.<name>.plist (KeepAlive, logs to the instance dir).
  6. Generate launcher script + ~/Applications/<Name> (VPN).app wrapper: --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.
  7. Verify: curl through the proxy, print exit IP/country. Fail loudly if the tunnel doesn't come up.

perbrowser list

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).

perbrowser update-config <name> <new.conf>

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).

perbrowser doctor [name]

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.

perbrowser rm <name>

Bootout + delete agent, app, instance dir. --keep-profile preserves cookies/history.

State

~/.config/perbrowser/<name>/state (KEY=VALUE: port, browser, created). Directory 700, confs 600. No central registry file. The directory listing is the registry.

6. Architecture notes

  • 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 print gives 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.

7. Distribution

  • 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; no curl | sh in 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.

8. Milestones

  1. M1, Core CLI: add, list, rm working end to end on macOS ARM; migrate the existing discord-brave instance. (One sitting.)
  2. M2, Lifecycle: update-config, doctor, Intel mac support, --keep-profile, shellcheck clean, basic bats tests.
  3. M3, Public: rename repo, README with screenshot demo, Homebrew tap, MIT license, CONTRIBUTING with the Firefox/Linux "help wanted" list.

9. Risks

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

10. Success criteria

  • add → working VPN browser in under 60 seconds on a clean machine.
  • update-config recovers 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.

11. Decisions log (2026-07-28)

  • 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.

12. Current state (starting point for implementation)

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 what add needs, 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 -k can fail with "Operation not permitted" on a loaded agent; bootout + bootstrap is the reliable restart path. Note bootout alone leaves the agent unloaded. Always pair them.
  • Claude Code's sandbox cannot run launchctl bootstrap/bootout/kickstart or pkill (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.