URnetwork SDK 入门
用你所用语言的包管理器安装 SDK,然后浏览各语言示例和套接字指南。
这份指南带开发者从空项目走到第一次连接:拿到账户 JWT,安装你所在平台的绑定,拉起一个设备,确认流量在跑。SDK 是一个 Go 模块,github.com/urnetwork/sdk,通过四个绑定暴露:
- Android — 核心被编译为原生代码,并为 Kotlin/Java 做绑定,以 AAR 形式发布。
- iOS / macOS — 同一条 gomobile 路径,为 Swift 做绑定,以 xcframework 形式发布。
- cgo(Windows、Linux) — 核心作为 c-shared 库,位于一套 C ABI 之后,其上再加一个 C++17 头文件。任何带 FFI 的语言都从这里接入它。
- JavaScript(wasm/web) — 核心被编译为 WebAssembly 并在页面里运行,由你的浏览器框架驱动。
它们背后是同一个核心,所以行为跨平台一致 — 但它们不可互换,而且这些差异决定的是你的架构,而不只是你的语法。挑选之前先读选择哪个绑定,每个绑定如何构建则见 SDK 导览。
URnetwork 使用成员运行的出口设备。在中继路径上,提供者收不到你用户们的来源 IP。基于 SDK 构建的设备也能把发往提供者的流量加密,但前提是你打开 PostQuantumEncryption;新设备启动时它是关闭的。完整模型见 URnetwork 的工作原理。
你需要什么
四个绑定面向的都是官方 URnetwork 应用赖以构建的同一个核心:设备(连接引擎)、API 客户端、网络空间配置模型(你对话的是哪个平台部署,用什么端点和标志),以及视图控制器 — 为常见屏幕准备的无界面"状态加事件"对象,你可以把 UI 绑上去,也可以无视它们。背后的运营方 API 文档在 /docs/api。你是在 URnetwork 的托管平台上构建自己的应用:API、中继和提供者就是线上服务,你的用户自带或创建账户。
各绑定的平台下限:
- Android — API 级别 24 或更新。Java 包名是
com.bringyour.sdk。 - iOS / macOS — iOS 16.0 和 macOS 13.5。
- cgo — Ubuntu 22.04+(glibc 2.35+)或 Windows 10+,amd64 和 arm64。
- JavaScript — 仅浏览器。
- 从源码构建 — Go 1.26+。
SDK 采用 MPL-2.0(Mozilla Public License):可以自由链接进闭源应用。文件级 copyleft 只要求分享对 SDK 自身文件的修改。
选择哪个绑定
原生绑定可以拥有设备的数据包路径。浏览器页面不可能拥有操作系统的 TUN 接口;它的 JavaScript Device 对象控制的是一台远程设备。更新后的 JS SDK 还通过一个支持套接字的远程 Device 暴露应用层套接字,包括 TCP、UDP、TLS/DTLS,以及 JavaScript 的 Direct Sockets 接口。这样就能路由这些应用连接,而不必把浏览器页面变成一个操作系统级的 VPN。
| Android | iOS / macOS | cgo | JavaScript | |
|---|---|---|---|---|
| 你写的语言 | Kotlin / Java | Swift | C++(或任何 FFI) | JS / TypeScript |
| 核心以何种形式到来 | 原生代码,经 gomobile 绑定 | 原生代码,经 gomobile 绑定 | c-shared 库,C ABI | 页面里的 wasm |
| 运行数据包路径 | 是,在你的应用进程里 | 是,在 NetworkExtension 里 | 是,在你编写的守护进程里 | 否 |
| 进程模型 | 单进程 | 应用 + 扩展,环回 mTLS | UI + 特权服务,环回 mTLS | 仅页面 |
| 产物 | ~37 MB AAR | ~116 MB xcframework | ~21–25 MB 库 | ~43.5 MB wasm |
这对你意味着什么代价,按它们会咬人的先后顺序:
- Android 最简单:一个进程同时承载 UI 和
VpnService,所以你的应用直接拥有DeviceLocal,而IoLoop在 Go 内部泵送 tun fd,没有每包一次的 JNI 穿越。 - iOS / macOS 是同一个绑定,形态却更难。每个 Apple VPN 都在一个单独的沙盒化进程里处理数据包,所以设备住在扩展里,你的应用远程驱动它。从第一天起就为重挂接的生命周期和扩展的内存上限留出预算 — 这不是边缘情形,操作系统会例行重启那个进程。
- cgo 可移植性最强,用起来也最不顺手。ABI 是句柄加 JSON,而不是带类型的对象,正是这一点让 Rust、Python 和 C# 能够接入它;C++17 头文件是唯一在易用性上下了功夫的地方。特权守护进程也得你自己写。
- JavaScript 用数据包路径换来覆盖面。你得到 API 表面、视图控制器,以及一个面向托管在别处的设备的
DeviceRemote— 还有一个需要懒加载的 43 MB 载荷。
在线程上,四者汇聚到同一条规则:非视图对象并发安全,视图控制器不是。浏览器 JS 天然满足这一点;另外三个不满足。细节见导览。
登录
下面每段代码都需要一个 byJwt:账户 JWT,账户完成认证时由 URnetwork API 签发的签名登录令牌。没有单独的 API key 体系。
你的应用通过 SDK 的 API 客户端跑一次登录流程。authLogin 报告某个标识符可用的认证方式;authLoginWithPassword 完成登录(authVerify 收尾邮件或短信验证码),钱包认证和 networkCreate 是并行的入口。钱包认证只凭签名:你发送 wallet_address、wallet_message 和 wallet_signature,永远不发密钥,也没有任何 URnetwork 界面会索要钱包的私钥或助记词。不带任何认证方式的 networkCreate 就是即时账户路径:它铸出一个永久账户并返回一个 seedphrase — URnetwork 为该账户准备的自有恢复短语,由服务器生成、只交还一次。当场把它展示给用户,否则这个账户就没有任何找回路径。
每条路径都返回 by_jwt。持久化它,调用 setByJwt,并把它传给设备构造函数。你不用写任何刷新代码;Api 的令牌管理器负责轮换 JWT。把硬性认证失败当作"重新跑一遍登录"。
账户同时是计费单位:设备持有谁的 JWT,计量的就是谁的套餐。免费是每日流量额度,Pro 是大额月度额度;当前数字见 ur.io/products。设计上应当一个终端用户一个账户 — 即时账户路径让这件事毫无摩擦 — 而不是用一个内嵌账户把所有用户的用量和行为汇集成单一主体。账户流量用尽时,传输会停摆直到额度刷新,所以在你的 UX 里要说"流量用完了",而不是抛一个笼统的网络错误。
SDK 还掌管付费套餐流程,因为客户端绝不能自报价格:用 createSolanaPaymentIntent 登记一笔意向(reference 来自 createPaymentReference,plan 取 "monthly" 或 "yearly"),从结果里取 amountUsd,传给 buildSolanaPaymentUrl。C ABI 有意向调用,还没有 URL 构建器。
安装
预构建产物是 github.com/urnetwork/build 上的发布资产。版本号基于日期,vYYYY.M.D-(例如 v2026.7.22-999364023),不是 SemVer:钉住一个发布版,升级要有意为之,因为版本号说的是它何时切出,而不是 API 有没有变。
Android。 下载 URnetworkSdk-.aar(加上供 IDE 导航用的 -sources.jar),丢进你的 Gradle 模块已经扫描的目录,例如:
dependencies {
implementation fileTree(dir: 'libs', include: ['*.aar'])
}iOS / macOS。 下载 URnetworkSdk.xcframework.zip,解压,在本地 Swift 包里作为 binaryTarget 引用:
// Package.swift
targets: [
.binaryTarget(name: "URnetworkSdk", path: "URnetworkSdk.xcframework")
]所有类型都带 Sdk 前缀(SdkDeviceLocal、SdkNetworkSpace 等)。xcframework 携带 ios/arm64、iossimulator/arm64、macos/arm64 和 macos/amd64;没有 Intel 的 iOS 模拟器切片。
cgo(Windows、Linux)。 c-shared 库,与上架的 URnetwork Linux 和 Windows 应用所构建于的是同一层,旁边带两个精选头文件:
libURnetworkSdk.so— Linux。URnetworkSdk.dll(+urnetwork_sdk.def)— Windows。urnetwork_sdk.h— 纯 C ABI。urnetwork_sdk.hpp— 其上的 header-only C++17 RAII 封装,要求 include 路径上有 nlohmann/json。
任何带 C 外部函数接口的语言(Rust bindgen、Python ctypes、C# P/Invoke)都能对上这套"句柄加 JSON"的 ABI;C++ 头文件是唯一的语言便利。用 MSVC 时,先从 .def 文件生成导入库:
lib /def:urnetwork_sdk.def /machine:x64 /out:URnetworkSdk.libJavaScript(浏览器和 Node)。 规范的包是 @urnetwork/sdk,其中包含加载器、TypeScript 声明、Go 的 wasm_exec.js 以及与之匹配的 SDK WASM。请使用一个支持套接字的发布版。
npm install @urnetwork/sdk@nightly # then pin the version it resolved toWASM 包含 Go 网络核心,下载量很大。把它作为静态资产伺服(gzip 或 brotli,强缓存),并且懒加载 — 只在用户到达连接界面时才加载。绝不要让打包器内联或改写它,并且永远从同一次构建里同时发布 sdk.wasm 和 wasm_exec.js;胶水层与编译该 wasm 的 Go 工具链按 ABI 配对,SDK 的构建以两者逐字节一致为闸门。
从源码。 你需要 Go 1.26+,移动端还要先 make init:它钉住切绑定所用的那个 gomobile,并安装 Android 目标要跑的 checksec。build_android 还需要设置 ANDROID_NDK_HOME,因为它用 NDK 的 llvm-objcopy 剥离 .comment。sdk/build-android.sh 和 sdk/build-ios.sh 把这两步都包好了。在 sdk/build 下:
make init # pinned gomobile + checksec; run before the mobile targets
make build_android # AAR
make build_apple # xcframework (build_ios is an alias)
make build_js # wasm + loader
make build_linux # c-shared .so, cross-compiled with zig
make build_windows # c-shared .dll; not in `all` — the shipped one builds in a VMbuild_android 以 gomobile 的跳过清单为闸门,所以符号缺失是版本不匹配,而不是绑定被悄悄丢掉;gomobile 不绑定结构体切片(列表以 SdkStringList、SdkIdList 等形式过境),也不绑定 context.Context。
第三方安全评估及其局限在威胁模型里有说明。
隧道权限
操作系统的同意步骤和隧道本身都属于你的应用。SDK 从数据包层开始:
- 在 Android 上,
VpnService是你的:你的应用声明它,用VpnService.prepare()获得 VPN 同意,再调用establish()。SDK 从那个文件描述符处接手。 - 在 Apple 平台上,这种拆分是 Apple 的要求,不是 SDK 的:数据包隧道运行在一个内存限制很紧的独立 NetworkExtension 里,所以隧道提供者进程拥有设备,你的应用进程远程挂接上去。
- 在浏览器里,页面不可能拥有网络接口,所以没有权限可问,JavaScript 层里也没有任何东西为页面自身的流量开隧道。两种 JavaScript 设备模型都改为驱动平台上的一个设备。URnetwork 扩展覆盖浏览器标签页;原生应用覆盖整台机器。
关于 URnetwork 对连接记录什么,见威胁模型。
连接
新设备从网络默认值启动,而这些默认值让客户端-提供者会话保持未密封。在你设置之前,设备没有任何性能配置,所以 PostQuantumEncryption — 它就是应用里"后量子加密"(Post Quantum Encryption)控制项背后的标志 — 一开始是关闭的,客户端也不会与提供者建立任何会话:流量走标准中继路径,在这条路径上,运营方能读取它所中继的数据包。要密封,就用 setPerformanceProfile 设置这个标志。当前所有提供者构建都启用应答一侧,而开启该标志时客户端是故障即关闭(fail-closed)的:它不会明文承载应用数据,遇到密封不了的提供者会跳过,而不是不密封就用。AllowDirect 默认关闭;它是自行选择的提速设置,会去掉匿名化那一跳、把你用户的真实 IP 交给那个提供者,应用把它反相呈现为"强匿名化"(Strong Anonymization),托管设备配置一律强制它关闭。防泄漏由你自己接线:断网保护(kill switch)的原语是 setRouteLocal,设备启动时允许本地路由,setRouteLocal(false) 让隧道断开时流量停住而不是回落。SDK 不内嵌任何分析或崩溃上报;它唯一连接的是平台端点,以及设备使用的中继和提供者。
Android
引导顺序:创建一个指向应用私有存储的 NetworkSpaceManager(该目录保存本地状态,包括凭证),创建网络空间,然后在它的 API 客户端上设置账户 JWT。键是主机名加环境名;生产环境是 ("ur.network", "main"),创建空间的是 updateNetworkSpace,而 getNetworkSpace 只读取已存在的空间,所以全新安装时它返回 null。URL 由键推导(https://api.、wss://connect.;非 main 环境给服务加前缀),但推导优先采用 migrationHostName,而生产环境把它设为 bringyour.com:上架应用连的是 api.bringyour.com,api.ur.network 并不解析。然后创建设备,把隧道的文件描述符交给它:
import com.bringyour.sdk.Sdk
val manager = Sdk.newNetworkSpaceManager(context.filesDir.absolutePath)
val key = Sdk.newNetworkSpaceKey("ur.network", "main")
val networkSpace = manager.updateNetworkSpace(key) { it.migrationHostName = "bringyour.com" }
networkSpace.api.setByJwt(byJwt)
val device = Sdk.newDeviceLocalWithMemoryTarget(
networkSpace,
byJwt,
deviceDescription, // free-form, shown in the account's device list
deviceSpec, // e.g. Build.MODEL
appVersion,
Sdk.newId(), // instanceId; persist and reuse per install
/* enableRpc */ false,
keyMaterial, // persisted DeviceLocalKeyMaterial, or null for ephemeral
memoryTargetByteCount,
)
// inside your VpnService, after establish():
val detachedFd = pfd.detachFd() // ParcelFileDescriptor -> raw fd
val ioLoop = Sdk.newIoLoop(device, detachedFd) { /* done callback */ }newIoLoop 取得已分离 fd 的所有权,并双向泵送数据包直到你关闭它。分离之后不要再从 Java 一侧碰那个 fd。
三个构造函数参数值得上心。instanceId:首次运行生成一个 Sdk.newId(),持久化它,并在这次安装的整个生命周期里复用(每进程一个存活设备);每次启动都换新 id 会往账户的设备列表里塞一个幽灵条目。keyMaterial:纯客户端传 null 就行,但如果这个设备将来会提供容量,就把 getKeyMaterial() 持久化在平台安全存储里。它是设备的提供者身份,弄丢它会清零该提供者的可靠性历史。memoryTargetByteCount:设备据以确定缓冲区大小和 GC 节奏的字节预算;挑一个符合你进程真实上限的值(见导览)。DeviceLocal 是进程内的:进程死了,隧道跟着死,所以把 VpnService 跑成前台服务,并在重启时用同一个 instanceId 和密钥材料重建设备。
iOS / macOS
NEPacketTunnelProvider 拥有 SdkDeviceLocal 和数据包流,应用进程经环回作为远程设备挂接上去:
// in the packet tunnel provider (owns the device):
var err: NSError?
let device = SdkNewDeviceLocalWithMemoryTarget(
networkSpace, byJwt, deviceDescription, deviceSpec, appVersion,
instanceId, /* enableRpc */ true, keyMaterial, memoryTarget, &err)
// in the app process (attaches to it):
let remote = SdkNewDeviceRemoteWithDefaults(networkSpace, byJwt, instanceId, &err)
try remote?.setRpcServer(clientPem, serverCertPem: serverCertPem, hostPort: hostPort)应用铸出密钥材料和 RPC PEM(SdkGenerateDeviceRpcKeyMaterial),经 NETunnelProviderProtocol 的 provider configuration 交给扩展;扩展从那里读取 rpc_server_pem、rpc_client_pem 和 rpc_listen_hostport。这条路径上没有 App Group。拆分为何存在、重连如何工作,见导览。
cgo(Windows、Linux)
ABI 契约,简述:
- 对象是不透明的
uint64_t句柄。urnet_release(h)释放句柄但不停止对象,所以凡是有*_close/*_stop的,先调它。 - 返回的
char*字符串归调用方所有;用urnet_free_string释放。 - 结构化数据以 UTF-8 JSON 字符串跨越边界;id 是 UUID 字符串,时间是 Unix 纪元毫秒(
0= 无)。 - 回调在任意 Go 管理的线程上触发;请自行封送到你自己的线程。它们的字符串和缓冲区只在那次调用期间存活,它们交给你的句柄由你负责释放。
- 可失败的调用带一个
char** out_error;失败时它被置为一条消息,用urnet_free_string释放。传NULL可忽略文本。
头文件带一处平台分叉:urnet_new_io_loop — Linux 应用用的 fd 泵 — 位于 #if !defined(_WIN32) 内,且不在 Windows 的 .def 里。在 Windows 上用 urnet_device_local_send_packet 和 urnet_device_local_add_receive_packet 搬运数据包。
一个能跑通的端到端示例在 cgo/smoke;在 sdk/cgo 里 make smoke_hpp 会针对宿主构建编译 C++ 封装的冒烟测试并运行它。
JavaScript(wasm/web)
import { URNetwork } from "@urnetwork/sdk";
const sdk = await URNetwork.init({
wasmUrl: "/wasm/sdk.wasm",
wasmExecUrl: "/wasm/wasm_exec.js",
});wasm 把它的导出注册在 globalThis 上,所以每页只跑一个模块实例。init 在模块内是幂等的(第二次调用返回同一个实例),但两份加载器 — 比如共享同一 realm 的两个 bundle 或两个 frame — 会争抢同一批全局量。在模块级单例里初始化一次,绝不要放进组件生命周期。
套接字示例使用 sdk.createPlatformDeviceRemote({...}),一个经 WebSocket 对一台托管 Device 说设备 RPC 的 DeviceRemote。从你的托管服务获取连接配置,以及那台托管 Device 实际的实例 ID:
const device = sdk.createPlatformDeviceRemote({
apiUrl: "api.bringyour.com",
platformUrl: "connect.bringyour.com",
byJwt, proxyUrl,
signedProxyId, // HMAC auth token, not the JWT — see the tour
instanceId, // the actual hosted Device instance, not a fresh UUID
});
const unsub = device.addConnectLocationChangeListener((loc) => {
console.log("location:", loc?.name);
});
device.setConnectLocation({ bestAvailable: true });createPlatformDeviceRemote 在加载的 wasm 早于 DeviceRemote 绑定时会抛出异常;那是 npm 标签太旧的症状。
确认生效
把设备拉起来,检查出口:任何走隧道的"我的 IP 是什么"检查现在都应报告某个提供者的地址,而不是这台机器的。同样的状态在代码里和账户里都看得到:连接变化时监听器会触发(上面的 JavaScript 代码片段逐条打出每次位置变化),设备也会以你传入的 deviceDescription 出现在账户的设备列表里。在 C ABI 上,urnet_live_handle_count() 报告存活句柄数;在泄漏测试里断言它回到基线。
如果出了问题
支持就是那些公开渠道:仓库上的 issue(github.com/urnetwork)、产品反馈 feedback.ur.io,以及发往 [email protected] 的安全报告(披露政策见 ur.io/vdp)。今天没有付费的 SDK 支持档。两种停摆有现成的解释:隧道正常但传输停摆,通常是账户流量用尽;JavaScript 加载器无物可载,则是 npm 的 latest 标签。发布之前要了解的失败模式,见导览。