The control plane chapter · processes

One side can rewrite your routing table.

The other side is a user interface. The channel between them is a privilege boundary.

01
the UI is the largest attack surface you ship

Why two processes

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.

02
one descriptor, once — never a packet

What crosses the boundary

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.

Crosses the boundary

  • Commands — connect, disconnect, reconfigure
  • Status, state, and metrics
  • Log and event streams
  • A user-entered secret, submitted once
  • On Linux, optionally: the tunnel file descriptor — one time

Never crosses

  • Packets. Not one.
  • Proxy keys, tokens, or credentials, read back out
  • Any per-flow data path
  • Trust in the caller's own claims about itself

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.

The consequence for the code. The core knows nothing about processes or IPC at all — no conditional compilation for "am I the service," no awareness of a channel. That is precisely what lets the same core run inside a Linux daemon, a Windows service, an Apple network extension, and an Android service without modification. The process model is glue around it, never a concern inside it.
03
small enough to read in an afternoon

The wire protocol

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.

u32 LE · length
one message, compact binary encoding
↑ that's the framing. one message per frame, with a hard cap on length.

Four properties do the real work:

  • Versioned, with negotiation. A handshake settles on the highest version both sides understand, so the interface and the service can be updated independently — which they will be, because on desktop they are separately installed artifacts and users update them whenever they feel like it.
  • Request IDs, echoed in responses. Concurrency without ordering assumptions, and every request carries a timeout. No unbounded waits anywhere.
  • A bounded cap on frame length. The privileged side is parsing input from a less-privileged one; an unbounded length prefix is an allocation primitive handed to an attacker.
  • Pure protocol, no transport. The message types and their encoding are a standalone library with no async runtime and no sockets in it.

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.

04
connecting is not the same as being allowed

Authenticating the caller

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.

PlatformMechanismWhat it establishes
Linuxpeer credentials on the socketThe 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.
Windowsnamed-pipe security descriptorOnly 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.
macOScode-signing requirementThe 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-mediatedApp 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.

The failure this prevents. Every one of these mechanisms exists to stop the same attack: an unprivileged process on the same machine talking to the service and telling it to route the user's traffic somewhere of the attacker's choosing. A VPN daemon that takes orders from anyone is a man-in-the-middle appliance that the user installed themselves and trusts completely.
05
the tunnel outlives the window

When one side dies

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.

  • The service is the source of truth. The interface is a view, not a replica. On every connect and reconnect it asks for full status and re-syncs, rather than assuming its picture survived the gap. State that lives only in the UI is state that lies after a restart.
  • The data plane keeps forwarding. If the control channel dies, traffic keeps flowing. Only control becomes temporarily unavailable — a window that can't currently talk to the tunnel is an inconvenience, whereas a tunnel that drops every connection because a window closed is a catastrophe, and on a hostile network it can be a dangerous one.
  • Backpressure, with an honest marker. Log and event streams are bounded, dropping oldest when a consumer stalls — and they say how many were dropped. A slow interface must never be able to exhaust memory in the privileged process, and it must never be able to quietly convince you that you saw everything.
  • Heartbeats both ways, with a bounded detection time. A dead peer that is never noticed is worse than one that is: it produces a UI confidently displaying a state that stopped being true some time ago.
  • Reconnect with capped backoff. A client that reconnects in a tight loop against a service that is down turns one failure into two.

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.