跳转到内容

WireGuard

etemenanki-app 可以把 WireGuard 对端用作出站:路由分配给它的每个流都经由加密隧道发出,在互联网上显示为对端的地址。可以用它让部分或全部流量使用某个 VPN 服务的出口 IP,或者使用你在别处运行的 WireGuard 服务器的出口 IP。

隧道完全在进程内运行。etemenanki-app 把一个 WireGuard 实现(boringtun)和一个用户态 TCP/IP 协议栈(smoltcp)组合在一起,因此不会创建网络接口,不会向主机添加路由,也不需要 root 权限或内核模块。它同时承载 TCP 和 UDP。

WireGuard 只能用作出站。protocol = "wireguard" 的 [[inbound]] 会导致构建失败,报 wireguard cannot be used as an inbound (no server implementation)。如果要在 IP 层接收设备的流量,见 TUN 入站。

flowchart LR
  C["客户端"] --> I["入站"]
  I --> R{"路由器"}
  R -->|"outbound = wg"| S["用户态 TCP/IP 协议栈"]
  S --> B["WireGuard 加密"]
  B -->|"UDP 发往 endpoint"| P["WireGuard 对端"]
  P --> T["目标"]

对每个流,出站在隧道内打开一条 TCP 连接或一个 UDP 关联,源地址取自你的某个隧道地址(address)。协议栈把它转换成 IP 包,WireGuard 对其加密,再由一个 UDP socket 发往对端的 endpoint。回包沿原路返回。路由到同一个出站的所有流共用这一条隧道和这一个 UDP socket,但每个流都有自己的有界缓冲区,因此一个停滞的流只会拖住它自己(见单个慢流)。

下面的配置在 1080 端口运行一个本地 SOCKS 代理,并把所有流量经由一个 WireGuard 对端发出。

wireguard.toml
# 一个本地 SOCKS5 代理,其流量全部经由一个 WireGuard 对端发出。
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
[[outbound]]
tag = "wg"
protocol = "wireguard"
[outbound.settings]
# 占位值。用以下命令生成真实的密钥对:wg genkey | tee private.key | wg pubkey
private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
endpoint = "vpn.example.com:51820"
address = ["10.0.0.2"]
keepalive = 25
[route]
default = "wg"

这些密钥是能通过解析的占位值。建立真实隧道时,用 wg genkey 生成私钥,再用 wg pubkey 推导出要在对端登记的公钥。更常见的情况是服务方直接给你一份完整的 wg-quick 文件;从 wg-quick 文件迁移说明了如何转换。

如果只想让部分流量走隧道、其余直连,可以添加一个 freedom 出站并配置路由规则;见路由和 WireGuard 出口示例。

WireGuard 的键写在 [outbound.settings] 下。未知的键会被拒绝,所以照搬 Xray 或 wg-quick 的写法(secretKey、allowed_ips)会报 unknown field,而不会被悄悄忽略。

键类型必填默认值说明
private_keystring是—本端的 Curve25519 私钥,即 wg-quick 文件里的 PrivateKey。32 字节,写成带填充的标准 base64(wg genkey 的输出格式)或 64 位十六进制。不带填充的 base64 会被拒绝。错误信息:invalid wireguard private_key。
peer_public_keystring是—对端的公钥,即 [Peer] 段里的 PublicKey。编码方式与 private_key 相同。错误信息:invalid wireguard peer_public_key。
preshared_keystring否—可选的预共享密钥,参与握手计算,即 PresharedKey。编码方式与 private_key 相同。只有对端为你配置了预共享密钥时才填写;两边不一致会导致握手始终无法完成。错误信息:invalid wireguard preshared_key。
endpointstring是—对端的 UDP 地址,格式为 host:port,在最后一个 : 处拆分。host 可以是 IP 地址或域名;域名在隧道启动时用系统解析器(而不是 [dns])解析,取第一个结果。IPv6 不要加方括号(2001:db8::1:51820):方括号不会被去掉,所以 [2001:db8::1]:51820 能通过 --test,但运行时解析失败。错误信息:wireguard endpoint must be host:port、invalid wireguard endpoint port。
addressarray of IPs是—对端分配给你的隧道内地址,写成不带前缀长度的纯 IP:["10.0.0.2", "2001:db8:a::2"]。10.0.0.2/32 这样的 CIDR 写法会报 invalid IP address syntax。它决定隧道能访问哪些地址族的目标:没有 IPv6 地址时,IPv6 目标不可达。空列表能通过 --test,但之后每个连接都会失败,报 wireguard: no tunnel-local addresses configured。
mtuinteger否1420隧道内 IP 包的最大字节数;用户态 TCP 协议栈据此确定分段大小。不做范围检查。如果小请求正常、大流量传输卡住,就把它调低(例如 1280)。
keepaliveu16否—持续保活(persistent keepalive)的间隔秒数,即 PersistentKeepalive。不写或写 0 表示关闭。本机位于 NAT 或有状态防火墙之后、且连接可能长时间空闲时应当设置(常用 25)。
reservedarray of integers否—恰好三个字节([0, 0, 0] 到 [255, 255, 255]),写入每个发出的 WireGuard 包头的第 1 到 3 字节,做法与 Xray 的 reserved 相同。无论是否设置 reserved,收到的包在解码前都会把这三个字节清零。只有服务方要求时才设置。其他长度都会报 invalid length …, expected an array of length 3。

此表中的错误以 outbound <tag>: … 开头。来自 TOML 解析器的错误(例如在 address 中写了 CIDR)以 outbound <tag>: invalid settings: … 开头,并在下一行指出对应的键:

configuration invalid: outbound wg: invalid settings: invalid IP address syntax
in `address`

三个密钥都是 32 字节。每个都可以写成带 = 填充的标准 base64(即 wg genkey、wg pubkey 和 wg genpsk 的输出格式),也可以写成 64 位十六进制(Xray 同样接受这种写法)。首尾空白会被忽略。

构建配置时只检查编码。能解码但内容错误的密钥(例如把对端的密钥粘贴进了 private_key)能通过 --test,但之后会失败:对端始终不回应握手。

endpoint 的格式是 host:port。出站在隧道启动时解析它,而不是在 --test 时:

  • IP 地址原样使用。IPv6 不要加方括号,写成 2001:db8::1:51820。出站在最后一个冒号处拆分,且不会去掉方括号,所以 [2001:db8::1]:51820 能通过 --test,但在第一个连接时失败,报 wireguard: tunnel start failed: failed to lookup address information: …。
  • 域名通过系统解析器(getaddrinfo)解析,而不是 [dns] 解析器,并且无论地址族如何,都取第一个结果。address_family 不影响这次查询。如果域名同时有 A 和 AAAA 记录,而本机无法通过解析器首先返回的那个地址族访问对端,请改写 IP 地址。
  • 只有在隧道重建时才会重新解析域名(见隧道生命周期)。正在运行的隧道一直使用启动时得到的地址。

连接对端的 UDP socket 绑定在 endpoint 所属地址族的通配地址上,使用临时端口,因此没有 ListenPort 可配置。

address 列出对端分配给你的地址,只写地址本身,不带前缀长度。它们在两个方面起作用:

  • 源地址。 连接 IPv4 目标时使用第一个 IPv4 条目,连接 IPv6 目标时使用第一个 IPv6 条目。

  • 可达的地址族。 没有对应条目的地址族无法经隧道访问。出站在尝试之前,会从域名的 DNS 结果中剔除这些地址。如果一个都不剩,连接失败,并在 debug 级别记录如下错误:

    wireguard: no usable auto destination address for 2001:db8::5:80 (local address supports IPv4 only)

出站通用键 address_family(见出站)在此基础上再施加策略。对 WireGuard 而言,它只作用于隧道内的目标,从不作用于 endpoint:

address_family 尝试的目标 对 address 的要求
auto(默认) address 覆盖的所有地址族,按解析器返回顺序 无
prefer_ipv4 / prefer_ipv6 address 覆盖的两个地址族,优先的那个在前 无
ipv4_only 仅 IPv4 至少一个 IPv4 条目
ipv6_only 仅 IPv6 至少一个 IPv6 条目

*_only 的要求在构建配置时检查,所以不匹配时 --test 就会失败:

outbound wg: wireguard address_family ipv6_only needs an IPv6 address
outbound wg: wireguard address_family ipv4_only needs an IPv4 address

对于 TCP 流,出站依次尝试剩下的每个地址,每个给 10 秒。发往域名的 UDP 数据报发送到第一个可用地址;每个 UDP 关联对一个域名只解析一次并复用结果,解析失败的域名,其数据报会被丢弃。发往 IP 地址的 UDP 数据报原样发送:address_family 和上面的地址族检查都不适用于它。

mtu 是隧道内 IP 包的最大字节数,默认 1420。用户态 TCP 协议栈据此确定分段大小。每个经隧道传输的包在发往对端时都会加上 WireGuard 的开销,如果结果超出路径所能容纳的大小,大包会丢失,而小包仍能通过。典型症状是 TLS 握手或小页面正常,而大文件下载卡住。请使用服务方指定的 MTU。如果服务方没有给出,而你又遇到了这种症状,可以试试 1280。

--test 不检查 mtu 的范围。

keepalive 不设置或为 0 时,隧道只在有流量时才发包。如果本机位于 NAT 或有状态防火墙之后,连接空闲期间 UDP socket 的映射可能过期,对端发来的下一个包就会丢失。把 keepalive 设为 25 秒可以保持映射,这也是 wg(8) 手册对大多数防火墙建议的间隔。

WireGuard 包在消息类型之后有三个保留字节,通常为零。有些服务会在这里放客户端标识,Xray 将其暴露为 reserved。设置了 reserved 时,出站会把它写入每个发出包的第 1 到 3 字节。无论是否设置 reserved,出站都会在解码前把每个收到的包中的这三个字节清零,所以对端在回包中填写这些字节也不影响使用。除非服务方给了你具体的值,否则不要设置 reserved。

reserved = [12, 34, 56] # 恰好三个整数,每个 0–255
键 行为
server、port 接受但忽略。对端地址由 settings.endpoint 指定。
[outbound.stream] 只检查 network 和 security:network 不是 tcp,或 security 不是 none,会报 protocol wireguard does not support stream network "…"(或 stream security)。块中的其他内容都被忽略。WireGuard 自己通过 UDP 传输。

服务方通常提供一份包含 [Interface] 和 [Peer] 两段的 wg-quick 文件。下面两个标签页描述的是同一条隧道。

[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.0.0.2/32, 2001:db8:a::2/128
DNS = 192.0.2.53
MTU = 1280
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
PresharedKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = 203.0.113.10:51820
PersistentKeepalive = 25
wg-quick etemenanki-app 说明
[Interface] PrivateKey private_key 原样复制。
[Interface] Address address 每个地址一个字符串,不带 /32 或 /128。
[Interface] MTU mtu 默认 1420,即 wg-quick 在没有 MTU 时通常算出的值。
[Interface] DNS 无 见下文。
[Interface] ListenPort 无 UDP socket 使用临时端口。
[Interface] Table、PreUp、PostUp、PreDown、PostDown 无 不存在需要管理的接口或主机路由。
[Peer] PublicKey peer_public_key
[Peer] PresharedKey preshared_key
[Peer] Endpoint endpoint IPv6 不加方括号:2001:db8::1:51820。
[Peer] PersistentKeepalive keepalive
[Peer] AllowedIPs 无 见下文。
无 reserved Xray 特有,不属于 wg-quick。

AllowedIPs 没有对应项。 在 wg-quick 中它承担两项职责:决定哪些流量进入隧道,以及过滤对端可以用哪些源地址回包。在 etemenanki-app 中,第一项职责由路由承担:一个流之所以进入隧道,是因为某条路由规则或 [route].default 指向了这个出站。第二项在这里不需要:隧道地址上除了出站自己打开的连接和 UDP 关联之外,没有任何监听。AllowedIPs = 0.0.0.0/0, ::/0 的配置相当于把所有流量路由到该出站;更窄的列表相当于使用 cidr 匹配条件的路由规则。cidr 规则只匹配以 IP 地址形式到达的目标;路由器不会为了匹配它而解析域名。

DNS 同样没有对应项。 wg-quick 用它在接口启用期间修改主机的解析器配置。etemenanki-app 不改动主机上的任何东西。当经隧道的流以域名指定目标时,出站用本机的 [dns] 解析器解析该域名,查询本身不经过隧道。这个出站始终在本机解析域名。如果查询不能以明文发出,请在 DNS 中配置加密的后端。

katana 节点 agent 也能构建 WireGuard 出站,但字段名不同:endpoint 来自 server 和 port,对端公钥是 public_key,预共享密钥是 pre_shared_key,隧道地址写在 local_address 中,并且允许带前缀长度。该格式见 katana 的出站文档。

  • 延迟启动。 构建配置时不会打开任何东西。第一个路由到该出站的流启动隧道:解析 endpoint,绑定 UDP socket,并启动驱动任务。WireGuard 握手在发送第一个包时进行,所以第一个连接需要等待握手完成。
  • 每个出站一条隧道。 路由到该出站的所有流共用这条隧道。两个使用相同密钥和对端的出站是两条独立的隧道,而对端会把它们当作同一个不断变换位置的客户端(见检查新的端点下的警告)。
  • 握手与密钥更新。 隧道承载流量期间,WireGuard 会按自己的节奏更新会话密钥。对端停止应答时,连接会超时,隧道大约每 5 秒重试一次握手。90 秒内没有应答则放弃,直到有新流量到达时再开始下一次握手。
  • 重建。 隧道的 UDP socket 报错时,驱动任务停止。发送数据包或收到包时,来自 ICMP 回复的错误(connection refused 或 reset、host 或 network unreachable)会被忽略,该包被丢弃。定时器触发的包(例如握手重试或 keepalive)发送失败时,无论什么错误,驱动任务都会停止。驱动任务停止后,下一个流会记录 wireguard: tunnel driver stopped, rebuilding,构建新隧道并重新解析 endpoint。如果旧隧道已运行至少 10 秒,会立即重建。如果旧隧道在此之前就已失效,或启动失败,出站会先等待 2 秒再尝试,之后每次加倍,最长 30 秒。等待期间到达的流会失败,报 wireguard: tunnel is down, waiting before the next attempt。
  • 热重载。 成功的热重载会重新构建所有出站。旧隧道连同其上的连接一起消失,下一个流会启动一条新隧道并重新握手。

同一条隧道上的所有流共用一个驱动任务,而每个流在应用与隧道之间都有自己的有界缓冲区:

  • TCP 上行。 当某个流的远端或隧道不再接收数据时,这个流先填满它 64 KiB 的 socket 发送缓冲区,再填满它容量为 256 次写入的 channel,之后入站一侧的下一次写入就会等待。驱动任务在每个流的 socket 之前最多只预取一次写入,并且只有在手上没有预取的写入时才读取该流的 channel,所以不会再为停滞的流排队任何数据。同一条隧道上的其他流照常传输,远端恢复读取后,那次等待中的写入就会发出。
  • 公平分配。 每一轮中,驱动任务交给一个流的 socket 的数据最多为一个 channel 的容量,即 256 次写入或 256 个数据报,因此一个繁忙的流无法独占驱动任务。
  • 下行。 只有当某个流通往应用的 channel 还有空间时,驱动任务才读取该流的 socket,因此读取缓慢的客户端只会拖住它自己的流。
  • UDP。 每个 UDP 关联有一个 64 KiB、64 个数据报的发送环形缓冲区。遇到环形缓冲区已满的数据报会等到下一轮,其后的数据报则在该关联的 channel 中等待。永远无法发送的数据报会被丢弃,而不是拖住整个关联及其后的所有数据报:即比整个环形缓冲区还大的数据报,或者发往无法寻址的目标(例如端口 0)的数据报。丢弃只在 trace 级别记录,格式为 wireguard: dropping a N-byte datagram to <addr>: <error>,其中错误为 buffer full 或 unaddressable。
  • UDP 关闭。 应用用完一个关联后,驱动任务会再保留它一轮,确保它最后发送的数据报仍能发出。

在把新的 WireGuard 配置交给用户使用之前,先证明它确实能承载流量。稳妥的做法是运行一个临时的 etemenanki-app 进程,只包含一个监听回环地址的 SOCKS 入站和这一个出站。它与正在运行的代理没有任何共享:不同的进程、不同的配置文件、不同的端口。

  1. 在一个空目录中写一个配置文件,填入服务方提供的隧道设置:

    wg-check.toml
    # 通过回环 SOCKS 代理临时检查一个 WireGuard 出站。
    [[inbound]]
    tag = "check"
    protocol = "socks"
    listen = "127.0.0.1"
    port = 10808
    [inbound.settings]
    udp = false
    [[outbound]]
    tag = "wg"
    protocol = "wireguard"
    [outbound.settings]
    private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
    peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
    endpoint = "203.0.113.10:51820"
    address = ["10.0.0.2"]
    mtu = 1280
    keepalive = 25
    [route]
    default = "wg"

    端口任选一个空闲的即可。listen 保持为 127.0.0.1,这样别人无法使用这个代理。

  2. 检查配置文件:

    终端窗口
    etemenanki-app --test -c wg-check.toml

    它应当输出 Configuration OK.。这只能证明语法和密钥编码正确,不能证明对端接受你。

  3. 在前台启动,并打开 WireGuard 自身的日志:

    终端窗口
    RUST_LOG=info,boringtun=debug,etemenanki_app=debug etemenanki-app -c wg-check.toml
  4. 在另一个终端中,向一个 IP 回显服务询问它看到的地址:

    终端窗口
    curl -m 30 --socks5-hostname 127.0.0.1:10808 https://ifconfig.me

    返回的应当是对端的出口地址,而不是本机自己的公网地址(可以与不走代理的 curl https://ifconfig.me 对比)。

  5. 如果第一次请求失败或超时,先再运行一次,再下结论。启动后的第一个连接需要等待隧道建立并完成握手,即使配置正确也可能失败。

  6. 按 Ctrl-C 停止进程。除了配置文件之外,不会留下任何东西。

日志的含义:

你看到的 含义
Sending handshake_initiation,然后大约每 5 秒一次 HANDSHAKE(REKEY_TIMEOUT),90 秒后出现 CONNECTION_EXPIRED(REKEY_ATTEMPT_TIME) 对端没有应答。检查 endpoint 及其端口、peer_public_key、对端是否登记了你的公钥、是否要求 reserved 或 preshared_key,以及出站 UDP 是否被拦截。
Received handshake_response、New session,但请求超时 隧道已建立,但对端不转发。首先怀疑密钥或账户已过期或被吊销。如果小请求正常、大请求卡住,调低 mtu。
wireguard: tunnel start failed: … 隧道无法启动。常见原因:endpoint 无法通过系统解析器解析,写成了带方括号的 IPv6 地址,或者 address 为空(no tunnel-local addresses configured)。
wireguard: tunnel driver stopped, rebuilding 隧道的 UDP socket 出错,下一个流正在构建新隧道(见隧道生命周期)。
… ended: wireguard: tunnel is down, waiting before the next attempt 最近一次启动失败,或上一条隧道在 10 秒内失效,出站正在退避等待。往上找第一条错误。
socks connection from … ended: wireguard: tunnel TCP connect failed for all resolved addresses (…: timed out) 每个地址在 10 秒内都没有经隧道收到 TCP 应答。结合上面的 WireGuard 日志一起看。
… ended: wireguard: no usable auto destination address for … 目标没有属于 address 所覆盖地址族的地址,例如只有 IPv4 隧道地址时访问仅支持 IPv6 的站点。
wireguard: dropping a N-byte datagram to …: … 只在 trace 级别记录:在 RUST_LOG 中加入 etemenanki_protocols=trace,或设置 [log] level = "trace"。某个 UDP 数据报大于该关联 64 KiB 的发送环形缓冲区,或者其目标无法寻址,因此被丢弃(见单个慢流)。

curl 只测试 TCP。出站同样承载 UDP,但要检查 UDP,需要一个支持 SOCKS5 UDP ASSOCIATE 的客户端;这种情况下,在检查用的配置中把 udp 保留为默认值(true)。

错误 原因与解决
wireguard cannot be used as an inbound (no server implementation) 在 [[inbound]] 下写了 protocol = "wireguard"。WireGuard 只能用作出站。
outbound wg: invalid wireguard private_key(或 peer_public_key、preshared_key) 值不是 32 字节的带填充 base64 或十六进制。检查是否缺少 =、粘贴不完整或混入了多余字符。
invalid settings: invalid IP address syntax / in `address` address 中带了前缀长度。写 "10.0.0.2",不要写 "10.0.0.2/32"。
invalid settings: missing field `address` address 是必填项,尽管空列表也能被接受。
wireguard endpoint must be host:port endpoint 中没有 :,因此没有端口。
invalid wireguard endpoint port 最后一个 : 之后的内容不是 0 到 65535 之间的数字。
invalid settings: invalid length 2, expected an array of length 3 / in `reserved` reserved 需要恰好三个整数。大于 255 的值会报 expected u8。
invalid settings: unknown field … 出站没有这个键,例如 allowed_ips 或 dns。错误信息会列出有效的键。
wireguard address_family ipv6_only needs an IPv6 address address_family 要求的地址族不在 address 覆盖范围内。
protocol wireguard does not support stream network "…" 删除 [outbound.stream]。
wireguard: no tunnel-local addresses configured(运行时) address = []。至少列出一个地址。