跳转到内容

第一个代理

本页带你完整地跑一遍 etemenanki-app。你将为本机上的 SOCKS 代理写一份简短的配置,在不启动任何东西的情况下检查它,用 curl 通过代理发出请求,然后在代理保持运行的同时添加一条屏蔽某个域名的路由规则。过程中你会看到程序实际打印的每一行输出,既有一切正常时的,也有写错配置时的。

这里不需要服务器,也不需要 root 权限。所有服务都监听在 127.0.0.1 上,因此只有运行代理的这台机器能访问它。

  • 已安装 etemenanki-app,并且它在你的 PATH 中。运行 etemenanki-app --version 检查;获取二进制文件的方法见安装。
  • 已安装 curl,并且这台机器可以直接访问互联网。
  • TCP 端口 1080 没有被其他程序监听。如果已被占用,换一个端口,并在下文所有地方使用它。

在一个空目录中操作。省略 -c 时,etemenanki-app 会读取当前目录下的 config.toml,不过本页的每条命令都显式传入了 -c config.toml。

  1. 编写配置。 将以下内容保存为 config.toml:

    config.toml
    # A first etemenanki-app config: a local SOCKS proxy on 127.0.0.1:1080 that
    # sends every connection straight out to the internet.
    # Accept SOCKS 4/4a/5 clients on the loopback interface only.
    [[inbound]]
    tag = "socks-in"
    protocol = "socks"
    listen = "127.0.0.1"
    port = 1080
    # Connect to the requested destination directly.
    [[outbound]]
    tag = "direct"
    protocol = "freedom"

    这个文件声明了两样东西。入站(inbound)是连接进来的地方:这里是一个绑定在回环地址 1080 端口上的 SOCKS 服务端。出站(outbound)是连接出去的地方:freedom 会直接连接客户端请求的目标地址。没有 [route] 部分时,所有连接都走第一个出站。

  2. 检查配置。 --test 会解析文件,并像真正启动时一样构建每个入站、出站和路由规则,但不绑定任何端口,也不建立任何连接:

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

    文件有效时,它打印一行并以状态码 0 退出:

    Configuration OK.

    文件无效时,它打印原因并以状态码 1 退出。具体效果见下文的写错配置时。

  3. 运行代理。 在前台启动代理:

    终端窗口
    etemenanki-app -c config.toml

    监听器启动后,它会输出日志:

    2026-09-24T20:21:58.014596Z INFO etemenanki_app::instance: inbound socks-in listening on 127.0.0.1:1080

    进程此时开始等待连接。让它保持运行,另开一个终端。

  4. 通过代理发出请求。 在第二个终端中执行:

    终端窗口
    curl --socks5-hostname 127.0.0.1:1080 https://example.com

    curl 会打印 Example Domain 页面的 HTML。请求的路径是 curl → socks-in → direct → example.com。

    --socks5-hostname 让 curl 把域名 example.com 交给代理,由代理来解析。如果用普通的 --socks5,curl 会自己解析域名,只把 IP 地址发给代理。两种方式都可以,而且域名规则对第二种方式同样生效。当客户端请求的是 IP 地址时,SOCKS 入站会最多等待 300 ms,读取连接最开始的几个字节,并从中取出域名(TLS 服务器名称或 HTTP Host 头)。这就是嗅探,默认开启。

这份配置只用到了少数几个配置项。etemenanki-app 会检查这些表中的每一个配置项:不认识的配置项会被当作错误,而不是被跳过。

配置项 类型 默认值 含义
[[inbound]] 表数组 无 每个监听器一个表。配置中可以一个都没有,此时进程照常运行,但不接受任何连接。
tag 字符串 必填 在日志和路由规则中使用的名称。入站之间的 tag 必须唯一,出站之间的 tag 也必须各自唯一。
protocol 字符串(枚举) 必填 入站使用的协议。"socks" 在同一个端口上同时接受 SOCKS 4、4a 和 5。
listen 字符串 "127.0.0.1" 要绑定的地址。省略时入站绑定回环地址,绝不会绑定所有网卡。以 / 开头的值表示 Unix socket 路径。
port u16 必填 TCP 端口。除非 listen 是 Unix socket 路径,否则必填;listen 是 Unix socket 路径时不允许设置。
[[outbound]] 表数组 无 每个目的地一个表。至少需要一个。
tag 字符串 必填 路由规则和 [route].default 引用的名称。
protocol 字符串(枚举) 必填 "freedom"(别名 "direct")连接到请求的目标地址。"blackhole"(别名 "block")丢弃连接。

SOCKS 入站自己的选项位于 [inbound.settings] 中,这份配置使用的都是默认值:不认证(auth = "none"),启用 UDP ASSOCIATE(udp = true),开启嗅探(sniffing = true,这是入站本身的配置项)。完整列表见 SOCKS。

现在添加第二个出站,用来丢弃连接,再添加一条规则,把某个域名发往这个出站。上一节启动的代理可以继续运行:etemenanki-app 会监视配置文件,文件变化时自动重新加载。

  1. 修改 config.toml,使其内容如下:

    config.toml
    # The first-proxy config extended with routing: connections to example.net
    # and its subdomains are dropped, everything else goes out directly.
    [[inbound]]
    tag = "socks-in"
    protocol = "socks"
    listen = "127.0.0.1"
    port = 1080
    [[outbound]]
    tag = "direct"
    protocol = "freedom"
    # Accepts a connection and closes it without sending anything.
    [[outbound]]
    tag = "block"
    protocol = "blackhole"
    [route]
    # Used when no rule matches. Without this line the first outbound is used.
    default = "direct"
    # Rules are tried in order; the first one that matches picks the outbound.
    [[route.rule]]
    domain_suffix = ["example.net"]
    outbound = "block"

    新增了三处内容。block 出站使用 protocol = "blackhole"。[route] 表把 direct 指定为默认出站,这正是第一份配置隐式做的事。还有一条 [[route.rule]],把 example.net 及其下所有域名发往 block。

  2. 保存文件,观察第一个终端。 片刻之后,正在运行的代理会记录这次变更并重启监听器:

    2026-09-24T20:21:59.323617Z INFO etemenanki_app::instance: config reload: outbounds +[block]; route changed
    2026-09-24T20:21:59.323905Z INFO etemenanki_app::instance: inbound socks-in listening on 127.0.0.1:1080

    +[block] 表示新增了一个带该 tag 的出站。同一行中,-[…] 表示被删除的 tag,~[…] 表示被修改的 tag。

  3. 测试规则的两种情况。 example.net 下的域名现在会被丢弃:

    终端窗口
    curl --socks5-hostname 127.0.0.1:1080 https://www.example.net
    curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to www.example.net:443

    具体的报错信息取决于你的 curl 构建版本。SOCKS 握手本身是成功的;随后 blackhole 出站一个字节都不发就关闭了连接,所以 curl 看到的是 TLS 握手被中断。如果是普通 HTTP,同一条规则的表现是 curl: (52) Empty reply from server。

    其他域名仍然直接连接:

    终端窗口
    curl --socks5-hostname 127.0.0.1:1080 https://example.com

每个连接的路由过程如下:

flowchart LR
  C["curl"] --> I["入站 socks-in"]
  I --> R{"规则 1:domain_suffix example.net?"}
  R -- "匹配" --> B["出站 block"]
  R -- "不匹配" --> D["route.default: direct"]
  D --> N["目标地址"]
  • 首个匹配生效。 规则按照在文件中出现的顺序依次尝试。第一条匹配的规则决定出站,后面的规则不再检查。没有任何规则匹配的连接走 [route].default;未设置 default 时,走第一个 [[outbound]]。
  • domain_suffix 遵循标签边界。 "example.net" 匹配 example.net 和 www.example.net,但不匹配 notexample.net。匹配时不区分大小写。
  • 嗅探到的域名同样有效。 域名规则针对客户端请求的域名进行检查。如果客户端只发送了 IP 地址,则改为针对嗅探从连接中读到的域名进行检查。所以只发送 IP 地址的 curl --socks5 同样会被屏蔽。完全不带域名的连接,例如直接连到某个 IP 地址的原始 TCP 连接,永远不会匹配域名规则。
  • 未知的 tag 是错误。 如果某条规则的 outbound 指向不存在的 tag,整份配置都无法通过。
配置项 类型 默认值 含义
[route].default 字符串 第一个 [[outbound]] 的 tag 没有任何规则匹配的连接所使用的出站。
[[route.rule]] 表数组 无 路由规则,按文件中的顺序依次尝试。
outbound 字符串 必填 匹配的连接所使用的出站(或负载均衡器)的 tag。
domain_suffix 字符串数组 [] 要匹配的域名,每个域名都包含其所有子域名。

etemenanki-app 采用 fail closed(出错即拒绝)策略:遇到不认识的配置项时会停止运行,而不是忽略它。这是出于安全考虑。如果拼错的 listen 被直接跳过,入站就会悄悄绑定到一个与你所写不同的地址上。

把第一份配置中的 listen 改成 lisen,再检查一次:

终端窗口
etemenanki-app --test -c config.toml
2026-09-24T20:23:41.522763Z ERROR etemenanki_app: configuration invalid: 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`

报错信息给出了行号和列号,指出了出错的配置项,并列出了该位置上所有合法的配置项。退出状态码为 1。

其他错误也以同样的方式报告。文件外层结构上的错误,例如未知配置项、类型错误和缺少必填项,会带有行号。之后在 etemenanki-app 构建入站、出站和路由时才发现的错误则没有行号,其中大多数会指出是哪个入站或出站。[inbound.settings] 和 [outbound.settings] 是在这个后期阶段检查的,所以那里的拼写错误报告时不带行号:

错误 etemenanki-app 在 configuration invalid: 之后打印的内容
配置项拼写错误 TOML parse error at line 8, column 1 … unknown field `lisen`, expected one of …
表名拼写错误,例如 [[route.rules]] unknown field `rules`, expected one of `default`, `geoip`, `geosite`, `rule`
端口写成了字符串 invalid type: string "1080", expected u16
端口大于 65535 invalid value: integer `70000`, expected u16
缺少 tag missing field `tag`
协议名拼写错误 outbound block: unknown protocol "blackhol"
[inbound.settings] 中的配置项拼写错误 inbound socks-in: invalid settings: unknown field `udp_bnd`, expected one of `auth`, `accounts`, `udp`, `udp_bind`
规则指向不存在的出站 route references unknown outbound tag: blocked
listen 是 IP 地址但没有 port inbound socks-in: port is required
完全没有 [[outbound]] config defines no outbounds
两个出站使用了相同的 tag duplicate outbound tag: direct
-c 路径错误 No such file or directory (os error 2)

在运行代理的终端中按 Ctrl-C,或者向它发送 SIGTERM。两者效果相同:etemenanki-app 输出日志

2026-09-24T20:22:00.756740Z INFO etemenanki_app: shutting down

然后关闭监听器,并以状态码 0 退出。它不会等待已打开的连接结束,而是立即关闭它们。

运行期间,etemenanki-app 会监视配置文件所在的目录,因此即使编辑器是先写入新文件、再重命名覆盖原文件,保存操作也能被检测到。检测到变化后,它会等待约 200 ms,让保存操作完成,然后重新读取文件。如果读到的内容与上次逐字节相同,则什么也不做。如果无法监视该目录,etemenanki-app 会输出 config hot-reload disabled: 及原因,并在不支持重新加载的情况下继续运行。

接下来的行为取决于新文件:

  • 新文件有效。 etemenanki-app 输出 config reload: 以及变更内容,停止所有监听器并关闭所有已打开的连接,然后启动新的监听器。客户端需要重新连接。只要文件内容有任何变化,哪怕只改了一行注释,都会这样处理:这种修改会输出 config reload: no changes,但仍然会重启全部服务。如果某个新监听器无法绑定,例如端口已被占用,etemenanki-app 会输出 inbound <tag> bind <address> failed: 及原因,照常启动其他入站,并在缺少这个入站的状态下运行,直到文件再次变化。

  • 新文件无效。 etemenanki-app 记录错误,并继续使用旧配置运行,不会中断任何服务:

    2026-09-24T20:22:11.344292Z ERROR etemenanki_app::instance: reload: parse failed, keeping current config: TOML parse error at line 5, column 1

    解析之后才发现的错误,例如未知的出站 tag,会以 reload: build failed, keeping current config: 的形式记录。修正文件后再保存一次即可。

由于重新加载会关闭所有连接,在修改一个有其他人在使用的代理的配置之前,先运行 etemenanki-app --test -c config.toml,再保存。详细说明见热重载,其中也包括哪些内容不会被重新加载:日志级别只在启动时读取一次,如果设置了 RUST_LOG 环境变量则取自该变量,否则取自 [log].level(默认为 info)。

现在你已经有了一个可用的代理,也大致了解了配置是如何检查的。接下来可以阅读:

  • 配置文件:介绍文件的整体结构以及每个顶层表。
  • 入站和出站:列出通用配置项以及所有协议。
  • 路由:介绍其他规则条件,包括 IP 段、端口、网络类型、geodata 等。
  • 运行 etemenanki-app:介绍如何将它作为服务运行。