The other side is a user interface. The channel between them is a privilege boundary.
Creating a tunnel device and editing the routing table requires root, a Linux capability, or Administrator. A user interface is the biggest, most frequently updated, most exposed thing in the product — web content, an update channel, whatever the user clicks. Running the second with the powers of the first is the whole problem in one sentence.
So a shipping client is two processes: a small privileged service that owns the tunnel, and an unprivileged client that draws the window. They frequently run as different users — a system service versus the logged-in human — which makes the channel between them a local privilege-escalation target and not merely an internal detail.
Android is the exception worth naming: the platform already puts the VPN in a system-managed service inside the same app sandbox, so there is no cross-user split to defend. The other platforms all have one, and each provides its own native way to check who is calling.
The split is drawn along the control plane. This is the rule the whole design hangs from, and it is worth being blunt about because the alternative is so tempting to write.
Shipping packets over a socket to another process would copy every one of them twice and spend the entire performance budget on ceremony. Whichever process owns the tunnel also runs the netstack and the transports, in its own address space. A few control messages a second cross the boundary; gigabytes do not.
If you want the core itself unprivileged — defence in depth, so the code parsing hostile packets isn't running as root — the answer still isn't packet IPC. A tiny privileged shim opens the tunnel and passes the descriptor to the unprivileged process, which then reads and writes the device directly. One descriptor crosses, once, at startup. After that the data path is entirely local again.
A local channel between two processes you ship together does not need a general-purpose RPC framework, and pulling one in would cost more in binary size and dependency surface than the entire protocol is worth. So it's about as small as a protocol gets.
Four properties do the real work:
That last one is what makes the protocol portable across wildly different channels. A Unix socket on Linux, a named pipe on Windows, a provider message through the OS on Apple platforms, and a direct in-process call on Android — the same messages ride all four, because the definition of a message never mentions how it travels. It also means the protocol cross-compiles and unit-tests everywhere, including places where you cannot open a socket at all.
The privileged side must never trust a peer merely because it managed to connect. Filesystem permissions on the socket are a useful layer and not an answer — they are easy to get subtly wrong at install time, and on some platforms they are advisory about who may open a path rather than who may command a daemon. Each platform provides a real mechanism, and the code uses it explicitly.
| Platform | Mechanism | What it establishes |
|---|---|---|
Linux | peer credentials on the socket | The caller's actual uid, gid, and pid, from the kernel rather than from the caller. Policy is explicit: root, or a member of one dedicated group. |
Windows | named-pipe security descriptor | Only the intended accounts can open the pipe at all, with the connecting token validated and impersonation guarded against. A loose descriptor here is a classic escalation bug. |
macOS | code-signing requirement | The daemon requires a specific signature on the connection, so only your signed application can drive it — not any process the user happens to be running. |
iOS / macOS (extension) | OS-mediated | App and extension share a team-scoped group; trust comes from signing and entitlements. Nobody rolls their own authorization here, which is the correct amount. |
Secrets move in one direction only. A user-entered credential is submitted once over the authenticated channel and persisted by the privileged side — in root-owned storage or the platform keychain — and is never read back out. The interface can ask whether a profile is configured; it cannot ask what the key is. Where several people share a machine, secrets and profiles are scoped per user, so the daemon cannot be turned into a device for reading someone else's.
Two processes means either one can vanish — crashed, updated, killed by the user, killed by the system. The interesting design work is almost entirely in what happens then.
None of this is exotic. It is the ordinary discipline of a system where the two halves are separately installed, separately updated, and separately killable — written down once, at the boundary, instead of rediscovered at each call site.