跳转到内容

用 geodata 实现分流路由

本配方搭建一份客户端侧的 etemenanki-app 配置,把流量分成四路:广告和跟踪器被丢弃,本地和私有目标直连,位于某个选定国家内的目标直连,其余流量经由代理服务器转发。广告域名、国内域名和国内地址段的列表都来自公开的 v2fly geodata 文件,所以你只需写六条规则,而不是成千上万条。

如果 etemenanki-app 运行在你自己的电脑或路由器上,作为远程服务器前面的本地 SOCKS 或 HTTP 代理,就可以使用本配方。本文介绍从哪里获取 geodata、规则为什么按这个顺序排列、嗅探起什么作用,以及如何让这些文件保持最新。

流量 出站 决定它的规则
你从广告列表中豁免的某个域名 direct 规则 1,domain_suffix
广告和跟踪器域名 block 规则 2,geosite = ["category-ads-all"]
localhost、*.lan、私有地址和回环地址 direct 规则 3,geosite 和 geoip 的 private
虽在国内列表中、但你希望走代理的某个域名 proxy 规则 4,domain_suffix
目标为国外 IP 的流 proxy 规则 5,geoip = ["!cn"]
国内的域名和 IP direct 规则 6,geosite 和 geoip 的 cn
其余所有流量 proxy [route].default

示例使用 cn,因为两个 v2fly 文件对这个国家的覆盖最完整。要换成其他国家需要改哪些地方,见选择其他国家。

etemenanki-app 读取 v2ray/Xray 的 .dat 格式:以 protobuf 编码的域名列表(geosite.dat)和 CIDR 地址段列表(geoip.dat)。v2fly 项目在 GitHub 上发布这两个文件,每个都附带一个 SHA-256 校验文件:

文件 下载 保存为
按国家划分的 IP 地址段,外加 private v2fly/geoip 发布的 geoip.dat /etc/etemenanki/geoip.dat
cn、private、category-ads-all 等域名列表 v2fly/domain-list-community 发布的 dlc.dat /etc/etemenanki/geosite.dat

域名列表以 dlc.dat 的名字发布。它与 geosite.dat 格式相同,只是文件名不同。

  1. 把两个文件及其校验文件下载到一个空目录:

    终端窗口
    cd "$(mktemp -d)"
    curl -fLO https://github.com/v2fly/geoip/releases/latest/download/geoip.dat
    curl -fLO https://github.com/v2fly/geoip/releases/latest/download/geoip.dat.sha256sum
    curl -fLO https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat
    curl -fLO https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat.sha256sum
  2. 校验文件。每一行都必须以 OK 结尾:

    终端窗口
    sha256sum -c geoip.dat.sha256sum dlc.dat.sha256sum
    geoip.dat: OK
    dlc.dat: OK
  3. 把它们安装到配置所指定的位置:

    终端窗口
    sudo install -D -m 0644 geoip.dat /etc/etemenanki/geoip.dat
    sudo install -D -m 0644 dlc.dat /etc/etemenanki/geosite.dat
/etc/etemenanki/config.toml
# A local client that splits traffic with v2fly geodata: ads are dropped,
# private and in-country (cn) destinations go direct, and everything else
# goes through a Trojan proxy. Download geoip.dat and geosite.dat first.
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
# The default. A flow addressed by IP is matched by its TLS SNI or HTTP Host
# as well, so the domain and geosite rules below still reach it.
sniffing = true
[[inbound]]
tag = "http-in"
protocol = "http"
listen = "127.0.0.1"
port = 8080
# The first outbound. [route].default names it explicitly anyway.
[[outbound]]
tag = "proxy"
protocol = "trojan"
server = "proxy.example.com"
port = 443
[outbound.stream]
network = "tls"
[outbound.settings]
password = "replace-with-a-long-random-password"
[[outbound]]
tag = "direct"
protocol = "freedom"
[[outbound]]
tag = "block"
protocol = "blackhole"
[route]
default = "proxy"
geoip = "/etc/etemenanki/geoip.dat"
geosite = "/etc/etemenanki/geosite.dat"
# Rules are tried from top to bottom and the first match wins. Inside one
# rule the matchers are alternatives: any one of them is enough.
# 1. Exception to rule 2: a name the ad list catches that you still need.
[[route.rule]]
outbound = "direct"
domain_suffix = ["metrics.example.com"]
# 2. Ads and trackers are dropped.
[[route.rule]]
outbound = "block"
geosite = ["category-ads-all"]
# 3. Local names and private addresses stay on the local network.
[[route.rule]]
outbound = "direct"
geosite = ["private"]
geoip = ["private"]
# 4. Exception to rule 6: names on the country list that you want proxied.
[[route.rule]]
outbound = "proxy"
domain_suffix = ["example.com"]
# 5. A flow addressed by an IP outside the country goes through the proxy,
# whatever name its payload claims. Rule 3 has already taken private IPs.
[[route.rule]]
outbound = "proxy"
geoip = ["!cn"]
# 6. In-country names and addresses go direct. apple@cn and steam@cn add the
# entries of those lists that carry the cn attribute, such as the
# in-country download hosts, which the cn list does not contain.
[[route.rule]]
outbound = "direct"
geosite = ["cn", "apple@cn", "steam@cn"]
geoip = ["cn"]
# Everything else falls through to [route].default, the proxy.

把 proxy.example.com 和密码换成你的 Trojan 服务器的地址和密码,或者把 proxy 出站换成任何能连到你服务器的其他出站;规则与它使用的协议无关。有一点需要注意:UDP 遵循同样的规则,而 http 和 shadowsocks 出站(包括 2022- 系列加密方法)不承载数据报,所以被路由到这些出站的 UDP 会被丢弃。把规则 1 和规则 4 中的 metrics.example.com 和 example.com 换成你想豁免的域名,或者直接删掉这两条规则。然后检查配置文件:

终端窗口
etemenanki-app --test -c /etc/etemenanki/config.toml
Configuration OK.

--test 会构建真正启动时构建的一切,包括路由表,因此它会打开两个 .dat 文件,并查找规则中用到的每一个代码。文件缺失或代码拼错会在这一步就报错,而不是等到启动代理时。

把应用程序指向 127.0.0.1:1080(SOCKS4、SOCKS4a 或 SOCKS5)或 127.0.0.1:8080(HTTP 代理)。两个入站共用同一套规则。

[route] 表包含默认出站和两个文件路径:

键类型必填默认值说明
defaultstring否—所有规则都不匹配时使用的出站或负载均衡器的 tag。不写时,文件里的第一个 [[outbound]] 就是默认出站;负载均衡器不会被隐式选为默认。若该 tag 既不对应出站也不对应负载均衡器,则报 route references unknown outbound tag: <tag>。
geoippath视情况—v2ray 格式 geoip.dat 的路径。只要有规则使用 geoip 就必须设置(否则报 a geoip matcher is used but no geoip file is configured),没有规则使用时根本不会打开这个文件。相对路径按进程的工作目录解析,而不是配置文件所在目录。启动时、--test 时以及每次重载时都会读取该文件,只保留规则里引用到的 code。
geositepath视情况—v2ray 格式 geosite.dat 的路径。只要有规则使用 geosite 就必须设置(否则报 a geosite matcher is used but no geosite file is configured),没有规则使用时根本不会打开这个文件。相对路径的解析和读取时机与 geoip 相同。
rulearray of tables否[]路由规则,写作 [[route.rule]] 块(单数:[[route.rules]] 是未知字段)。按文件中的顺序逐条尝试,第一条匹配的规则决定出站。

这些规则只用到了三个匹配条件键:domain_suffix、geosite 和 geoip。其他匹配条件(domain_full、domain_keyword、domain_regex、cidr、source_cidr、port、network、inbound_tag)见路由。

一切由两条原则决定:

  • 规则之间,首条匹配生效。 etemenanki-app 从上到下依次尝试各个 [[route.rule]] 块,在第一条匹配的规则处停止。没有任何规则匹配的流交给 [route].default。
  • 同一条规则内,任一匹配条件满足即可。 规则 3 匹配位于 private 域名列表中的流,或位于 private 地址段内的流。一条规则无法要求两个匹配条件同时满足;路由介绍了如何借助规则顺序达到这种效果。

下图是一个新的流在示例配置中经过的路径:

flowchart TB
  F["新的流:一个域名或 IP,可能附带嗅探到的域名"] --> R1{"1. 在 metrics.example.com 之下?"}
  R1 -- 是 --> D["direct"]
  R1 -- 否 --> R2{"2. 在 geosite category-ads-all 中?"}
  R2 -- 是 --> B["block"]
  R2 -- 否 --> R3{"3. geosite private 或 geoip private?"}
  R3 -- 是 --> D
  R3 -- 否 --> R4{"4. 在 example.com 之下?"}
  R4 -- 是 --> P["proxy"]
  R4 -- 否 --> R5{"5. geoip cn 之外的 IP?"}
  R5 -- 是 --> P
  R5 -- 否 --> R6{"6. geosite cn、apple@cn、steam@cn 或 geoip cn?"}
  R6 -- 是 --> D
  R6 -- "否:[route].default" --> P

一些流及其去向:

客户端请求 嗅探到的域名 首条匹配规则 出站
doubleclick.net:443 不需要 2 block
metrics.example.com:443,即使它在广告列表中 不需要 1 direct
192.168.1.1:80 无 3,geoip private direct
nas.lan:445 不需要 3,geosite private direct
adcdownload.apple.com:443 不需要 6,apple@cn direct
国内某个 IP 的 443 端口 无 6,geoip cn direct
国外某个 IP 的 443 端口 国内列表中的某个域名 5 proxy
国外某个 IP 的 443 端口 doubleclick.net 2 block
不在任何列表中的域名 不需要 无 proxy(默认)

列表中的每个位置都有其原因。如果你要添加规则,请保持以下关系:

  1. 例外规则要放在它所豁免的宽泛规则之前。 规则 1 把一个域名从广告列表中豁免出来,它之所以有效,是因为它位于规则 2 之前。如果放在规则 2 之后,它永远不会被执行到:它覆盖的每个域名都已经被屏蔽了。规则 4 对规则 6 中的国内列表起同样的作用。
  2. 屏蔽规则要放在任何转发规则之前。 这样无论广告位于本地、国内还是国外,都会被丢弃。
  3. private 要放在 !cn 之前。 !cn 匹配所有不在 cn 列表中的地址,其中也包括 192.168.0.0/16、10.0.0.0/8 和回环地址。如果上方没有规则 3,规则 5 就会让你的局域网流量走代理。
  4. 对于目标为 IP 的流,规则 5 让地址优先于嗅探到的域名。 地址才是数据真正发往的地方;嗅探到的域名只是客户端在第一个数据包里写的内容。一个发往国外地址、但 TLS SNI 在国内列表中的流会走代理。如果你更愿意相信域名,就把规则 5 移到规则 6 之下。

永远执行不到的规则不算错误,完全没有匹配条件键的规则也不算。etemenanki-app 接受这两种规则,它们只是永远不会匹配。当某条规则看起来不起作用时,先检查它上面的规则。

geosite 和 geoip 的行为不同。最重要的区别在于它们与什么进行比较。

geosite geoip
加载自 [route].geosite [route].geoip
比较对象 目标域名,以及嗅探到的域名 仅目标 IP
目标为域名的流 按域名匹配 永不匹配。 etemenanki-app 不会为路由解析域名
目标为 IP 的流 只能通过嗅探到的域名匹配 按地址匹配
code 该列表中的每一项 该列表中的每个地址段
code@attr 仅带有该属性的项 不支持:geoip code not found: cn@ads
!code 不支持:geosite code not found: !cn 该列表之外的所有地址
大小写 代码和属性都不区分大小写 代码不区分大小写
未知代码 geosite code not found: <code> geoip code not found: <code>

代码不要带前缀:写 geosite = ["cn"],而不是 geosite = ["geosite:cn"],后者会报错 geosite code not found: geosite:cn。

许多 geosite 列表会给其中一部分条目打上属性。v2fly 文件使用了三种属性:cn 表示某个国际服务在国内提供服务的域名,!cn 与之相反,ads 表示某个公司列表中的广告域名。apple@cn 只保留 apple 列表中带有 cn 属性的条目,例如 adcdownload.apple.com,而 cn 列表本身并不包含它。这就是规则 6 在 cn 旁边加上 apple@cn 和 steam@cn 的原因。

第一个 @ 之后的所有内容都视为一个属性名,所以 google@!cn 同样有效。没有任何条目带有的属性也会被接受,只是得到一个空列表:apple@cm 能通过 --test,但永远不会匹配任何东西。属性的拼写需要你自己检查。

有些列表名本身就包含 !,例如 geolocation-!cn(大体上位于国外的网站)。这是一个普通代码,而不是取反:! 只有出现在 geoip 条目开头时才表示“非”。

geoip = ["!cn"] 匹配不在 cn 列表任何地址段内的目标 IP。这带来两个结果:

  • 它同样会匹配私有地址、回环地址和链路本地地址。要像规则 3 那样,在它上方放一条 private 规则。
  • 与所有 IP 匹配条件一样,它永远不会匹配目标为域名的流。!cn 的意思不是“国外的一切”,而是“国外的所有地址”。没有被任何规则匹配的域名会交给 [route].default。
  • v2fly 的 geoip.dat 为每个两字母国家代码提供一项,另外还有 private 和一个 test 项。对 IPv4,private 覆盖 0.0.0.0/8、RFC 1918 地址段、回环地址、链路本地地址、CGNAT(100.64.0.0/10)、文档和基准测试地址段,以及从 224.0.0.0 开始往上的所有地址。对 IPv6,它覆盖 :: 和 ::1、唯一本地地址 fc00::/7、链路本地地址 fe80::/10 和组播地址 ff00::/8。
  • v2fly 的 geosite.dat 包含大约 1,500 个列表:apple、google、steam 等按公司划分的列表,category-ads-all、category-games 等按类别划分的列表,以及 cn、geolocation-cn、geolocation-!cn 等按国家划分的列表。private 覆盖 localhost、lan、local、internal、常见的路由器登录域名、反向解析区域,以及任何单标签域名。cn 包含整个 .cn 顶级域名。

etemenanki-app 在加载列表时会把每个条目转为小写。无法使用的条目,例如被 Rust regex 引擎拒绝的正则表达式,会被跳过并记录一条警告(skipping invalid geosite regex …),列表的其余部分照常加载。

列表名称及其内容由 v2fly 维护,并会随版本变化。在依赖某个列表之前,请浏览 domain-list-community 仓库的 data/ 目录,查看它包含哪些内容。

以域名编写的规则,包括所有 geosite 规则,只能匹配 etemenanki-app 知道域名的流。它是否知道域名取决于客户端:

客户端设置 etemenanki-app 收到的内容
使用远程 DNS 的 SOCKS5(curl 中的 socks5h://,Firefox 中的“使用 SOCKS v5 时代理 DNS 查询”),SOCKS4a 域名
使用本地 DNS 的 SOCKS5(socks5://),SOCKS4 客户端自己解析出的 IP 地址
HTTP 代理 通常是域名:CONNECT 目标,或普通请求的 Host 头
TUN 入站 始终是 IP 地址

在第二和第四种情况下,以及对目标为 IP 地址的 HTTP CONNECT,嗅探能够找回域名。普通 HTTP 代理请求按其 Host 头路由,从不嗅探。当入站设置了 sniffing = true(这是默认值)时,etemenanki-app 会读取客户端在目标为 IP 的流上发送的最初几个字节,并从中提取域名:

  • TLS ClientHello 提供其中的 SNI,因此 HTTPS 和大多数其他 TLS 流量都能覆盖;
  • 明文 HTTP/1 请求提供绝对形式请求 URL 中的主机名,否则提供其 Host 头。

找回的域名随后会交给每一个域名匹配条件(domain_suffix、domain_full、domain_keyword、domain_regex 和 geosite)尝试,目标本身也照常参与匹配。IP 匹配条件(cidr、geoip)会忽略它。

要预测一个流的去向,下面这些细节很重要:

  • 只检查目标为 IP 的流式连接。 已经指明域名的流会立即按该域名路由。UDP 数据报(包括 QUIC)从不嗅探;每个数据包都按其自身的地址和端口路由。
  • 域名只是路由提示,仅此而已。 出站仍然连接客户端请求的 IP 地址。SNI 或 Host 中的 IP 字面量会被忽略。
  • 嗅探最多等待 300 ms,最多读取 4 KiB。 一旦识别出 ClientHello 或请求,它就会提前结束。如果客户端在这段时间内没有发送任何内容(例如 SMTP 或 FTP 这类由服务器先发言的协议),或发送的字节既不是 TLS 也不是 HTTP(例如 SSH 客户端的版本行),这个流就会等满 300 ms,然后只按 IP 路由。
  • 入站先作答。 在 SOCKS、HTTP CONNECT 或 Hysteria 2 入站上,客户端在代理回复之前不会发送任何内容。因此对于开启了嗅探、目标为 IP 的请求,etemenanki-app 会在路由和拨号之前就回复“已连接”。如果随后拨号失败,客户端已经被告知连接成功,所以它看到的是连接被关闭,而不是一个它可以处理的 HTTP 502 或 SOCKS 错误回复。

在入站上设置 sniffing = false 即可跳过以上全部过程。此后该入站上目标为 IP 的流只会被 IP 规则匹配。该字段与其他通用入站字段一起在入站中介绍。

运行示例配置后,比较 curl 使用 SOCKS 代理访问广告列表中某个域名的两种方式:

终端窗口
curl -x socks5h://127.0.0.1:1080 https://doubleclick.net

curl 发送的是域名,规则 2 直接匹配它。无论嗅探开启与否,结果都一样。

被屏蔽的请求不会得到错误页面。blackhole 接受这个流,丢弃客户端发送的内容并立即结束数据流,因此 HTTPS 客户端会在 TLS 握手阶段失败,明文 HTTP 客户端则收不到任何回复。使用基于 OpenSSL 构建的 curl 时,表现如下:

curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to doubleclick.net:443
curl: (52) Empty reply from server

要按其他国家分流,修改示例中出现 cn 的地方:

  • 规则 5 中的 geoip = ["!cn"] 和规则 6 中的 geoip = ["cn"]:换成该国家的两字母代码,大小写均可。v2fly 的 geoip.dat 为每个国家代码都提供了一项。

  • 规则 6 中的 geosite = ["cn", "apple@cn", "steam@cn"]:v2fly 文件中拥有自己域名列表的国家很少。少数国家有,例如 category-ru 和 tld-ru,或 category-ir;请查看 domain-list-community 的 data/ 目录。删掉 @cn 这几项,它们只对 cn 有意义。

  • 没有列表时,自己匹配该国家的顶级域名。domain_suffix = ["de"] 匹配 .de 下的所有域名:

    [[route.rule]]
    outbound = "direct"
    domain_suffix = ["de"]
    geoip = ["de"]

保持规则 5 位于该国家规则之上,并换成新的代码。

etemenanki-app 在构建配置时解码 .dat 文件:启动时、执行 --test 时以及重载时。运行中的进程把加载的内容保存在内存里,所以你可以随时覆盖这些文件而不影响它。反过来说,新文件只有在构建新配置时才会生效。

etemenanki-app 监视配置文件所在的目录。该目录每次发生变化时,它都会重新读取配置文件,把其字节与上次加载的版本比较,只有不同时才重载。替换 geoip.dat 或 geosite.dat 时,即使它们位于同一目录,配置文件的字节也没有变化,因此不会触发重载。你有两种选择:

  • 修改配置文件。 任何字节的变化都算数,包括注释。重载会重新构建整个配置,并读取新的 .dat 文件。如果只改了注释,日志会显示 config reload: no changes;但新的 geodata 无论如何都已生效。
  • 重启 etemenanki-app。

两种方式都会断开所有已打开的连接,因为重载会替换所有监听器和出站。见热重载。

下面的脚本完成整个更新过程。它在配置文件中维护一行标记注释,例如 # geodata: 2026-09-24T03:00:00Z:首次运行时添加,之后每次运行时改写,正是这一变化触发了重载:

update-geodata.sh
#!/bin/sh
# Update the v2fly geodata and make a running etemenanki-app load it.
set -eu
dir=/etc/etemenanki
config="$dir/config.toml"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cd "$tmp"
for url in \
https://github.com/v2fly/geoip/releases/latest/download/geoip.dat \
https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat
do
curl -fsSLO "$url"
curl -fsSLO "$url.sha256sum"
done
sha256sum -c geoip.dat.sha256sum dlc.dat.sha256sum
# Keep the current files so a bad release can be rolled back.
cp "$dir/geoip.dat" "$dir/geoip.dat.old"
cp "$dir/geosite.dat" "$dir/geosite.dat.old"
install -m 0644 geoip.dat "$dir/geoip.dat"
install -m 0644 dlc.dat "$dir/geosite.dat"
# Every code the rules name must still exist in the new files.
if ! etemenanki-app --test -c "$config"; then
mv "$dir/geoip.dat.old" "$dir/geoip.dat"
mv "$dir/geosite.dat.old" "$dir/geosite.dat"
exit 1
fi
# Change the config's bytes, which triggers the reload.
stamp="# geodata: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
if grep -q '^# geodata: ' "$config"; then
sed -i "s/^# geodata: .*/$stamp/" "$config"
else
printf '%s\n' "$stamp" >> "$config"
fi

以 root 身份通过每周一次的 cron 任务或 systemd timer 运行它。--test 这一步最关键:v2fly 偶尔会重命名或删除某个列表,而规则中用到的代码如果在新文件中不存在,就会报错 geosite code not found: <code>。先做检查,可以保证运行中的进程和下一次重启使用的都是可用的文件。

配置错误会同样导致 --test、启动和重载失败。三种情况下的错误原因相同,只有日志行的前缀不同:--test 为 configuration invalid:,启动为 failed to start:,重载为 reload: build failed, keeping current config:。

原因 起因 解决方法
a geosite matcher is used but no geosite file is configured 有规则使用了 geosite,但没有设置 [route].geosite 添加路径
a geoip matcher is used but no geoip file is configured 有规则使用了 geoip,但没有设置 [route].geoip 添加路径
No such file or directory (os error 2) 某个 .dat 路径不存在。错误信息不会指明是哪个文件。相对路径相对于进程的工作目录解析,而不是配置文件所在的目录 使用绝对路径
Permission denied (os error 13) 进程无法读取某个 .dat 文件 让运行 etemenanki-app 的用户能读取该文件
geosite code not found: <code> 代码拼写错误、带了 geosite: 前缀、在 geosite 代码前加了 !、文件中已不存在该列表,或文件为空 在 domain-list-community 的 data/ 目录中核对名称
geoip code not found: <code> 代码拼写错误、在 geoip 代码上加了 @attr,或精简文件(如 geoip-only-cn-private.dat)中没有该代码 使用两字母国家代码或 private,最多在开头加一个 !
geosite decode: failed to decode Protobuf message: … geosite 路径指向的不是 v2ray 格式的域名列表:可能是 geoip.dat、下载失败时保存下来的 HTML 页面,或其他工具格式的文件 重新下载 dlc.dat 并校验
geoip decode: failed to decode Protobuf message: … geoip 路径出现同样的问题 重新下载 geoip.dat 并校验

有些错误根本不会报错,只会表现为某条规则永远不匹配:

  • 没有任何条目带有的属性,例如 apple@cm;
  • 带前导点的 domain_suffix:.example.com 什么也匹配不到,应写成 example.com;
  • 本想匹配域名的 geoip 规则:它永远不会匹配目标为域名的流;
  • 针对以 IP 形式到达的流量的域名或 geosite 规则,而入站设置了 sniffing = false,或流量走的是 UDP;
  • 位于某条更宽泛规则之下的规则,而那条宽泛规则已经拿走了它的全部流。