HTTP 代理
http 协议是经典的 HTTP/1.1 正向代理,浏览器、curl、包管理器和大多数 SDK 都能通过 http_proxy 或 https_proxy 设置使用它。etemenanki-app 实现了两端:
- 入站接受本地客户端发来的
CONNECT隧道和普通http://请求; - 出站是一个
CONNECT客户端,可以把路由后的流量经由上游 HTTP 代理发出;放在 TLS 传输层上时,上游也可以是 HTTPS 代理。
HTTP 代理只承载 TCP。如果客户端需要 UDP,请改用 SOCKS 入站。
一个需要密码、所有流量直接发出的本地代理:
[[inbound]]tag = "http-in"protocol = "http"listen = "127.0.0.1"port = 8080
[[inbound.settings.accounts]]user = "alice"pass = "replace-with-a-long-random-password"
[[outbound]]tag = "direct"protocol = "freedom"先用 etemenanki-app --test -c config.toml 检查配置,启动后把客户端指向它:
# HTTPS URL:curl 打开一条到 example.com:443 的 CONNECT 隧道。curl -x http://alice:replace-with-a-long-random-password@127.0.0.1:8080 https://example.com/
# 普通 HTTP URL:curl 发送 "GET http://example.com/ HTTP/1.1",由代理转发。curl -x http://127.0.0.1:8080 -U alice:replace-with-a-long-random-password http://example.com/
# 即使是普通 HTTP URL,也强制使用 CONNECT 隧道。curl -p -x http://alice:replace-with-a-long-random-password@127.0.0.1:8080 http://example.com/如果没有 [[inbound.settings.accounts]] 块,任何能连到该端口的人都能使用这个代理。在回环地址以外的地址上监听之前,请先阅读认证。
以下键写在 [inbound.settings] 下。入站通用键(tag、listen、port、sniffing、stream、address_family)见入站。
| 键 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
accounts | array of tables | 否 | [] | 用于校验客户端 Proxy-Authorization: Basic 头的账户列表。为空表示开放代理:任何客户端都无需凭据即可使用。设置后,没有匹配账户的请求会收到 407 响应。 |
accounts[].user | string | 是 | — | 用户名,区分大小写。不能包含 ::Basic 凭据按第一个冒号切分,含冒号的用户名永远无法通过认证。两个条目用户名相同时,后一个生效。 |
accounts[].pass | string | 是 | — | 密码,必须完全一致(区分大小写),可以包含 :。每个条目都必须同时写 user 和 pass,缺少任一项时配置报 missing field。 |
allow_transparent | bool | 否 | false | 接受 origin-form 形式的普通请求(GET /path),从 Host 头取目标,未写端口时用 80。为 false 时,这类请求会收到 400 响应。CONNECT 请求以及 http:// 或 https:// 形式的 absolute-form 请求不受此项影响。 |
未知键会被拒绝,因此拼写错误会让配置无法通过,而不是悄悄保留默认值:
configuration invalid: inbound http-in: invalid settings: unknown field `allow_transparant`, expected `accounts` or `allow_transparent`请求的处理流程
Section titled “请求的处理流程”入站读取一个请求头,判断请求类型,然后打开隧道、转发请求,或者回复错误并关闭连接。
flowchart TD
A["读取请求头"] --> B{"凭据正确?"}
B -- "否" --> R407["407,关闭"]
B -- "是,或开放代理" --> C{"方法是 CONNECT?"}
C -- "是" --> D{"IP 目标且开启嗅探?"}
D -- "是" --> E["立即回复 200,嗅探首批字节"] --> F["路由并连接"]
D -- "否" --> G["路由并连接"] --> H{"已连接?"}
H -- "是" --> R200["200,隧道"]
H -- "否" --> R502["502,关闭"]
C -- "否" --> I{"目标是 http:// 或 https:// URL?"}
I -- "是" --> J["改写为 origin form 并转发"]
I -- "否" --> K{"allow_transparent?"}
K -- "是" --> J
K -- "否" --> R400["400,关闭"]
CONNECT 隧道
Section titled “CONNECT 隧道”CONNECT host:port 请求会打开一条到 host:port 的 TCP 隧道。客户端正是通过这种方式经代理访问 HTTPS 网站以及其他非普通 HTTP 的服务。
- 目标取自请求的 authority,例如
example.com:443。没有端口时,etemenanki 使用 443。 - 域名保持为域名:路由看到的是
example.com,由出站负责解析(如果出站是另一个代理,则原样传给它)。 - IPv6 目标必须加方括号,如
[2001:db8::1]:443。未加方括号的 IPv6 地址有歧义,会被拒绝并关闭连接。 - 出站连接成功后,入站回复
HTTP/1.1 200 Connection established,并双向中继字节,直到任一方关闭。如果出站无法连接,入站回复502 Bad Gateway并关闭连接。
“连接成功后才回复 200”的唯一例外是下面的嗅探。
嗅探 IP 目标
Section titled “嗅探 IP 目标”客户端发送 CONNECT 203.0.113.10:443 时,路由只有一个 IP 地址可供匹配,域名规则和 geosite 列表都无法生效。当 sniffing = true(入站默认值)时,etemenanki 会从客户端的首批字节中读取 TLS SNI 或 HTTP Host,并把这个名字交给路由。
走隧道的客户端在收到 200 之前不会发送任何数据,因此对于开启嗅探的 IP 目标,顺序会变为:
- 入站立即回复
200 Connection established,此时尚未路由,也未连接。 - 读取客户端的首批字节,直到找到 TLS SNI 或 HTTP
Host、已收到 4 KiB,或已过去 300 ms,以先发生者为准。 - 如果嗅探到了名字,就按该名字路由,然后连接,并转发已经读到的字节。
嗅探到的名字只用于路由。出站仍然连接客户端请求的那个 IP 地址。
普通(非 CONNECT)请求从不嗅探:代理已经能从请求本身得知主机。
普通 HTTP 请求
Section titled “普通 HTTP 请求”GET http://example.com/path?q=1 HTTP/1.1 这样的请求是 absolute-form 请求。代理会自己把它转发给源站,而不是打开隧道:
- 路由并连接到目标主机。URL 没有端口时使用 80;
https://URL 使用 443。 - 把请求行改写为 origin form:
GET /path?q=1 HTTP/1.1。无论客户端写的是什么版本,都改为HTTP/1.1。 - 把
Host设为 URL 的 authority。 - 移除代理头和逐跳(hop-by-hop)头:
Proxy-Connection、Proxy-Authenticate、Proxy-Authorization、TE、Trailers、Transfer-Encoding、Upgrade、Connection和Keep-Alive,以及客户端Connection头中列出的所有头。 - 追加
Connection: close,发送改写后的请求头,随后发送客户端发来的请求体。 - 把源站的响应原样中继回去。
每个客户端连接只有第一个请求会这样处理。转发完这个请求后,代理对后续字节原样透传,而它追加的 Connection: close 会让源站在响应后关闭连接。客户端随后会为下一个请求新建一个代理连接,curl 和浏览器看到连接关闭时就是这样做的。
如果目标不可达,代理不发送任何响应就关闭连接。curl 会将其报告为服务器返回空回复(empty reply)。
由于 Upgrade 和 Connection 会被移除,WebSocket 或其他任何协议升级都无法通过普通转发工作。需要协议升级的客户端必须像浏览器那样使用 CONNECT。普通请求中的 https:// URL 只改变默认端口:请求仍以明文转发,因此客户端访问 HTTPS 时都使用 CONNECT。
透明(origin-form)请求
Section titled “透明(origin-form)请求”目标只有路径的请求,如 GET /index.html HTTP/1.1,是客户端发给 Web 服务器而不是发给代理的请求。默认情况下,入站对它回复 400 Bad Request 并关闭连接。其他不是 http:// 或 https:// URL 的目标也一样,例如 OPTIONS * 或 ftp:// URL。
设置 allow_transparent = true 即可接受这类请求。此时代理从 Host 头取目标(Host 未写端口时用 80),并按上文所述转发请求。当普通 HTTP 流量在客户端不知情的情况下到达代理时(例如经防火墙重定向),这个选项很有用。它只适用于普通 HTTP:重定向到该端口的 TLS 流量不是 HTTP 请求,会解析失败。在 allow_transparent = true 时,既没有绝对 URL 也没有 Host 头的请求会被直接丢弃,不发送响应。
[[inbound]]tag = "http-transparent"protocol = "http"listen = "127.0.0.1"port = 8080
[inbound.settings]allow_transparent = true当 accounts 至少有一个条目时,每个请求(无论 CONNECT 还是普通请求)都必须带有匹配的 Proxy-Authorization: Basic 头。凭据缺失、格式错误或不正确时,会收到:
HTTP/1.1 407 Proxy Authentication RequiredProxy-Authenticate: Basic realm="proxy"Connection: close随后连接关闭。浏览器收到后会弹窗要求用户输入用户名和密码。
值得注意的细节:
- 只接受
Basic方案,且必须写作Basic或basic,后跟一个空格。BASIC或其他写法都按缺少凭据处理。 - 解码后的凭据按第一个
:切分。密码可以包含冒号,用户名不可以。 - 用户名和密码区分大小写。
- 代理会从转发的普通请求中移除
Proxy-Authorization,源站永远看不到凭据。 - 在普通 TCP 监听器上,凭据以明文传输。客户端经不可信网络访问时,请把入站放到 TLS 上(见传输层)。
| 状态 | 何时发送 | 之后 |
|---|---|---|
200 Connection established |
CONNECT 的出站连接成功;或开启嗅探时,对 IP 目标的 CONNECT 立即发送 |
隧道开始中继字节 |
400 Bad Request |
allow_transparent = false 时,普通请求的目标不是 http:// 或 https:// URL(如 /index.html 这样的 origin form) |
连接关闭 |
407 Proxy Authentication Required |
设置了 accounts,而请求没有匹配的 Basic 凭据 |
连接关闭 |
502 Bad Gateway |
CONNECT 的出站连接失败,且尚未发送 200 |
连接关闭 |
| 无响应 | 请求头格式错误、请求头超过 64 KiB、超过 128 个头、目标缺失、无效或有歧义、普通请求的目标不可达,或 10 秒内未发完请求头 | 连接关闭 |
除上述情况外,普通请求的响应都来自源站,而不是代理。
| 限制 | 值 | 结果 |
|---|---|---|
| 请求头大小 | 64 KiB | 连接关闭,debug 日志中记录 http head exceeds maximum size |
| 每个请求的头数量 | 128 | 请求按格式错误处理,连接关闭 |
| 发送请求头的时限 | 10 s | 连接关闭 |
| 嗅探窗口 | 300 ms 或 4 KiB | 用已读到的内容对流进行路由 |
空闲超时和每个入站的连接数限制适用于所有协议,见限制。以错误结束的连接会在 debug 级别记录为 http connection from … ended: …,并附带原因。
HTTP 入站可以运行在任何 stream 传输层上,而不仅限于普通 TCP:
[inbound.stream] |
结果 |
|---|---|
省略,或 network = "tcp" |
普通 HTTP 代理 |
network = "tls" |
HTTPS 代理:客户端先与代理建立 TLS,再在其中发送 HTTP 代理请求 |
network = "ws" 或 "grpc",可选 security = "tls" |
在 WebSocket 或 gRPC 中承载 HTTP 代理协议,适合在两个 etemenanki 实例之间使用 |
TLS 传输层需要 tls.cert_file 和 tls.key_file;缺少时 --test 报告 inbound https-proxy: tls stream needs tls.cert_file。当 listen 是 Unix socket 路径时,入站只提供普通 HTTP:network 不是 "tcp" 或 security 不是 "none" 都会被拒绝。全部选项见传输层。
[[inbound]]tag = "https-proxy"protocol = "http"listen = "0.0.0.0"port = 8443
[inbound.stream]network = "tls"
[inbound.stream.tls]cert_file = "/etc/etemenanki/proxy.example.com.crt"key_file = "/etc/etemenanki/proxy.example.com.key"
[[inbound.settings.accounts]]user = "alice"pass = "replace-with-a-long-random-password"
[[inbound.settings.accounts]]user = "bob"pass = "replace-with-another-long-random-password"
[[outbound]]tag = "direct"protocol = "freedom"# https:// 形式的代理 URL 让 curl 与代理本身建立 TLS。curl -x https://alice:replace-with-a-long-random-password@proxy.example.com:8443 https://example.com/
# 代理使用私有 CA 或自签名证书时:curl --proxy-cacert /path/to/ca.pem \ -x https://alice:replace-with-a-long-random-password@proxy.example.com:8443 https://example.com/http 出站为每个 TCP 流向上游 HTTP 代理发送一个 CONNECT 请求,经由上游代理转发该流。上游可以是任何支持 CONNECT 的 HTTP/1.1 代理,包括另一个 etemenanki-app 的 http 入站。
server 和 port 是上游代理的地址,两者都必填;其他出站通用键见出站。以下键写在 [outbound.settings] 下:
| 键 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
user | string | 否 | — | 上游代理的用户名。设置后,每个 CONNECT 请求都会带上由 user:pass 编码而成的 Proxy-Authorization: Basic 头。不设置则不发送凭据。 |
pass | string | 否 | "" | 上游代理的密码,只和 user 一起使用:没有 user 时会被忽略;只写 user 不写 pass 时发送空密码。 |
一个本地 SOCKS 代理,除私有地址段直连外,其余流量都经上游 HTTPS 代理发出:
[[inbound]]tag = "socks-in"protocol = "socks"listen = "127.0.0.1"port = 1080
# 上游只承载 TCP,所以不提供 UDP ASSOCIATE。[inbound.settings]udp = false
[[outbound]]tag = "upstream"protocol = "http"server = "proxy.example.com"port = 8443
[outbound.stream]network = "tls"
[outbound.settings]user = "alice"pass = "replace-with-a-long-random-password"
[[outbound]]tag = "direct"protocol = "freedom"
[route]default = "upstream"
[[route.rule]]outbound = "direct"cidr = ["10.0.0.0/8", "192.168.0.0/16"]上游是普通 HTTP 代理时,删掉 [outbound.stream] 块即可。使用 network = "tls" 时,TLS 服务器名在设置了 tls.server_name 时取该值,否则取 server;证书按系统根证书校验,除非设置了 tls.ca_file 或 tls.allow_insecure。见传输层。
出站发送的内容
Section titled “出站发送的内容”对于一个到 example.com:443 的流,出站连接上游并发送:
CONNECT example.com:443 HTTP/1.1Host: example.com:443Proxy-Authorization: Basic YWxpY2U6cmVwbGFjZS13aXRoLWEtbG9uZy1yYW5kb20tcGFzc3dvcmQ=Proxy-Connection: Keep-Alive- 目标按流中的形式发送:域名保持为域名,由上游解析;IPv6 地址加方括号。
- 只有设置了
user时才带Proxy-Authorization。 - 只有上游回复
200后,流才算连接成功。其他任何状态都会让流以proxy responded with status 407(或实际返回的状态码)失败。随后入站会向自己的客户端报告该失败,例如 HTTP 入站会回复502。 - 收到
200后,出站原样中继字节。 - 整个请求必须在 1024 字节以内。即使域名长达 253 个字符,也还能容纳约 300 字节的
user:pass。超出限制的请求会让流以http: CONNECT request exceeds the codec's reserve失败。
不支持 UDP
Section titled “不支持 UDP”HTTP 出站只承载 TCP。路由到它的 UDP 包会被丢弃,失败的尝试会在 debug 级别记录为 udp fan-out: opening an outbound failed: http carries no datagrams。如果你有入站接受 UDP(SOCKS、Trojan、VLESS、VMess、Hysteria 2、TUN),请添加一条 network = "udp" 的路由规则,把 UDP 发往能承载它的出站。见路由。
| 消息 | 原因 | 解决方法 |
|---|---|---|
inbound http-in: invalid settings: unknown field … |
[inbound.settings] 下有拼错的键 |
只使用 accounts 和 allow_transparent |
inbound http-in: invalid settings: missing field `pass` |
accounts 条目缺少 pass(或缺少 user) |
每个条目都写上这两个键 |
outbound upstream: invalid settings: unknown field `password`, expected `user` or `pass` |
出站使用了 Xray 风格的键名 | 改名为 user 和 pass |
outbound upstream: missing server / missing port |
上游地址不完整 | 同时设置 server 和 port |
inbound https-proxy: tls stream needs tls.cert_file |
设置了 network = "tls" 但没有证书 |
设置 tls.cert_file 和 tls.key_file |
inbound http-in: protocol http over a unix socket does not support stream network "tls" |
在 Unix socket 监听器上使用了非 TCP 的 stream network | 删除 [inbound.stream] 块,或改为监听 IP |
security = "tls" is not valid with network = "tcp" |
按 WebSocket 的写法在普通 TCP 上配置 TLS | 使用 network = "tls" |
客户端收到 400 Bad Request |
客户端发送的是 origin-form 请求,而不是代理请求 | 配置客户端使用代理,或设置 allow_transparent = true |
客户端已发送凭据却收到 407 |
密码错误、用户名含 :,或使用了非 Basic 方案 |
检查账户;使用 Basic 认证 |
debug 日志中出现 proxy responded with status … |
上游拒绝了 CONNECT |
检查出站的 user 和 pass 以及上游的访问规则 |
debug 日志中出现 http carries no datagrams |
UDP 被路由到了 http 出站 |
把 network = "udp" 路由到其他出站 |