↑ ↓ or scroll
ovpn-dco-win

How we test
a kernel driver
in CI

Three rigs, on every pull request, against machines of their own. PowerShell and shell scripts — the only binary involved is the driver you are testing.

Three rigs, three questions

Perf

How fast are we?

Windows Linux

Five tunnels, both directions, every pull request.

Verdict — a floor, not a target.
Ioctl

Fuzzing and racing the API

driver

Malformed input OpenVPN never sends; orderings it can never produce.

Verdict — Driver Verifier, not the output.
Stress

Does it stay correct?

server

Twenty thousand peer sessions a run, keys rotating throughout.

Verdict — no loss, no failed decrypt, still alive.
The machines

Seven EC2 machines, one placement group

perf · four machines stress · two ioctl · one device under test Windows Server 2025 load generator · drives the run Ubuntu 24.04 second Windows peer Windows Server 2025 second Linux peer Ubuntu 24.04 device under test Windows Server 2025 runs every client Ubuntu 24.04 target Windows Server 2025 ALL SEVEN, ONE CLUSTER PLACEMENT GROUP 16 vCPU each ENA adapters; a reset costs ~90 s 4965 → 9529 Mbit/s one raw flow, outside the group vs inside stopped between runs SSH opened to the runner alone, then revoked
Windows Server 2025the same kernel base as Windows 11 24H2, so the rigs deploy the -Win11 driver build. Server 2022 machines are kept, stopped, as the way back.
Built fresh, elsewhereevery run compiles the driver on a hosted runner and stages the .sys; the machines carry no toolchain. Stress and ioctl take a checked build with Verifier armed, perf a release one — a checked build traces the data path and reads a factor low.
Pets, not cattlea run starts and stops them; it never builds them. Each carries hand-made state — test signing, iperf3 and OpenVPN staged on disk, and a DCO adapter that must already exist. The baked image is deliberately not sysprepped: generalising regenerates the SID and disturbs that adapter.
Nothing identifying in the repoinstance ids, the security group and the region live in Actions variables. The role is OIDC — there are no long-lived AWS keys.
Perf

Both directions, five tunnels, every PR

AES-256-GCM — the default cipher Gbit/s · iperf3 −P4 · median of 3 runs
Windows client ↔ Linux server · UDP
Windows → Linux
6.70
Linux → Windows
7.44
Windows client ↔ Linux server · TCP
Windows → Linux
5.70
Linux → Windows
4.30
Windows server ↔ Linux client · UDP
Linux → Windows
7.37
Windows → Linux
7.01
Windows client ↔ Windows server · UDP
client → server
6.96
server → client
6.49
Linux ↔ Linux · UDP — the control
client → server
6.15
server → client
7.22
per-flow ceiling on these instances, ≈7.4 Gbit/s inside the tunnel
one CI run (5 Oct, PR #159) — a floor is judged over many runs, never off one chart. Here the Linux control itself landed low.
Ioctl

Fuzzing the API

Every ioctl, called with mutated buffers — in multipeer mode, then again point-to-point, where the multipeer half should be refused — plus control codes the driver does not implement. Three things get mutated:

Buffer shapes

Input truncated to one byte and to one short of the struct, oversized, absent. Output too small, absent. Two past findings lived here, so it runs against every ioctl.

Values

Peer ids at the ends of the range, key lengths that are not 16, 24 or 32, enums past their last member, prefix lengths past 32 and 128, and the sockaddr families that decide how far the driver reads.

Pointers

Null with a non-zero length, and lengths that would overflow a size calculation.

Nothing asserts on the status a call returns. A refusal is the right answer to most of this and the driver may choose which refusal, so the run only calls out what should never have been accepted at all — a buffer shorter than the struct the driver reads, or a value outside its documented range. Driver Verifier judges the rest.

Ioctl

Racing the API

create / delete peers install / swap keys move iroutes read stats park on NOTIFY_EVENT control channel — WriteFile control channel — ReadFile datagrams at the listen port datagrams into the tunnel \\.\ovpn-dco a single shared handle ovpn-dco.sys Driver Verifier on

Ids come from a deliberately small space, so they are reused constantly, and one thread installs keys on the ids another is deleting.

On the control channel a reader finds the queue empty and parks, while the receive side takes the same lock to decide whether to hand it a packet or queue one. That window is the bug. So control traffic is deliberately rare — one datagram in 256. Flood it and the queue is never empty, the reader always finds a packet waiting, never parks, and never races.

Stress

Three client populations, all at once

4 pairs stay connected, iperf3 to each other client-to-client — never leaves the kernel reads the VPN-address table 16 swarm connect, ping-flood the server, get killed mid-flight, reconnect frees peers under live packets 800 flood dev null — no interface, no address. Handshake, idle, exit. A wave at a time churns peers and trie entries Windows DUT — ovpn-dco.sys as multipeer server 4 peer tables peer id · VPN addr v4/v6 · transport 2 iroute tries IPv4 · IPv6
20 000+
peer sessions in a run
~90k
packets between epoch rekeys — both directions, unmodified clients
1 cert
shared by every client, so the server cannot tell them apart
Stress

Testing iroutes

A peer's own address is in the peer table; subnets reachable behind a peer are in a trie, consulted only when that table misses. Peer churn rewrites the trie thousands of times a run, and traffic between tunnel addresses never once reads it — so the probes have to manufacture the lookups.

Every client is handed the same iroute, 10.90.0.0/24, so the prefix always has an owner and ownership moves every time anyone connects. Addressing that subnet rather than a tunnel address is what pushes a lookup past the peer table. It happens on two paths:

1  the server pings into the subnet

send path · destination lookup

A destination no peer holds as its own address. The trie resolves the prefix to its owner, which does hold it and answers. The reply proves the trie routed out.

2  a client pings from inside it

receive path · source lookup

Reverse-path filtering checks the source against the sending peer, misses, falls to the trie. Only the real owner is answered, so a reply proves the trie validated a source coming in — and names the owner.

3  everyone pings an address nobody holds

send path again · 100/s · all run

No table can ever answer it, so every packet reaches the trie — and nothing replies, because the owner is usually a dev null client. It asserts nothing. It re-runs probe 1 while churn rewrites the structure underneath.

Probes 1 and 2 run before the swarm and the flood begin, and that ordering matters: later the owner is usually a flood client with no interface at all, which would resolve and deliver correctly and still never answer.

What it has caught

ioctl

Write-path self-send deadlock — present since 2.4.0

Needs a control write addressed to the driver's own endpoint while another writer is queued. An ordering OpenVPN never produces.

stress

A timer holding a freed peer

The window is microseconds. The swarm kills clients with pings still in flight — exactly when the driver frees a peer and detaches its timer.

ioctl

A lost wakeup on the control read path

A reader parks, a packet arrives, both take the same lock. It needs a reader that actually has to park — a flood would just leave a backlog and race nothing.

perf

Control writes held pending — 6.5 s handshakes instead of 0.05 s

Correctness tests pass: the handshake completes. Only the clock shows it.

Case study · PR #163, not merged

ChaCha20 is slow on Windows

CNG's ChaCha20-Poly1305 is scalar and bound per byte. On one core AES-256-GCM runs 25× faster, because AES-NI does it in hardware. That is fine while AES is the default — but anyone who configures ChaCha pays for it.

one core, Gbit/s
AES-256-GCM · CNG
67
ChaCha20-Poly1305 · CNG
2.7
ChaCha20-Poly1305 · CRYPTOGAMS
12.7
one tunnel, Windows ↔ Windows on ChaCha, Gbit/s
peer → DUT
5.46
 
7.33
DUT → peer
4.89
 
5.92
interleaved, medians of 3 · CNG in red, CRYPTOGAMS in green

We take the assembly from dot-asm/cryptogams, not OpenSSL: same author, same code, but the original 3-clause BSD rather than OpenSSL's Apache-2.0, which a GPLv2 driver cannot take. The cipher got 4.7× faster; the tunnel gained 34% and 21%.

Case study · what it cost

Then it bugchecked the machine

Two gigabytes into a run, the box died — in unrelated code, with ovpn_dco nowhere on the stack. poly1305's VPMADD52 path ends in vzeroall, which zeroes ymm0–15. On Win64 the low halves of XMM6–15 are the caller's to keep, and MSVC had live values in them.

volatile — fine to clobber non-volatile on Win64 — the caller's live values
✓
MAC always correct — known-answer tests passed
2M
randomised user-mode iterations, all clean
0
findings from Driver Verifier special pool
~2 GB
of sustained real traffic before it showed

The fix is one word — vzeroall to vzeroupper — after which 50.6 GB went through clean at 7.24 Gbit/s with the fast path still live. Nothing cheaper would have found it.

OpenSSL knew: #12744 reported exactly this in 2020, #21522 the same for the 8x path, and #21808 fixed both by excluding VPMADD52 on Win64 outright. dot-asm's tree — the BSD-licensed one a GPLv2 driver can actually use — still ships it unguarded.

Where it's thin

  • One architecture. Everything is x86-64. ARM64 builds in CI, never runs on hardware.
  • Short runs. 15 s per direction catches collapses, not slow leaks.
  • Throughput only. No latency, no jitter, no loss behaviour.
  • The network caps half of it. Several results sit on a per-flow ceiling near 7.4 Gbit/s.
  • CI figures aren't absolutes. On one build CI reported 5.89 Gbit/s for the Windows server's send direction, where a hand-run interleaved measurement of that same build gave 7.41. Unexplained.

tests/perf · tests/ioctl · tests/stress — each with a README covering setup and what every counter means.