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["网络"]
- 启动时,etemenanki-app 创建网卡,分配每个
address,并把routes中的每一项安装为经过该网卡的路由。 - 目标地址匹配这些路由之一(或落在某个
address前缀覆盖的子网内)的数据包,都会被内核送进网卡。 - 用户态协议栈自己应答 TCP 握手,把每条连接作为一个流(stream)交出。UDP 按客户端的源地址和端口分组。
- 每个流依据目标 IP 地址和端口、网络类型(
tcp或udp)、客户端源 IP 和入站的 tag 进行路由,TCP 还会加上嗅探得到的域名。选中的出站随后拨号连接目标。 - etemenanki-app 停止或重载时,网卡被关闭,内核会连同其地址和路由一起移除它。
可运行的示例
Section titled “可运行的示例”下面的配置在运行 etemenanki-app 的主机上捕获 203.0.113.0/24,并把其中的 TCP 和 UDP 流量发往位于 192.0.2.10 的上游 SOCKS5 代理。上游位于被捕获的网段之外,这正是这套配置不会形成环路的原因。UDP 443 端口(QUIC)被送往 blackhole,使浏览器回退到 TCP,这样嗅探就能还原出域名。
# 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 = 1500udp = trueudp_idle_timeout = 60max_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"-
检查配置。
--test会像启动时一样校验网卡设置,但不创建任何东西,因此不需要权限:终端窗口 etemenanki-app --test -c tun.toml它会输出
Configuration OK.或遇到的第一个错误。 -
按权限一节所述为进程授予
CAP_NET_ADMIN,然后启动。启动日志会给出网卡名:inbound tun-in owns tun device etm0inbound tun-in listening on tun etm0 -
查看内核中现在的状态:
终端窗口 ip address show dev etm0ip route show dev etm0第二条命令会列出
203.0.113.0/24。etemenanki-app 停止后它就会消失。 -
用 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] 中:
| 键 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
name | string | 否 | — | 要创建的网卡名,例如 "etm0"。不设置时由内核挑选(tun0、tun1……),启动日志里会打印实际名字。Linux 上最多 15 个字节;更长的名字能通过 --test,但启动时报 device name too long。如果防火墙规则或脚本要引用这块网卡,请固定一个名字。 |
mtu | u16 | 否 | 1500 | 网卡以及其后用户态 IP 协议栈的 MTU,单位字节。最小值是 1280,即 IPv6 允许的最小 MTU;更小的值报 tun mtu must be at least 1280。大于 65535 的值无法解析。 |
address | array of strings | 否 | [] | 分配给网卡的地址,每项写作 "地址/前缀长度",IPv4 或 IPv6 均可,例如 "10.77.0.1/32" 或 "2001:db8:77::1/64"。不写前缀长度时按主机地址处理(/32 或 /128)。前缀长度同时会让内核把整个子网路由进这块网卡。这里允许主机位不为零。格式错误的项报 bad tun address。 |
routes | array 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。 |
udp | bool | 否 | true | 除 TCP 外是否也中继 UDP。为 false 时,进入网卡的 UDP 包一律丢弃,不做任何回应。 |
udp_idle_timeout | u64 | 否 | 60 | UDP 流(一个客户端地址和端口与一个对端之间的通信)连续多少秒没有流量后由用户态协议栈回收。一个 UDP 关联会一起轮询它的所有流,所以其中任意一个有流量,所有流都保持存活。关联在最后一个流被回收时结束;无论如何,双向都没有数据报满 300 秒也会结束。0 可以写,但 UDP 将无法使用:流在第一个数据报之后立即被回收,回复来不及到达。 |
max_flows | integer | 否 | 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
Section titled “address 与 routes”这两个字段都决定哪些数据包进入网卡,但方式不同:
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)。
让代理自身的流量绕开网卡
Section titled “让代理自身的流量绕开网卡”出站建立的每条连接都是主机上的普通 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 入站的变体;下面是这里相关的几行:
[Service]User=etemenankiAmbientCapabilities=CAP_NET_ADMINCapabilityBoundingSet=CAP_NET_ADMIN然后运行 sudo systemctl daemon-reload 并重启服务。
- 如果还有其他入站监听 1024 以下的端口,请在这两行中同时加上
CAP_NET_BIND_SERVICE。 - 网卡通过
/dev/net/tun创建。PrivateDevices=yes会对服务隐藏它,所以不要设置该选项。 - 路由通过 netlink 安装。如果 unit 设置了
RestrictAddressFamilies=,请包含AF_NETLINK。
把 capability 附加到二进制文件上:
sudo setcap cap_net_admin+ep /usr/local/bin/etemenanki-app此后任何能运行该文件的用户都会获得这项 capability。安装新的二进制文件会替换该文件并丢失 capability,所以每次升级后都要重新执行这条命令。
以 root 身份运行 etemenanki-app 也可以,但这会让每个入站和出站都拥有全部权限。优先使用另外两种方式。
用户态协议栈在任何出站介入之前就自己完成 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 与其他协议
Section titled “ICMP 与其他协议”协议栈无处可发送 ICMP,除 TCP 和 UDP 外的其他协议也都不会被中继。这些数据包会被丢弃,并以 trace 级别记录日志。对被捕获地址的 ping 和 traceroute 都得不到回应。
路由 TUN 流
Section titled “路由 TUN 流”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。其他情况下没有值 |
重载、重启与网卡
Section titled “重载、重启与网卡”每次重载(包括只改了注释引起的重载)时,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 秒。随后重新创建同名网卡可能会失败。 |