开发者

套接字

7 分钟阅读以 Markdown 格式查看 ↗

用 SDK 套接字,让你应用的 TCP 或 UDP 客户端经由一台 URnetwork Device 建立连接。它具有熟悉的读、写、截止时间和关闭行为,而它的流量走的是该 Device 所选的连接路径。你可以只为一个客户端使用它,而不必让机器上的其余流量都经过操作系统级 VPN。

先按照 SDK 入门准备好一台已初始化的 Device。套接字方法存在于更新后的 SDK 源码中,并要求远程 Device 具备与之匹配的套接字支持。在 RPC 连接的两端都使用包含这些 API 的 SDK 构建。

原生包管理器命令见安装;十二种语言的可运行套接字集成和 HTTP 库集成见示例,其中 JavaScript 有彼此独立的 Node 程序和浏览器程序。

它与内核套接字有何不同

内核套接字属于操作系统的网络栈。SDK 套接字属于 Device,使用一个进程内的 TCP/IP 栈。Device 经它配置好的路由发送数据包;经由 URnetwork 连接时,这条路由也包括它所选的提供者。

内核套接字SDK 套接字
网络路径操作系统路由表与接口选择Device 路由、提供者选择与策略
应用接口文件描述符或平台套接字对象Go net.Conn、JS Conn、C 句柄或移动端 Socket
范围宿主网络上的应用连接归某一台 Device 所有的应用连接
这条连接是否需要系统 VPN取决于操作系统路由如何配置仅为调用 SDK 套接字,不需要任何操作系统 TUN/VPN 接口
本地地址宿主接口/地址空间Device 用户态栈中的虚拟地址
关闭 Device没有关系关闭它的 SDK 连接
套接字选项与监听依平台而定的操作系统 API仅限出站连接;没有文件描述符、任意套接字选项或监听 API

虚拟的 localAddr 不是提供者的公网出口 IP,绑定到它也不会让你的应用能从互联网访问。底层的 URnetwork 连接和提供者仍然可以使用操作系统的网络功能。SDK 套接字本身不会改变 Device 已配置的路由模式,也不会自行建立提供者连接。

TLS 和 DTLS 对应用到其目的地的连接进行加密。普通 TCP 和 UDP 保留应用的明文协议;URnetwork 到提供者的传输加密,并不会把那个协议变成到目的地的 TLS。

网络类型与主机名

用以下网络类型之一拨号 host:port:

网络类型行为
tcpTCP;对主机名使用 IPv4/IPv6 Happy Eyeballs
tcp4、tcp6限定于该地址族的 TCP
udpUDP;双地址族的主机名通过让第一个数据报/回复竞速来选定对端
udp4、udp6限定于该地址族的 UDP

IPv6 字面地址要加方括号,例如 [2001:db8::10]:443。主机名解析使用 Device 的套接字解析器和数据包路径。没有可用的提供者时,它不会回落到经由宿主机直接解析或打开目的地。

TCP 的 Happy Eyeballs 让多个连接尝试竞速。TLS/DTLS 拨号器在某个地址族的安全握手成功之后才选定该地址族。显式的地址族名称和字面地址会绕过双地址族竞速。

UDP:可能的重复投递

UDP 没有连接握手。对同时有 A 和 AAAA 记录的 udp,第一次写入先发往 IPv6,若 250 ms 内没有回复,再发往 IPv4。IPv6 写入立即失败会让回落更早开始。最初的那个应用数据报可能同时到达两个地址。第一个回复选定对端,之后的写入只发往那个对端。

当重复投递会造成影响时,使用请求 ID 或其他应用层的去重机制。第一次写入成功,既不能证明已经送达,也不会选定对端。如果服务器从不回复,之后的写入会一直等待对端选定,直到它们的截止时间到达或连接关闭。对只发不收的协议,使用 udp4、udp6 或字面 IP。零字节的回复也是有效回复。

每次 UDP 写入就是一个数据报。每次读取消费一个数据报;读缓冲区太小,剩余的字节会被丢弃。空数据报是数据,不是 EOF。让消息保持在目的地协议和路径的限制之内。

Go

DeviceLocal 和 DeviceRemote 提供 Dial、DialContext、DialTls 和 DialTlsContext。普通的拨号方法与 Go 的 net.Dialer 签名相同,并返回一个与 net.Conn 兼容的连接。

// device is an initialized sdk.Device.
conn, err := device.DialContext(ctx, "tcp", "example.com:80")
if err != nil {
    return err
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
    return err
}
_, err = conn.Write([]byte("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n"))

现有的 HTTP 客户端可以直接使用 Device:

transport := &http.Transport{DialContext: device.DialContext}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 20 * time.Second}
// net/http performs normal HTTPS certificate verification above this dialer.

要直接加密,调用 device.DialTlsContext(ctx, "tcp", "example.com:443", nil)。TCP 使用 TLS;UDP 使用 DTLS 1.2。TLS 配置为 nil 时,使用常规的证书校验。原始主机名提供默认的服务器名称。DTLS 支持的配置面比 Go TLS 小,并会拒绝不支持的安全选项。

读和写可能在返回错误的同时返回部分数据;要处理已返回的字节。拨号的 context 控制的是连接的建立,而不是一条已成功建立的连接的生命周期。为后续 I/O 设置绝对截止时间,并用 time.Time{} 清除它们。用完后关闭连接。建立连接/安全握手的默认时限是 30 秒,调用方给出更早的截止时间时会相应缩短。

JavaScript 与 TypeScript

SDK 的 Device 封装提供基于 Promise 的 dial 和 dialTls 方法。浏览器页面使用一台已配置、支持套接字的远程 Device。SDK 通过 WASM 暴露这条远程连接;它不需要浏览器原生的 Direct Sockets 支持,也不需要打包成 Isolated Web App。

const conn = await device.dialTls("tcp", "example.com:443", undefined, {
  timeoutMillis: 5000,
});
try {
  await conn.setDeadline(Date.now() + 5000);
  await conn.write(new TextEncoder().encode(
    "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n"
  ));
  for (let chunk; (chunk = await conn.read()) !== null;) {
    console.log(new TextDecoder().decode(chunk));
  }
} finally {
  await conn.close();
}

read() 返回一个 Uint8Array,遇到 EOF 时返回 null。空数组是一个有效的 UDP 数据报。readable 和 writable 提供 Web Streams 适配器;每个方向要么用直接方法,要么用适配器,二者择一。在支持的情况下,关闭 TCP 流会发送写端 FIN。显式调用 close() 会关闭整条连接。

用 AbortSignal 取消打开过程。已建立的套接字使用截止时间和关闭。截止时间方法接受纪元毫秒数、一个 Date,或用 null 清除。部分写入的错误包含 bytesWritten;部分读取的数据会先返回,与之相伴的错误则在下一次直接读取时返回。拥有套接字的远程服务断开时,套接字流量随之停止;重连之后不会重放。

Direct Sockets API

对 UR Device 使用 Direct Sockets 的构造函数签名:

const {TCPSocket, UDPSocket} = device.directSockets;
const socket = new TCPSocket("echo.example", 9000);
const {readable, writable} = await socket.opened;
const reader = readable.getReader({mode: "byob"});
const writer = writable.getWriter();
try {
  await writer.write(new TextEncoder().encode("hello"));
  await writer.close();
  console.log(await reader.read(new Uint8Array(1024)));
} finally {
  await Promise.allSettled([reader.cancel(), writer.abort()]);
  reader.releaseLock(); writer.releaseLock();
  await socket.close();
  await socket.closed;
}

对于 UDP,构造 new UDPSocket({remoteAddress: "echo.example", remotePort: 9001})。先等待 opened,然后写入 {data: new Uint8Array([1, 2, 3])},并读取 value.data。每个对象就是一个数据报,零字节的数据报也不例外。已连接 UDP 的消息省略远程地址字段,并拒绝逐条消息的目的地覆盖。

导出的 createDirectSockets(device) 工厂函数也返回这些构造函数。两种接口都有 opened 和 closed 两个 promise,以及异步的 close()。TCP 支持默认读取器和 BYOB 读取器,以及 BufferSource 写入。关闭它的可写流会发送 FIN,而读取仍然可用。关闭套接字之前,先取消/中止挂起的操作,并释放读取器/写入器的锁;否则 close() 会以 InvalidStateError 拒绝。网络故障以 NetworkError 拒绝。

Chrome 的原生 API 可供 Isolated Web App 使用。SDK 的实现经由已配置的 Device,在普通浏览器和 Node 中都能工作,不需要 IWA 包,也不需要原生 Direct Sockets 权限。它不会替换浏览器的全局对象。参见 Chrome 文档和 Direct Sockets 提案。

本发布版实现了出站 TCP 和已连接 UDP。用 dnsQueryType: "ipv4" 或 "ipv6" 限定解析;省略时保留 Device 的 Happy Eyeballs。绑定 UDP、多播、监听,以及逐套接字的缓冲区/no-delay/keep-alive 调优都不可用,对这些功能的有效请求会以 NotSupportedError 失败。这是一个客户端兼容性子集,不是完整的浏览器 API。

对双地址族的 UDP 名称,opened 在发送之前就会兑现。它的端点字段起初描述的是第一个候选地址,在消费第一个回复之后更新。最初的数据报可能同时到达两个地址。如果在打开时就需要一个固定的端点,请选择字面 IP 或某个 DNS 地址族。示例用一个计时器来取消停滞的流 I/O;另外的 Conn API 也提供截止时间。

Direct Sockets 构造函数打开的是普通 TCP/UDP 连接。TLS/DTLS 请用 dialTls。可运行的 JavaScript Node/浏览器示例和 TypeScript 示例包括 TCP/UDP 回显、超时、清理以及 HTTP 库集成。

原生绑定与支持的平台

绑定套接字接口与平台
Go在 SDK 支持的 Go 目标平台上提供 net.Conn 行为
Android Kotlin/JavaAAR 中可移植的 OpenSocket/Socket;Android API 24+,适用于随附的各 ABI
Apple SwiftXCFramework 中可移植的 OpenSocket/Socket;iOS 16+ 和 macOS 13.5+,适用于随附的真机/模拟器切片
C/C++ 及其他 FFI 客户端面向 amd64/arm64 上 Windows 10+ 和 Linux glibc 2.35+ 的 C ABI;另有供开发使用的 macOS 宿主构建
浏览器与 Node 的 JS/TSWASM、Conn 和 Direct Sockets Web Streams,外加一个可用的、支持套接字的 Device RPC 端点

移动端调用方使用 OpenSocket(network, address, timeoutMillis, tlsOptions)。TLS 选项为 nil 时请求普通连接;非 nil 的配置请求 TLS/DTLS。Socket.Read 返回一个带有 Data 和 Eof 的结果;截止时间方法接受纪元毫秒数,传零表示清除。阻塞操作请放到工作线程或合适的协程调度器上执行。

C 调用方使用 urnet_device_dial / urnet_device_dial_tls,随后是 urnet_conn_read、urnet_conn_write 以及截止时间/关闭函数。读取返回一个字节数和一个单独的 EOF 标志。它会立即消费数据;它不是对输出缓冲区大小的查询。即使返回了错误字符串,也要处理部分字节数。用 urnet_free_string 释放错误/地址字符串,用 urnet_release 释放连接句柄,这一步也会关闭该连接。

SDK 目前提供客户端连接。服务器/监听套接字将作为单独的新增功能推出,并带有明确的可达性和所有权语义。绑定/构建模型见 SDK 导览,Device 设置见入门指南。