URnetwork SDK 导览
这是入门指南背后的深度导览:每个绑定层如何构成、它出厂的默认值,以及我们已知的锋利边角。一切都跑在 URnetwork 的托管平台上。你带来的是凭证,不是基础设施。对象模型(设备、API、视图控制器)在 /docs/overview 和 /docs/api。
一个 Go 核心实现整个客户端:传输、合同、数据包路径。每个产物(AAR、xcframework、wasm、c-shared 库)都是同一个核心的绑定,这就是行为跨平台一致的原因,也是产物偏大的原因(每个都带着 Go 运行时)。核心承载的还有产品规则,不只是数据包。Solana Pay 住在这里(CreatePaymentReference、BuildSolanaPaymentUrl),因为它曾经不在:Web 应用把支付引用铸成了十六进制 uuid,而 Solana Pay 要求 base58 的 32 字节公钥,于是客户可能付了钱却永远匹配不回账户;Android 则硬编码了金额和商户地址。重新实现一条应用们已经遵守的规则,正是这一层存在要防止的那类 bug。
移动端
DeviceLocal,以及 Apple 上的应用进程拆分
DeviceLocal 是真身,运行中的客户端引擎:它拥有传输、合同状态(URnetwork 对一个账户可传输字节的记账),以及数据包路径。DeviceRemote 是住在另一个进程或平台上的 DeviceLocal 的客户端,API 表面相同:真设备的遥控器。在 Android 上一个进程同时承载 UI 和 VpnService,所以应用直接持有 DeviceLocal。
Apple 把 VPN 数据包处理放在它自己的沙盒化 NetworkExtension 进程里(每个 iOS/macOS VPN 都这样拆),所以 SDK 跟着拆:
NEPacketTunnelProvider扩展拥有SdkDeviceLocal,并在它上面调用setRpcServer(serverPem, clientCertPem, hostPort)启动监听。- 应用进程创建一个
SdkDeviceRemote,经环回 mTLS 设备 RPC 连接挂接:镜像的setRpcServer(clientPem, serverCertPem, hostPort),每一侧先报自己的身份、再报对端的证书。设备 RPC 是 SDK 自己的协议:方法调用加订阅流,走 Go 的 net/rpc,不是 gRPC。环回上的双向 TLS 不是仪式。localhost 每个本地进程和用户都够得着,只有持匹配 PEM 的一方才能挂接。 - 铸密钥材料的是应用,不是扩展。
SdkGenerateDeviceRpcKeyMaterial()每个 VPN 会话返回一对新的自签名服务器和客户端密钥对;应用把两份 PEM 和 host:port 放进NETunnelProviderProtocol的 provider configuration,隧道启动时扩展就是从那里拿到它们的。不涉及任何 App Group 容器。 - 盯住
RemoteChangeListener:remoteConnected第一次变成 true,那份材料就被证明有效,把它作为"最后已知可用"持久化,下次启动时重新应用。这就是应用能挂接到已在运行的扩展的原因。
重建是家常便饭:隧道每次重启,扩展就重建它的设备——用户切换 VPN、系统重启扩展,或 iOS 因超出扩展内存上限把它杀掉。第一天就把重挂接接好线(铸材料、启动隧道、setRpcServer、重新应用你自己的 UI 状态)。在 DeviceLocal 上能做的一切在 DeviceRemote 上都能做;remote 代理调用,并在重连之间重放订阅。两个层级:普通的重连(同一个设备,RPC 链路断了又回来)会替你重建订阅;重建(底下换了一个新设备实例)意味着再跑一遍那套序列。一个陷阱:DeviceRecreatedListener 在设备代数变化时触发,而只有平台托管的 RPC 路径会盖这个代数戳,所以在环回上,要从你自己的隧道生命周期和 RemoteChangeListener 驱动重挂接。
IoLoop(仅 Android)
Sdk.newIoLoop(device, detachedFd) 是数据包泵:
- 传入一个已分离的、非阻塞的 fd(
ParcelFileDescriptor.detachFd())。那次调用之后,fd 归 Go 所有,并将由 Go 关闭;绝不要再从 Java 包装或关闭它。两个所有者意味着双重关闭:fd 号立即回收,Java 一侧多余的一次 close 可能踩到下一个拿到这个号的无关描述符,损坏发生在离原因很远的地方。 - 循环在 Go 内部双向泵送(tun→设备和设备→tun),避免了每包一次的 JNI 穿越和缓冲区拷贝。这就是 API 收一个裸 fd 而不是暴露读写方法的原因。
- 停止时关闭 IoLoop(不是 fd);循环退出时触发完成回调。如果它在你没要求的时候触发——因为 tun fd 遇到 EOF 或错误,或设备关停——把它当作"隧道没了":结束
VpnService会话,然后重建或显示已断开。回调到达在 SDK 线程上,Go 在退出路上关闭 fd;绝不要在处理器里碰它。
Apple 不用 IoLoop:隧道提供者经 packetFlow 搬运数据包,NetworkExtension 提供的唯一接口。每个 iOS VPN 都付这次穿越的成本,它的批量读取把成本摊薄。
共享模式,以及密钥材料为何要紧
SetProvideMode 控制设备是否向网络提供容量。默认关闭:新构造的设备共享模式是"none",在你设置一个模式之前什么都不提供,所以嵌入 SDK 绝不会悄悄共享你用户的带宽。今天有实际作用的模式是两个:public,以及 network——把共享限定在同一 URnetwork 账户的其他设备。协议枚举里还有更多值,但平台把你网络之外的每个对端都归为 public,所以真实的选择是:不共享、只给我自己的设备,或者给任何人。
共享正是密钥材料持久化要紧的地方:你在构造时传入的 DeviceLocalKeyMaterial 就是设备的提供者身份,一个客户端密钥种子加上共享 TLS 证书和密钥。把它持久化在平台安全存储里(Keystore、Keychain),每次启动传同一份材料,否则网络每次都看到一个崭新的提供者,丢掉选择机制所偏好的可靠性历史。纯客户端用临时材料(null)没问题。
如果你把共享做成界面,把交换讲明白:陌生人的流量从用户的 IP 出网,提供者位置看得到目的地 IP 和 TLS SNI(像 ISP 一样),但默认看不到发起用户的真实 IP。提供者安全是在引擎里做出来的。开源的 ip_security 层检查提供者自己的出口,在 DMCA 类和 CFAA 类流量离开之前把它们丢弃。判定就是丢包,任何地方都不记录目的地、域名或内容。BitTorrent 特征命中还会向运营方发出一个滥用标记,只携带对端设备 id 和一个布尔值,而运营方今天没有处理它的代码;不透明加密的丢弃是静默的。提供者参与 UR protocol;ur.xyz 记录了奖励。
运行时笔记
- GC 节奏按操作系统自动调好:iOS 上节奏因子 10(扩展内存限制很残酷),Android 上 50,其余 100。你不用设。
memoryTargetByteCount是另一个旋钮:设备用来切分并确定自身缓冲区大小的字节预算,dns 2 : 客户端 14 : 提供者 4,共享关闭时提供者份额垫给客户端那对(默认 20 MB)。它既不是上限也不是 GC 设置:进程级软性占用上限是另一个SetMemoryLimit,硬性击杀是操作系统的扩展上限。把目标设在它之下;让扩展几乎不含逻辑。- gomobile 绑定在构建里设了闸,机制本身重要:gobind 会默默略过它绑不了的东西,在生成的源码里只留一行
// skipped注释,所以构建会 grep 那些源码,对任何不在显式白名单上的遗漏直接失败。允许的跳过都是内部项(RPC gob 载荷、代理/平台表面、gomobile 表达不了的uint64/[][]byte形状):是有意为之,不是漂移。
JavaScript
为什么 wasm 里没有 DeviceLocal
浏览器页面不可能拥有 tun 接口,所以 wasm 的 DeviceLocal 将无物可泵。JS 层只带客户端那一半:API 表面、视图控制器,以及 DeviceRemote——一个设备住在别处的完整设备客户端。页面持有控制面(连接状态、位置、统计、账户),流量路径则由任何能使用托管代理的东西消费。因此有两个工厂:createProxyDevice 是薄模型,解析托管代理 URL,把流量留给消费它们的东西(扩展的代理配置、一个 fetch 智能体);createPlatformDeviceRemote 是厚模型,一个真正的 DeviceRemote,带完整的监听器和视图控制器表面,架在平台替你托管的设备上。
signedProxyId 认证
createPlatformDeviceRemote 向 wss:///device-rpc 打开一条设备 RPC websocket。那条 websocket 不用账户 JWT 认证(byJwt,URnetwork 登录发的 bearer 令牌;"by"是 BringYour 的遗留)。它用 signedProxyId 认证,即平台的 /network/auth-client 端点随代理 URL 一起返回的 auth_token。这是有意的权限分离:签名代理 id 恰好授权一条 websocket,不携带任何账户权限,所以宽权限的令牌从不搭乘数据面套接字。把(proxyUrl、signedProxyId)当作一份凭证。一起请求、一起传递、一起刷新,代理拒绝套接字时重新请求这一对,而不是缓存其中一半。
监听器模式与视图控制器
每个订阅都是同一个形状:add*ChangeListener(fn) 返回一个退订函数。拿住它,在拆除时调用。在 React 里,把它 return 出去:
useEffect(() => {
const unsub = device.addConnectChangeListener(setConnectEnabled);
return unsub;
}, [device]);这经得起 React 严格模式的双重调用效应(add、unsub、add),最后恰好剩一个存活订阅。视图控制器表面(连接、位置、设备、合同、拦截动作)也绑进了 wasm,挂在设备上,如 device.openConnectViewController(),所以 Web 应用复用移动应用同一套呈现逻辑,而不是从裸监听器重新推导状态。
ur.io 如何使用它
ur.io 上的 /app 界面是参考消费者:wasm 懒加载,只在用户到达连接界面时才加载,所以落地页从不付那约 43 MB 的成本,而且每个标签页只有一个 DeviceRemote。导出对页面是全局的,所以 init 藏在一个单例后面。
浏览器扩展打包了同一份 wasm,但今天并不实例化它:它经平台代理加浏览器自己的代理 API 驱动连接,消费的是 npm 包的另一个入口点 @urnetwork/sdk-js/react——纯 fetch 的 API hook 和生成的类型,不需要 wasm。如果你只要 REST 表面,导入它、永远不调 init:加载器在运行时解析 wasm URL,而不是经静态的 new URL(..., import.meta.url),所以打包器不会为不用它的消费者打出它。
cgo
守护进程的拆分
上架的 URnetwork Linux 和 Windows 应用就构建在这一层上,两者用同一个双进程模式——任何要碰 tun 设备的 cgo 集成都该照抄的形状。SDK 提供引擎和 C API;守护进程是你的,那两个已发布的应用是开源参考:
- 一个 root/服务进程(systemd 单元、Windows 服务)运行
DeviceLocal并拥有 tun 接口。 - 无特权的 UI 进程运行一个
DeviceRemote,经环回 mTLS 设备 RPC 挂接;SDK 的默认地址是127.0.0.1:12025。 - mTLS 的 PEM 经一条由操作系统亲自授权的通道递给 UI:Linux 上是经
SO_PEERCRED检查的 unix 套接字(在 accept 时检查,先于读任何帧),Windows 上是命名管道。那次握手,而不是 TCP 端口,才是真正的授权边界;环回 mTLS 只是把其他本地用户挡在端口之外。
RAII 封装语义(urnetwork_sdk.hpp)
- 句柄被包在拥有型类型里,析构函数调用
urnet_release。但释放不等于关闭/停止:丢掉最后一个包装并不会停止设备或关闭连接。先调*_close/*_stop(包装的.close()/.stop()),再让包装释放。多个句柄可能指向同一个活对象,所以作用域退出绝不能默默杀死一条活会话。 - 订阅以
urnet::Sub返回;析构函数负责退订。 - 错误以
urnet::Error异常浮出,携带out_error的文本。
一条硬规则,和人们的预期相反:
sub = device.addConnectChangeListener([&](bool enabled) { /* ... */ });
sub.close(); // returns immediately; a callback may still be running
// do NOT free what that lambda captured here监听器列表是写时复制的,所以退订只是移除条目并返回。这是有意的(回调被允许在自己内部把自己移除),但它意味着 Sub 没了之后,回调可能仍在另一个线程上运行。销毁那个监听器捕获的状态才是真正的崩溃源;用 shared_ptr 或一个回调会检查的标志,把它养过拆除期。
泄漏纪律:在一个场景之前对 urnet_live_handle_count() 拍快照,跑一遍构造/使用/关闭/释放,断言它回到基线。cgo/smoke 干的正是这件事,是照抄的模板。
实际绑定了什么
C ABI 是从 Go 表面生成的,生成器会写出 cgo/coverage_report.txt:每个导出符号,以及每个没有跨过去的符号的原因(Go context、net.Conn 内部、函数参数、池所有权调用、RPC gob 类型)。回答"这个能从 C 调吗",读那个文件,不要读 Go 源码。新的 Go 导出只有在生成器运行后才会到达头文件,所以头文件与切出它的库一起发布。把它们钉在一起。
线上协议兼容性
设备 RPC 线上协议是版本钉住的(DeviceRpcVersion,当前为 1)并由本地端在每次同步时强制检查。它刻意不是发布版本:托管的两半独立部署,绑在一起就会在每次服务器部署后拒绝所有浏览器。不匹配不会抛异常。remote 保持未同步并持续重试,看起来和"守护进程没在跑"一模一样。用 GetRemoteConnected() 加 GetSyncError() 区分它们:同步错误为空意味着还够不到,而 "device rpc version mismatch: ..." 或 "device instance mismatch: ..." 是重连永远修不好的拒绝。守护进程和 UI 从同一个 SDK 发布版出货。
横切事项
承载政策的设置
这些标志装的是产品决策,不是调优,它们的默认值就是你用户得到的隐私姿态。一台原装设备出厂时:
| 设置 | 默认 | 效果 | 主要代价 |
|---|---|---|---|
SetPerformanceProfile | nil(自动) | 质量和速度窗口并排运行;流量同时从几个提供者出网(通常 3–8 个),带按站点固定 | 钉住一个 WindowType 就收窄到一个窗口 |
AllowDirect | 关 | 保留匿名化跳点,没有任何提供者看到用户的真实 IP | 开:吞吐量更高,且提供者看到用户的真实 IP |
PostQuantumEncryption | 开 | 密封客户端↔提供者会话,运营方中继的是自己读不了的字节 | 密封不了的提供者会被跳过,而非不密封使用 |
SetRouteLocal | 允许 | 隧道断开时流量回落到本地路由 | 禁止就是断网保护:流量改为停住 |
SetProvideMode | none(无) | 设备不向网络提供任何容量 | public 或 network 共享会让他人流量从用户的 IP 出网 |
随表附送的注意事项:
AllowDirect是速度设置,它暴露的 IP 恰好就是它去掉的那一跳。应用把它反相呈现为"强匿名化"(Strong Anonymization),默认开启,而且托管设备配置上无论调用方设什么都强制它关闭。绝不要把它作为免费的速度呈现给你的用户;把它作为它本来的那笔交换呈现。PostQuantumEncryption是应用以"后量子加密"(Post Quantum Encryption)名义发布的端到端客户端↔提供者会话,启动即默认开启且提供者侧就绪:当前所有提供者构建都启用应答一侧。开启期间客户端是故障即关闭(fail-closed)的:它不会明文承载应用数据,密封不了的提供者会被跳过,而不是不密封就用。代价在可用性,不在机密性。把标志关掉,流量才又能走标准路径。SetRouteLocal是断网保护(kill switch)的原语("允许本地流量")。把它关掉,并把每个 URnetwork 应用都有的那个开关交给用户,让隧道断开时流量停住而不是回落。
提供者对你用户身份的失明是无条件的;运营方对内容的失明默认也成立。两条性质,原装配置都为真,这才让"没有任何单独一方同时握有你用户的身份和活动"这句话说得准确。翻转任何一个标志,你就是把对应的性质交换出去,而不是挣到什么。
那两个标志选出的三种模式,为你所路由的用户陈述如下:
| 模式 | 运营方看到 | 提供者看到 | 如何得到 |
|---|---|---|---|
| 中继密封 | 账户/来源连接、提供者关联、密文及时序/数据量 | 目的地流量和一个设备/合同 id,不含用户的真实 IP | 默认:PostQuantumEncryption 开,AllowDirect 关 |
| 中继标准 | 账户/来源连接、提供者关联、内层目的地和数据包字节 | 目的地流量和一个设备/合同 id,不含用户的真实 IP | 仅在 PostQuantumEncryption 关闭时 |
| 直连 | 更少的中继参与 | 用户的真实 IP及目的地流量 | 自行选择开启:AllowDirect 开(托管配置上强制关闭) |
默认已经是第一行,什么都不用设。第二行要把 PostQuantumEncryption 关掉才会出现:开启期间,客户端遇到密封不了的提供者会跳过,而不是落到那一行。没有逐连接的 API 报告某条会话最终落在哪一行。威胁模型把这几行逐一对照具名的对手推演,并明说每一行在哪里失守。
线程规则
- 非视图对象(
DeviceLocal、DeviceRemote、Api、网络空间)并发安全。从任何线程调用都行。 - 视图控制器是单线程的,除非某个明确写了不是。每个都从一个线程驱动(通常是你的 UI 线程)。浏览器 JS 天然满足;在 Android、Apple 和 cgo 上会咬人,那里回调到达在 Go 管理的线程上。
- 回调在任意 SDK 管理的线程上触发。碰 UI 之前先封送到你的 UI 线程,而且绝不要在回调里阻塞:
DeviceRemote把它的回调串行化在一条带缓冲的通道里,所以一个慢监听器会反压它上面的每个订阅。
产物大小
围绕这些数字规划下载和打包预算:
| 产物 | 大小(标注处为压缩后) |
|---|---|
| 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 稳定性
版本基于日期(vYYYY.M.D-);还没有 SemVer 契约,npm 包是 beta 且每夜重发。在 1.0 之前把三个表面都当作移动中的:钉死精确版本,每次升级都读发布说明,并让成对的产物(守护进程和 UI、扩展和应用)保持在同一个发布版上。