跳转到内容

TUN

源码文件:22 个 · 核对版本 Etemenanki 596916d
  • Etemenanki/protocols/src/tun/mod.rs
  • Etemenanki/protocols/src/tun/config.rs
  • Etemenanki/protocols/src/tun/device.rs
  • Etemenanki/protocols/src/tun/inbound.rs
  • Etemenanki/protocols/src/tun/tracked.rs
  • Etemenanki/protocols/src/tun/udp.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/lib.rs
  • Etemenanki/protocols/Cargo.toml
  • Etemenanki/app/src/inbound/tun.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/transport.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/connector.rs
  • Etemenanki/app/src/main.rs
  • Etemenanki/protocols/tests/pipeline/tun.rs
  • Etemenanki/protocols/tests/unit/tun/udp.rs
  • Etemenanki/protocols/tests/unit/core/mod.rs
  • Etemenanki/app/tests/unit/inbound.rs
  • Etemenanki/app/tests/integration/e2e_tun.rs

TUN 入站把一个三层网络接口变成被代理流量的来源。它创建接口、分配地址、安装运维人员列出的路由,然后把文件描述符交给一个用户态 IP 协议栈(ipstack crate)。主机路由进该接口的每条 TCP 连接和每个 UDP 流都由这个协议栈终结;随后每条 TCP 连接作为独立的 ProxyServerRuntime 运行在 PassthroughCore 之上,而来自同一客户端源(地址和端口)的所有 UDP 流共用一个运行在 TunUdpCore 之上的运行时。

本页面向修改 protocols/src/tun/ 或其位于 app/src/inbound/tun.rs 的胶水代码的贡献者,并假定读者已了解 服务端协议核心 和 服务端运行时 中的协议核心与运行时模型。运维视角的内容(设置、示例、如何把主机流量路由进设备)见 TUN 用户指南。

为什么 TUN 不是协议核心之下的传输层

Section titled “为什么 TUN 不是协议核心之下的传输层”

流式入站把一个字节流传输层(TCP、TLS、WebSocket、gRPC)与一个解码单个客户端字节流的协议核心配对。TUN 设备不符合这种形态:它是一个文件描述符,承载操作系统路由进来的所有主机的流量,每次一个 IP 包。其中没有哪一条流能代表一条连接。

因此,与 Hysteria 2 监听器 一样,TunInbound 在整个 generation 生命周期内独占整个设备。用户态协议栈把数据包解复用为 TCP 流和 UDP 流,入站为每条 TCP 流启动一个运行时,为每个 UDP 客户端源启动一个运行时。这里没有协议头,也没有凭据:IP 协议栈已经知道每个流的目的地址,协议核心无需解析任何内容。

入站自始至终拥有这个接口。open 创建接口、分配地址并安装路由;没有任何代码显式删除它们。最后一个描述符关闭时(也就是 generation 结束时),内核销毁接口,绑定在该设备上的路由也随之消失。

流量 处理方式 位置
ICMP 以及其他所有非 TCP/UDP 协议 丢弃;协议栈无处可送。以 trace 级别记录 tun: dropping a packet of an unsupported protocol。 protocols/src/tun/inbound.rs → TunInbound::run 中的 UnknownTransport / UnknownNetwork 分支
udp = false 时的 UDP IpStackUdpStream 立即被丢弃;其 Drop 会注销协议栈中的会话。 TunInbound::run
来自客户端从未发送过数据的对端的 UDP 回复,或属于链路已注销的流的回复 丢弃:ipstack 只能从已见过的数据包发起流,因此这个回复没有可承载它的流。以 debug 级别记录 tun: dropping a reply from <addr>: the client never addressed it。 protocols/src/tun/udp.rs → TunUdpLink::poll_send_to
源为域名的 UDP 回复 丢弃:IP 包无法以域名作为源地址。以 debug 级别记录。 TunUdpCore::handle,Event::Datagram
max_flows 许可(permit)耗尽时新建的 TCP 连接,或需要新 UDP 关联的 UDP 流 丢弃。以 debug 级别记录 tun: dropping a flow; the flow limit is reached。 TunInbound::run
所属关联的队列已满时新建的 UDP 流 丢弃;由客户端重传。 TunInbound::run,TrySendError::Full

由于回复只能沿客户端打开的流返回,模块文档把客户端所见的行为描述为地址受限锥形(address-restricted cone):一个关联(在只有一个出站时,也就是一个出站 socket)服务客户端通信的所有对端,但客户端未曾发送过数据的对端无法把任何数据送达客户端。TunUdpLink 以远端完整的 SocketAddr 作为流的键,所以实际的过滤比这个名称更严格:只有来自客户端发送过数据的那个确切地址和端口的回复才会被投递,同一主机其他端口的回复会被丢弃。

每个流都由出站通过主机真实的上行链路拨出。如果运维人员把默认路由(或出站所用的服务器)路由进 TUN 设备,这些拨号会被引回设备,代理就会把自己的流量中继给自己。入站不会检测这种环路;模块文档和用户指南都提醒运维人员只路由需要代理的流量。

该模块有两道门控:

  • protocols/Cargo.toml → feature tun = ["dep:ipstack", "dep:tun-rs", "dep:rtnetlink"],默认关闭,这样传统协议不会连带引入一整套接口管理依赖。rtnetlink 是仅限 Linux 的 target 依赖。
  • protocols/src/lib.rs → #[cfg(all(feature = "tun", unix))] pub mod tun;。

etemenanki-app 启用了该 feature。katana 没有启用,因此没有 TUN 入站。

protocols/src/tun/config.rs 保存 app 完成校验后入站所需的内容:

pub const DEFAULT_MTU: u16 = 1500;
pub const DEFAULT_UDP_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
pub const DEFAULT_MAX_FLOWS: usize = 65_536;
pub struct TunConfig<T> {
pub user: Arc<T>,
pub mtu: u16,
pub udp: bool,
pub udp_idle_timeout: Duration,
pub max_flows: usize,
}

由于线路上没有凭据,user 就是每个流归属的用户。app 传入 Arc::new(()),每个 Flow 携带一个 NetworkUser,其授权信息是一个空的 UserAuthorization::UsernamePassword。

app 把 [inbound.settings] 解析为 app/src/config.rs → TunInboundSettings(deny_unknown_fields),并在 app/src/inbound/tun.rs → build_tun_inbound 中把它拆分给设备和入站:

TunInboundSettings 字段 去向 默认值与校验
name: Option<String> DeviceSpec::name None:由内核选择名称。
mtu: Option<u16> DeviceSpec::mtu 和 TunConfig::mtu DEFAULT_MTU(1500)。小于 1280 时报错 tun mtu must be at least 1280。
address: Vec<String> DeviceSpec::addresses 用 cidr::IpInet 解析。不带前缀的地址视为主机地址(/32 或 /128)。解析失败时报告 bad tun address。
routes: Vec<String> DeviceSpec::routes 用 cidr::IpCidr 解析,因此主机位必须为零(10.77.1.5/24 会报错 bad tun route … host part of address was not zero)。
udp: Option<bool> TunConfig::udp true。
udp_idle_timeout: Option<u64> TunConfig::udp_idle_timeout 单位为秒;DEFAULT_UDP_IDLE_TIMEOUT(60 秒)。
max_flows: Option<usize> TunConfig::max_flows DEFAULT_MAX_FLOWS(65 536)。

本表及下文中的每个构建错误都以 inbound <tag>: 开头。build_tun_inbound 还会拒绝入站上的 listen 或 port(tun owns a network interface and has no listener; remove listen/port),并通过 reject_stream(最先检查)拒绝 network 设为空或 tcp 以外的值、或 security 设为空或 none 以外的值的 [inbound.stream](protocol tun does not support stream network … / … stream security …)。入站级别的 sniffing 键(默认 true)决定使用 TunInbound::new 还是 .without_sniffing()。

protocols/src/tun/device.rs:

pub struct DeviceSpec {
pub name: Option<String>,
pub mtu: u16,
pub addresses: Vec<(IpAddr, u8)>,
pub routes: Vec<(IpAddr, u8)>,
}
pub fn check_platform(spec: &DeviceSpec) -> io::Result<()>;
pub async fn open(spec: &DeviceSpec) -> io::Result<(OwnedFd, String)>;
#[cfg(target_os = "linux")]
async fn install_routes(routes: &[(IpAddr, u8)], ifindex: u32) -> io::Result<()>;
pub(crate) struct TunDevice(Arc<AsyncFd<File>>);
impl TunDevice {
pub(crate) fn new(fd: OwnedFd) -> io::Result<Self>;
pub(crate) fn handle(&self) -> Weak<AsyncFd<File>>;
}
  • check_platform 在 Linux 以外的平台上拒绝带路由的 spec(io::ErrorKind::Unsupported,tun routes are installed only on Linux; add them with the OS route tool)。build_tun_inbound 和 open 都会调用它,因此 --test 与真正启动拒绝的是同一批配置。

  • open 用 tun_rs::DeviceBuilder 构建接口:

    1. 设置 MTU,给定名称时设置名称;
    2. 把第一个 IPv4 地址(ipv4(addr, prefix, None))和所有 IPv6 地址(ipv6_tuple)交给 builder;
    3. 调用 build_sync,由于 builder 只接受一个 IPv4 地址,其余 IPv4 地址再用 add_address_v4 在线添加;
    4. 把描述符切换为非阻塞,并读回名称和接口索引;
    5. 在 Linux 上安装路由;
    6. 把设备转换为 OwnedFd,连同内核最终确定的名称一起返回。

    任何一步失败都会丢弃构建到一半的设备,从而销毁它,所以失败的 open 不会留下接口。

  • install_routes 相当于 netlink 版的 ip route add <net>/<prefix> dev <ifindex>。它打开一个 rtnetlink 连接并启动其驱动任务,用 RouteMessageBuilder::<IpAddr>(destination_prefix、output_interface(ifindex)、scope(RouteScope::Link))逐条添加路由,然后丢弃 handle 并中止驱动任务。错误信息会指明是哪条路由:route <net>/<prefix>: <netlink error>。路由从不被显式删除;它们绑定在设备上,随设备一起消失。

  • TunDevice 是协议栈读取所经的异步适配器:一个 AsyncFd<File>,一次 read 就是一个包,一次 write 也是一个包。std::io::Read 和 Write 都为 &File 实现,所以不需要 unsafe 或 libc。遇到 WouldBlock 时它会清除就绪状态(try_io)并再次轮询。poll_flush 和 poll_shutdown 不做任何事。handle() 返回一个 Weak;协议栈任务丢弃设备后,upgrade 就会失败,shutdown 正是借此得知描述符已关闭。

protocols/src/tun/inbound.rs:

pub struct TunInbound<T> {
config: Arc<TunConfig<T>>,
sniff: bool,
device: Arc<Mutex<Option<Weak<AsyncFd<File>>>>>,
live_tcp: Arc<AtomicUsize>,
}
impl<T> TunInbound<T> {
pub fn new(config: TunConfig<T>) -> Self;
pub fn without_sniffing(mut self) -> Self;
pub async fn shutdown(&self);
}
impl<T: Send + Sync + 'static> TunInbound<T> {
pub async fn run<C, F>(
&self,
fd: OwnedFd,
make_connector: F,
token: CancellationToken,
) -> io::Result<()>
where
F: Fn(IpAddr) -> C + Send + Sync + 'static,
C: Connector<Flow<T>> + Send + 'static,
C::Future: Send,
C::Stream: Send,
C::Datagram: DatagramLink<Addr = Destination> + Send;
}

TunInbound 手工实现了 Clone(没有 T: Clone 约束);各个克隆共享 device 和 live_tcp,因此一个 generation 中这两者各只有一份。device 是一个 parking_lot::Mutex,在描述符到达时由 run 填入;live_tcp 统计尚未完成丢弃的 TCP 流。

make_connector 在每条 TCP 连接和每个 UDP 关联建立时各调用一次,参数为客户端 IP。app 的闭包构建一个 AppConnector,其 FlowContext 携带入站 tag 和 source: Some(ip),因此按源地址匹配的路由规则可以生效(见 出站)。

run 把所有 per-flow 工作都放在一个 JoinSet 中,因此丢弃它的 future 会连带结束所有存活的流。

TCP:TrackedTcp、PassthroughCore 与 serve_stream

Section titled “TCP:TrackedTcp、PassthroughCore 与 serve_stream”

protocols/src/tun/tracked.rs:

pub struct TrackedTcp {
stream: Option<IpStackTcpStream>,
live: Arc<AtomicUsize>,
}
impl TrackedTcp {
pub fn new(stream: IpStackTcpStream, live: Arc<AtomicUsize>) -> Self;
pub fn stream(&self) -> Option<&IpStackTcpStream>;
}

TrackedTcp 包装一条 ipstack TCP 流,并把它计入入站的 live_tcp。new 让计数加一;Drop 先丢弃内部的流(self.stream.take()),然后才让计数减一。这个顺序很重要:当流的协议任务仍在运行时,IpStackTcpStream 自身的 Drop 会在 tokio::task::block_in_place 加 Handle::block_on 中阻塞,直到该任务结束,而计数必须覆盖这段阻塞。流被丢弃之后,所有 AsyncRead/AsyncWrite 方法都返回 NotConnected(tun: tcp stream already dropped)。

protocols/src/core/mod.rs → PassthroughCore 是每条 TUN TCP 连接运行的协议核心:

pub struct PassthroughCore<T> {
pending: Option<Flow<T>>,
sniff: bool,
prefix: SniffPrefix,
relay: Passthrough<Single>,
timing: Timing,
}
impl<T> PassthroughCore<T> {
pub const BUF_SIZE: usize = 8 * 1024;
pub fn new(flow: Flow<T>) -> Self;
pub fn sniffing(flow: Flow<T>) -> Self;
pub fn is_established(&self) -> bool;
}

它的目的地址在第一个字节到来之前就已知,所以什么都不解析:它在客户端的首批字节到达时(如果客户端未发送数据就关闭,则在客户端 EOF 时)打开流,并在两个方向上原样中继(STAGING_RESERVE = 0)。sniffing 仅在 worth_sniffing 判定目的地址是裸 IP 时才开启嗅探,而在 TUN 设备上这总是成立。嗅探数据的收集见 嗅探。

async fn serve_stream<T, C, P>(
tcp: TrackedTcp,
core: PassthroughCore<T>,
connector: C,
) -> io::Result<()>
where
T: Send + Sync + 'static,
C: Connector<Flow<T>, Datagram = P>,
P: DatagramLink<Addr = Destination>;

serve_stream 构建 ProxyServerRuntime::<{ PassthroughCore::<()>::BUF_SIZE }, _, _, _>::new(tcp, core, connector).showing_progress(),并把它当作由 Result<Traffic, RuntimeError<io::Error>> 步骤组成的 Stream 来轮询。在 runtime.core().is_established() 为真之前,每一步都包在 tokio::time::timeout(HANDSHAKE_TIMEOUT, runtime.next()) 中;此后轮询不再带外层超时,改由协议核心自身的中继空闲截止时间生效。之所以需要外层超时,是因为运行时在客户端首批字节到来之前不会产生任何事件,否则一个沉默的客户端永远不会触发协议核心的截止时间。

protocols/src/tun/udp.rs:

pub const FLOW_QUEUE: usize = 16;
pub struct TunUdpLink {
new_flows: mpsc::Receiver<IpStackUdpStream>,
flows: HashMap<SocketAddr, IpStackUdpStream>,
order: Vec<SocketAddr>,
next: usize,
}
impl TunUdpLink {
pub fn new(first: IpStackUdpStream, new_flows: mpsc::Receiver<IpStackUdpStream>) -> Self;
}
impl DatagramLink for TunUdpLink {
type Addr = SocketAddr;
// poll_send_to, poll_recv_from
}

ipstack 为每个(源,目的)对打开一个 IpStackUdpStream。TunUdpLink 把同一客户端源的所有流聚合为一个以远端寻址的 DatagramLink:

  • 接纳。 new 接纳第一个流。每次读取都先排空 new_flows(adopt_pending),以每个排队流的 peer_addr() 为键接纳它(在 ipstack 的命名中,peer_addr 是数据包的目的地址,local_addr 是其源地址)。如果通道已关闭且没有剩余的流,链路报告 NotConnected(tun: every flow of this association is gone)。
  • 读取。 poll_recv_from 从 next 开始轮流(round-robin)轮询各个流,返回第一个数据报及其来源远端。随后 next 移到该流之后,这样一个繁忙的远端不会饿死其他远端。读取完成却没有数据的流(其 ipstack 空闲超时已触发,或会话已不存在)会被注销,扫描重新开始。客户端发来的零长度数据报看起来也一样,所以同样会注销它所在的流。order 变空时,链路报告 NotConnected,关联随之结束。
  • 写入。 poll_send_to 查找 to 指定的流。找不到说明客户端从未向该对端发送过数据:回复被丢弃,但报告为已发送。写入出错会注销该流,同样报告成功,因此一个失效的远端永远不会让整个关联失败。一次写入就是一个包:ipstack 会把回复截断到 MTU 减去 IP 和 UDP 头的长度,而不是再发第二个包。
pub struct TunUdpCore<T> {
user: Arc<T>,
source: IpAddr,
opened: bool,
timing: Timing,
}
impl<T> TunUdpCore<T> {
pub const BUF_SIZE: usize = 8 * 1024;
pub fn new(user: Arc<T>, source: IpAddr) -> Self;
pub fn is_established(&self) -> bool;
}
impl<T: Send + Sync + 'static> ProxyCoreDecode for TunUdpCore<T> {
type Key = Single;
type Target = Flow<T>;
type Error = io::Error;
type TransportAddr = SocketAddr;
const STAGING_RESERVE: usize = 4096;
const MAX_DATAGRAM: usize = 4096;
// handle
}

TunUdpCore 把链路变成一个只有单一出站键(Single)的关联:

事件 协议核心的动作
TransportDatagram(第一个) 发出 Effect::Open,其 Flow 指向该远端(DialNetwork::Udp,Remote::IpAddr),带上配置的用户和 source;进入 Phase::Relay。
TransportDatagram 刷新空闲截止时间;负载非空时,向数据包所发往的远端发出 Effect::SendTo。
Datagram(回复) 刷新空闲截止时间。源为 IP 时用 fx.put_to(SocketAddr, data) 暂存,由链路写入其来源对应的流;源为域名时丢弃。
ConnectFailed、OutboundError Effect::Finish,以 debug 级别记录 tun: association ended: …。
TransportEof Effect::Finish。
Deadline 空闲:Timing 推送 Effect::Finish。
SendFailed、TransportSendFailed 丢弃该包;关联继续。

使用 AppConnector 时,数据报出站是一个 FanOutLink,它按每个包各自的目的地址路由,因此一个关联可以到达多个出站。

app/src/inbound/tun.rs:

pub fn build_tun_inbound(cfg: &InboundConfig) -> io::Result<(InboundKind, BindSpec)>;
pub async fn run_tun_inbound(
inbound: TunInbound<()>,
tag: CompactString,
fd: OwnedFd,
router: Arc<Router>,
token: CancellationToken,
);

build_tun_inbound 返回 InboundKind::Tun(TunInbound<()>) 和 BindSpec::Tun(DeviceSpec)。在 app/src/instance.rs 中,bind_inbound 对 BindSpec::Tun 调用 etemenanki_protocols::tun::open,记录 inbound <tag> owns tun device <name>,并返回 Listener::Tun(OwnedFd)。随后 spawn_generation 启动 run_tun_inbound:它运行入站,若 run 返回错误则以 error 级别记录 tun inbound failed: <error>,并且在任务结束前总会 await inbound.shutdown()。外围的生命周期见 服务 和 Generation 与重载。

TunInbound::run 把描述符包装为 TunDevice,记录其 Weak handle,配置 IpStackConfig(mtu、udp_timeout(udp_idle_timeout),以及仅在 macOS 和 iOS 上启用的 packet_information,因为这些平台的设备会在每个包前加一个 4 字节的头),然后启动 IpStack::new(config, device)。之后它在三个分支之间 select:取消 token、stack.accept() 和 flows.join_next()。

flowchart TB
  A["stack.accept()"] --> K{"IpStackStream"}
  K -->|"Tcp"| P1{"有 permit?"}
  P1 -->|"否"| D1["丢弃该流"]
  P1 -->|"是"| T["TrackedTcp、PassthroughCore,启动 serve_stream"]
  K -->|"Udp 且 udp = false"| D2["丢弃该 stream"]
  K -->|"Udp"| Q{"该源已有关联?"}
  Q -->|"有,队列未满"| S["try_send 进其 FLOW_QUEUE"]
  Q -->|"有,队列已满"| D3["丢弃该流"]
  Q -->|"没有,或其通道已关闭"| P2{"有 permit?"}
  P2 -->|"否"| D4["丢弃该流"]
  P2 -->|"是"| U["TunUdpLink、TunUdpCore,启动运行时"]
  K -->|"UnknownTransport 或 UnknownNetwork"| D5["丢弃该包"]

associations: HashMap<SocketAddr, mpsc::Sender<IpStackUdpStream>> 把每个客户端源(IP 和端口)映射到其关联的队列。UDP 关联任务返回 Some(src);join_next 分支收到它时,如果该源的 sender 已关闭,就删除对应条目。try_send 发现通道已关闭时也会删除条目,并由该流打开一个新的关联。

sequenceDiagram
  participant C as 客户端主机
  participant S as ipstack
  participant L as TunInbound run 循环
  participant R as serve_stream
  participant O as Connector
  C->>S: SYN(被路由进设备)
  S->>L: IpStackStream::Tcp(收到 SYN 时)
  S-->>C: SYN-ACK,握手在协议栈内完成
  L->>L: 取得 permit,包装为 TrackedTcp
  L->>R: 以 PassthroughCore 启动
  C->>S: 首批负载字节
  S->>R: Event::Transport
  R->>R: 嗅探,直到 SNIFF_LIMIT 或 SNIFF_TIMEOUT
  R->>O: 带嗅探到的域名发出 Effect::Open
  O-->>R: 出站流
  R->>O: 先发暂存的前缀,再双向中继

ipstack 在收到 SYN 时就把流交给 accept 循环,并自行完成与客户端的 TCP 握手,此时还没有拨号。只有在客户端发送了字节(或关闭了自己一侧)之后,才会拨出站。由此产生两个后果:

  • 客户端 connect 成功并不代表目的地址可达。如果拨号失败,PassthroughCore 通过 Passthrough::on_outbound_gone 处理 ConnectFailed,关闭传输层并结束。
  • 服务端先发言的协议(SMTP、MySQL)经过此入站会卡住:客户端等待问候语,运行时等待客户端,最终 serve_stream 在 HANDSHAKE_TIMEOUT 后以 tun: the client never spoke 放弃。

TUN 连接上协议核心唯一截止时间(Timing)的各个阶段:

stateDiagram-v2
  [*] --> Handshake: 未设置任何截止时间
  Handshake --> Sniff: 首批字节,嗅探开启
  Handshake --> Relay: 首批字节且嗅探关闭,或客户端 EOF
  Sniff --> Relay: 得出结论、SNIFF_LIMIT、SNIFF_TIMEOUT 或 EOF,然后 Open
  Relay --> Closing: 超过 RELAY_IDLE_TIMEOUT
  Relay --> [*]: 两侧都已关闭,或出站失败
  Closing --> [*]

is_established() 在 Relay 和 Closing 阶段为真,此时 serve_stream 不再套用外层的 HANDSHAKE_TIMEOUT。

sequenceDiagram
  participant C as 客户端源 A
  participant L as TunInbound run 循环
  participant K as TunUdpLink
  participant U as TunUdpCore
  participant O as 数据报出站
  C->>L: 流 A 到 X(新的源)
  L->>K: 新链路,FLOW_QUEUE 通道
  K->>U: 来自 X 的 TransportDatagram
  U->>O: Open,然后 SendTo X
  C->>L: 流 A 到 Y(同一个源)
  L->>K: try_send 进队列
  K->>U: 来自 Y 的 TransportDatagram
  U->>O: SendTo Y
  O-->>U: 来自 Y 的 Datagram
  U->>K: put_to Y
  K-->>C: 从 Y 到 A 的包
  O-->>U: 来自 Z 的 Datagram
  U->>K: put_to Z
  K->>K: 没有到 Z 的流,丢弃

udp_idle_timeout 是 ipstack 针对每条流的 UDP 超时:每当该流被写入或被轮询读取时,协议栈都会重新设置计时器;计时器触发后,该流的下一次读取返回错误,从而被链路注销。由于 TunUdpLink 每次读取都会轮流轮询各个流,关联中其他流的活动可能会重置一个安静流的计时器,所以 udp_idle_timeout 并不是针对每个远端的精确限制。最后一个流被注销且没有新的流排队时,poll_recv_from 返回 NotConnected,运行时结束,关联的 permit 被释放。协议核心的 RELAY_IDLE_TIMEOUT 也会结束完全没有任何数据往来的关联。

不变量 保证方式 由谁锁定
每个客户端源只有一个 UDP 关联;第二个目的地址复用该关联,不会再次 Open。 TunInbound::run 中的 associations 映射加 FLOW_QUEUE 通道;TunUdpCore 中的 opened。 udp_flows_share_one_association_per_source(protocols/tests/pipeline/tun.rs);the_first_packet_opens_the_association_and_every_packet_is_sent(protocols/tests/unit/tun/udp.rs)
UDP 回复以从其来源发往客户端的包返回,地址互换;源为域名的回复被丢弃。 TunUdpCore::handle 中的 fx.put_to,以及 TunUdpLink::poll_send_to 中按远端的查找。 replies_go_back_as_packets_from_their_origin_and_domains_are_dropped(protocols/tests/unit/tun/udp.rs);udp_flows_share_one_association_per_source
出站失败或空闲时,UDP 关联结束。 ConnectFailed / OutboundError 时的 Effect::Finish;中继阶段的 Timing::expired。 the_association_finishes_when_the_outbound_fails_or_idles(protocols/tests/unit/tun/udp.rs)
TCP 连接只在客户端发言后,才带着客户端的源地址向数据包的目的地址拨号;首批字节会被带上,HTTP Host 成为嗅探到的域名。 PassthroughCore::on_transport 和 SniffPrefix;Effect::ForwardHeld。 a_tcp_connection_becomes_a_stream(protocols/tests/pipeline/tun.rs);a_sniffing_passthrough_core_holds_the_prefix_and_opens_with_the_host、a_sniffing_passthrough_core_opens_on_the_sniff_deadline(protocols/tests/unit/core/mod.rs)
沉默的 TCP 客户端会被切断。 serve_stream 中每个 open 前步骤外包的 tokio::time::timeout(HANDSHAKE_TIMEOUT, …)。 没有专门的测试锁定。
同时运行的 TCP 连接与 UDP 关联总数至多为 max_flows。 Semaphore::new(max_flows) 和 try_acquire_owned;OwnedSemaphorePermit 移入启动的任务,直到任务返回才释放。 没有专门的测试锁定。
TCP 流在其阻塞的 Drop 完成之前一直被计数。 TrackedTcp::drop 先取出并丢弃流,再 fetch_sub。 由两个 TUN pipeline 测试的清理过程覆盖(Fake::stop 会 await shutdown)。
构建时拒绝启动时会拒绝的配置。 build_tun_inbound 和 open 都调用 check_platform;build_tun_inbound 中的 MTU、地址和路由解析。 tun_owns_its_interface_and_takes_no_listener、tun_refuses_an_mtu_below_the_stack_floor、tun_parses_addresses_and_routes(app/tests/unit/inbound.rs)
接口及其路由的存在时间恰好等于进程为其提供服务的时间。 绑定在设备上的路由(RouteScope::Link、output_interface);最后一个描述符关闭时内核同时删除两者。 a_routed_connect_is_answered_while_the_app_runs(app/tests/integration/e2e_tun.rs)
失败 结果
启动时 open 失败(没有 CAP_NET_ADMIN、名称已被占用、netlink 错误) bind_inbound 返回错误,Instance::start 以 inbound <tag> bind tun <name> failed: … 失败(未命名时为 tun auto)。构建到一半的设备已被销毁。
重载时 open 失败 spawn_generation 以非严格模式运行:错误被记录为 inbound <tag> bind tun <name> failed: …,其他入站照常启动。旧 generation 已经不在,因此 TUN 入站保持停止,直到之后某次重载把它启动起来。
TunDevice::new 或 IpStackConfig::mtu 失败 run 返回错误;run_tun_inbound 记录 tun inbound failed: …,并仍然 await shutdown。
stack.accept() 返回错误 协议栈任务已结束,代码将其视为设备已不存在:循环退出,run 返回 Ok(()),所以不会有 error 级别日志,入站在 generation 结束前不再提供任何服务。
TCP 客户端在 open 前沉默 serve_stream 返回 TimedOut(tun: the client never spoke),以 debug 级别记录为 tun: tcp flow <src> -> <dst> ended: …。
TCP 流上的运行时错误 用 io::Error::other(e.to_string()) 转换,以 debug 级别记录。
TCP 流拨号失败 协议核心关闭传输层并结束。
UDP 关联结束(所有流已注销、出站失败、空闲) 运行时的结果以 trace 级别记录为 tun: udp association of <src> ended: …;任务返回 Some(src),映射中的条目被删除。
写入某个 UDP 流失败 注销该流;关联继续。

run 由其 CancellationToken 取消。退出循环即从 run 返回,这会丢弃 JoinSet(中止所有流任务)和 IpStack。IpStack 的 Drop 会中止拥有 TunDevice 的协议栈任务,但中止要等 tokio 运行时下次调度该任务时才生效,所以描述符会比 drop 多存活片刻。

shutdown 用来填补这段空隙。它从 mutex 中取出 Weak 设备 handle,每隔 RELEASE_POLL(20 毫秒)轮询一次,直到该 handle 无法再 upgrade 且 live_tcp 为零,最多等待 RELEASE_TIMEOUT(3 秒)。超时时记录一条警告(tun: device fd or <n> tcp flows still open after 3s)后返回。

sequenceDiagram
  participant I as Instance 重载
  participant T as run_tun_inbound
  participant S as ipstack 任务
  participant N as 下一个 generation
  I->>T: token.cancel()
  T->>T: run 返回,丢弃 JoinSet 和 IpStack
  T->>S: 中止
  S-->>T: 丢弃 TunDevice,fd 关闭
  T->>T: shutdown 轮询 Weak 和 live_tcp
  T-->>I: accept handle 完成
  I->>N: spawn_generation,打开同名设备

Instance::reload 和 Instance::shutdown 在取消之后会 await 每个 accept handle,而 run_tun_inbound 只在 shutdown 之后才返回。这个顺序让下一个 generation 能以相同的 name 重新创建接口,也保证不会有 TCP 流存活到运行时关闭阶段。因此,一次重载会关闭所有 TUN 连接,并重新创建接口及其路由。

常量 值 位置 含义
DEFAULT_MTU 1500 protocols/src/tun/config.rs 未设置 mtu 时接口和协议栈的 MTU。
MTU 下限 1280 build_tun_inbound;ipstack 的 IpStackConfig::mtu IPv6 的最小值;更小的值会导致构建失败。
DEFAULT_UDP_IDLE_TIMEOUT 60 秒 protocols/src/tun/config.rs ipstack 针对每条流的 UDP 超时,每个远端一条流;传给 IpStackConfig::udp_timeout。
DEFAULT_MAX_FLOWS 65 536 protocols/src/tun/config.rs TCP 连接与 UDP 关联共享的 permit 数;与 TCP 监听器路径取值一致,因为每个流都可能占用出站的一个 socket。
FLOW_QUEUE 16 protocols/src/tun/udp.rs 发往同一关联的新 UDP 流最多排队的数量,超出后丢弃。
RELEASE_TIMEOUT 3 秒 protocols/src/tun/inbound.rs shutdown 等待描述符和存活 TCP 流的最长时间。
RELEASE_POLL 20 毫秒 protocols/src/tun/inbound.rs 上述等待的轮询间隔。
HANDSHAKE_TIMEOUT 10 秒 protocols/src/core/mod.rs TCP 流 open 之前每一步的时限,由 serve_stream 施加。
SNIFF_TIMEOUT 300 毫秒 protocols/src/sniff/mod.rs 嗅探窗口,超时后 TCP 流不带域名直接 open。
SNIFF_LIMIT 4 KiB protocols/src/sniff/mod.rs 为寻找域名而检查的字节数。
RELAY_IDLE_TIMEOUT 300 秒 protocols/src/core/mod.rs 处于中继阶段的 TCP 连接或 UDP 关联的空闲上限。
PassthroughCore::BUF_SIZE 8 KiB protocols/src/core/mod.rs TCP 运行时三个缓冲区各自的大小。
TunUdpCore::BUF_SIZE 8 KiB protocols/src/tun/udp.rs UDP 运行时三个缓冲区各自的大小。
TunUdpCore::MAX_DATAGRAM 4096 protocols/src/tun/udp.rs 两个方向上能完整投递的最大数据报;更长的客户端数据报或回复会被截断,只有 mtu 大于约 4 KiB 时才会碰到。
TunUdpCore::STAGING_RESERVE 4096 protocols/src/tun/udp.rs 运行时在投递事件前保留的暂存空间,确保每个回复都作为一个完整的包暂存。

解析器接受 max_flows = 0,此时入站会丢弃所有流。udp_idle_timeout = 0 同样被接受;这时 ipstack 会在流的首个数据报之后的第一次读取时就判定超时,关联几乎立即结束。

测试 文件 证明了什么
udp_flows_share_one_association_per_source protocols/tests/pipeline/tun.rs 每个源一个关联;回复返回时地址和端口互换;第二个目的地址复用同一关联,并收到自己的回复。
a_tcp_connection_becomes_a_stream protocols/tests/pipeline/tun.rs 协议栈应答 SYN;拨号发生在请求之后,携带请求并嗅探出 Host;回复以 TCP 分段返回。
the_first_packet_opens_the_association_and_every_packet_is_sent protocols/tests/unit/tun/udp.rs 第一个数据报以正确的目的地址和源地址 open,设置 RELAY_IDLE_TIMEOUT,每个数据报都变成一次 SendTo。
replies_go_back_as_packets_from_their_origin_and_domains_are_dropped protocols/tests/unit/tun/udp.rs 回复被暂存为来自其来源的包;源为域名的回复被丢弃。
the_association_finishes_when_the_outbound_fails_or_idles protocols/tests/unit/tun/udp.rs ConnectFailed 和空闲截止时间都会结束关联。
tun_owns_its_interface_and_takes_no_listener、tun_refuses_an_mtu_below_the_stack_floor、tun_parses_addresses_and_routes app/tests/unit/inbound.rs 构建期校验及其生成的 DeviceSpec。
a_routed_connect_is_answered_while_the_app_runs app/tests/integration/e2e_tun.rs 真实接口:连接被路由的地址时由协议栈应答,app 进程及其接口消失后不再应答。

pipeline 测试不需要任何特权。protocols/tests/pipeline/tun.rs 用 UnixDatagram::pair() 充当描述符:一端成为传给 run 的 OwnedFd,另一端的每次 send 或 recv 恰好是一个 IP 包,与 TunDevice 一次读取一个包的约定一致。测试用 etherparse::PacketBuilder(etemenanki-protocols 的一个 dev-dependency)构造数据包,并用 SlicedPacket::from_ip 解析协议栈的应答。

一个 Capture connector 代替各个出站。对 TCP 流,它返回 tokio::io::duplex 的一端,把另一端交给测试;对 UDP 流,它返回一个 FakeLink,其发出的包和注入的回复都通过无界的测试通道传递。Fake::stop 模仿 app 的清理过程:取消 token,await 任务,丢弃线路端,然后 await TunInbound::shutdown。

app/tests/integration/e2e_tun.rs 用一个 tun 入站(address = ["10.77.0.1/24"]、routes = ["10.77.1.0/24"])和一个 blackhole 出站运行真实的二进制。没有经过设备的环路,就没有任何数据能在进程内流回,所以证据是 TCP 握手:内核把 connect 路由进设备,协议栈应答 SYN,在进程退出之前 connect 都会成功。测试会先自行调用 open,遇到 PermissionDenied 时以 SKIP: cannot create a tun device 跳过。

终端窗口
cargo test -p etemenanki-protocols --features tun --lib tun::
cargo test -p etemenanki-protocols --features tun --test pipeline pipeline::tun
cargo test -p etemenanki-app --bin etemenanki-app tun_

整个 workspace 的测试布局见 测试。