跳转到内容

配置文件

etemenanki-app 读取一个 TOML 文件。这个文件描述客户端从哪里接入(入站)、流量从哪里离开(出站)、每个流如何选择出站(路由),以及日志、DNS 等几项进程级设置。本页介绍文件的整体情况:结构、校验有多严格、会看到哪些错误,以及 [log] 表。文件中每一部分都有单独的页面,链接列在本页末尾。

第一次手写配置文件之前,或者 --test 拒绝了文件而错误信息又不够清楚时,请先阅读本页。

最小的可用配置是一个本地 SOCKS 代理,把所有流量直接发出:

config.toml
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
[[outbound]]
tag = "direct"
protocol = "freedom"

用 -c(长格式为 --config)指定文件。不指定时,etemenanki-app 读取当前工作目录下的 config.toml。用 --test 可以只检查文件、不启动任何东西:

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

--test 会执行真正启动时的全部检查,并读取配置用到的所有文件,但不绑定任何监听器,也不打开 TUN 设备。成功时输出 Configuration OK.,退出状态为 0;失败时记录 configuration invalid: …,退出状态为 1。

  1. 编写或修改文件。

  2. 运行 etemenanki-app --test -c <file>。

  3. 如果失败,修正它指出的错误后再运行一次。每次只报告第一个错误,所以一个有三处错误的文件需要改三轮。

  4. 用 etemenanki-app -c <file> 启动代理;如果代理已经在运行,直接保存文件即可,它会自动重载(见热重载)。

一个文件由六个顶层条目组成。对 TOML 解析器来说它们都是可选的,但构建阶段至少需要一个 [[outbound]]。

键类型必填默认值说明
logtable否{}日志设置,写作 [log],只有 level 一个键。见配置文件页面的 [log] 一节。
dnstable否{}出站和负载均衡器探测使用的域名解析,写作 [dns]。不写时,etemenanki-app 使用主机的系统解析器,并在其前面始终加一层缓存。
inboundarray of tables否[]监听器,每个 [[inbound]] 块一个。键名是单数:[[inbounds]] 会被当作未知字段。没有入站也能通过校验,只是进程不接受任何连接。入站之间的 tag 不能重复。
outboundarray of tables是—流量的出口,每个 [[outbound]] 块一个。键名是单数。至少要有一个,否则构建阶段报 config defines no outbounds。除非 [route] 设置了 default,文件中第一个 [[outbound]] 就是默认路由。出站之间的 tag 不能重复。
balancerarray of tables否[]一组可以互相替代的出站,每个 [[balancer]] 块一个。键名是单数。负载均衡器的 tag 可以用在任何接受出站 tag 的地方,因此不能与出站或其他负载均衡器的 tag 重复。
routetable否{}路由设置,写作 [route],规则写作 [[route.rule]] 块(单数;[[route.rules]] 会被当作未知字段)。第一条匹配的规则生效。不写 [route] 时,所有流都走第一个 [[outbound]]。

各部分之间的关系如下。虚线表示查询,而不是流量。

flowchart LR
  IN["[[inbound]]"] --> R["[route] 和 [[route.rule]]"]
  R --> OUT["[[outbound]]"]
  R --> BAL["[[balancer]]"]
  BAL --> OUT
  DNS["[dns]"] -.-> OUT
  DNS -.-> BAL

inbound、outbound、balancer 和 route.rule 都是表数组:每个条目写一次带双层方括号的 [[inbound]]。这些名称是单数,尽管 Xray 的 JSON 把同样的列表写作 inbounds 和 outbounds。

写法 结果
[[inbound]] 新增一个入站。
[[inbounds]] 解析错误:unknown field `inbounds`, expected one of `log`, `dns`, `inbound`, `outbound`, `balancer`, `route` 。
[inbound](单层方括号) 解析错误:invalid type: map, expected a sequence。[outbound]、[balancer] 和 [route.rule] 也一样。
[[route.rule]] 新增一条路由规则。
[[route.rules]] 解析错误:unknown field `rules`, expected one of `default`, `geoip`, `geosite`, `rule` 。

[inbound.settings]、[inbound.stream]、[outbound.stream.tls] 这类嵌套表,属于它们上方最近的那个 [[inbound]] 或 [[outbound]]。这是 TOML 本身的规则,也是移动配置块时最容易犯的错误。

各部分在文件中的位置无关紧要,但有两个例外:

  • [[outbound]] 的顺序。 [route] 没有设置 default 时,第一个 [[outbound]] 就是默认路由。
  • [[route.rule]] 的顺序。 规则从上到下依次尝试,第一条匹配的规则生效。见路由。

文件中有多处错误时,顺序还决定先看到哪一个错误,见阶段 2:构建。

etemenanki-app 采用 fail closed(出错即拒绝):出了错,代理会报错停止,而不是悄悄改变行为。如果拼错的 listen 退回默认值,本应只在 localhost 上监听的代理可能暴露到网络上;如果拼错的 security 退回明文,凭据就会以明文发送。这两种情况都会被拒绝。

文件中的每个表都会拒绝它不认识的键。这包括嵌套的 stream、tls、ws 和 grpc 表,以及每个有设置项的协议的 settings 表。键名区分大小写,所以 Tag 和 lisen 一样都是未知键。

TOML parse error at line 8, column 1
|
8 | lisen = "127.0.0.1"
| ^^^^^
unknown field `lisen`, expected one of `tag`, `protocol`, `listen`, `port`, `stream`, `address_family`, `sniffing`, `settings`

整个配置结构中唯一的别名,是 Shadowsocks 入站 settings 中用 clients 代替 users,这是为了兼容 Xray。

protocol、network、strategy 这样的值,对 TOML 解析器来说只是普通字符串。构建步骤会把每个值与它支持的取值逐一比对,其他值一律报错,不会退回任何默认值。

这些比较大多是精确匹配、区分大小写的,少数几个比较宽松:

值 接受的写法 被拒绝的值示例
protocol(入站和出站) 精确匹配,小写。别名:hysteria 和 hy2 表示 hysteria2;出站还可以用 direct 表示 freedom,用 block 表示 blackhole。 "Freedom" → outbound direct: unknown protocol "Freedom"
[.stream] network 精确匹配:tcp、tls、ws、grpc。 "WS" → inbound a: unknown stream network "WS"
[.stream] security 去掉首尾空格后精确匹配:tls、none 或空值。network = "tcp" 时不接受 tls;TCP 上的 TLS 写作 network = "tls"。 "TLS" → unknown stream security "TLS" (expected "tls" or "none")
[[balancer]] strategy 精确匹配:failover、round_robin。 "Failover" → unknown balancer strategy "Failover" (expected "failover" or "round_robin")
[dns] backend 精确匹配:system、udp、tls、https。 "UDP" → dns: unknown backend "UDP" (expected "system", "udp", "tls" or "https")
[[route.rule]] network 精确匹配:tcp、udp。 "TCP" → invalid rule network "TCP" (expected "tcp" or "udp")
SOCKS 入站的 auth、Hysteria 2 的 obfs 精确匹配。 "Password" → unknown socks auth "Password"
出站的 address_family 不区分大小写,去掉首尾空格,- 视同 _。 "ipv5" → invalid address_family "ipv5"
VMess 出站的 security 不区分大小写。 "none" → unknown vmess security "none"
Shadowsocks 的 method AEAD 方法不区分大小写;2022- 方法精确匹配。 "2022-BLAKE3-AES-128-GCM" → inbound a: unknown shadowsocks-2022 method "2022-BLAKE3-AES-128-GCM"
[log] level 不区分大小写,但从不拒绝。见日志:[log]。 "warning" 会被接受,并让日志完全静默。

tag 同样精确比较:default = "direct" 找不到 tag 为 "Direct" 的出站。

etemenanki-app 每次读取文件时,无论是 --test、启动还是每次重载,都会执行同样的两个阶段。只有真正启动时才会接着绑定监听器。

flowchart LR
  A["读取文件"] --> B["阶段 1:TOML 解析"]
  B -->|"报错,带行号和列号"| X["拒绝"]
  B --> C["阶段 2:构建"]
  C -->|"报错,不带行号"| X
  C --> T["--test: Configuration OK."]
  C --> S["启动或重载:绑定监听器"]
阶段 1:解析 阶段 2:构建
检查内容 TOML 语法、UTF-8、未知的键、值的类型(该写数字的地方写了字符串、端口大于 65535)、tag 和 protocol 等必填键 每个 settings 表、每个枚举值、tag 及其引用、配置中指定的文件、各协议特有的规则
错误位置 行号和列号,并引用出错的那一行 没有行号;大多数错误会指出对象,例如 inbound socks-in: 或 dns:,但也有一些什么都不指出,例如文件错误和 invalid rule network "TCP" (expected "tcp" or "udp")
停止于 第一个错误 第一个错误

解析器把文件转换成带类型的表。它的错误会引用出错的行:

TOML parse error at line 7, column 8
|
7 | port = "1080"
| ^^^^^^
invalid type: string "1080", expected u16

各协议的 [inbound.settings] 或 [outbound.settings] 表不在这一阶段检查。它的结构取决于 protocol,所以解析器把它保留为无类型的表,交给构建步骤处理。

构建步骤会构造进程运行所需的一切,但不绑定任何东西,顺序如下:

  1. 检查至少存在一个 [[outbound]];
  2. [dns],包括读取它的 ca_file;
  3. 按文件顺序构建每个 [[outbound]],包括它的 settings、stream 设置,以及证书或 CA 文件;
  4. 按文件顺序构建每个 [[balancer]];
  5. [route]:先按文件顺序构建规则,再构建默认路由,如果有规则需要,最后加载 geosite 和 geoip 文件;
  6. 按文件顺序构建每个 [[inbound]],包括它的 settings、stream 设置,以及证书和私钥。

它在第一个错误处停止,所以即使入站在文件中写在前面,出错的规则也会先于出错的入站被报告。

settings 表在这一阶段检查,所以它的错误没有行号,而是指出对象,形式为 inbound <tag>: invalid settings: … 或 outbound <tag>: invalid settings: …:

[[inbound]]
tag = "socks-in"
protocol = "socks"
port = 1080
[inbound.settings]
auth = "password"
acounts = [{ user = "alice", pass = "replace-with-a-long-random-password" }]
inbound socks-in: invalid settings: unknown field `acounts`, expected one of `auth`, `accounts`, `udp`, `udp_bind`

在文件中搜索错误信息里的 tag,就能找到对应的表。

无论在什么情况下,错误文本都相同,只是前缀不同:

情况 日志行 结果
etemenanki-app --test configuration invalid: <error> 以状态 1 退出。
启动 failed to start: <error> 以状态 1 退出。
重载,阶段 1 reload: parse failed, keeping current config: <error> 继续使用当前运行的配置。
重载,阶段 2 reload: build failed, keeping current config: <error> 继续使用当前运行的配置。
重载,文件无法读取 reload: cannot read <path>: <error> 继续使用当前运行的配置。

--test 发现不了只有在绑定 socket 时才会出现的问题,例如端口已被占用,或者没有相应权限却使用特权端口。启动时,这类问题会让进程停止,例如 failed to start: inbound socks-in bind 127.0.0.1:1080 failed: Address already in use (os error 98)。重载时,旧的监听器此时已经关闭,所以错误会记录为 inbound <tag> bind <address> failed: <error>,该入站保持停止状态,其他入站照常启动。见热重载。

日志行(包括这些错误)以及 Configuration OK. 都输出到标准输出。

每个入站、出站和负载均衡器都有一个 tag。规则、负载均衡器和 [route] default 通过 tag 引用出站,规则还可以按入站 tag 匹配。

规则 违反时的错误
入站之间的 tag 唯一。 duplicate inbound tag: <tag>
出站之间的 tag 唯一。 duplicate outbound tag: <tag>
负载均衡器的 tag 不能与任何出站 tag 或其他负载均衡器的 tag 相同,因为凡是能用出站的地方都能用负载均衡器。 balancer tag <tag> collides with an outbound tag
至少存在一个 [[outbound]]。 config defines no outbounds
[route] default 或规则的 outbound 中指定的每个 tag 都必须存在,可以是出站,也可以是负载均衡器。 route references unknown outbound tag: <tag>
负载均衡器 outbounds 中的每个 tag 都必须指向一个 [[outbound]],不能是另一个负载均衡器。 balancer <tag> references unknown outbound tag: <member>

入站和出站的 tag 位于不同的命名空间,所以入站和出站可以使用相同的 tag。规则的 inbound_tag 列表不会与入站核对:一个不对应任何入站的 tag,只会让该条件永远不匹配。

默认路由是没有规则匹配时流的去向:

  • 如果 [route] 设置了 default,就是它指定的出站或负载均衡器;
  • 否则是文件中的第一个 [[outbound]]。

因此,没有 [route] 的文件会把所有流量发往第一个出站。如果你在代理出站上方加了一个 blackhole 或 freedom 出站,请显式设置 default,否则所有流量都会改走新的第一个条目。

有些键指定了文件路径。etemenanki-app 在构建阶段读取这些文件,所以只要有一个文件缺失或无法读取,--test 就会失败。

键 何时读取
[inbound.stream.tls] cert_file、key_file 入站的 stream 使用 TLS 时(network = "tls",或在 ws、grpc 下设置 security = "tls")。
[outbound.stream.tls] ca_file 出站的 stream 使用 TLS 时。
[dns] ca_file 只要设置了这个键就读取,与 backend 无关。
[route] geosite 仅当至少有一条规则带 geosite 条件时。
[route] geoip 仅当至少有一条规则带 geoip 条件时。
Hysteria 2 入站的 settings.cert_file、settings.key_file 总是读取。
Hysteria 2 出站的 settings.ca_file 设置了这个键时。

如果规则用了 geosite 或 geoip,而 [route] 中没有对应的键,会报 a geosite matcher is used but no geosite file is configured(或对应的 geoip 版本)。如果文件存在,但缺少某条规则指定的代码,会报 geosite code not found: <code> 或 geoip code not found: <code>。

有两点容易让人出错:

  • 相对路径相对于进程的工作目录解析,而不是配置文件所在的目录。 在配置目录下运行 --test 时,cert_file = "certs/server.pem" 能正常工作;但服务管理器从 / 启动进程时就会失败。请使用绝对路径。
  • 文件错误不会指出是哪个文件。 文件不存在时报 No such file or directory (os error 2),没有权限时报 Permission denied (os error 13),既没有路径也没有 tag。请逐一检查配置中的每个路径,从上面构建顺序中靠前的开始。
configuration invalid: No such file or directory (os error 2)

如果 -c 指定的配置文件本身不存在,也会出现同样的信息。

每次重载都会重新读取这些文件,但修改其中某个文件本身并不会触发重载。见热重载。

[log] 只有一个键。

键类型必填默认值说明
levelstring否"info"一条 tracing 的 EnvFilter 指令,语法与 RUST_LOG 相同:一个级别(off、error、warn、info、debug、trace,或 0 到 5 的数字,不区分大小写),后面可以跟按 target 覆盖的级别,例如 "warn,etemenanki_protocols=debug"。环境变量 RUST_LOG 只要能解析为过滤器,就会取代这个值;设为空字符串也算合法,结果是什么都不输出。这个值不做校验:不是级别的词(例如 "warning")会被当成 target 名,结果是所有日志(包括错误)都不再输出。值为空,或者没有一条指令能解析时,只输出错误日志。只在启动时读取一次,热重载时不生效。

level 是一条过滤指令,语法与 tracing crate 的 EnvFilter 相同,也就是 RUST_LOG 使用的语法。单独一个级别作用于所有日志;用逗号分隔的 target=level 对可以为某个 crate 或模块单独覆盖级别。

level 效果
"info"(默认) 启动、监听器、重载、警告和错误。
"debug" 另外输出每个连接的事件,例如握手失败和被拒绝的连接。在繁忙的服务器上会非常多。
"warn,etemenanki_protocols=debug" 所有模块的警告和错误,以及各协议实现的 debug 输出。
"off" 什么都不输出,包括错误。

日志级别只在启动时确定一次:

  1. 如果设置了环境变量 RUST_LOG,并且能解析为过滤器,就使用它,忽略 [log] level。空的 RUST_LOG 也算合法的过滤器,结果是什么都不输出。无法解析的 RUST_LOG 会被忽略。
  2. 否则,如果文件通过了阶段 1,就使用其中的 level;文件没有 level 时使用 info。
  3. 否则使用 info,因此 TOML 解析错误总会被输出。

重载时会发现 [log] 的变化,重载日志行也会报告 log changed,但新的级别不会生效。要修改级别,请重启进程。

这个文件用到了大部分配置段:日志、DNS over HTTPS、两个本地入站、放在 failover 负载均衡器后面的 VLESS 和 Trojan 出站、direct 和 blackhole 出站,以及几条路由规则。只要文件中指定的两个路径上存在包含 category-ads-all 和 private 代码的 geodata 文件,它就能通过 --test;否则构建会以 No such file or directory (os error 2) 停止。请把其中的占位服务器名、UUID 和密码换成你自己的。

config.toml
# A complete, annotated etemenanki-app configuration.
#
# It runs a local gateway: SOCKS5 and HTTP proxies on the loopback interface,
# traffic carried to a VLESS server over WebSocket + TLS, a Trojan server that
# takes over when the VLESS server stops answering, and rules that block ads,
# keep private addresses direct and refuse outgoing mail.
#
# Check it before you use it:
# etemenanki-app --test -c /etc/etemenanki/config.toml
#
# Every key is spelled exactly as etemenanki-app expects it. An unknown key is
# an error, not a silent no-op. Relative paths resolve against the working
# directory of the process, not against this file, so paths here are absolute.
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
[log]
# An EnvFilter directive: a level, optionally with per-target overrides such
# as "info,etemenanki_protocols=debug". A RUST_LOG variable that parses as a
# filter replaces it. Read at startup only: a reload does not change it.
level = "info"
# ---------------------------------------------------------------------------
# Name resolution
# ---------------------------------------------------------------------------
# Used for the outbound servers below, for "direct" destinations and for
# balancer probes. Leave the whole table out to use the host resolver.
[dns]
backend = "https" # "system" | "udp" | "tls" | "https"
server = "1.1.1.1:443" # an IP literal with a port, never a name
url = "https://cloudflare-dns.com/dns-query"
# ---------------------------------------------------------------------------
# Inbounds: where clients connect
# ---------------------------------------------------------------------------
# A table such as [inbound.settings] belongs to the closest [[inbound]] above
# it, so keep each inbound's sub-tables directly under it.
[[inbound]]
tag = "socks-in" # unique among inbounds; rules can match on it
protocol = "socks"
listen = "127.0.0.1" # the default; write "0.0.0.0" to accept remote clients
port = 1080
[inbound.settings] # checked at build time, after the TOML parse
auth = "none"
udp = true
[[inbound]]
tag = "http-in"
protocol = "http"
listen = "127.0.0.1"
port = 8080
sniffing = true # the default: for an IP destination, read the name from TLS SNI or HTTP Host
[inbound.settings]
accounts = [{ user = "alice", pass = "replace-with-a-long-random-password" }]
# ---------------------------------------------------------------------------
# Outbounds: where flows leave
# ---------------------------------------------------------------------------
# Without [route].default, the first [[outbound]] is the default route. This
# file sets default = "auto" below, so the order here only affects the order
# in which build errors are reported.
[[outbound]]
tag = "vless-ws"
protocol = "vless"
server = "proxy.example.com"
port = 443
[outbound.stream]
network = "ws"
security = "tls" # "tls" or "none"; any other value is an error
[outbound.stream.ws]
path = "/ws"
[outbound.stream.tls]
server_name = "proxy.example.com"
[outbound.settings]
id = "11111111-2222-3333-4444-555555555555"
[[outbound]]
tag = "trojan-tls"
protocol = "trojan"
server = "203.0.113.10"
port = 443
[outbound.stream]
network = "tls" # TLS over plain TCP; no security key needed
[outbound.stream.tls]
server_name = "example.com" # the name the server certificate is checked against
[outbound.settings]
password = "replace-with-a-long-random-password"
[[outbound]]
tag = "direct"
protocol = "freedom" # connect to the destination directly
[[outbound]]
tag = "block"
protocol = "blackhole" # accept the flow and drop everything
# ---------------------------------------------------------------------------
# Balancers: several outbounds behind one tag
# ---------------------------------------------------------------------------
[[balancer]]
tag = "auto" # must not repeat an outbound tag
outbounds = ["vless-ws", "trojan-tls"]
strategy = "failover" # list order is priority; or "round_robin"
probe_interval = 30 # seconds between TCP health probes (default 30)
probe_timeout = 5 # seconds before a probe counts as failed (default 5)
# ---------------------------------------------------------------------------
# Routing
# ---------------------------------------------------------------------------
[route]
default = "auto" # an outbound or balancer tag
# Each file is read only when a rule below uses a geoip or geosite condition.
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 flow matches if ANY of the listed conditions matches: the second
# rule below sends a flow direct when its address is private OR its name ends
# in .lan. Conditions are never combined with AND.
[[route.rule]]
outbound = "block"
geosite = ["category-ads-all"]
[[route.rule]]
outbound = "direct"
geoip = ["private"]
domain_suffix = ["lan"]
# Never relay outgoing mail. Ports are strings: "25", or a range "8000-9000".
[[route.rule]]
outbound = "block"
port = ["25", "465", "587"]
错误 原因 解决方法
unknown field `X`, expected one of …(带行号) 键名拼错、键放错了表,或数组名用了复数。 使用列出的名称之一。检查这个键是否位于正确的 [...] 标题下。
invalid type: map, expected a sequence [inbound] 或 [outbound] 用了单层方括号。 写成 [[inbound]]。
inbound <tag>: invalid settings: … 该入站的 [inbound.settings] 中有问题。 阅读信息的后半部分,那是 settings 表的 serde 错误。
config defines no outbounds 文件中没有 [[outbound]]。 至少添加一个出站,哪怕只是 freedom。
route references unknown outbound tag: <tag> [route] default 或规则的 outbound 指定了未定义的 tag,可能只是大小写不同。 定义该出站或负载均衡器,或修正拼写。
outbound <tag>: unknown protocol "<value>" 协议名不受支持,或用了大写。 使用出站或入站页面中的小写名称。
No such file or directory (os error 2) -c 指定的文件或其中的某个路径不存在,常见原因是相对路径按错误的目录解析。 使用绝对路径,并逐一检查。
--test 什么都不输出,以状态 1 退出 [log] level 不是有效级别或为 off,或者 RUST_LOG 为空或为 off,错误被过滤掉了。 运行 RUST_LOG=info etemenanki-app --test -c <file> 查看错误,然后修正 level。