跳转到内容

WireGuard 出口

本方案让少数网站使用某个 WireGuard 对端的出口地址,例如一个 VPN 服务,或你在另一个地区运行的 WireGuard 服务器;其余流量照常从本机发出。最终得到一个本地 SOCKS5 代理、一个作为默认出站的 freedom 出站,以及一个由路由规则为选定域名和 geosite 列表选中的 wireguard 出站。

当你的 WireGuard 服务给了你一份 wg-quick 文件(wg0.conf),而你想在 etemenanki-app 中使用它时,就可以按本页操作。WireGuard 协议页面是该出站的完整参考;本页则以一个贴近实际的配置为例,从文件一路走到验证过的出口。

flowchart LR
  C["浏览器或应用"] -->|"SOCKS5,127.0.0.1:1080"| I["socks-in"]
  I --> R{"路由规则"}
  R -->|"example.com、geosite netflix"| W["wg 出站"]
  R -->|"没有规则匹配"| D["direct 出站"]
  W -->|"加密的 UDP"| P["WireGuard 对端"]
  P --> T1["网站,看到的是对端的出口地址"]
  D --> T2["网站,看到的是本机地址"]
  • SOCKS 入站监听回环地址,因此只有本机上的程序能使用它。
  • 路由器从上到下依次尝试规则。匹配 WireGuard 规则的流进入隧道;其他流一律落到 [route].default。
  • WireGuard 出站在进程内运行隧道,使用用户态 TCP/IP 协议栈。它不创建网络接口,不添加主机路由,也不需要 root 权限。它同时承载 TCP 和 UDP。
wireguard-egress.toml
# A local SOCKS5 proxy that sends a few sites out through a WireGuard tunnel
# and everything else directly. The WireGuard values are placeholders: copy
# yours from the wg-quick file your WireGuard service gives you.
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
# sniffing = true is the default: a flow addressed by IP is still matched by
# its TLS SNI or HTTP Host, so the domain rules below apply to it too.
# Listed first, so it is also the implicit default. [route].default names it
# explicitly anyway.
[[outbound]]
tag = "direct"
protocol = "freedom"
[[outbound]]
tag = "wg"
protocol = "wireguard"
# An IPv4-only tunnel: never try a destination's IPv6 addresses, even if an
# IPv6 entry is added to `address` later, and make --test fail if the IPv4
# address below is ever removed.
address_family = "ipv4_only"
[outbound.settings]
# [Interface] PrivateKey. Generate your own pair with:
# wg genkey | tee private.key | wg pubkey
private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
# [Peer] PublicKey
peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
# [Peer] Endpoint
endpoint = "203.0.113.10:51820"
# [Interface] Address, without the /32
address = ["10.2.0.2"]
# Conservative MTU for an IPv4-only tunnel; use your service's value if it
# gives one.
mtu = 1280
# [Peer] PersistentKeepalive
keepalive = 25
# Only if your service asks for it:
# reserved = [0, 0, 0]
[route]
default = "direct"
# Read because the rule below uses a geosite matcher.
geosite = "/etc/etemenanki/geosite.dat"
# Rules are tried from top to bottom; the first match wins. Inside one rule
# the matchers are alternatives: any one of them is enough.
[[route.rule]]
outbound = "wg"
domain_suffix = ["example.com"]
geosite = ["netflix"]

其中的密钥和 endpoint 都是解析器能接受的占位值,下一节会用你的 wg-quick 文件填入真实值。geosite 匹配条件需要下载 v2fly 域名列表(以 dlc.dat 发布,见分流),并保存到 [route].geosite 指定的路径。如果只按域名路由,就把 geosite 匹配条件和 geosite 路径一起删掉:--test 会读取该文件,文件不存在时会失败。

服务方提供的文件大致如下。上面 TOML 中的注释标明了每个值来自 wg-quick 的哪一行。

wg0.conf
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.2.0.2/32
DNS = 192.0.2.53
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
AllowedIPs = 0.0.0.0/0
Endpoint = 203.0.113.10:51820
PersistentKeepalive = 25
  1. 添加出站。 给它一个规则中会引用的 tag。不要添加 server、port 或 [outbound.stream] 表:对端地址写在 settings.endpoint 中,而 WireGuard 自己通过 UDP 传输。WireGuard 出站会忽略 server 和 port,并拒绝 stream 表。

    [[outbound]]
    tag = "wg"
    protocol = "wireguard"
    [outbound.settings]
  2. 复制密钥。 PrivateKey 对应 private_key,[Peer] 段中的 PublicKey 对应 peer_public_key。如果文件中有 PresharedKey 行,把它复制到 preshared_key;否则不写这个键。

    private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
    peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="

    原样粘贴这些值,包括末尾的 =。每个密钥都是 32 字节,写成带填充的 base64(即 wg genkey 和 wg pubkey 输出的格式)或 64 位十六进制数字。--test 只检查编码:一个能正常解码、但属于别人的密钥也能通过检查,之后对端永远不会应答。

  3. 复制 endpoint。 Endpoint 对应 endpoint,格式为 host:port。

    endpoint = "203.0.113.10:51820"

    该值在最后一个 : 处拆分。IPv6 endpoint 不加方括号,写成 2001:db8::1:51820:方括号不会被去掉,因此 [2001:db8::1]:51820 能通过 --test,却会在隧道启动时失败。主机名在隧道启动时用系统解析器解析,既不经过 [dns],也不在 --test 期间解析,并使用第一个解析结果。

  4. 复制地址,去掉前缀长度。 Address = 10.2.0.2/32 写成 address = ["10.2.0.2"]。每个地址是一个单独的字符串;10.2.0.2/32, 2001:db8:a::2/128 这样的列表写成 ["10.2.0.2", "2001:db8:a::2"]。

    address = ["10.2.0.2"]

    带前缀会导致构建失败,报 invalid IP address syntax / in `address`。这里列出的地址族就是隧道能到达的地址族:没有 IPv6 条目时,无论 address_family 如何设置,IPv6 目标都会被跳过。

  5. 复制 keepalive。 PersistentKeepalive 对应 keepalive,单位为秒(最大 65535)。如果本机位于 NAT 或有状态防火墙之后,请保留它:它能防止连接空闲时通往对端的 UDP 映射过期。不写这个键,或写 0,都会关闭 keepalive。

    keepalive = 25
  6. 设置 MTU;如果隧道只支持 IPv4,就固定地址族。 如果文件中有 MTU 行,把它复制到 mtu;默认值是 1420。如果服务只提供 IPv4 隧道(只有一个 IPv4 Address,或者有一个服务实际并不路由的 IPv6 地址),就加上下面两行:

    # 写在 [[outbound]] 表中,与 protocol 放在一起
    address_family = "ipv4_only"
    # 写在 [outbound.settings] 中
    mtu = 1280

    address_family = "ipv4_only" 让出站只使用目标的 IPv4 地址。address 中只有一个 IPv4 条目时,隧道本来就会跳过 IPv6 目标,所以这里的设置起的是保护作用:

    • --test 会检查它。如果 address 中没有 IPv4 条目,构建会失败,报 outbound wg: wireguard address_family ipv4_only needs an IPv4 address。
    • 如果服务还列出了一个它并不路由的 IPv6 地址,而你把它复制进了 address,出站仍然不会尝试目标的 IPv6 地址。若使用默认的 auto,出站会按解析器返回的顺序尝试这些地址,每次尝试最多等待 10 秒才换下一个。

    mtu = 1280 是一个保守值,也是 IPv6 允许的最小 MTU,能适应比常见的 1500 字节更窄的路径。如果服务指定了 MTU,就改用该值。

  7. 只有服务要求时才添加 reserved。 WireGuard 数据包带有三个保留的头部字节,通常为零。少数服务把它们用作客户端标识。如果你的服务给了三个数字,就写成列表:

    reserved = [1, 2, 3]

    如果给的是一个很短的 base64 字符串(四个字符,例如 AQID),先把它解码成三个数字:

    终端窗口
    printf '%s' 'AQID' | base64 -d | od -An -tu1
    # 1 2 3

    etemenanki-app 会把这些字节写入它发送的每个数据包,并在收到的每个数据包中将其清零。如果服务没有这样要求,就不要写 reserved:标准的 WireGuard 对端会把这些字节当作消息类型的一部分来读取,非零值会导致它丢弃你的数据包。

  8. 其余行不需要迁移。 下面这些 wg-quick 行在出站中没有对应项:

    wg-quick 行 改由什么负责
    AllowedIPs 由路由规则决定哪些流进入隧道(见下一节)。
    DNS 主机上不做任何改动。经隧道到达的目标由本机的 [dns] 解析器解析。
    ListenPort 通往对端的 UDP socket 使用临时端口。
    Table、PreUp、PostUp、PreDown、PostDown 不存在需要管理的接口或主机路由。

    误复制进来的键会明确报错:invalid settings: unknown field `allowed_ips` ,后面附有所有有效键的列表。

  9. 检查文件。

    终端窗口
    etemenanki-app --test -c wireguard-egress.toml

    当语法、密钥编码和路由都有效时,它会输出 Configuration OK.。它不会联系对端。

对照结果如下:

[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.2.0.2/32
DNS = 192.0.2.53
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
AllowedIPs = 0.0.0.0/0
Endpoint = 203.0.113.10:51820
PersistentKeepalive = 25

katana 节点也能承载同样的隧道,但使用 katana 自己的字段名(例如 local_address,它允许带前缀长度)。该格式见 katana 的出站文档。

在 wg-quick 中,AllowedIPs = 0.0.0.0/0 会让整台机器的流量都走隧道。而在这里,只有被路由规则发往 wg 的流才会进入隧道。示例使用一条规则:

[route]
default = "direct"
geosite = "/etc/etemenanki/geosite.dat"
[[route.rule]]
outbound = "wg"
domain_suffix = ["example.com"]
geosite = ["netflix"]
  • 同一条规则中的匹配条件是“或”的关系。 发往 example.com、它的任意子域名,或 netflix geosite 列表中任意域名的流,都会发往 wg。
  • 第一条匹配的规则生效。 如果希望广告即使出现在走隧道的网站上也被拦截,就把拦截广告的 blackhole 规则放在这条规则之上;如果有更宽泛的规则会抢走相同的流,就把这条规则放在它之上。
  • 其他流量使用 default。 请显式写出这一行。没有这一行时,文件中的第一个 [[outbound]] 就是默认出站;这里的默认出站之所以是 direct,只是因为它排在第一个。
  • 如果要改为全部走隧道,就设置 default = "wg",并把例外流量路由到 direct。
  • 按地址路由。 cidr 或 geoip 匹配条件按目标 IP 分流。客户端以域名给出的目标永远不会匹配它们。

当客户端把域名交给代理、而不是自己解析时,域名规则的效果最好:curl 和大多数工具中使用 socks5h://,浏览器中启用“使用 SOCKS v5 时代理 DNS 查询”。当客户端发送的是纯 IP 地址时,嗅探仍然能从 TLS 或 HTTP 请求中还原出域名,使规则能够匹配,但这条流随后会拨号到那个 IP。使用 ipv4_only 时,以这种方式发送的 IPv6 地址无法进入隧道。所有匹配条件见路由页面。

能通过 --test 的配置只说明语法正确,还没有证明对端会转发你的流量。在任何真实流量依赖这条隧道之前,先用一个临时进程检查这一点。这次检查与正在运行的代理没有任何共享:不同的进程、不同的文件、不同的端口。

  1. 做一份检查用的副本。 在一个空目录中复制配置,并改动三处:端口(任选一个空闲端口,这里用 10808);default = "wg",让每个请求都走隧道;为了让检查自成一体,删除 [[route.rule]] 块和 geosite 路径。

    wg-check.toml
    [[inbound]]
    tag = "check"
    protocol = "socks"
    listen = "127.0.0.1"
    port = 10808
    [[outbound]]
    tag = "direct"
    protocol = "freedom"
    [[outbound]]
    tag = "wg"
    protocol = "wireguard"
    address_family = "ipv4_only"
    [outbound.settings]
    private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
    peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
    endpoint = "203.0.113.10:51820"
    address = ["10.2.0.2"]
    mtu = 1280
    keepalive = 25
    [route]
    default = "wg"
  2. 校验配置。

    终端窗口
    etemenanki-app --test -c wg-check.toml
  3. 在前台启动,并打开 WireGuard 日志。 RUST_LOG 会覆盖 [log].level。

    终端窗口
    RUST_LOG=info,boringtun=debug,etemenanki_app=debug etemenanki-app -c wg-check.toml
  4. 记下本机自己的地址。 在第二个终端中,不经代理访问一个 IP 回显服务:

    终端窗口
    curl -m 30 https://ifconfig.me
  5. 再经隧道访问一次。

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

    返回的必须是对端的出口地址,并且与第 4 步的结果不同。任何 IP 回显服务都可以;如果服务还会报告地址所在位置,就能顺便确认出口地区。

  6. 如果第一次请求失败或超时,再运行一次,然后再下结论。隧道要等第一个流到达时才启动,所以第一个连接要等待隧道建立并完成握手,即使配置正确也可能失败。第二次仍然失败,才是真正的失败。

  7. 用 Ctrl-C 停止进程。 除了检查用的文件,它不会留下任何东西。

curl 只测试了 TCP。该出站也承载 UDP,但测试 UDP 需要一个支持 SOCKS5 UDP ASSOCIATE 的客户端;SOCKS 入站默认允许 UDP(udp = true)。

正式配置运行起来之后,通过端口 1080 检查两条路径:

  • curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me 返回的是本机地址,因为回显服务不匹配任何规则,走的是 direct。
  • 要查看隧道路径,临时把回显服务加入 WireGuard 规则,例如 domain_full = ["ifconfig.me"],保存文件后重复请求。这次返回的是出口地址。完成后删掉这一行。

etemenanki-app 会在文件保存时重新加载。重载会替换所有出站,因此会断开已打开的连接,下一个流到达时隧道会重新启动并进行新的握手;见安全地编辑运行中的配置。

情形 会发生什么
启动和 --test 不打开任何东西。第一个路由到 wg 的流会解析 endpoint、绑定 UDP socket 并启动隧道;握手随第一个数据包进行。
多个流 路由到同一个出站的所有流共用一条隧道和一个 UDP socket。每个流在隧道内都有自己的有界缓冲区,因此一个慢速的流不会拖住其他流。
目标停止读取 只有这个流的发送方会等待。它的数据先填满该流 64 KiB 的 socket 缓冲区,再填满一个可容纳 256 个待写数据块的队列,之后代理停止从该客户端读取,直到目标重新开始读取。隧道上的其他流照常传输。
无法发送的 UDP 数据报 如果数据报比该 UDP 关联整个 64 KiB 的发送缓冲区还大,或者隧道无法寻址它的目标,该数据报会被丢弃。同一 UDP 关联上后续的数据报仍会照常发出。只是遇到缓冲区已满的数据报不会被丢弃,而是等待缓冲区腾出空间。
有多个地址的 TCP 目标 出站依次尝试每个允许的地址,每个 10 秒。
发往域名的 UDP 数据报 发往第一个允许的地址;域名无法解析时丢弃该数据报。
对端停止应答 隧道保持运行,不会重建,因为对端沉默不属于 socket 错误。每个新的 TCP 连接对每个地址等待 10 秒后放弃,报 wireguard: tunnel TCP connect failed for all resolved addresses (…: timed out)。
隧道的 socket 出错 下一个流会记录 wireguard: tunnel driver stopped, rebuilding,构建新隧道并重新解析 endpoint。已运行至少 10 秒的隧道会立即重建。如果启动失败,或隧道在 10 秒内就失效,出站会先等待 2 秒再尝试,之后每次加倍,最长 30 秒;等待期间到达的流会失败,报 wireguard: tunnel is down, waiting before the next attempt。

协议页面的隧道生命周期有更多细节。

以下是 protocol = "wireguard" 时 [outbound.settings] 下的所有键。未知的键会被拒绝。通用出站键 address_family 写在 [[outbound]] 表本身;对 WireGuard 而言,它作用于隧道内的目标,从不作用于 endpoint。

键类型必填默认值说明
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。

address_family 接受 auto(默认值)、ipv4_only、ipv6_only、prefer_ipv4 和 prefer_ipv6,不区分大小写,- 视同 _,还接受 ipv4、v4 等简写。无法识别的值会失败,报 outbound wg: invalid address_family "…"。完整列表见出站。

现象 可能的原因及解决方法
invalid IP address syntax / in `address` address 中带了前缀。应写 "10.2.0.2",而不是 "10.2.0.2/32"。
outbound wg: invalid wireguard private_key(或 peer_public_key、preshared_key) 粘贴不完整、缺少 =,或混入了多余字符。
invalid length 2, expected an array of length 3 / in `reserved` reserved 必须恰好是三个 0 到 255 之间的数字;更大的数字会失败,报 invalid value: integer `300`, expected u8。base64 字符串会失败,报 invalid type: string "…", expected an array of length 3;按第 7 步的方法解码。
日志中出现 wireguard: tunnel start failed: … 隧道无法启动,例如 endpoint 中的主机名或带方括号的 IPv6 地址无法解析。冒号后面是具体错误。
wireguard address_family ipv4_only needs an IPv4 address address_family 指定的地址族在 address 中没有对应地址。
geosite code not found: … 你的 geosite.dat 中没有这个代码。
日志反复出现 Sending handshake_initiation,始终没有应答 对端没有应答:检查 endpoint、peer_public_key、preshared_key、reserved,以及出站 UDP 是否被阻断。
握手成功,但每个请求都超时 对端不为这个密钥转发流量。重试一次;如果问题依旧,先怀疑密钥或账户已过期或被吊销,重新获取配置,然后再去排查 MTU 或路由。
小页面能加载,大文件下载卡住 MTU 对这条路径来说太大。调低 mtu,例如调到 1280。
wireguard: no usable ipv4_only destination address for … 目标没有 IPv4 地址,或客户端发送的是 IPv6 地址。这条隧道只能承载 IPv4。
已列出的网站仍然显示本机地址 这条流没有匹配规则:检查域名,并让客户端发送域名(socks5h);或者是更靠前的规则抢走了这条流。

协议页面解释了更多 WireGuard 日志行。