Technical deep-dive

How Wuse works underneath

This page documents the client-side technical implementation of Wuse — the app, the system network extensions and the self-hosted node agent — broken down by subsystem. The cloud service's internals are not disclosed here.

🏗️

Architecture overview

Two planes: a control plane over HTTPS REST, and a data plane where the VPN tunnel runs directly between client and node.

ComponentStackResponsibility
macOS clientSwiftUI · MVVMUI, profile management, connection orchestration. Logic in ViewModels, views only display.
WireGuard extapp.wuse.wireguardNEPacketTunnelProvider embedding WireGuardKitGo (Go core).
OpenVPN extapp.wuse.openvpnNEPacketTunnelProvider embedding the openvpn3 C++ core via a C-ABI bridge.
Wuse backendCloud API serviceNode registration, config distribution and health monitoring (internals not disclosed).
KeyLockr backendExternal IdPQR pairing, public-key distribution, 2FA. Wuse stores no passwords.
Node agentwuse_node (Go)Runs on each self-hosted VPS: WG config, NAT, heartbeat, self-heal, self-upgrade.

Key principle: the control plane (REST over HTTPS) only orchestrates — distributing configs, managing peers, reporting health and traffic counters. The actual VPN traffic (the data plane) is always a direct encrypted tunnel between client and node, and never passes through the Wuse backend.

🪪

Authentication (KeyLockr SSO)

Passwordless identity: each device holds an Ed25519 signing keypair, and the server verifies you by signature rather than by password.

Device keys

On first launch the app uses libsodium to generate two keypairs on-device, stored in the system Keychain: an Ed25519 signing pair and an X25519 encryption pair. Private keys never leave the device and are never uploaded.

Scan to pair

On first sign-in the app shows a QR code; scan it with the KeyLockr mobile app to pair this Mac as your access point. Pairing only exchanges public keys and device info — your password never leaves your phone, and Wuse never stores one.

Request signing

After pairing, the app signs every API request with the device's Ed25519 private key; the server verifies it with your public key to confirm identity — no password anywhere. The exact signing format and verification flow are internal protocol details and are not published.

WireGuard support

The default protocol for self-hosted and shared nodes. The client embeds WireGuard's official Go core and runs it inside a system network extension.

Client: one shared tunnel for many nodes

Each imported standalone WireGuard profile gets its own NETunnelProviderManager, while all self-hosted / shared nodes reuse a single “shared tunnel.” The shared tunnel uses one locally-generated WireGuard keypair whose private key only ever lives in the system Keychain; only the public key is uploaded. The server assigns that key a private tunnel address, reused across every node.

Connecting a node = adding that node's [Peer] and AllowedIPs to the shared interface and hot-updating via sendProviderMessage(0x03)WireGuardAdapter.update(tunnelConfiguration:) — no tunnel restart. A restart happens only when the “has a full-tunnel peer” state flips, because macOS applies the default route and global DNS only at tunnel start.

  • Routing: the shared interface's includedRoutes is the union of all peers' AllowedIPs; when a full-tunnel peer exists, each peer's public endpoint is added to excludedRoutes so the encrypted UDP isn't swallowed back into 0.0.0.0/0.
  • DNS: pure Custom-Port peers get no DNS; when any full-tunnel peer exists the shared interface is set to 1.1.1.1 / 8.8.8.8.
  • Traffic: per-peer counters come from sendProviderMessage(0x02) runtime config, mapped back to nodes by peer public key; if IPC doesn't respond, it falls back to a /private/tmp/wuse_runtime_wg_* file (which contains no private keys).

Server side

The WireGuard interface on a node is always named wuse0, configured at /etc/wireguard/wuse0.conf, listening on 30000/udp, and managed by the wg-quick@wuse0 systemd unit. See “Node agent internals.”

🛡️

OpenVPN support

To be compatible with vast existing infrastructure, Wuse compiles the openvpn3 C++ core straight into the system extension and calls it from Swift over a pure C ABI — no Objective-C++ layer.

Swift ↔ C++ bridge

module.modulemap exposes the plain-C header OpenVPNAPI.h (extern "C") as a Swift module; OpenVPNAPI.cc subclasses openvpn::ClientAPI::OpenVPNClient internally and exports flat ovpn_* C functions. Swift holds only an opaque handle and never touches C++ types.

Swift → C ABI → openvpn3
import OpenVPNAPI // via module.modulemap ovpn_init() h = ovpn_client_create() ovpn_eval_config(h, ovpn) // parse .ovpn ovpn_provide_creds(h, user, pass) // CRV1 PIN/OTP → ovpn_provide_challenge_response() ovpn_set_tun_builder(h, &builder) ovpn_set_event_callback(h, cb, ctx) ovpn_connect(h) // blocking, on a dedicated Thread

The connect runs blocking on a dedicated background thread (ovpn_connect), while the main actor awaits a CheckedContinuation resolved by the CONNECTED event or a 30s timeout. ovpn_eval_config enables legacy algorithms and tlsCertProfileOverride=legacy for old servers, while NCP still negotiates AES-GCM / ChaCha20 with modern ones. CRV1 dynamic challenge (PIN then OTP) is handled multi-step by rebuilding the client.

TunBuilder & packet pump

openvpn3 calls back into the host to build the tun via a set of C function pointers (ovpn_set_tun_builder). The core invokes, in order, tun_new, add_address, add_route / exclude_route, set_dns_options, reroute_gw, establish_tun. The host creates an AF_UNIX socketpair, builds and applies NEPacketTunnelNetworkSettings from the callbacks, then hands the socket fd back to openvpn3 as its tun fd.

The packet pump bridges packetFlow and the socketpair both ways: outbound reads packetFlow → prepends a 4-byte big-endian address-family prefix → writes the socket; inbound is a poll loop with batched writePackets (up to 64 per batch). After connecting, the extension reports the actual reroute_gw state so the app knows whether this OpenVPN is split or was server-pushed up to a full tunnel.

The core dependencies (openvpn3, asio, lz4, mbedtls, fmt) are all compiled from source statically into the extension target; mbedtls is built with USE_MBEDTLS=1.

📦

One-command install (setup.sh)

One command bootstraps everything; the rest is the node agent's job. The script does only minimal bootstrapping — keys, config, forwarding and startup are all managed by wuse_node itself.

root@vps
bash <(curl -sL https://wuse.app/release/setup.sh)

The script runs these steps in order (requires root):

StepAction
detect_osReads /etc/os-release; supports ubuntu / debian / centos / rocky / almalinux / rhel / fedora / arch / manjaro.
check_ipv4Detects the default egress; if it's IPv6 (common on dual-stack VPS) it aborts and prints commands to disable IPv6 — backend registration needs IPv4.
install_wgInstalls via the distro's package manager (apt / yum+epel / dnf / pacman).
check_wg_conflictAborts if another WG interface already uses port 30000, with instructions to disable it.
setup_cfgChoose node type: [1] Private (enter SafeID, i.e. KeyLockr ID · keylockr.app) / [2] Public (needs review). Writes /etc/wuse/cfg.ini (0600).
install_workerDownloads wuse_node to /usr/local/bin, writes the systemd unit wuse_node.service (Restart=always, RestartSec=30), enables and starts it.
setup_ufwOnly if UFW is active, opens 30000/udp (WG) and 30000/tcp (the health-probe endpoint).
safeThe script itself never generates keys, writes WG config or changes routes — all of that is managed idempotently by wuse_node, so re-running it or using --reconfig is safe.
🔐

SSH & intranet access

A private node isn't just an exit — it's a trusted network you can SSH into and reach the LAN through. This is achieved by node-subnet derivation, crypto-routing and iptables policy together.

Node intranet address derivation

Each self-hosted node's WG intranet is derived from its node id: 10.(id/256)%256.(id%256).0/24, with the server always at .1. This formula must match exactly on both the node agent (wg.go) and the app (HostedWG); the .1 address is the SSH / Custom-Port target.

Custom Port mode (intranet / SSH only)

In Custom Port mode the client rewrites the node's WG config to AllowedIPs = server-WG-IP /32 and drops the DNS line: only traffic to the node's intranet address goes through the tunnel; ordinary browsing and DNS are untouched. The default target port is SSH (22); you can add ports like 8080, 3000 in node detail, shown as SSH+8080.

from your Mac
# node id 5 → server WG IP = 10.0.5.1 ssh [email protected] curl http://10.0.5.1:8080

Thanks to AllowedIPs=/32 crypto-routing, a Custom-Port client only ever accepts source .1, making it immune to lateral access from other peers.

Private node = a fully-trusted network

A private node's agent installs INPUT -i wuse0 -j ACCEPT and FORWARD -i wuse0 -j ACCEPT on the WG interface, plus the client return route and RELATED,ESTABLISHED acceptance. So the owner and every ACL-authorized guest can reach all ports on the node host (not just 22), the node's LAN, and each other's WG addresses through FORWARD hairpin.

trustThis is the intended trust model, not a bug: sharing a private node places those users into one mutually-trusted segment. By contrast, Public / VIP node agents install INPUT DROP and FORWARD DROP to private ranges — no SSH-to-node, and LAN access is blocked.
⚙️

Node agent internals (wuse_node)

The node agent is a self-healing Go daemon that idempotently keeps the node in its desired state: WG config, NAT, forwarding, heartbeat and self-upgrade.

Config generation & self-heal

_ensureWg runs: get the public IPv4 (needed for SNAT) → _ensureConf ensures wuse0.conf's [Interface] section matches the node-id-derived expectation → enable ip_forward → _ensureWgUp ensures the interface is actually up. Whether to rewrite is driven by “does the current Address equal the expected one,” not a one-shot flag — so any failed step is retried on the next heartbeat. The heartbeat trusts the real wg show wuse0, not the conf text (a oneshot service may report active while the interface is down).

NAT policy

ScenarioRuleWhy
Direct public (iface IP == public IP)SNAT --persistentEndpoint-Independent Mapping (EIM), good for P2P hole punching.
Behind NATMASQUERADESNAT to the public IP would break the return path.

NAT is scoped only to the private subnet used by client tunnels, never the server's own traffic. iptables rules differ by node type (private uses ACCEPT, public / VIP use DROP), and all use -I to insert at the head of the chain so they win over UFW's reject rules.

Hardening invariants

  • Sensitive files (wuse0.conf with the WG private key, the node's credential file) are always written atomically (temp + fsync + rename, 0600), preventing truncated or world-readable files.
  • Every curl carries --max-time and POSTs go through a timeout client: no network call can freeze the startup / heartbeat loop.
  • iptables cleanup never depends on _getPublicIP (otherwise a bad rule breaks the network → IP unavailable → cleanup unreachable → deadlock).
  • Self-upgrade atomically replaces the binary on the same filesystem (not mv), backs off failed versions, and won't re-download every 60s.
  • IPv6-heartbeat nodes actively probe the public IPv4 every 10 heartbeats to detect an IP change that requires re-doing SNAT.
🔒

Secret handling

All secrets go only into the system Keychain; the plaintext preferences file always holds a stripped config.

On save / import, ProfileSecretStore strips sensitive material into a single Keychain blob per profile id: WireGuard's PrivateKey and PresharedKey, and OpenVPN's inline <key>, <pkcs12>, <tls-auth>, <tls-crypt>, <tls-crypt-v2>, <secret>, <auth-user-pass> blocks. Plain <cert> / <ca> are not treated as secrets.

  • Only at connect time does the protocol manager fetch the re-injected full config from rawConfigForConnection to write providerConfiguration; UI / AppState always hold the stripped profile.
  • Fail-closed: if a user-initiated save can't write the Keychain, it aborts without writing plaintext and asks to retry.
  • Startup migration doesn't interrupt the user: on Keychain failure it keeps the existing plaintext as-is and logs, to avoid data loss.
⬆️

Auto-update (OTA)

Signed over-the-air updates, EdDSA-verified end to end — only builds signed with our private key are ever installed.

Each release is first signed with an Apple Developer ID and notarized (notarize + staple), then the update package is EdDSA-signed. The app verifies the signature after download before installing, with a one-click in-app restart to finish the upgrade.

📥

Import formats

The ProfileImporter dispatches by extension, converting external configs into a unified internal profile.

ExtParserHandling
.conf_importConfTries Pritunl JSON (with ovpn_data) first, else WireGuard; server extracted from the Endpoint line.
.ovpn_importOpenVPNParses remote; external certs are inlined after you pick them (sandbox); PKCS#12 is converted to PEM in place.
.tar_importPritunlPure-Swift tar reader (512-byte blocks, 10MB/file cap), then dispatches the inner .ovpn / .conf.

The internal model is a flat VPNProfile (name, serverAddress, protocolType ∈ WireGuard / OpenVPN, rawConfig); secrets are stripped into the system Keychain before saving, so rawConfig holds only the stripped config text and is re-injected at connect time (see “Secret handling”).

roadmapProxy formats such as Clash, Surge, Shadowsocks/SSR, V2Ray and Trojan are not yet parsed by the client and are on the roadmap; this page documents only what is actually implemented.