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
curl -fsSL https://wuse.app/setup | bash

Not root? Use | sudo bash.

The script must run as root and performs these steps in main() order:

StepAction
check_archOnly x86_64 / amd64 is supported; any other architecture stops before anything already installed is touched.
_lock_setupChecks that /etc/wuse and /etc/wireguard are root-owned and not symlinks, and takes a flock so two installs cannot run at once.
detect_osReads /etc/os-release to pick the package manager used later.
repair_dns_trustOnly when systemd-resolved enforces DNSSEC and resolving wuse.app explicitly reports a DNSSEC validation failure, adds the IANA root trust anchors (/etc/dnssec-trust-anchors.d/root.positive) and reloads resolved; otherwise changes nothing.
check_ipv4Only checks for a local IPv4 route and stops if there is none. The public source address is confirmed by the backend at registration and heartbeat.
install_wgIf missing, installs it with the distro's package manager (apt / yum+epel / dnf / pacman; ubuntu / debian / centos / rocky / almalinux / rhel / fedora / arch / manjaro); other distros stop here.
install_python3If missing, installs python3 the same way (used to read node state files safely); stops if that fails.
check_wg_conflictWhen wuse0 is not running, stops if another WireGuard interface already uses port 30000, and shows how to disable it.
setup_cfgChoose node type: [1] Private (enter SafeID, i.e. KeyLockr ID · keylockr.app) / [2] Public (needs review). The SafeID format is checked locally and re-prompted if invalid.
install_workerDownloads wuse_node and verifies its size, Ed25519 signature and manifest, then repairs the program and its service, which runs under a dedicated account. The same SafeID and type preserve identity, approval, ACL and peers. Only a first install, changed SafeID or type, or --reconfig registers a new node. Unreadable existing identity stops setup.
setup_ufwIf UFW is active, restores missing rules for 30000/udp (WG) and 30000/tcp (health probes), including on ordinary re-runs.
_wait_for_registrationWaits up to 180 seconds until wuse_node is running, the node ID is written by a fresh accepted heartbeat and wuse0 is up; if the server rejects the node (e.g. invalid_secret, pending_limit) it shows the reason and stops.
re-runRe-running with the same SafeID and type keeps your existing node; --reconfig creates a new pending node and does not carry over approval, ACL or peers.

Re-run vs. new node

What running the install command again does Re-running with the same SafeID and type keeps the existing node; the first install, a different type or SafeID, or --reconfig registers a new pending node. start checks pass same SafeID + type first run, changed, or --reconfig check readiness start wuse_node reused new node Run as root on the VPS curl -fsSL https://wuse.app/setup | bash [-s -- --reconfig] Preflight — any failure stops before the node identity is touched x86_64 only · install lock · DNSSEC trust-anchor repair (only when validation fails) · IPv4 route install WireGuard and python3 if missing · port 30000 not used by another WireGuard interface Choose node type [1] Private + SafeID (format checked locally) · [2] Public (needs review) Keep the existing node verify software, repair and restart service approval, ACL and peers kept repair UFW rules if enabled unreadable state → stop, keep identity Register a new pending node download wuse_node, verify size + signatures stop old services, clear old registration UFW active → open 30000/udp and /tcp old approval, ACL and peers not carried over Wait for ready (up to 180 s) wuse_node running · node ID written · wuse0 up; a server rejection shows the reason and stops Done: approval unchanged the worker keeps updating itself Done: confirm the new node Private: confirm in the App · Public: review
  • Same SafeID and type: the program is verified and repaired along with its services and UFW rules; approval, ACL and peers stay unchanged.
  • First install, a different type or SafeID, or --reconfig: a new pending node is registered; approval, ACL and peers are not carried over.
  • The new wuse_node binary replaces the old one only after its size, Ed25519 signature and signed manifest check out.
  • If the existing node's state is incomplete or cannot be read safely, setup stops and keeps the identity instead of silently registering a duplicate.
🔐

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.