跳转到内容

TUN

TUN 入站(protocol = "tun")让 etemenanki-app 在主机上创建一块虚拟网卡。内核把发往你所列网段的数据包路由进这块网卡,etemenanki-app 在用户态 IP 协议栈中终结这些数据包:每条 TCP 连接和每个 UDP 流都成为一个被代理的流,和来自其他入站的流一样,经 [route] 规则路由到某个出站。

它适合代理那些对代理一无所知的程序。程序照常连接被捕获网段中的地址,连接就会经由出站发出,程序里无需任何 SOCKS 或 HTTP 设置。代价是这个入站作用于主机的路由表,而代理自身也在使用同一张表:在确定 routes 之前,请先阅读让代理自身的流量绕开网卡。

TUN 入站
创建什么 一块三层(IP)网卡,每个入站一块
承载 TCP;udp = true(默认)时也承载 UDP。ICMP 及其他所有协议都被丢弃
listen、port 必须省略
[inbound.stream] 不使用。network 不是 "tcp" 或 security 不是 "none" 时会被拒绝
认证 无。网卡是本地的,所有流都归属于该入站
权限 启动需要 CAP_NET_ADMIN 或 root。--test 不需要任何权限
路由 在 Linux 上随网卡创建而安装,随网卡一起移除。在其他 Unix 系统上需自行添加
方向 仅入站;没有 TUN 出站
flowchart LR
  P["主机上的程序"] -->|"connect 203.0.113.7:443"| K["内核路由表"]
  K -->|"203.0.113.0/24 dev etm0"| D["TUN 网卡 etm0"]
  D --> S["用户态 IP 协议栈"]
  S -->|"TCP 连接"| R["路由器"]
  S -->|"UDP 流,按来源分组"| R
  R --> O["出站,例如 socks"]
  O -->|"经真实上行链路拨号"| N["网络"]
  1. 启动时,etemenanki-app 创建网卡,分配每个 address,并把 routes 中的每一项安装为经过该网卡的路由。
  2. 目标地址匹配这些路由之一(或落在某个 address 前缀覆盖的子网内)的数据包,都会被内核送进网卡。
  3. 用户态协议栈自己应答 TCP 握手,把每条连接作为一个流(stream)交出。UDP 按客户端的源地址和端口分组。
  4. 每个流依据目标 IP 地址和端口、网络类型(tcp 或 udp)、客户端源 IP 和入站的 tag 进行路由,TCP 还会加上嗅探得到的域名。选中的出站随后拨号连接目标。
  5. etemenanki-app 停止或重载时,网卡被关闭,内核会连同其地址和路由一起移除它。

下面的配置在运行 etemenanki-app 的主机上捕获 203.0.113.0/24,并把其中的 TCP 和 UDP 流量发往位于 192.0.2.10 的上游 SOCKS5 代理。上游位于被捕获的网段之外,这正是这套配置不会形成环路的原因。UDP 443 端口(QUIC)被送往 blackhole,使浏览器回退到 TCP,这样嗅探就能还原出域名。

tun.toml
# A TUN inbound that captures 203.0.113.0/24 on this host and sends its TCP
# and UDP traffic through an upstream SOCKS5 proxy. Starting it needs
# CAP_NET_ADMIN (or root); `--test` does not.
[log]
level = "info"
[[inbound]]
tag = "tun-in"
protocol = "tun"
# No listen and no port: the inbound owns a network interface instead.
[inbound.settings]
name = "etm0"
# A /32 gives the interface an address without routing a subnet into it.
address = ["10.77.0.1/32"]
# Only these networks enter the device. Host bits must be zero.
routes = ["203.0.113.0/24"]
mtu = 1500
udp = true
udp_idle_timeout = 60
max_flows = 65536
# The upstream proxy. Its address must stay outside `routes`, or the
# outbound's own connection would be routed back into the device.
[[outbound]]
tag = "upstream"
protocol = "socks"
server = "192.0.2.10"
port = 1080
[[outbound]]
tag = "block"
protocol = "blackhole"
[route]
default = "upstream"
# QUIC (UDP 443) cannot be sniffed for a domain. Dropping it makes browsers
# fall back to TCP and TLS, which domain rules can see.
[[route.rule]]
network = "udp"
port = ["443"]
outbound = "block"
  1. 检查配置。--test 会像启动时一样校验网卡设置,但不创建任何东西,因此不需要权限:

    终端窗口
    etemenanki-app --test -c tun.toml

    它会输出 Configuration OK. 或遇到的第一个错误。

  2. 按权限一节所述为进程授予 CAP_NET_ADMIN,然后启动。启动日志会给出网卡名:

    inbound tun-in owns tun device etm0
    inbound tun-in listening on tun etm0
  3. 查看内核中现在的状态:

    终端窗口
    ip address show dev etm0
    ip route show dev etm0

    第二条命令会列出 203.0.113.0/24。etemenanki-app 停止后它就会消失。

  4. 用 TCP 测试,不要用 ping。ICMP 会被丢弃,所以即使一切正常,ping 203.0.113.7 也永远收不到回复:

    终端窗口
    curl -v http://203.0.113.7/

    请求会经由上游代理到达 203.0.113.7。

TUN 入站使用入站中的通用 [[inbound]] 字段 tag、protocol 和 sniffing。它没有监听器,所以 listen 和 port 必须省略:设置其中任何一个都会报 tun owns a network interface and has no listener; remove listen/port。它自己的字段写在 [inbound.settings] 中:

键类型必填默认值说明
namestring否—要创建的网卡名,例如 "etm0"。不设置时由内核挑选(tun0、tun1……),启动日志里会打印实际名字。Linux 上最多 15 个字节;更长的名字能通过 --test,但启动时报 device name too long。如果防火墙规则或脚本要引用这块网卡,请固定一个名字。
mtuu16否1500网卡以及其后用户态 IP 协议栈的 MTU,单位字节。最小值是 1280,即 IPv6 允许的最小 MTU;更小的值报 tun mtu must be at least 1280。大于 65535 的值无法解析。
addressarray of strings否[]分配给网卡的地址,每项写作 "地址/前缀长度",IPv4 或 IPv6 均可,例如 "10.77.0.1/32" 或 "2001:db8:77::1/64"。不写前缀长度时按主机地址处理(/32 或 /128)。前缀长度同时会让内核把整个子网路由进这块网卡。这里允许主机位不为零。格式错误的项报 bad tun address。
routesarray of strings否[]路由进网卡的网段,必须是严格的 CIDR 写法,例如 "203.0.113.0/24":主机位必须全为零,所以 "203.0.113.5/24" 会报 bad tun route "203.0.113.5/24": host part of address was not zero。不写前缀长度时是一条主机路由。这些路由在创建网卡时装进 main 路由表,随网卡一起消失。只在 Linux 上安装:其他系统上列表非空会报 tun routes are installed only on Linux; add them with the OS route tool。
udpbool否true除 TCP 外是否也中继 UDP。为 false 时,进入网卡的 UDP 包一律丢弃,不做任何回应。
udp_idle_timeoutu64否60UDP 流(一个客户端地址和端口与一个对端之间的通信)连续多少秒没有流量后由用户态协议栈回收。一个 UDP 关联会一起轮询它的所有流,所以其中任意一个有流量,所有流都保持存活。关联在最后一个流被回收时结束;无论如何,双向都没有数据报满 300 秒也会结束。0 可以写,但 UDP 将无法使用:流在第一个数据报之后立即被回收,回复来不及到达。
max_flowsinteger否65536整个网卡上同时存在的流的上限:每条 TCP 连接算一个,每个 UDP 关联(同一客户端地址和端口的全部 UDP 流量)也算一个。达到上限后,新的 TCP 连接或 UDP 关联会被丢弃,并以 debug 级别记录日志。0 可以写,但会丢弃所有流。

[inbound.settings] 中的未知字段会以 invalid settings: unknown field 被拒绝,错误信息会列出可接受的字段。因此把 routes 拼错会让配置无法通过,而不是启动一块什么也不捕获的网卡。

所有值都在构建配置时检查,所以 --test 拒绝的值与启动时一致。有些失败只会在创建网卡时发生,因为 --test 不创建任何东西:缺少权限、name 超过 15 个字节(device name too long),以及内核拒绝某条路由。

这两个字段都决定哪些数据包进入网卡,但方式不同:

address routes
用途 为网卡分配它自己的地址 列出要代理的网段
格式 "10.77.0.1/32"、"2001:db8:77::1/64",或不带前缀的地址(主机前缀) 严格的 CIDR,如 "203.0.113.0/24",或不带前缀的地址(主机路由)
主机位 允许非零 必须为零
捕获的流量 其前缀覆盖的子网,经由内核自己的子网路由 恰好是所列网段
平台 全部 仅 Linux

两个列表都可以混合 IPv4 和 IPv6 条目,也都可以为空。routes 为空时,只有 address 的子网会通向网卡;两者都为空时,网卡存在,但在你自行添加路由之前不会有任何流量到达它。

address 上的前缀本身也是一条路由。address = ["10.77.0.1/24"] 会把 10.77.0.0/24 中除 10.77.0.1 本身以外的全部地址送进网卡,而发往 10.77.0.1 的数据包留在主机本地。如果只想给网卡一个地址而不捕获子网,就像示例那样使用主机前缀:IPv4 用 /32,IPv6 用 /128。

routes 之所以严格,是因为设置了主机位的网段几乎总是笔误:

[inbound.settings]
routes = ["203.0.113.5/24"] # 本意是 203.0.113.0/24,还是 203.0.113.5/32?
inbound tun-in: bad tun route "203.0.113.5/24": host part of address was not zero

表示网段时写 "203.0.113.0/24",表示单个主机时写 "203.0.113.5/32"(或 "203.0.113.5")。

这些路由以经过网卡的 link 作用域路由(proto static)装入内核的 main 路由表,不单独指定 metric,因此使用内核的默认值。etemenanki-app 从不显式删除它们:它们属于网卡,网卡关闭时由内核删除。etemenanki-app 以独占方式添加每条路由,从不替换已有路由。如果 main 表中已有相同前缀、相同 metric 的路由,内核会拒绝新路由,启动失败并报 route 203.0.113.0/24: Received a netlink error message File exists (os error 17)。

出站建立的每条连接都是主机上的普通 socket,内核用 TUN 路由所在的同一张表来路由它。etemenanki-app 不给自己的 socket 打标记,也不把它们绑定到某块网卡。因此,如果某个出站自己要连接的目标落在 routes 之内,它的连接就会被引回网卡:TUN 入站把它当作一个新的流接收,路由到同一个出站,该出站再次拨号,又进入网卡。没有任何流量到达网络。环路每转一圈就多占一个流,流不断累积,直到达到 max_flows(或进程的文件描述符上限);此后入站会丢弃新的流,正常的流也不例外。

flowchart LR
  P["程序"] -->|"发往 203.0.113.7"| D["TUN 入站"]
  D --> O["socks 出站"]
  O -->|"发往 192.0.2.10:1080"| K{"192.0.2.10 是否在 routes 内?"}
  K -->|"否:走默认路由"| U["真实上行链路,上游代理"]
  K -->|"是:路由进 etm0"| D

在编写 routes 之前,列出 etemenanki-app 自己会连接的每个地址,并确保它们都不在被捕获的网段内:

etemenanki-app 连接的对象 来源
每个代理出站的上游服务器 socks、http、shadowsocks、trojan、vless、vmess、hysteria2 出站的 server
SOCKS5 上游报告的 UDP 中继地址 上游对 UDP ASSOCIATE 的应答
WireGuard 对端 wireguard 出站的 endpoint
DNS 服务器 把出站 server 名称和域名目标解析为地址的解析器;见 DNS
freedom 出站的每个目标 流自身的目标

最后一行说明了为什么 TUN 入站不能与 freedom 出站搭配。freedom 会拨号连接流自身的目标,而 TUN 流的任何目标按定义都在被捕获的网段内,所以拨号会直接回到网卡。请把 TUN 流路由到代理出站或 blackhole,永远不要路由到 freedom。如果配置中还有其他入站,且默认出站是 freedom,请在所有其他规则之前放一条 inbound_tag = ["tun-in"] 指向代理出站的规则。

创建网卡和安装路由需要 CAP_NET_ADMIN。没有它时,--test 仍会通过,但启动会失败:

failed to start: inbound tun-in bind tun etm0 failed: Operation not permitted (os error 1)

可以用以下任一方式授予该 capability:

在运行 etemenanki-app 的 unit 中添加该 capability,这样进程以非特权用户运行,只持有所需的权限。运行 etemenanki-app 中有一个完整的 unit,并附有适用于 TUN 入站的变体;下面是这里相关的几行:

/etc/systemd/system/etemenanki.service (excerpt)
[Service]
User=etemenanki
AmbientCapabilities=CAP_NET_ADMIN
CapabilityBoundingSet=CAP_NET_ADMIN

然后运行 sudo systemctl daemon-reload 并重启服务。

  • 如果还有其他入站监听 1024 以下的端口,请在这两行中同时加上 CAP_NET_BIND_SERVICE。
  • 网卡通过 /dev/net/tun 创建。PrivateDevices=yes 会对服务隐藏它,所以不要设置该选项。
  • 路由通过 netlink 安装。如果 unit 设置了 RestrictAddressFamilies=,请包含 AF_NETLINK。

用户态协议栈在任何出站介入之前就自己完成 TCP 握手。因此即使出站无法到达目标,程序也会看到连接已建立。之后如何进行取决于程序发出的最初几个字节:

阶段 发生什么
握手 由协议栈立即应答。
等待客户端 程序发送数据之前不会拨号。如果 10 秒内什么都没发,连接被关闭。
嗅探 sniffing = true(默认)时,入站会先扣住最初的字节,直到从中读出 TLS 服务器名或 HTTP Host、收到 4 KiB,或经过 300 ms,以先到者为准。因此既不是 TLS 也不是 HTTP 的流量在拨号前最多等待 300 ms。
拨号 路由选中的出站拨号连接目标 IP 和端口。拨号失败则关闭连接。
中继 双向复制字节。用户态协议栈有自己的会话计时器,etemenanki-app 不会修改它:双向都没有数据满 60 秒的连接会被重置。它比 etemenanki-app 的 300 秒中继空闲上限先生效。

由于客户端开口之前不会拨号,由服务器先发言的协议(例如 SMTP 或 MySQL)经过 TUN 入站时会卡住,并在 10 秒后被关闭。请通过其他入站访问这类服务,或把它们排除在 routes 之外。

嗅探到的域名只影响路由。出站仍然拨号连接程序所连的那个 IP 地址。

由于 60 秒的会话计时器,可能长时间空闲的长连接(例如没有开启 keepalive 的 SSH 会话)经过 TUN 入站时会被重置。请为这类程序启用间隔短于 60 秒的应用层 keepalive。

udp = true 时,入站按客户端的源地址和端口对 UDP 分组:

  • 来自某个来源的第一个数据报会建立一个 UDP 关联,它在 max_flows 中只计一次;
  • 同一来源发往其他对端的数据报加入同一个关联;
  • 每个数据报按自己的目标单独路由,因此一个关联可以经由不同出站到达不同对端;被路由到 blackhole 的数据报会被丢弃,其余数据报照常转发;
  • 只有当回复恰好来自客户端所发往的地址和端口时才会被送达。来自其他任何地址或端口的回复,以及用域名而非 IP 地址标明来源的回复,都会被丢弃;
  • 大于 4096 字节的载荷在两个方向上都会被截断为 4096 字节;
  • 一个流(客户端与某一个对端之间的通信)连续 udp_idle_timeout 秒没有流量后,由用户态协议栈回收。关联会一起轮询它的所有流,所以其中任意一个有流量,所有流都保持存活。关联在最后一个流被回收时结束;无论如何,双向都没有数据报满 300 秒也会结束。

UDP 从不嗅探。因此域名和 geosite 规则永远不会匹配来自 TUN 入站的 UDP;请按 cidr、geoip、port 或 network 路由它。哪些出站能承载 UDP 见出站;不支持 UDP 的出站会丢弃路由给它的数据报。

udp = false 时,UDP 包会被丢弃且不做任何回应。使用 UDP 的程序(包括向 routes 内的服务器发起的 DNS 查询)随后会超时。

协议栈无处可发送 ICMP,除 TCP 和 UDP 外的其他协议也都不会被中继。这些数据包会被丢弃,并以 trace 级别记录日志。对被捕获地址的 ping 和 traceroute 都得不到回应。

TUN 流本身不带域名,只有程序所发往的 IP 地址。路由匹配条件看到的值如下:

匹配条件 对 TUN 流看到的值
inbound_tag 入站的 tag
cidr、geoip 目标 IP 地址
port 目标端口
network tcp 或 udp
source_cidr 数据包的源 IP。对本机上的程序,通常是网卡的某个地址;对主机从其他机器转发来的数据包,则是原始发送方
domain_suffix、domain_keyword、domain_full、domain_regex、geosite 嗅探到的 TLS 服务器名或 HTTP Host,仅限 sniffing = true 时的 TCP。其他情况下没有值

每次重载(包括只改了注释引起的重载)时,etemenanki-app 都会停止整个 generation(一代实例)。TUN 入站关闭它的网卡,并最多等待 3 秒让描述符释放,以便新的 generation 能重新创建同名网卡。经过网卡的所有连接都会断开。见热重载。

如果重载期间创建网卡失败,错误会记录为 inbound <tag> bind tun <name> failed: …,其他入站会在缺少它的情况下启动。etemenanki-app 不会重试:排除故障后,请再次保存配置文件(使其字节有任何变化)或重启服务。

以下错误会让 --test 和启动都失败。错误信息带有 inbound <tag>: 前缀,--test 会把它们打印在 configuration invalid: 之后。

错误 原因与解决
tun owns a network interface and has no listener; remove listen/port 设置了 listen 或 port。把两者都删掉。
tun mtu must be at least 1280 mtu 低于最小值。使用 1280 或更大的值。
invalid settings: invalid value: integer `70000`, expected u16 mtu 大于 65535。
bad tun route "203.0.113.5/24": host part of address was not zero 某个 routes 条目设置了主机位。写成网段地址,或写成 /32、/128 主机路由。
bad tun address "10.77.0.1/33": invalid length for network: … 某个 address 条目格式错误,或前缀长度超出其地址族的范围。
tun routes are installed only on Linux; add them with the OS route tool 在 Linux 以外的系统上 routes 不为空。将其留空,用系统自带的工具添加路由。
protocol tun does not support stream network "ws"、protocol tun does not support stream security "tls" [inbound.stream] 设置了非 tcp 的 network 或非 none 的 security。删掉这个块;TUN 入站没有传输层。
invalid settings: unknown field `…` [inbound.settings] 中有拼错的字段。错误信息会列出可接受的字段。

以下错误只在创建网卡时(启动或重载时)出现。启动时它们报告为 failed to start: inbound <tag> bind tun <name> failed: …,未设置 name 时 <name> 为 auto。重载时会记录相同的文本,只是没有 failed to start:。

错误 原因与解决
Operation not permitted (os error 1) 进程缺少 CAP_NET_ADMIN。见权限。
device name too long name 超过 15 个字节。
route 203.0.113.0/24: Received a netlink error message … 内核拒绝了某条路由。File exists (os error 17) 表示 main 表中已有相同前缀、相同 metric 的路由。

运行时入站会记录以下日志,除非另有说明,级别均为 debug:

日志 含义
tun: dropping a flow; the flow limit is reached 存活的流已达 max_flows。一条新的 TCP 连接或 UDP 关联被丢弃。路由环路最终也会走到这里。
tun: tcp flow … ended: tun: the client never spoke 某个程序打开连接后 10 秒内什么都没发送。
tun: dropping a reply from …: the client never addressed it 某个 UDP 回复来自客户端从未发送过数据的对端。
tun: device fd or N tcp flows still open after 3s warn 级别:关闭或重载时,网卡关闭耗时超过 3 秒。随后重新创建同名网卡可能会失败。