Developers

A tour of the URnetwork SDK

14 min readView as markdown ↗

This is the deep dive behind getting started: how each of the four bindings is put together, the defaults it ships, and the sharp edges we know about. Everything runs against URnetwork's hosted platform. You bring credentials, not infrastructure. The object model (device, API, view controllers) is in /docs/overview and /docs/api.

One Go core implements the entire client: transport, contracts, packet path. Every artifact (AAR, xcframework, wasm, c-shared library) is a binding of that same core, which is why behavior is identical across platforms, and why the artifacts are large (each carries the Go runtime). The core carries product rules too, not only packets. Solana Pay lives here (CreatePaymentReference, BuildSolanaPaymentUrl) because it once did not: the web app minted the payment reference as a hex uuid where Solana Pay requires a base58 32-byte pubkey, so a customer could pay and never be matched back to their account, and Android hardcoded the amount and merchant address. Reimplementing a rule the apps already follow is the class of bug this layer exists to prevent.

Android

Go compiled to native code and bound for Kotlin/Java by gomobile, shipped as an AAR. One process holds everything, which makes this the simplest of the four to reason about.

DeviceLocal, in one process

DeviceLocal is the real thing, the running client engine: it owns the transport, the contract state (URnetwork's accounting of the bytes an account may move), and the packet path. DeviceRemote is a client for a DeviceLocal living in another process or on the platform, with the same API surface: a remote control for the real device. On Android one process hosts both the UI and the VpnService, so the app holds the DeviceLocal directly.

IoLoop

Sdk.newIoLoop(device, detachedFd) is the packet pump:

  • Pass a detached, non-blocking fd (ParcelFileDescriptor.detachFd()). After that call, Go owns the fd and will close it; never wrap or close it from Java again. Two owners means double-close: fd numbers recycle immediately, so a stray close from Java can stomp whatever unrelated descriptor got the number next, corruption far from the cause.
  • The loop pumps both directions (tun→device and device→tun) inside Go, which avoids per-packet JNI crossings and buffer copies. That is why the API takes a raw fd rather than exposing read/write methods.
  • Close the IoLoop (not the fd) to stop; the done callback fires when the loop exits. If it fires when you didn't ask, because the tun fd hit EOF or an error or the device shut down, treat it as "the tunnel is gone": end the VpnService session, then re-establish or show disconnected. The callback arrives on an SDK thread, and Go closes the fd on the way out; never touch it from the handler.

iOS / macOS

The same gomobile path, bound for Swift and shipped as an xcframework — but the OS forces a two-process shape the Android build does not have.

The app-extension split

Apple runs VPN packet handling in its own sandboxed NetworkExtension process (every iOS/macOS VPN is split this way), so the SDK splits with it:

  • The NEPacketTunnelProvider extension owns the SdkDeviceLocal and calls setRpcServer(serverPem, clientCertPem, hostPort) on it to start the listener.
  • The app process creates an SdkDeviceRemote and attaches over a loopback mTLS device-RPC connection: the mirror-image setRpcServer(clientPem, serverCertPem, hostPort), each side naming its own identity first and the peer's certificate second. Device-RPC is the SDK's own protocol: method calls plus subscription streams over Go's net/rpc, not gRPC. Mutual TLS on loopback is not ceremony. Localhost is reachable by every local process and user, and only the holder of the matching PEMs can attach.
  • The app mints the key material, not the extension. SdkGenerateDeviceRpcKeyMaterial() returns a fresh self-signed server and client keypair per VPN session; the app puts both PEMs and the host:port into the NETunnelProviderProtocol provider configuration, which is how the extension gets them when the tunnel starts. No app-group container is involved.
  • Watch RemoteChangeListener: the first time remoteConnected goes true that material is proven, so persist it as last-known-good and re-apply it at next launch. That is what lets the app attach to an already-running extension.

Rebuilds are routine: the extension rebuilds its device whenever the tunnel restarts, because the user toggles the VPN, the OS restarts the extension, or iOS kills it for exceeding the extension memory limit. Wire that re-attach (mint, start the tunnel, setRpcServer, re-apply your own UI state) on day one. Everything you can do on a DeviceLocal you can do on a DeviceRemote; the remote proxies calls and replays subscriptions across reconnects. Two tiers: a plain reconnect (same device, the RPC link dropped and came back) re-establishes subscriptions for you; a recreate (a new device instance underneath) means running that sequence again. One trap: DeviceRecreatedListener fires on a change of device generation, and only the platform-hosted RPC path stamps one, so on loopback drive re-attach from your own tunnel lifecycle and RemoteChangeListener.

The packet path

Apple does not use IoLoop: the tunnel provider moves packets through packetFlow, the only interface NetworkExtension offers. Every iOS VPN pays that crossing, and its batched reads amortize the cost.

cgo (Windows, Linux)

The core as a c-shared library behind a generated C ABI, with a header-only C++17 wrapper over it. The widest reach of the four and the least ergonomic: handles and JSON rather than typed objects, which is exactly what lets any language with an FFI bind to it.

The daemon split

The shipping URnetwork Linux and Windows apps are built on this layer, and both use the same two-process pattern, the shape to copy for any cgo integration that touches a tun device. The SDK ships the engine and the C API; the daemon is yours, with those two released apps as open-source references:

  • A root/service process (systemd unit, Windows service) runs the DeviceLocal and owns the tun interface.
  • The unprivileged UI process runs a DeviceRemote and attaches over loopback mTLS device-RPC; the SDK's default address is 127.0.0.1:12025.
  • The mTLS PEMs are handed to the UI over a channel the OS itself authorizes: a SO_PEERCRED-checked unix socket on Linux (checked at accept, before any frame is read), a named pipe on Windows. That handshake, not the TCP port, is the real authorization boundary; loopback mTLS only keeps other local users off the port.

RAII wrapper semantics (urnetwork_sdk.hpp)

  • Handles are wrapped in owning types whose destructor calls urnet_release. But release is not close/stop: dropping the last wrapper does not stop a device or close a connection. Call *_close / *_stop (wrapper .close() / .stop()) first, then let the wrapper release. Several handles can refer to one live object, so a scope exit must never silently kill a live session.
  • Subscriptions come back as urnet::Sub; the destructor unsubscribes.
  • Errors surface as urnet::Error exceptions carrying the out_error text.

One hard rule, the opposite of what people expect:

sub = device.addConnectChangeListener([&](bool enabled) { /* ... */ });
sub.close();   // returns immediately; a callback may still be running
// do NOT free what that lambda captured here

Listener lists are copy-on-write, so unsubscribe removes the entry and returns. That is deliberate (a callback is allowed to remove itself from inside itself), but it means a callback can still be running on another thread after the Sub is gone. Destroying state that listener captured is the real crash; keep it alive past teardown with a shared_ptr or a flag the callback checks.

For leak discipline, snapshot urnet_live_handle_count() before a scenario, run construct/use/close/release, and assert it returns to baseline. cgo/smoke does exactly this and is the pattern to copy.

What is actually bound

The C ABI is generated from the Go surface, and the generator writes cgo/coverage_report.txt: every exported symbol, and for each one that did not cross, the reason (Go contexts, net.Conn internals, function parameters, pool-ownership calls, RPC gob types). Read that file, not the Go source, to answer "is this callable from C?" A new Go export reaches the header only when the generator runs, so the header ships beside the library it was cut with. Pin them together.

Wire compatibility

The device-RPC wire protocol is version-pinned (DeviceRpcVersion, currently 1) and enforced by the local on every sync. It is deliberately not the release version: the hosted halves deploy independently, so tying them would reject every browser after a server deploy. A mismatch does not throw. The remote stays unsynced and retries, which looks identical to "the daemon isn't running". Distinguish them with GetRemoteConnected() plus GetSyncError(): an empty sync error means not reachable yet, while "device rpc version mismatch: ..." or "device instance mismatch: ..." is a rejection reconnecting will never fix. Ship daemon and UI from the same SDK release.

JavaScript (wasm/web)

The core compiled to WebAssembly and run in the page, driven by whatever browser framework you already use. The one binding that cannot move packets — which changes what you build, not just how you write it.

Why there is no DeviceLocal in wasm

A browser page cannot own a tun interface, so a wasm DeviceLocal would have nothing to pump. The JS layer ships only the client-side halves: the API surface, the view controllers, and DeviceRemote, a full device client whose device lives elsewhere. The page holds the control plane (connect state, locations, stats, account) while the traffic path is consumed by whatever can use the hosted proxy. Hence the two factories: createProxyDevice is the thin model, resolving hosted proxy URLs and leaving traffic to whatever consumes them (an extension proxy config, a fetch agent); createPlatformDeviceRemote is the thick model, a real DeviceRemote with the full listener and view-controller surface over a device the platform hosts for you.

signedProxyId auth

createPlatformDeviceRemote opens a device-RPC websocket to wss:///device-rpc. That websocket does not authenticate with the account JWT (byJwt, the bearer token from URnetwork login; the "by" is a BringYour holdover). It authenticates with signedProxyId, the auth_token the platform's /network/auth-client endpoint returns alongside the proxy URL. That is deliberate scope separation: the signed proxy id authorizes exactly one websocket and carries no account authority, so the broad token never rides the data-plane socket. Treat (proxyUrl, signedProxyId) as one credential. Request, pass and refresh them together, and re-request the pair when the proxy rejects the socket rather than caching a piece.

Listener pattern and view controllers

Every subscription follows the same shape: add*ChangeListener(fn) returns an unsubscribe function. Hold it and call it on teardown. In React, return it:

useEffect(() => {
  const unsub = device.addConnectChangeListener(setConnectEnabled);
return unsub; }, [device]);

That survives React strict mode's double-invoked effects (add, unsub, add), leaving exactly one live subscription. The view-controller surface (connect, locations, devices, contracts, block actions) is bound into the wasm too, hanging off the device, as in device.openConnectViewController(), so a web app reuses the same presentation logic the mobile apps do instead of re-deriving state from raw listeners.

How ur.io uses it

The /app surface on ur.io is the reference consumer: the wasm is lazy-loaded only when the user reaches a connect surface, so landing pages never pay the ~43 MB cost, and there is one DeviceRemote per tab. Exports are global to the page, so init sits behind a singleton.

The browser extension packages the same wasm but does not instantiate it today: it drives connectivity through the platform proxy plus the browser's own proxy APIs, and consumes the npm package's other entry point, @urnetwork/sdk-js/react, plain fetch-based API hooks and generated types needing no wasm. If you only want the REST surface, import that and never call init: the loader resolves the wasm URL at runtime rather than via a static new URL(..., import.meta.url), so bundlers don't emit it for consumers who don't.

Cross-cutting

Provide modes, and why key material matters

SetProvideMode controls whether the device offers capacity to the network. The default is off: a freshly constructed device has provide mode "none" and offers nothing until you set a mode, so embedding the SDK never silently shares your users' bandwidth. Two modes do something today: public, and network, which limits providing to other devices in the same URnetwork account. The protocol enum carries more values, but the platform resolves every peer outside your network to public, so the real choice is off, my own devices, or anyone.

Providing is where key material persistence matters: the DeviceLocalKeyMaterial you pass at construction is the device's provider identity, a client key seed plus the provide-TLS certificate and key. Persist it in platform secure storage (Keystore, Keychain) and pass the same material every launch, or the network sees a brand-new provider each time and loses the reliability history that selection prefers. Ephemeral (null) material is fine for pure clients.

If you surface providing, state the trade: strangers' traffic egresses from the user's IP, and the provider position sees destination IPs and TLS SNI (like an ISP), though by default not the originating user's real IP. Provider safety is engineered in the engine. The open-source ip_security layer inspects the provider's own egress and drops DMCA-class and CFAA-class traffic before it leaves. The verdict is a dropped packet, with no destination, domain or contents recorded anywhere. A BitTorrent signature match also emits an abuse flag to the operator carrying only the peer's device id and a boolean, and the operator ships no handler for it today; opaque-encrypted drops are silent. Providers participate in the UR protocol; ur.xyz documents rewards.

Runtime notes

  • GC pacing is tuned per OS automatically: a pacing factor of 10 on iOS (extension memory limits are brutal) against 50 on Android and 100 elsewhere. You do not set it.
  • memoryTargetByteCount is a different knob: a byte budget the device splits to size its own buffers, dns 2 : client 14 : provider 4, with the provider share backing the client pair while providing is off (default 20 MB). Neither a cap nor a GC setting: the process-wide soft footprint limit is the separate SetMemoryLimit, and the hard kill is the OS's extension limit. Set the target under it; keep the extension nearly logic-free.
  • The gomobile bind is gated in the build, and the mechanism matters: gobind silently omits anything it cannot bind, leaving only a // skipped comment in the generated sources, so the build greps those sources and fails on any omission not on an explicit allowlist. The allowed skips are internal (RPC gob payloads, the proxy/platform surface, uint64/[][]byte shapes gomobile can't express): deliberate, not drift.

Settings that carry policy

These flags hold product decisions, not tuning, and their defaults are the privacy posture your users get. What a stock device ships:

SettingDefaultEffectMain cost
SetPerformanceProfilenil (auto)quality and speed windows run side by side; traffic exits through several providers at once (commonly 3–8), with per-site affinitypinning a WindowType narrows to one window
AllowDirectoffkeeps the anonymization hop, so no provider sees the user's real IPon: more throughput, and the provider sees the user's real IP
PostQuantumEncryptiononseals the client↔provider session, so the operator relays bytes it cannot reada provider it cannot seal to is skipped, not used unsealed
SetRouteLocalallowtraffic falls back to the local route when the tunnel dropsdisallow is the kill switch: traffic stops instead
SetProvideModenonethe device offers no capacity to the networkpublic or network providing egresses others' traffic from the user's IP

Caveats that go with the table:

  • AllowDirect is the speed setting, and the IP it exposes is exactly the hop it removes. The apps surface it inverted as "Strong Anonymization", on by default, and it is forced off on hosted device profiles whatever a caller sets. Never present it to your users as free speed; present it as the trade it is.
  • PostQuantumEncryption is the end-to-end client↔provider session the apps ship as "Post Quantum Encryption", on by default at launch and provider-ready: every current provider build enables the responder side. While it is on the client is fail-closed — it will not carry application data in the clear, and a provider it cannot seal to is skipped rather than used unsealed. The cost is availability, not confidentiality. Turn the flag off and traffic can take the standard path again.
  • SetRouteLocal is the kill-switch primitive ("allow local traffic"). Turn it off, and give users the toggle every URnetwork app has, so traffic stops rather than falling back when the tunnel drops.

Provider blindness to your user's identity is unconditional; operator blindness to content is too, by default. Two properties, both true of a stock profile, which is what makes it accurate to say no single party holds both your user's identity and their activity. Flip either flag and you trade the matching property away, not earn it.

The three modes those two flags select, stated for the users you route:

ModeOperator seesProvider seesHow you get it
Relayed sealedaccount/source connection, provider association, ciphertext and timing/volumedestination traffic and a device/contract id, not the user's real IPthe default: PostQuantumEncryption on, AllowDirect off
Relayed standardaccount/source connection, provider association, inner destinations and packet bytesdestination traffic and a device/contract id, not the user's real IPopt-out: PostQuantumEncryption off
Directless relay involvementthe user's real IP and destination trafficopt-in: AllowDirect on (forced off on hosted profiles)

The default already ships the first row with nothing to set. The second requires turning PostQuantumEncryption off: while it is on the client skips a provider it cannot seal to rather than dropping to that row. No per-connection API reports which row a given session ended up on. The threat model works these rows against named adversaries, and is explicit about where each one fails.

Threading rules

  • Non-view objects (DeviceLocal, DeviceRemote, Api, network spaces) are concurrent-safe. Call them from any thread.
  • View controllers are single-threaded unless one documents otherwise. Drive each from one thread (normally your UI thread). Browser JS satisfies this for free; it bites on Android, Apple, and cgo, where callbacks arrive on Go-managed threads.
  • Callbacks fire on arbitrary SDK-managed threads. Marshal to your UI thread before touching UI, and never block in one: a DeviceRemote serializes its callbacks through a single buffered channel, so a slow listener back-pressures every subscription on it.

Artifact sizes

Plan download and packaging budgets around these:

ArtifactSize (compressed where noted)
Android AAR~37 MB
Apple xcframework (zip)~116 MB
JS wasm~43.5 MB
Linux c-shared (zip)~25 MB
Windows c-shared (zip)~21 MB

API stability

Versions are date-based (vYYYY.M.D-); there is no SemVer contract yet, and the npm package is beta and republished nightly. Treat all four surfaces as moving until a 1.0: pin exact versions, read release notes on every bump, and keep paired artifacts (daemon and UI, extension and app) on one release.