Most developers use Dart and Flutter exclusively for UI development. But recently, I wanted to see how far I could push Dart's capabilities down into low-level systems programming.
I set out to build sstp_client: a pure-Dart implementation of the Microsoft Secure Socket Tunneling Protocol (MS-SSTP) alongside a Point-to-Point Protocol (PPP) engine and cross-platform TUN device routing.
Here is how the architecture works, how I handled binary protocols natively, and a fascinating quirk I uncovered while debugging raw memory bindings on Apple Silicon.
The Architecture Layer Cake
Building a VPN client from scratch requires handling everything from a standard HTTPS handshake down to individual IP packets. To keep the project clean, I split the network stack into decoupled layers:
+-----------------------------------------------------------+
| TunnelBackend |
| (LinuxTunBackend | WindowsTunBackend | MacosUtun) |
+-----------------------------------------------------------+
▲
│ (Raw IP Datagrams)
▼
+-----------------------------------------------------------+
| SstpSession |
| (PPP Engine: LCP -> MSCHAPv2 Auth -> IPCP) |
+-----------------------------------------------------------+
▲
│ (Control/Data Frames)
▼
+-----------------------------------------------------------+
| SstpFramer |
| (Reassembly Buffer & SSTP Encapsulation) |
+-----------------------------------------------------------+
▲
▼
+-----------------------------------------------------------+
| TlsTransport |
| (dart:io SecureSocket + HTTP Bootstrap) |
+-----------------------------------------------------------+
1. The Bootstrapping & Framing Layers (TlsTransport & SstpFramer)
SSTP is essentially a mechanism to tunnel PPP traffic through an encrypted HTTPS channel over port 443.
- First,
TlsTransportspins up a standarddart:io SecureSocketand executes an HTTPSSTP_DUPLEX_POSTrequest to establish the tunnel. - Once connected,
SstpFramertakes over, managing a stateful reassembly buffer to slice and dice incoming bytes into distinct SSTP control frames or raw data payloads.
2. The State Machine (SstpSession + PPP)
Once the SSTP tunnel is open, we have to negotiate a connection using the Point-to-Point Protocol (PPP). I implemented the handshake state machine entirely natively in Dart:
- LCP (Link Control Protocol): Negotiates configuration options like the Maximum Receive Unit (MRU).
- MSCHAPv2: Handles cryptographic authentication using RFC 2759 vectors.
- IPCP (Internet Protocol Control Protocol): Negotiates the network layer configuration, allowing the remote server to assign us a virtual IP address.
3. The Hardware Plane (TunnelBackend)
Getting an IP address isn't enough; you have to intercept system network traffic. I built a unified, platform-agnostic interface (TunnelBackend) with three separate system-level backends using Dart's Foreign Function Interface (dart:ffi):
-
Linux: Opens
/dev/net/tunand triggers anioctl(TUNSETIFF)system call to create a virtual network interface, using nativeip routecommands to modify routing tables. -
Windows: Windows lacks a native userspace TUN driver, so this backend interacts with Wintun (the highly efficient driver used by WireGuard). It dynamically loads
wintun.dlland updates routing vianetsh. -
macOS: Utilizes the kernel's built-in
utunframework by spinning up aPF_SYSTEMcontrol socket. Becauseutunprefixes every packet with a 4-byte address family header (unlike Linux), the Dart backend dynamically strips it inbound and prepends it outbound.
Overcoming the Asynchronous Bottleneck
Dart is famously single-threaded, running on an event loop. Reading from a kernel TUN interface or a Windows driver ring buffer is a blocking operation. If you block the main thread waiting for a packet, your network stack grinds to a halt.
To solve this, I decoupled the read loop entirely. Each platform backend spawns a dedicated, isolated worker thread (Dart Isolate). The isolate runs a tight, continuous blocking read loop inside C-land, passing raw packet buffers back to the main Dart event loop via an asynchronous SendPort without stuttering or latency spikes.
A Fascinating Find: The Apple Silicon Variadic-ABI Bug
The most rewarding part of building this was dealing with low-level cross-architecture bugs. During Continuous Integration (CI) testing on an Apple Silicon (ARM64) runner, a piece of code that compiled perfectly on x86-64 Linux suddenly threw garbage pointer exceptions in the kernel.
The culprit? The ioctl system call signature.
On x86-64 architectures, standard and variadic arguments are routinely passed across CPU registers. However, on macOS ARM64, the platform ABI specifies that variadic arguments (...) must be passed on the stack, not in registers.
A standard Dart FFI binding didn't differentiate them, resulting in the kernel looking in the wrong location for our arguments. The fix required explicitly telling Dart's FFI allocator to use the VarArgs declaration specifically for Darwin targets:
// The fix that keeps ioctl from breaking on ARM64 macOS
typedef NativeIoctl = ffi.Int32 Function(ffi.Int32, ffi.UnsignedLong, ffi.VarArgs);
What's Next?
Right now, the client successfully handles full-tunnel and split-tunnel routing over IPv4 with robust cleanups that restore your system's original routes instantly upon disconnect.
The core stack is fully unit-tested entirely offline using mocked crypto vectors (RFC 2759) and synthetic pipes—no root permissions or live servers required to verify the code integrity.
If you are interested in exploring low-level networking in Dart, handling byte streaming, or inspecting system FFI implementations, feel free to dive into the code:
👉 Check out the sstp_client repository on GitHub
Have you ever pushed a high-level language like Dart down to the system kernel layer? Let's chat in the comments!
Top comments (0)