DEV Community

Cover image for How I Built a Pure-Dart SSTP & PPP VPN Client From Scratch (Linux, Windows, macOS)
sstp-pinger
sstp-pinger

Posted on

How I Built a Pure-Dart SSTP & PPP VPN Client From Scratch (Linux, Windows, macOS)

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)           |
+-----------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

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, TlsTransport spins up a standard dart:io SecureSocket and executes an HTTP SSTP_DUPLEX_POST request to establish the tunnel.
  • Once connected, SstpFramer takes 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:

  1. LCP (Link Control Protocol): Negotiates configuration options like the Maximum Receive Unit (MRU).
  2. MSCHAPv2: Handles cryptographic authentication using RFC 2759 vectors.
  3. 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/tun and triggers an ioctl(TUNSETIFF) system call to create a virtual network interface, using native ip route commands 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.dll and updates routing via netsh.
  • macOS: Utilizes the kernel's built-in utun framework by spinning up a PF_SYSTEM control socket. Because utun prefixes 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);
Enter fullscreen mode Exit fullscreen mode

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)