跳转到内容

WireGuard

源码文件:17 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/wireguard/mod.rs
  • Etemenanki/protocols/src/wireguard/config.rs
  • Etemenanki/protocols/src/wireguard/device.rs
  • Etemenanki/protocols/src/wireguard/slot.rs
  • Etemenanki/protocols/src/wireguard/connector.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/concepts/src/link.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/protocols/tests/pipeline/wireguard.rs
  • Etemenanki/protocols/tests/support/wireguard.rs
  • Etemenanki/protocols/tests/unit/wireguard/device.rs
  • Etemenanki/protocols/tests/unit/wireguard/slot.rs
  • Etemenanki/protocols/tests/unit/helpers/address_family.rs
  • Etemenanki/app/tests/integration/e2e_wg.rs
  • Etemenanki/app/tests/unit/outbound.rs
  • katana/src/outbound/mod.rs

与 etemenanki-protocols 中的代理协议不同,WireGuard 并不是由一个服务端协议核心(core)加一个客户端 codec 构成的。它是三层 VPN:隧道承载的是原始 IP 包,而不是“一条到目标 X 的连接”。因此该模块只提供出站方向。WgConnector 实现普通的 Connector trait,在它背后由单个驱动任务运行 WireGuard 状态机(boringtun)和用户态 TCP/IP 协议栈(smoltcp),每个被路由过来的流都会变成该协议栈内的一条 TCP 连接或一个 UDP 关联。

本页面向修改 protocols/src/wireguard/ 的贡献者。它沿着一个流从 connector 一路追到 UDP socket 再返回,逐一说明沿途的每个 channel、缓冲区和常量,并列出固定这些行为的测试。运维视角下的同一出站见 WireGuard 用户指南。

该模块做四件事:

  • 解析配置。 WgConfig 保存密钥材料、对端 endpoint、隧道本地地址和传输参数;parse_key 从 base64 或 hex 解码密钥。
  • 运行隧道。 WgDevice 拥有一个驱动任务,负责把 smoltcp 接到 boringtun,再把 boringtun 接到一个已 connect 的 UDP socket。
  • 每个出站保持一条存活的隧道。 DeviceSlot 在第一个流到来时惰性构建 device,之后的所有流共享它;驱动停止后按退避策略重建。
  • 把隧道呈现为 connector。 WgConnector 把 TCP 流变成 WgStream,把 UDP 流变成 WgDatagramLink。

以下事项有意交给其他部分处理:

关注点 所在位置
读取 [outbound.settings] 表 etemenanki-app 的 app/src/outbound/mod.rs → build_wg_config;katana 的 src/outbound/mod.rs 用面板数据构建同样的 WgConfig
解析目标域名 通过 WgConnector::with_resolver 传入的 Resolver(见 DNS)
路由、嗅探、中继 调用 connector 的单连接运行时(见 客户端运行时)
WireGuard 与 Noise 协议 boringtun crate(Tunn)
隧道内的 TCP 和 UDP smoltcp crate(Interface、tcp::Socket、udp::Socket)

驱动自行把一个 tokio::net::UdpSocket 绑定并连接到对端,不经过 etemenanki-environment 中的拨号器,因此 拨号器 中描述的 socket 策略不适用于隧道的外层 socket。

WireGuard 入站需要在三层终结一个对端,之后看到的是一串原始 IP 包。其他入站交给运行时的流各自带有一个已解码的 Destination;要从 IP 包得到这样的流,就需要一个 netstack 拦截每个新的 TCP 或 UDP 流,并为每条连接合成一个流。这就是透明代理 netstack,它位于协议之上而不是协议内部,在这里实现它就意味着要扭曲 concepts 的契约。protocols/src/wireguard/mod.rs 的模块文档记录了这一省略是有意为之,并指出 device.rs 中的驱动是可复用的构建块。

app 会强制这一点:app/src/inbound/mod.rs 拒绝在入站上使用 protocol = "wireguard",报错 wireguard cannot be used as an inbound (no server implementation)。在 IP 层接收流量是 TUN 入站 的职责。

  • 文件夹protocols/src/wireguard/
    • mod.rs 模块文档与重新导出
    • config.rs WgConfig、parse_key、KeyError、DEFAULT_MTU
    • device.rs WgDevice、驱动任务、Uplink、InMemoryDevice
    • slot.rs DeviceSlot、acquire_device、connect_tcp_any
    • connector.rs WgConnector、WgStream、WgDatagramLink
  • 文件夹protocols/src/helpers/
    • address_family.rs AddressFamilyStrategy、FamilySupport、resolve_candidates

mod.rs 重新导出 AddressFamilyStrategy、DEFAULT_MTU、KeyError、WgConfig、parse_key、WgConnector、WgDatagramLink、WgStream、TunnelTcp、TunnelUdp 和 WgDevice。WireGuard 模块不受 feature flag 控制:boringtun 和 smoltcp 是该 crate 的普通依赖。

protocols/src/wireguard/config.rs 描述一个点对点关联:一个对端,一个 endpoint。

pub const DEFAULT_MTU: usize = 1420;
pub struct WgConfig {
pub private_key: [u8; 32],
pub peer_public_key: [u8; 32],
pub preshared_key: Option<[u8; 32]>,
pub endpoint: Destination,
pub local_addrs: Vec<IpAddr>,
pub mtu: usize,
pub persistent_keepalive: Option<u16>,
pub reserved: Option<[u8; 3]>,
}
impl WgConfig {
pub fn new(
private_key: [u8; 32],
peer_public_key: [u8; 32],
endpoint: Destination,
local_addrs: Vec<IpAddr>,
) -> Self;
}
字段 含义 WgConfig::new 的设置
private_key 本端的 Curve25519 静态私钥,传给 x25519::StaticSecret::from。 参数
peer_public_key 对端的 Curve25519 公钥。 参数
preshared_key 可选,混入 Noise 握手的密钥。 None
endpoint 对端的 UDP endpoint,驱动直接连接它。 参数
local_addrs 虚拟接口的隧道本地地址,同时决定出站能访问哪些地址族。 参数
mtu 虚拟接口的 IP 层 MTU。 DEFAULT_MTU(1420,Xray-core 的默认值)
persistent_keepalive keepalive 间隔(秒),传给 Tunn::new。 None
reserved Xray 风格的 3 字节头部字段(见 保留字节)。 None

WgConfig 派生了 Clone;connector 把它包在 Arc 中,使每次重建都从同一个值开始。

pub enum KeyError {
Invalid,
}
pub fn parse_key(text: &str) -> Result<[u8; 32], KeyError>;

parse_key 先去掉首尾空白,然后依次尝试两种编码:

  1. 带填充的标准 base64(base64::engine::general_purpose::STANDARD),即 wg genkey 和 wg pubkey 的输出格式;
  2. hex,Xray-core 同样接受这种格式。

只有解码结果恰好是 32 字节时,该次尝试才算成功。两种编码不会冲突:32 字节对应 44 个 base64 字符(含 = 填充),或 64 个十六进制数字。其他情况都返回 KeyError::Invalid,其消息为 invalid WireGuard key: expected base64 or hex encoding of 32 bytes。调用方会再包装一层:etemenanki-app 报告 outbound <tag>: invalid wireguard private_key(或 peer_public_key、preshared_key),katana 报告 wireguard outbound <tag> <field>: <KeyError>。

protocols/src/wireguard/device.rs 对外提供一个指向运行中驱动的句柄,以及两组 channel。

pub struct TunnelTcp {
pub tx: mpsc::Sender<Bytes>,
pub rx: mpsc::Receiver<Bytes>,
}
pub struct TunnelUdp {
pub tx: mpsc::Sender<(SocketAddr, Bytes)>,
pub rx: mpsc::Receiver<(SocketAddr, Bytes)>,
}
pub struct WgDevice {
cmd_tx: mpsc::Sender<Command>,
local_addrs: Vec<IpAddr>,
}
impl WgDevice {
pub async fn start(config: Arc<WgConfig>) -> io::Result<WgDevice>;
pub fn is_alive(&self) -> bool;
pub async fn connect_tcp(&self, ip: IpAddr, port: u16) -> io::Result<TunnelTcp>;
pub async fn connect_udp(&self) -> io::Result<TunnelUdp>;
}

WgDevice 有意不实现 Clone,以 Arc<WgDevice> 的形式共享。它与驱动之间唯一的联系是 cmd_tx,即命令 channel 的发送端。is_alive 返回 !self.cmd_tx.is_closed():接收端归驱动所有,所以 channel 关闭恰好意味着驱动任务已经返回。

命令 channel 承载两种请求。Command 是 device.rs 的私有类型:

enum Command {
ConnectTcp {
dst: SocketAddr,
src: IpAddr,
out_rx: mpsc::Receiver<Bytes>,
in_tx: mpsc::Sender<Bytes>,
reply: oneshot::Sender<io::Result<()>>,
},
ConnectUdp {
out_rx: mpsc::Receiver<(SocketAddr, Bytes)>,
in_tx: mpsc::Sender<(SocketAddr, Bytes)>,
reply: oneshot::Sender<io::Result<()>>,
},
}

connect_tcp 和 connect_udp 为每条连接创建两对 mpsc::channel(CHANNEL_CAP):out 用于上行(应用到隧道),in 用于下行。它们把驱动一侧的端点(out_rx、in_tx)放进命令、发送出去,然后等待 oneshot 回复。调用方保留另一侧的端点,以 TunnelTcp 或 TunnelUdp 的形式返回。由于会等待回复,TCP 连接失败会表现为 connect_tcp 返回的错误,而不是 stream 上过早出现的 EOF。

connect_tcp 还负责选择源地址:local_addr_for 返回 local_addrs 中第一个与目标同族的地址;找不到时返回 AddrNotAvailable,消息为 wireguard: no tunnel-local address matching destination family。connect_udp 不接受目标参数,因为每个数据报都自带目标。

WgDevice::start 会 spawn 一个任务,下面的一切都归它所有。该结构体是私有的:

struct Driver {
tunn: Tunn,
device: InMemoryDevice,
iface: Interface,
sockets: SocketSet<'static>,
udp: UdpSocket,
cmd_rx: mpsc::Receiver<Command>,
reserved: Option<[u8; 3]>,
scratch: Vec<u8>,
recv_buf: Vec<u8>,
tcp_conns: HashMap<SocketHandle, TcpConn>,
udp_conns: HashMap<SocketHandle, UdpConn>,
next_port: u16,
}
struct TcpConn {
in_tx: mpsc::Sender<Bytes>,
uplink: Uplink<Bytes>,
connect_reply: Option<oneshot::Sender<io::Result<()>>>,
uplink_closed: bool,
}
struct UdpConn {
in_tx: mpsc::Sender<(SocketAddr, Bytes)>,
uplink: Uplink<(SocketAddr, Bytes)>,
}
字段 作用
tunn boringtun 的 Tunn:负责 Noise 握手、会话密钥、定时器和加密。它是纯状态机,自身不做 I/O。
device InMemoryDevice,即 smoltcp 的 phy::Device。其 rx 队列存放解密后等待 smoltcp 读取的 IP 包;其 tx 队列收集 smoltcp 发出的 IP 包。
iface、sockets smoltcp 的 Interface,以及为每条隧道内连接各保存一个 smoltcp socket 的 SocketSet。
udp 已 connect 到对端 endpoint 的 UDP socket。
cmd_rx 命令 channel 的接收端。
scratch、recv_buf 大小为 SCRATCH 的缓冲区,分别用于 boringtun 输出和 UDP 接收。
tcp_conns、udp_conns 按 smoltcp SocketHandle 索引的单连接状态。每条连接在自己的 Uplink 中持有其上行接收端。
next_port 下一个隧道侧 socket 使用的本地端口。

每条连接的 uplink 最多持有一个条目:驱动已从上行 channel 取出、但 smoltcp 尚未接收的那一个。connect_reply 在 TCP 握手有结果之前一直持有 oneshot 发送端。

Uplink 是 device.rs 的私有类型,同样私有的还有一个同时等待所有连接上行的自由函数:

struct Uplink<T> {
rx: Option<mpsc::Receiver<T>>,
held: Option<T>,
}
impl<T> Uplink<T> {
fn new(rx: mpsc::Receiver<T>) -> Self;
fn take(&mut self) -> Option<T>;
fn hold(&mut self, item: T);
fn is_finished(&self) -> bool;
fn poll_refill(&mut self, cx: &mut Context<'_>) -> Poll<()>;
}
fn poll_uplinks(
tcp: &mut HashMap<SocketHandle, TcpConn>,
udp: &mut HashMap<SocketHandle, UdpConn>,
cx: &mut Context<'_>,
) -> Poll<()>;
项 行为
rx 该连接上行 channel 的接收端。所有发送端都消失且 channel 已取空后变为 None。
held socket 尚未接收的那一个条目。
new 包装上行接收端,不持有任何条目。
take 返回持有的条目,否则返回 channel 中已有的下一个条目(try_recv)。它从不等待条目到来。channel 报告 Disconnected 时,把 rx 设为 None 以记录结束。
hold 保留 socket 未接收的条目;下次 take 会先交出它。
is_finished rx 已消失且没有持有条目时为 true:应用已结束,它发送的一切都已交给 socket。
poll_refill 没有持有条目时,等待 channel:其下一个条目成为持有条目,或记录其结束,然后返回 Ready。已持有条目时返回 Pending(见 背压)。
poll_uplinks 对每条 TCP 连接和每个 UDP 关联调用 poll_refill,只要其中任一返回 Ready 就返回 Ready。

任务之外没有任何东西触碰这些状态,所以驱动不需要锁。smoltcp 和 boringtun 都是同步、靠轮询驱动的,由这个任务来驱动它们。

protocols/src/wireguard/slot.rs 保存同一出站所有流共享的隧道。

#[derive(Default)]
pub struct DeviceSlot {
pub device: Option<Arc<WgDevice>>,
pub failures: u32,
pub retry_at: Option<StdInstant>,
pub started_at: Option<StdInstant>,
}
impl DeviceSlot {
pub fn died_young(&self) -> bool;
pub fn note_failure(&mut self);
}
pub async fn acquire_device(
slot: &Mutex<DeviceSlot>,
config: &Arc<WgConfig>,
) -> io::Result<Arc<WgDevice>>;
pub fn tunnel_down() -> io::Error;
pub async fn connect_tcp_any(
device: &WgDevice,
candidates: Vec<IpAddr>,
port: u16,
) -> io::Result<TunnelTcp>;

槽位用的是 Option 而不是 OnceCell,这样才能丢弃并替换已死亡的 device。这里的 Mutex 是 tokio::sync::Mutex。字段设为 public 是为了让单元测试检查它们;WgConnector::slot 暴露槽位也是出于同样原因。

protocols/src/wireguard/connector.rs 把隧道适配到 concepts/src/link.rs 中的 Connector trait(见 链路与类型)。

pub struct WgConnector {
config: Arc<WgConfig>,
address_family: AddressFamilyStrategy,
resolver: Resolver,
device: Arc<Mutex<DeviceSlot>>,
}
impl WgConnector {
pub fn new(config: WgConfig) -> Self;
pub fn with_address_family(config: WgConfig, address_family: AddressFamilyStrategy) -> Self;
pub fn with_resolver(mut self, resolver: Resolver) -> Self;
pub fn slot(&self) -> &Arc<Mutex<DeviceSlot>>;
}
impl<T: Send + Sync + 'static> Connector<Flow<T>> for WgConnector {
type Stream = WgStream;
type Datagram = WgDatagramLink;
type Future = DialFuture;
fn connect(&mut self, flow: Flow<T>) -> DialFuture;
}
type DialFuture =
Pin<Box<dyn Future<Output = io::Result<Outbound<WgStream, WgDatagramLink>>> + Send>>;

WgConnector::new 使用 AddressFamilyStrategy::Auto 和 Resolver::default()(即系统解析器)。etemenanki-app 和 katana 都调用 with_address_family(...).with_resolver(resolver.clone()),因此隧道内的域名会经过配置的解析器。查询本身在主机上进行,只有到解析结果地址的连接才经过隧道。

Clone 是手写实现的,只克隆各个 Arc,所以每个克隆都共享同一个 DeviceSlot,也就共享同一条隧道。connect 把 self 克隆进一个装箱的 future,由它调用私有的 dial。

pub struct WgStream {
tx: PollSender<Bytes>,
rx: mpsc::Receiver<Bytes>,
pending: Bytes,
}
pub struct WgDatagramLink {
tx: PollSender<(SocketAddr, Bytes)>,
rx: mpsc::Receiver<(SocketAddr, Bytes)>,
resolver: Resolver,
strategy: AddressFamilyStrategy,
support: FamilySupport,
resolved: HashMap<CompactString, Option<IpAddr>>,
resolving: Option<(CompactString, ResolveFuture)>,
}

WgStream 在 TunnelTcp 的 channel 之上实现 AsyncRead 和 AsyncWrite;pending 是最近收到的数据块中尚未读取的剩余部分。WgDatagramLink 在 TunnelUdp 的 channel 之上实现 DatagramLink<Addr = Destination>,并逐包解析域名目标。

普通拨号器让内核选择源地址,并在某个地址族不可用时快速失败。smoltcp netstack 没有主机路由表,因此 WireGuard 根据自己的隧道本地地址自行回答“能用哪些地址族”这个问题(protocols/src/helpers/address_family.rs):

pub struct FamilySupport {
ipv4: bool,
ipv6: bool,
}
impl FamilySupport {
pub fn both() -> Self;
pub fn from_addrs(addrs: &[IpAddr]) -> Self;
pub fn supports(self, ip: IpAddr) -> bool;
pub fn describe(self) -> &'static str;
}
pub async fn resolve_candidates(
context: &str,
dest: &Destination,
strategy: AddressFamilyStrategy,
support: FamilySupport,
resolver: &Resolver,
) -> io::Result<Vec<IpAddr>>;

dial 计算 FamilySupport::from_addrs(&self.config.local_addrs),并把它与运维配置的 AddressFamilyStrategy 一起传给 resolve_candidates。后者保留所有解析出的地址,去掉策略禁止的或隧道无法作为源发起的地址,并针对 prefer_ipv4 和 prefer_ipv6 做稳定排序,使另一个地址族仍可作为后备。如果一个都不剩,错误消息会指明能力限制:wireguard: no usable auto destination address for example.com:443 (local address supports IPv4 only)。

两个前端在构建出站时也会检查这一组合。etemenanki-app 的 validate_wg_address_family 会拒绝没有 IPv4 address 的 ipv4_only,以及没有 IPv6 address 的 ipv6_only(outbound <tag>: wireguard address_family ipv6_only needs an IPv6 address);katana 对 local_address 做同样的检查。

每条 WireGuard 消息都以 4 字节头部开始。WireGuard 规范规定第 1 到 3 字节为零;Xray-core 允许运维在这里填入一个 3 字节值,有些对端用它来识别客户端。驱动通过 WgConfig::reserved 支持这一点。

偏移 长度 字段 发送时 接收时
0 1 消息类型(1 发起握手,2 握手响应,3 cookie 回复,4 传输数据) 由 boringtun 写入 由 boringtun 读取
1 3 reserved WgConfig::reserved 为 Some 时由 apply_reserved 覆写 数据报长于 3 字节时,decapsulate_all 在解析前将其清零
4 其余 消息体 由 boringtun 写入 由 boringtun 读取

接收时清零是必需的,不是装饰:boringtun 把前四个字节当作一个小端序消息类型读取,保留字节非零会导致每个数据报都无法解析。apply_reserved 作用于全部三条出向路径:封装后的数据(flush_tx)、解封装过程中产生的握手与 cookie 回复(send_all),以及定时器包。

device.rs 的模块文档把整条路径画成穿过一个任务的环路。重绘如下:

flowchart LR
  app["WgStream / WgDatagramLink"] -->|"上行 channel"| held["Uplink 持有槽,一个条目"]
  held -->|"socket 有空间时 send_slice"| sock["smoltcp socket"]
  sock -->|"Interface::poll"| devtx["InMemoryDevice tx"]
  devtx -->|"Tunn::encapsulate、apply_reserved"| udp["已 connect 的 UdpSocket"]
  udp -->|"UDP"| peer["WireGuard 对端"]
  peer -->|"UDP"| udp
  udp -->|"清零 reserved、Tunn::decapsulate"| devrx["InMemoryDevice rx"]
  devrx -->|"Interface::poll"| sock
  sock -->|"在下行 channel 上 try_reserve"| app

WgDevice::start(config) 执行以下步骤,任务 spawn 后立即返回:

  1. local_addrs 为空时以 InvalidInput 拒绝:wireguard: no tunnel-local addresses configured。
  2. 解析 endpoint(resolve_endpoint)。IP 字面量直接使用;域名通过 tokio::net::lookup_host 解析,而不是配置的 Resolver,并取第一个结果。没有结果时返回 NotFound:wireguard: endpoint domain did not resolve。该地址在 device 的整个生命周期内固定不变;重建的 device 会重新解析域名。
  3. 按 endpoint 的地址族把 UDP socket 绑定到 0.0.0.0:0 或 [::]:0,再 connect 到 endpoint。已 connect 的 socket 只接收对端的数据报,并会报告针对它们的 ICMP 错误。
  4. 用静态私钥、对端公钥、预共享密钥、keepalive、一个随机的 u32 会话索引构建 Tunn,速率限制器传 None,让 boringtun 为这条隧道自行创建握手速率限制器。
  5. 构建 smoltcp Interface(build_interface):Medium::Ip 上的 HardwareAddress::Ip,随机种子,每个本地地址作为一条主机路由(IPv4 为 /32,IPv6 为 /128),并为每个拥有本地地址的地址族添加一条经由该族第一个地址的默认路由。在 Medium::Ip 上,网关从不用于下一跳解析;默认路由只是让非直连目标变得可路由。device 以 config.mtu 作为 max_transmission_unit 上报。
  6. 创建命令 channel(CHANNEL_CAP)并 spawn 驱动。

start 不执行握手。boringtun 在封装第一个 IP 包时才发起握手,也就是第一个流发送 SYN 或第一个数据报时;如果设置了 persistent_keepalive,也可能是 update_timers 中第一次 keepalive 到期时。所以即使对端不可达,start 也会成功;这正是 DeviceSlot 依据隧道存活时长(MIN_HEALTHY_LIFETIME)而不是 start 是否返回 Ok 来判断隧道状态的原因。

etemenanki-app 在配置阶段就构建 connector,但那时并不调用 start。address = [] 的 settings 表可以通过 --test,上面的 InvalidInput 错误要到第一个流时才出现。katana 则在构建出站时就拒绝空的 local_address 列表(wireguard outbound <tag> needs at least one local_address)。

Driver::run 反复执行相同的三个阶段,然后等待五个事件源。

flowchart TB
  poll["Interface::poll:rx 包送入 socket,socket 发出的包进入 tx"] --> svc["service_sockets:连接回复、上行写入 socket、socket 数据送入下行 channel、关闭"]
  svc --> flush["flush_tx:封装每个 tx 包,通过 UDP 发送"]
  flush --> wait{"select!"}
  wait -->|"udp.recv"| dec["decapsulate_all,发送握手回复"]
  wait -->|"cmd_rx.recv"| cmd["open_tcp / open_udp,收到 None 则返回"]
  wait -->|"poll_fn(poll_uplinks)"| push["填充每个就绪连接的持有槽"]
  wait -->|"timer.tick"| tim["Tunn::update_timers,发送其产生的包"]
  wait -->|"sleep"| idle["无操作"]
  dec --> poll
  cmd --> poll
  push --> poll
  tim --> poll
  idle --> poll

各阶段:

  • 轮询。 self.iface.poll(now, &mut self.device, &mut self.sockets) 把 InMemoryDevice::rx 中的每个包交给 smoltcp,并让 smoltcp 把报文段、ACK 和数据报发到 InMemoryDevice::tx。
  • 处理 socket。 service_sockets 遍历每条连接:处理待定的 TCP 连接,在 smoltcp socket 有空间时把上行条目交给它(每条连接每轮最多 CHANNEL_CAP 个条目,使任何生产者都无法独占驱动),在 channel 有空位时把收到的数据移入下行 channel,并收集需要移除的 socket。它返回 needs_repoll,当某个下行 channel 已满时为 true。
  • 刷出。 flush_tx 从 InMemoryDevice::tx 逐个取出 IP 包,封装到 scratch,复制出结果,写入保留字节后用 send_datagram 发送。没有会话时,boringtun 会把包排队,并以 WriteToNetwork 返回一个握手发起消息;如果握手已在进行中,则返回 TunnResult::Done;排队的包稍后由 decapsulate 循环发出。Done 和 TunnResult::Err 会被跳过。
  • 等待。 compute_sleep 把 Interface::poll_delay 转换为 tokio::time::Sleep:设置了 needs_repoll 时为 2 ms,smoltcp 给出延迟时用该延迟,否则为 3600 s。随后 select! 等待以下任一事件最先发生:
分支 动作
self.udp.recv(&mut self.recv_buf) decapsulate_all 清零保留字节并调用 Tunn::decapsulate。WriteToNetwork 结果(握手响应、cookie 回复,或 boringtun 之前排队的包)会被收集起来,并按 boringtun 的约定用空数据报反复调用 decapsulate,直到它不再产生此类结果。WriteToTunnelV4 或 WriteToTunnelV6 结果被推入 InMemoryDevice::rx。之后由 send_all 发送收集到的包。
self.cmd_rx.recv() Some(cmd) 交给 handle_command。None 表示所有 WgDevice 句柄都已释放,驱动返回。
std::future::poll_fn(poll_uplinks) 只要任一未持有条目的连接在其上行 channel 中有条目,或已看到 channel 结束,就会完成。poll_uplinks 会轮询所有这样的连接,而不只是第一个,所以下一轮会处理全部这些连接。该分支只填充持有槽;由 service_sockets 在下一轮把条目交给 socket。
timer.tick() 每隔 TIMER_TICK(250 ms,MissedTickBehavior::Delay)调用一次 Tunn::update_timers。WriteToNetwork 结果(握手重试、keepalive)写入保留字节后直接用 udp.send 发送,而不经过 send_datagram;这次发送的任何错误都会结束驱动。其他结果被忽略。
sleep 唤醒后重新轮询 smoltcp,处理其自身的定时器(重传、延迟 ACK)或已满的下行 channel。
sequenceDiagram
  participant R as 运行时
  participant C as WgConnector
  participant S as DeviceSlot
  participant D as WgDevice
  participant T as 驱动任务
  participant P as 对端
  R->>C: connect(flow)
  C->>S: acquire_device(加锁)
  S-->>C: WgDevice 的 Arc
  C->>C: 结合 FamilySupport 执行 resolve_candidates
  C->>D: connect_tcp_any,逐个尝试候选地址
  D->>T: 经命令 channel 发送 Command::ConnectTcp
  T->>T: open_tcp:tcp::Socket::connect,保存 reply
  T->>P: 封装后的 SYN(必要时先握手)
  P-->>T: SYN-ACK,解封装后送入 smoltcp
  T-->>D: may_send() 成立后回复 Ok
  D-->>C: TunnelTcp
  C-->>R: Outbound::Stream(WgStream)

WgConnector::dial 按 dest.network 分支。对于 TCP,它用 resolve_candidates("wireguard", ...) 解析目标,然后调用 connect_tcp_any 按顺序尝试候选地址。每次尝试都是 tokio::time::timeout(TCP_CONNECT_ATTEMPT_TIMEOUT, device.connect_tcp(ip, port)),上限 10 s;第一个成功的胜出。如果全部失败,错误为 TimedOut,并附上每次尝试的原因:wireguard: tunnel TCP connect failed for all resolved addresses (192.0.2.1: timed out; ...)。

在驱动内部,open_tcp 创建一个收发缓冲区均为 TCP_BUFFER 的 tcp::Socket,从 next_ephemeral_port 取一个本地端口,并调用 socket.connect 从 src 连接到 dst。如果 smoltcp 拒绝这次 connect 调用,回复为 InvalidInput(wireguard: connect failed: <smoltcp error>),且不保存任何状态。否则,socket 加入 SocketSet,一个持有 reply 和 Uplink::new(out_rx) 的 TcpConn 加入 tcp_conns。在之后的某一轮中,service_sockets 给出回复:

  • socket.may_send() 为 true(握手已到达 Established):Ok(())。
  • socket.state() == tcp::State::Closed(对端重置了连接,或 smoltcp 放弃):ConnectionRefused,消息为 wireguard: tunnel TCP connect failed。

本地端口来自一个从 EPHEMERAL_BASE(49152)开始的计数器,超过 65535 后回绕到起点。TCP socket 和 UDP 关联共用这个计数器。

上行。 WgStream::poll_write 在 PollSender::poll_reserve 中等待上行 channel 的空位,把整个缓冲区复制成一个 Bytes 数据块,并报告已全部写入。poll_flush 什么也不做。每一轮中,service_sockets 调用 uplink.take() 取下一个数据块,最多 CHANNEL_CAP 次。take 在 can_send() 检查之前执行,所以无论 socket 处于什么状态,都能发现上行已结束。之后:

  • socket 无法发送:持有该数据块;
  • send_slice 接收了整个数据块:循环取下一个;
  • send_slice 只接收了一部分:剩余部分 chunk.split_off(sent) 被持有,循环停止;
  • send_slice 返回错误:持有该数据块,循环停止。

持有的数据块在下一轮最先交出。不会有任何东西被放回队列,因为驱动每条连接最多只保留这一个数据块。

下行。 对每个可读的 socket,service_sockets 先用 in_tx.try_reserve() 取得一个许可(permit),再把 smoltcp 接收缓冲区中连续可读的部分复制成一个 Bytes 并发送,只要 can_recv() 成立就重复。channel 已满时,数据留在 smoltcp 接收缓冲区中,并设置 needs_repoll,驱动会在 2 ms 后回来重试。数据滞留期间,smoltcp 通告的接收窗口会缩小,从而减慢远端发送方。WgStream::poll_read 借助 pending,把一个收到的数据块分摊到调用方缓冲区所需的任意多次读取中。

UDP 流在拨号时不做任何解析。dial 调用 device.connect_udp(),驱动的 open_udp 创建一个 smoltcp udp::Socket,每个方向有 64 个元数据槽和 TCP_BUFFER 字节的载荷存储,把它绑定到下一个临时端口(不固定地址),保存一个持有 Uplink::new(out_rx) 的 UdpConn,并立即回复 Ok。

WgDatagramLink::poll_send_to(cx, buf, to) 先在 poll_target 中把 Destination 映射为 SocketAddr:

  • IP 字面量直接使用;策略和 FamilySupport 过滤只作用于域名。
  • 已在 resolved 中的域名使用缓存结果。缓存把失败记为 None,结果和失败在链路的整个生命周期内都不会过期。
  • 否则在 resolving 中发起一次查询:一个装箱的 future,用链路的策略和 FamilySupport 运行 resolve_candidates,只保留第一个候选地址。同一时间只持有一个查询。发往另一个域名的包会直接返回 Pending,不会轮询正在进行的查询;该查询只有在针对它自己的域名再次调用 poll_send_to 时才会推进。

域名无法解析时,该包被丢弃(以 trace 级别记录),poll_send_to 仍返回 Ok(buf.len()):丢包本来就是 UDP 的行为。否则链路在上行 channel 中预留一个空位并发送 (target, payload)。每一轮中,service_sockets 用 uplink.take() 最多取 CHANNEL_CAP 个数据报,并用 udp::Socket::send_slice(payload, target) 逐个发送:

  • Ok:数据报已进入 socket 的发送环形缓冲区;循环取下一个。
  • SendError::BufferFull 且载荷能放进环形缓冲区(payload.len() <= socket.payload_send_capacity()):持有该数据报,直到下一次轮询清空环形缓冲区,循环停止。
  • 其他任何错误,即数据报大于整个发送环形缓冲区,或 SendError::Unaddressable:丢弃该数据报并记录一条 trace 日志 wireguard: dropping a <n>-byte datagram to <target>: <error>,循环继续。因此,永远无法发送的数据报不会阻塞排在它后面的数据报。

收到的数据报以 (source, payload) 形式返回。poll_recv_from 把载荷中能放进调用方缓冲区的部分复制过去,并以 Destination { network: DialNetwork::Udp, remote: Remote::IpAddr(..), port } 形式返回来源。

device.rs 模块文档的 # Backpressure 一节给出了上界,由 Uplink 实现。驱动只在某条连接没有持有条目时才读取它的上行 channel。当 socket 不再接收数据(远端不再读取,或隧道不再排空)时,该连接保留其持有条目,上行 channel(CHANNEL_CAP,256 个条目)随之被填满。此时 WgStream::poll_write 在 PollSender::poll_reserve 中等待,背压由此传到生产者,驱动也不再为该连接取任何东西。其他每条连接都有自己的 channel 和持有槽,poll_uplinks 会持续填充那些没有持有条目的连接,所以它们照常推进。UDP 关联的方式相同,由 WgDatagramLink::poll_send_to 等待 channel 空位。

已持有条目时,poll_refill 有意返回 Pending,且不注册 waker。此时连接等待的是它的 socket,而不是它的 channel;socket 的进展(打开窗口的 ACK、清空 UDP 发送环形缓冲区的轮询)总是经由驱动循环到来,而驱动循环的下一轮会再次交出持有条目。不读取 channel,正是向应用施加背压的方式。

对一条 TCP 连接,上行最多容纳:

环节 上界
smoltcp 发送环形缓冲区 TCP_BUFFER,64 KiB
上行 channel CHANNEL_CAP,256 个条目
Uplink 中的持有槽 1 个条目

channel 和持有槽按条目计数,而不是按字节。一个 poll_write 数据块就是调用方的整个缓冲区,所以满 channel 背后的字节数取决于写入方缓冲区的大小。

下行则反过来受限:只有在其下行 channel 有空位时才读取 socket(见 TCP 数据的搬运)。

TCP 的关闭由 channel 驱动:

stateDiagram-v2
  [*] --> Connecting: open_tcp 保存 TcpConn
  Connecting --> Open: may_send,回复 Ok
  Connecting --> Closed: 状态为 Closed,回复 ConnectionRefused
  Connecting --> Closed: 上行在 SYN-SENT 时结束(放弃拨号)
  Open --> LocalClosed: 上行已结束,发送 FIN
  Open --> LocalClosed: 下行接收端已释放,在下次有数据时发现
  Open --> RemoteClosed: 收到远端 FIN,尚未 EOF
  LocalClosed --> Closed: 远端 FIN,随后 10 s TIME-WAIT
  RemoteClosed --> Closed: 本地关闭,LAST-ACK 被确认
  Open --> Closed: 重置
  Closed --> [*]: 移除 socket 和 TcpConn
  • WgStream::poll_shutdown 关闭 PollSender。一旦 uplink.is_finished() 为 true(发送端已消失、channel 已取空、最后一个条目已交给 socket),service_sockets 调用一次 socket.close()(发送 FIN),并设置 uplink_closed。丢弃 WgStream 的效果相同。
  • 如果应用释放了下行接收端,驱动会在该 socket 下一次有数据要交付时发现:try_reserve 返回 Closed,驱动调用 socket.close()。
  • socket 到达 tcp::State::Closed 时,驱动把它从 SocketSet 和 tcp_conns 中移除。移除 TcpConn 会释放 in_tx,于是 WgStream::poll_read 返回文件结束,之后的写入以 BrokenPipe 失败(wireguard: the tunnelled connection is closed)。
  • in_tx 只在 tcp::State::Closed 时释放,而不是在收到远端 FIN 时。远端发来 FIN 后,socket 停留在 CLOSE-WAIT,读取方直到本地也关闭(LAST-ACK,然后 Closed)才会看到文件结束。本地先关闭时,socket 会经过 TIME-WAIT(smoltcp 保持 10 s)才到达 Closed。
  • 如果在 socket 仍处于 SYN-SENT 时放弃拨号,socket.close() 会让它直接进入 Closed,驱动在同一轮中将其移除。

UDP 关联在以下两种情况下被移除:

  • 其上行已结束(所有发送端都已释放,最后一个数据报已交给 socket)。它在发生这件事的那一轮的下一轮退役:service_sockets 在该关联的处理开始时检查 uplink.is_finished(),因此开启那一轮的 Interface::poll 已经发出了它最后的数据报。
  • 其下行接收端已被释放,且有数据报到达。

丢弃 WgDatagramLink 会同时释放两端,因此适用第一种情况。在驱动一侧已消失的链路上调用 poll_recv_from 会返回 BrokenPipe,消息相同。

acquire_device 在槽位的互斥锁下运行,处理三种情况。

stateDiagram-v2
  [*] --> Empty
  Empty --> Live: start 返回 Ok,started_at = now
  Empty --> Waiting: start 返回 Err,note_failure
  Live --> Live: is_alive,交出 Arc
  Live --> Empty: 存活超过 MIN_HEALTHY_LIFETIME 后驱动停止,重置 failures
  Live --> Waiting: 驱动过早停止,note_failure
  Waiting --> Waiting: 未到 retry_at,返回 tunnel_down 错误
  Waiting --> Empty: 已过 retry_at
  1. device 存活。 device.is_alive() 为 true:返回 Arc 的一个克隆。
  2. device 已死亡。 以 warn 级别记录 wireguard: tunnel driver stopped, rebuilding 并丢弃它。如果它 died_young(从未记录启动时间,或运行时间短于 MIN_HEALTHY_LIFETIME,即 10 s),就用 note_failure 记一次失败并返回 tunnel_down():note_failure 刚刚把 retry_at 设为至少 2 s 之后,所以这次请求永远不会触发重建。如果它运行得更久,就把这次死亡视为新问题:重置 failures 和 retry_at,并立即重建。
  3. 没有 device。 如果 retry_at 还在将来,返回 tunnel_down(),即 BrokenPipe,消息为 wireguard: tunnel is down, waiting before the next attempt。否则调用 WgDevice::start。成功则保存 device 和 started_at;失败则调用 note_failure,记录 wireguard: tunnel start failed: <error> 并返回该错误。

note_failure 让 failures 加一(饱和运算),并把 retry_at 设为当前时间加上 REBUILD_BACKOFF_BASE * 2^min(failures, 5),上限为 REBUILD_BACKOFF_MAX:

调用后的 failures 下次尝试前的等待时间
1 2 s
2 4 s
3 8 s
4 16 s
5 或更多 30 s(REBUILD_BACKOFF_MAX)

成功的 start 不会重置 failures;只有存活至少 MIN_HEALTHY_LIFETIME 的隧道死亡时才会重置。

互斥锁在整个 WgDevice::start(包括 endpoint 查询)期间都被持有。隧道构建期间到达的流会等待这次构建完成,然后拿到同一个 device,而不是各自启动一条隧道。

不变量 机制 由谁固定
路由到同一出站的所有流共享一条隧道和一个 UDP socket。 WgConnector 的克隆共享 Arc<Mutex<DeviceSlot>>;device 存活期间,acquire_device 返回保存的 Arc<WgDevice>。 protocols/tests/unit/wireguard/slot.rs 中的 a_dead_device_is_replaced_on_the_next_request(对两次 acquire_device 调用做 Arc::ptr_eq);protocols/tests/pipeline/wireguard.rs 中的 a_tcp_flow_rides_the_tunnel 在同一个 connector 上打开第二个流
已停止的驱动在下次请求时被替换,而不是被复用。 WgDevice::is_alive 检查 cmd_tx.is_closed();acquire_device 丢弃已死亡的 device 并启动新的。 a_dead_device_is_replaced_on_the_next_request 自行清空槽位,并检查下次请求会构建一个存活的 device;is_alive() == false 分支没有专门的测试
退避闸门关闭期间,不会创建任何 socket 或驱动任务。 acquire_device 在调用 WgDevice::start 之前检查 retry_at。 the_backoff_gate_refuses_without_building_another_tunnel、a_failed_start_backs_off_before_retrying
退避随连续失败增长,且不超过 30 s。 DeviceSlot::note_failure,配合 REBUILD_BACKOFF_BASE、min(5) 指数和 REBUILD_BACKOFF_MAX。 repeated_failures_escalate_the_backoff
启动后 10 s 内死亡的隧道计为一次失败。 DeviceSlot::died_young 与 MIN_HEALTHY_LIFETIME 比较。 a_short_lived_tunnel_counts_as_a_failure
不可达对端的 ICMP 错误出现在接收、flush_tx 或 send_all 路径上时,不会结束驱动。 在 udp.recv 分支和 send_datagram 中,is_transient_send_error 把 ConnectionRefused、ConnectionReset、HostUnreachable、NetworkUnreachable 和 WouldBlock 视为可恢复。定时器分支发送时不经过这层过滤。 an_unreachable_peer_does_not_kill_the_driver
隧道内 TCP 连接失败以错误的形式报告,而不是一个立即关闭的 stream。 TcpConn::connect_reply 中的 oneshot 回复,在 may_send() 或 tcp::State::Closed 时给出;connect_tcp_any 中的单次尝试超时。 a_tcp_flow_rides_the_tunnel 覆盖成功路径
只尝试隧道拥有本地地址的地址族中的目标。 FamilySupport::from_addrs 和 select_candidate_ips;local_addr_for 作为兜底;构建时的 validate_wg_address_family。 protocols/tests/unit/helpers/address_family.rs 中的 auto_skips_families_without_a_local_address、the_capability_clause_is_omitted_for_a_kernel_routed_dialer;app/tests/unit/outbound.rs 中的 wireguard_ipv6_only_requires_ipv6_address、wireguard_ipv4_only_builds_with_ipv4_address
一条连接的上行有上界,停滞的流只阻塞它自己的写入方,而不阻塞其他流。 Uplink 最多持有一个条目;只在没有持有条目时读取 channel;service_sockets 每轮最多交给一个 socket CHANNEL_CAP 个条目。 protocols/tests/unit/wireguard/device.rs 中的 a_held_item_keeps_the_channel_unread、the_uplink_finishes_only_after_its_last_item;protocols/tests/pipeline/wireguard.rs 中的 a_stalled_tcp_flow_blocks_its_writer
无法发送的数据报被丢弃,而不是重试。 UDP send_slice 返回错误时丢弃该数据报,除非错误是 BufferFull 且载荷能放进发送环形缓冲区。 an_oversized_datagram_does_not_wedge_the_association
驱动从不因读取方缓慢而阻塞。 下行使用 try_reserve;channel 已满时数据留在 smoltcp 中,并安排 2 ms 后重新轮询。 没有专门的测试固定
每个出向数据报都写入保留字节,每个入向数据报在 boringtun 解析前都被清零。 flush_tx、send_all 和定时器路径上的 apply_reserved;decapsulate_all 开头的清零。 只有 wireguard_outbound_live_env_tcp(已 ignore,见下文)能通过 ETEMENANKI_WG_RESERVED 设置非零的 reserved;进程内对端都不使用
TCP 流变成 stream,UDP 流变成数据报链路。 dial 按 dest.network == DialNetwork::Udp 分支。app 把另一种组合转为 wireguard dialed a TCP flow as UDP(或反过来)。 a_tcp_flow_rides_the_tunnel、a_udp_flow_rides_the_tunnel
位置 io::ErrorKind 消息
WgDevice::start InvalidInput wireguard: no tunnel-local addresses configured
WgDevice::start NotFound wireguard: endpoint domain did not resolve
WgDevice::start 操作系统报告的类型 lookup_host、bind 或 connect 错误本身
acquire_device BrokenPipe wireguard: tunnel is down, waiting before the next attempt
resolve_candidates NotFound wireguard: destination did not resolve(应答为空时;解析器错误原样透传)
resolve_candidates AddrNotAvailable wireguard: no usable <strategy> destination address for <host>:<port> (local address supports <families>)
WgDevice::connect_tcp AddrNotAvailable wireguard: no tunnel-local address matching destination family
WgDevice::connect_tcp、connect_udp BrokenPipe wireguard driver stopped(命令 channel 或回复被丢弃)
驱动,open_tcp InvalidInput wireguard: connect failed: <smoltcp error>
驱动,open_udp InvalidInput wireguard: udp bind failed: <smoltcp error>
驱动,service_sockets ConnectionRefused wireguard: tunnel TCP connect failed
connect_tcp_any TimedOut,与各次尝试的具体原因无关 wireguard: tunnel TCP connect failed for all resolved addresses (<ip>: <reason>; ...)
WgStream、WgDatagramLink BrokenPipe wireguard: the tunnelled connection is closed

发生以下任一情况时,驱动任务返回,隧道随之结束:

原因 日志
所有 WgDevice 句柄都已释放,cmd_rx.recv() 产出 None。这是正常关闭。 无
flush_tx 或 send_all 遇到 is_transient_send_error 不涵盖的发送错误。 warn:wireguard: tunnel driver stopping, send failed: <error>
udp.recv 返回 is_transient_send_error 不涵盖的错误。 warn:wireguard: tunnel driver stopping, receive failed: <error>
发送定时器包(握手重试或 keepalive)时出现任何错误。这条路径不使用 is_transient_send_error。 无

接收路径上的临时错误以 debug 级别记录(wireguard: transient receive error: <error>);在 flush_tx 和 send_all 路径上,数据报会被丢弃,并记录一条 debug 日志 wireguard: dropping datagram, transient send error: <error>。WireGuard 是无连接的,并由定时器重传握手,所以对端恢复后无需重建就能重新连通。

驱动返回时会释放其 Driver 值:所有 smoltcp socket、所有 in_tx 以及所有上行接收端。现有 WgStream 的读取方看到文件结束,写入方得到 BrokenPipe,WgDevice::is_alive 变为 false,于是下一次 acquire_device 会重建。

  • 在等待回复时丢弃拨号 future。 oneshot 接收端随之消失,驱动之后 reply.send 的结果被忽略。上行发送端随 future 一起被释放,因此上行 channel 断开,该连接的 Uplink 结束,service_sockets 按 关闭 中所述关闭并移除 socket。
  • 在 connect_tcp_any 内部丢弃拨号 future。 对正在进行的那次尝试,效果同上。单次尝试超时也正是以这种方式丢弃该次尝试,然后转向下一个候选地址。
  • 在 acquire_device 期间丢弃拨号 future。 互斥锁守卫随 future 一起释放。如果 WgDevice::start 尚未 spawn 任务,它的 socket 会被直接丢弃。
  • 丢弃出站。 最后一个 WgConnector 克隆拥有 DeviceSlot,槽位拥有 Arc<WgDevice>。一旦没有进行中的拨号持有该 Arc 的其他克隆,命令 channel 就会关闭,驱动随之关闭,隧道中的所有连接都会结束。在 etemenanki-app 中,这发生在热重载时退役某个 generation(一代实例)的时候(见 Generation 与重载)。
常量 值 位置 限制的对象
DEFAULT_MTU 1420 config.rs 未配置时的接口 MTU
CHANNEL_CAP 256 device.rs 命令 channel 以及每条连接的上行、下行 channel 中的条目数;也是一条连接每轮最多交给其 socket 的条目数
持有的上行条目 每条连接 1 个 device.rs → Uplink 驱动已从上行 channel 取出、但 socket 尚未接收的条目
TCP_BUFFER 64 KiB device.rs smoltcp TCP socket 每个方向的缓冲区;也是 UDP socket 每个方向的载荷存储
UDP 包元数据 64 device.rs → open_udp smoltcp UDP socket 每个方向能容纳的数据报数
SCRATCH 64 KiB device.rs scratch 和 recv_buf,远大于一个 MTU 大小的包加 32 字节 WireGuard 开销
TIMER_TICK 250 ms device.rs Tunn::update_timers 的调用节奏
EPHEMERAL_BASE 49152 device.rs 隧道侧 socket 的第一个本地端口;计数器超过 65535 后回绕到这里
重新轮询延迟 2 ms device.rs → compute_sleep 重试已满的下行 channel 前的等待时间
空闲延迟 3600 s device.rs → compute_sleep smoltcp 没有待触发定时器时的休眠时间
TCP_CONNECT_ATTEMPT_TIMEOUT 10 s slot.rs connect_tcp_any 中每个候选地址的尝试
REBUILD_BACKOFF_BASE 1 s slot.rs 重建退避的基数
REBUILD_BACKOFF_MAX 30 s slot.rs 重建退避的上限
MIN_HEALTHY_LIFETIME 10 s slot.rs 存活时间低于此值的已停止隧道计为一次失败

经过隧道收发流量的测试连接的是进程内对端,而不是真实服务器;槽位测试则把 device 指向无人监听的 127.0.0.1:1,或者在任何 socket 创建之前就失败。protocols/tests/support/wireguard.rs → spawn_echo_peer(server_priv, client_pub, sink_reads: Arc<AtomicBool>) 构建驱动的镜像:第二个 boringtun Tunn,桥接到它自己位于 SERVER_TUN_IP(10.0.0.1/24)的 smoltcp 接口,带有 LISTENERS(4)个在每次连接后重新监听的 TCP 监听器和一个 UDP socket,全部在 ECHO_PORT(5555)上回显。它还在 SINK_PORT(5556)上运行一个缓冲区为 64 KiB 的 TCP sink。在 sink_reads 被设置之前,sink 什么也不读,因此它 64 KiB 的接收窗口会关闭,隧道也就不再排空写往它的写入方;连接关闭后它会重新监听。对端绑定 127.0.0.1:0,从收到的每个数据报中获取客户端地址(在收到第一个数据报之前不发送任何内容),把每个长于 3 字节的入向数据报的第 1 到 3 字节清零,并每 200 ms 驱动一次定时器。spawn_peer 生成两对固定密钥,返回一个包含 endpoint、客户端私钥、服务端公钥和 sink_reads 的 Peer,sink_reads 是测试用来允许 sink 读取的标志。对端会在它的一个定时器周期内察觉到变化。客户端使用 CLIENT_TUN_IP(10.0.0.2)。

测试 文件 固定的行为
a_tcp_flow_rides_the_tunnel protocols/tests/pipeline/wireguard.rs 通过 WgConnector 完成握手、TCP 连接和回显;同一个 connector 上的第二个流也能回显;shutdown 成功
a_udp_flow_rides_the_tunnel protocols/tests/pipeline/wireguard.rs UDP 流变成 WgDatagramLink;回显能返回,且其来源等于发送时的目标
a_stalled_tcp_flow_blocks_its_writer protocols/tests/pipeline/wireguard.rs 以 1 KiB 数据块向不读取的 sink 写入,写入方在最多 512 KiB 后阻塞(预期上界为 385 KiB:sink 的 64 KiB 窗口、socket 的 64 KiB、256 个 channel 数据块和 1 个持有数据块);同一隧道上的第二个流仍能回显;设置 sink_reads 后,被阻塞的写入完成,并且另外 1024 个数据块顺利通过
an_oversized_datagram_does_not_wedge_the_association protocols/tests/pipeline/wireguard.rs 一个 70,000 字节、大于 64 KiB 发送环形缓冲区的数据报被丢弃,同一关联上的下一个数据报能回显
a_held_item_keeps_the_channel_unread protocols/tests/unit/wireguard/device.rs 持有条目时,poll_refill 保持 Pending,channel 被填满直到 try_send 报告 Full;之后 take 先返回持有条目,再按顺序返回 channel 中的条目
the_uplink_finishes_only_after_its_last_item protocols/tests/unit/wireguard/device.rs 发送端释放后,只要还有持有或排队的条目,is_finished 就保持 false,取走最后一个条目并看到 channel 结束后变为 true;在已断开的 channel 上 poll_refill 返回 Ready 并记录结束
an_unreachable_peer_does_not_kill_the_driver protocols/tests/unit/wireguard/slot.rs 对 127.0.0.1:1(会回应 ICMP 端口不可达)进行 300 ms 的连接尝试后,驱动仍然存活
a_dead_device_is_replaced_on_the_next_request protocols/tests/unit/wireguard/slot.rs 存活的 device 被复用(Arc::ptr_eq);测试清空槽位并释放其句柄后,下次请求会构建一个存活的 device
a_failed_start_backs_off_before_retrying protocols/tests/unit/wireguard/slot.rs start 失败(InvalidInput)之后是 BrokenPipe 闸门错误
a_short_lived_tunnel_counts_as_a_failure protocols/tests/unit/wireguard/slot.rs 从未启动、刚刚启动和长期存活三种情况下的 died_young
repeated_failures_escalate_the_backoff protocols/tests/unit/wireguard/slot.rs 四次失败的等待时间严格递增,多次失败后封顶于 REBUILD_BACKOFF_MAX
the_backoff_gate_refuses_without_building_another_tunnel protocols/tests/unit/wireguard/slot.rs 闸门关闭期间不保存任何 device
auto_skips_families_without_a_local_address 及其他 address_family 测试 protocols/tests/unit/helpers/address_family.rs 按 FamilySupport 和策略过滤候选地址,以及错误消息中的能力说明子句
wireguard_ipv4_only_builds_with_ipv4_address、wireguard_ipv6_only_requires_ipv6_address app/tests/unit/outbound.rs 构建时检查 address_family 与 address 是否匹配
app_socks_to_wireguard_outbound_tcp app/tests/integration/e2e_wg.rs 真实的 etemenanki-app 二进制:SOCKS 入站、使用 hex 密钥的 WireGuard 出站,通过测试文件自带的对端副本(一个监听器,仅 TCP)完成 TCP 回显
wireguard_outbound_live_env_tcp app/tests/integration/e2e_wg.rs 标记为 #[ignore]。以同样的 app 路径连接一个真实对端,对端通过 ETEMENANKI_WG_* 环境变量配置(密钥、endpoint、地址、可选的 MTU、keepalive、reserved、探测目标和 host),并期望收到 HTTP 响应

在 Etemenanki workspace 中运行:

终端窗口
cargo test -p etemenanki-protocols --test pipeline wireguard
cargo test -p etemenanki-protocols --lib wireguard
cargo test -p etemenanki-protocols --lib address_family
cargo test -p etemenanki-app --bin etemenanki-app wireguard
cargo test -p etemenanki-app --test integration e2e_wg

--lib wireguard 这次运行包含 device 单元测试,device.rs 用 #[path] 从 protocols/tests/unit/wireguard/device.rs 挂载它们,与 slot.rs 挂载槽位测试的方式相同。app 的单元测试位于二进制 target 中,因为 etemenanki-app 没有库 target。