Technical writing

The Voidly Probe: Tauri + boringtun Network Measurement at the Operator's Edge

· 11 min read· AI Analytics
CensorshipVoidlyInfrastructureTauri

Voidly's 37+ probe nodes run from a cross-platform desktop application: macOS, Linux, and Windows. Each probe is an independent measurement vantage — a real device, on a real ISP, inside a jurisdiction where censorship may be active. This post covers how the probe application works: the technology stack, the WireGuard tunnel that routes measurement traffic back to the collector, how keys are generated and why they never leave the device, and the constraints that come from designing for operator safety first.

Why a desktop app

The most common model for distributed network measurement is containerized server probes — a VM somewhere that runs tests on a schedule. We rejected that model for two reasons:

  • Vantage diversity. A VM in a data center in Tehran doesn't see the same network as a residential ADSL connection in Tehran. Censorship is often applied differently at the ISP level — a block that appears at the residential customer level may not be visible from a co-located server. Desktop probes running on consumer ISPs are more representative of the experience that journalists and ordinary users have.
  • Operator safety. In jurisdictions where running a censorship probe could attract legal or physical risk, the operator must be able to run the probe without leaving a trace — no account, no email registration, no server that can be seized. A desktop app with locally-generated keys that never leave the device satisfies this constraint in a way that a server-side probe model doesn't.

Tauri 2: the application framework

The probe is built with Tauri 2 — a Rust-based framework for cross-platform desktop applications with a web frontend. Tauri uses the OS's native WebView rather than bundling a browser engine, making the binary small (under 10MB) and memory-efficient compared to Electron.

The architecture: a Rust backend (Tauri core + probe logic) communicates with a lightweight web frontend (the probe's UI) via Tauri's IPC bridge. The UI shows the probe's current status, measurement history, and the WireGuard peer address. The frontend talks to no external network — all network traffic is mediated by the Rust backend.

// Tauri command exposed to the frontend
#[tauri::command]
async fn get_probe_status(state: State<'_, ProbeState>) -> Result<ProbeStatus, String> {
    let status = state.lock().await;
    Ok(ProbeStatus {
        connected: status.tunnel_connected,
        measurements_today: status.measurement_count,
        last_measurement: status.last_run,
        asn: status.detected_asn.clone(),
    })
}

// Frontend calls it via Tauri's invoke API
const status = await invoke('get_probe_status');

boringtun: userspace WireGuard

All measurement traffic is routed through a WireGuard tunnel implemented with boringtun 0.7 — Cloudflare's pure-Rust userspace WireGuard implementation. Userspace WireGuard runs without kernel privileges, making it deployable on macOS and Windows without administrator rights (beyond TUN device creation, which requires a one-time permission grant).

Using userspace WireGuard rather than the kernel WireGuard module is a deliberate portability choice. The kernel module is faster, but requires root and isn't available on all platforms (particularly older macOS versions or locked-down enterprise Windows). boringtun runs anywhere Rust runs.

use boringtun::noise::Tunn;
use boringtun::noise::TunnResult;

// Instantiate a WireGuard tunnel with on-device keys
let peer_key = PublicKey::from_bytes(&remote_public_key)?;
let tun = Tunn::new(
    local_private_key.clone(),
    peer_key,
    None,   // no preshared key
    Some(25),  // keepalive every 25 seconds
    0,         // index
    None,      // rate limiter
)?;

// Encrypt an outbound packet
match tun.encapsulate(plaintext_packet, &mut encrypted_buf) {
    TunnResult::WriteToNetwork(bytes) => {
        udp_socket.send_to(bytes, &collector_endpoint).await?;
    }
    TunnResult::Done => { /* nothing to send */ }
    _ => { /* handle other states */ }
}

tun-rs: the kernel TUN device

Routing measurement traffic through the WireGuard tunnel requires a TUN (network tunnel) virtual interface. The probe uses tun-rs, a cross-platform Rust crate that creates TUN devices:

  • macOS — uses the utun API (no root required for creation)
  • Linux — uses the /dev/net/tun kernel interface (requires CAP_NET_ADMIN or root)
  • Windows — uses Wintun, a userspace TUN driver that doesn't require kernel-mode drivers
use tun_rs::AsyncDevice;

// Create TUN device
let config = tun_rs::Configuration::default();
config.address("10.0.0.2");
config.netmask("255.255.255.0");
config.mtu(1420);   // WireGuard standard MTU
config.up();

let device = AsyncDevice::new(config)?;

// Route measurement traffic through the TUN interface
// (done via OS routing table, configured during startup)
set_route(&device.name(), "10.0.0.1", "0.0.0.0/0").await?;

X25519-Dalek: on-device key generation

Each probe generates its own WireGuard key pair using x25519-dalek, Dalek Cryptography's pure-Rust X25519 implementation. The private key is generated at first launch, stored in the OS keychain (macOS Keychain, Linux GNOME Keyring / KWallet, Windows Credential Manager), and never leaves the device.

use x25519_dalek::{EphemeralSecret, PublicKey};
use rand_core::OsRng;

fn generate_probe_keypair() -> (StaticSecret, PublicKey) {
    let secret = StaticSecret::random_from_rng(OsRng);
    let public = PublicKey::from(&secret);
    (secret, public)
}

// Store private key in OS keychain — never written to disk directly
keychain::store("voidly-probe", "wg-private-key", &secret.to_bytes())?;

// The public key is registered with the Voidly collector
// so the collector knows which device is connecting
register_public_key(&public).await?;

The collector side maintains a whitelist of registered public keys. A probe can only connect if its public key was registered — either by the probe operator sharing the key via the app's “Register this probe” flow, or via the automated registration path for vetted operators. Unregistered keys are rejected at the WireGuard handshake layer.

The measurement cycle

Once the tunnel is established, the probe runs its measurement cycle every 5 minutes:

async fn run_measurement_cycle(tunnel: &WireGuardTunnel) {
    for domain in TEST_LIST.iter() {
        // DNS check: does the resolver return the expected IP?
        let dns_result = check_dns(domain, tunnel).await;

        // TLS check: does the handshake complete? Is the cert valid?
        let tls_result = check_tls(domain, tunnel).await;

        // HTTP check: is the response a block page?
        let http_result = check_http(domain, tunnel).await;

        // BGP check: is the domain's origin AS visible?
        let bgp_reachable = check_bgp_reachability(domain).await;

        let measurement = Measurement {
            domain: domain.to_string(),
            timestamp: Utc::now(),
            dns: dns_result,
            tls: tls_result,
            http: http_result,
            bgp: bgp_reachable,
            asn: tunnel.detected_asn(),
            country: tunnel.detected_country(),
        };

        // Encrypt and send to collector via WireGuard tunnel
        tunnel.send(measurement.encode()).await?;
    }
}

The 80-domain test list is fetched from the collector at startup. The list is versioned — probes track the list version they're running and report it alongside measurements, so the collector can filter measurements that used an outdated list. The list is distributed over the tunnel, not downloaded from a cleartext URL, so it can't be trivially blocked.

Operator anonymity properties

The probe is designed so that operating it doesn't expose the operator to risks beyond the measurement traffic itself:

  • No account or email. Registration uses a public key exchange — the operator shares a QR code or a hex-encoded public key, and the collector adds it to the whitelist. No username, no password, no email address.
  • Private key never leaves the device.Stored in the OS keychain, never written to a log file or transmitted. If the device is seized, the key can't be extracted (assuming the device is locked) because the OS keychain enforces access control.
  • Measurement traffic looks like WireGuard.An observer on the network sees UDP traffic to a fixed IP — the same traffic pattern as a WireGuard VPN. There's no protocol fingerprint distinguishing measurement traffic from ordinary VPN usage.
  • No DNS leak. All measurement DNS queries route through the WireGuard tunnel to a resolver the collector operates, not to the local ISP's resolver. This is important: the censorship check is whether the local resolver tampers with DNS — done by comparing the local ISP resolver result (queried directly, without the tunnel) to the trusted resolver result (queried through the tunnel).
  • Graceful shutdown. Closing the app terminates the tunnel and removes the TUN interface. No persistent services, no startup daemons — the probe is active only while the app is running.

Operating at 37+ vantage points

The collector receives measurements from all active probes, tags each with its source ASN (detected from the WireGuard handshake's observed IP), and pipes them into the same anomaly classifier and cross-source reconciler described in the cross-source verification post.

Vantage selection favors ASN diversity over country diversity: we prefer two probes on different ISPs in the same country over two probes on the same ISP in different countries. A measurement that appears only on one ISP but not others in the same country is a strong signal that the block is ISP-specific (a common pattern for commercially-motivated blocking as opposed to government-ordered shutdown events).

The open-source probe and the server side

The desktop probe application is open source at github.com/voidly-ai/voidly-probe-app. The collector-side infrastructure (the WireGuard server, the anomaly classifier, and the cross-source reconciler) is not public — it's a single point of failure that could be targeted if its design were fully disclosed. The measurement protocol (what gets sent, how it's encoded, and what the collector does with it) is documented in the repo README so that independent parties can audit the data pipeline end-to-end without needing access to the collector code.


For the URL test list the probe measures every 5 minutes: Voidly's URL test list: how we curate the domains that reveal internet censorship →

For how the probe's raw measurements are classified into interference types: The Voidly anomaly classifier: five interference classes and why we optimize for recall →

For how the classifier output is reconciled across OONI, CensoredPlanet, and IODA: Cross-source censorship verification: reconciling OONI, CensoredPlanet, and IODA →

For the 7-day forecast model that uses verified incidents as training data: Seven-day internet shutdown forecasting →

For how the probe's control comparison distinguishes censorship from network errors: The Voidly control server: how we tell censorship from a bad network →

For how Voidly monitors these probe nodes, detects degraded performance, and replaces failed ones: Voidly probe health monitoring: how we detect and replace failing probe nodes →

For how the scheduler decides which domains each probe tests and when — priority tiers, anomaly-driven boosts, ±15% jitter, and per-country task budgets: The Voidly measurement scheduler: how we decide which domains to probe and when →

For the full protocol lifecycle once the probe begins a test — DNS, TCP, TLS, HTTP layers and how each maps to interference types: How Voidly measures HTTP and HTTPS censorship: the full protocol lifecycle from DNS through TLS to body comparison →

For how the probe maintains connectivity and uploads measurements from hostile networks — QUIC/443, domain fronting, certificate pinning, and local buffering: Voidly probe networking: staying connected through NAT, firewalls, and censored infrastructure →

For how the Rust async engine inside the probe orchestrates concurrent measurements — tokio Semaphore, MeasurementState machine, per-layer timeouts, and Ed25519 signing: The Voidly probe test runner: concurrent measurement orchestration with Rust and tokio →

For how the probe buffers measurements locally during upload failures — SQLite ring buffer, LZ4 compression, and chunked delivery with per-chunk acknowledgment: Voidly probe local measurement buffer: SQLite ring buffer, batch compression, and resilient upload →