跳转到内容

HTTP 代理

http 协议是经典的 HTTP/1.1 正向代理,浏览器、curl、包管理器和大多数 SDK 都能通过 http_proxy 或 https_proxy 设置使用它。etemenanki-app 实现了两端:

  • 入站接受本地客户端发来的 CONNECT 隧道和普通 http:// 请求;
  • 出站是一个 CONNECT 客户端,可以把路由后的流量经由上游 HTTP 代理发出;放在 TLS 传输层上时,上游也可以是 HTTPS 代理。

HTTP 代理只承载 TCP。如果客户端需要 UDP,请改用 SOCKS 入站。

一个需要密码、所有流量直接发出的本地代理:

config.toml
[[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)见入站。

键类型必填默认值说明
accountsarray of tables否[]用于校验客户端 Proxy-Authorization: Basic 头的账户列表。为空表示开放代理:任何客户端都无需凭据即可使用。设置后,没有匹配账户的请求会收到 407 响应。
accounts[].userstring是—用户名,区分大小写。不能包含 ::Basic 凭据按第一个冒号切分,含冒号的用户名永远无法通过认证。两个条目用户名相同时,后一个生效。
accounts[].passstring是—密码,必须完全一致(区分大小写),可以包含 :。每个条目都必须同时写 user 和 pass,缺少任一项时配置报 missing field。
allow_transparentbool否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`

入站读取一个请求头,判断请求类型,然后打开隧道、转发请求,或者回复错误并关闭连接。

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 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”的唯一例外是下面的嗅探。

客户端发送 CONNECT 203.0.113.10:443 时,路由只有一个 IP 地址可供匹配,域名规则和 geosite 列表都无法生效。当 sniffing = true(入站默认值)时,etemenanki 会从客户端的首批字节中读取 TLS SNI 或 HTTP Host,并把这个名字交给路由。

走隧道的客户端在收到 200 之前不会发送任何数据,因此对于开启嗅探的 IP 目标,顺序会变为:

  1. 入站立即回复 200 Connection established,此时尚未路由,也未连接。
  2. 读取客户端的首批字节,直到找到 TLS SNI 或 HTTP Host、已收到 4 KiB,或已过去 300 ms,以先发生者为准。
  3. 如果嗅探到了名字,就按该名字路由,然后连接,并转发已经读到的字节。

嗅探到的名字只用于路由。出站仍然连接客户端请求的那个 IP 地址。

普通(非 CONNECT)请求从不嗅探:代理已经能从请求本身得知主机。

GET http://example.com/path?q=1 HTTP/1.1 这样的请求是 absolute-form 请求。代理会自己把它转发给源站,而不是打开隧道:

  1. 路由并连接到目标主机。URL 没有端口时使用 80;https:// URL 使用 443。
  2. 把请求行改写为 origin form:GET /path?q=1 HTTP/1.1。无论客户端写的是什么版本,都改为 HTTP/1.1。
  3. 把 Host 设为 URL 的 authority。
  4. 移除代理头和逐跳(hop-by-hop)头:Proxy-Connection、Proxy-Authenticate、Proxy-Authorization、TE、Trailers、Transfer-Encoding、Upgrade、Connection 和 Keep-Alive,以及客户端 Connection 头中列出的所有头。
  5. 追加 Connection: close,发送改写后的请求头,随后发送客户端发来的请求体。
  6. 把源站的响应原样中继回去。

每个客户端连接只有第一个请求会这样处理。转发完这个请求后,代理对后续字节原样透传,而它追加的 Connection: close 会让源站在响应后关闭连接。客户端随后会为下一个请求新建一个代理连接,curl 和浏览器看到连接关闭时就是这样做的。

如果目标不可达,代理不发送任何响应就关闭连接。curl 会将其报告为服务器返回空回复(empty reply)。

由于 Upgrade 和 Connection 会被移除,WebSocket 或其他任何协议升级都无法通过普通转发工作。需要协议升级的客户端必须像浏览器那样使用 CONNECT。普通请求中的 https:// URL 只改变默认端口:请求仍以明文转发,因此客户端访问 HTTPS 时都使用 CONNECT。

目标只有路径的请求,如 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 Required
Proxy-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" 都会被拒绝。全部选项见传输层。

config.toml
[[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"

http 出站为每个 TCP 流向上游 HTTP 代理发送一个 CONNECT 请求,经由上游代理转发该流。上游可以是任何支持 CONNECT 的 HTTP/1.1 代理,包括另一个 etemenanki-app 的 http 入站。

server 和 port 是上游代理的地址,两者都必填;其他出站通用键见出站。以下键写在 [outbound.settings] 下:

键类型必填默认值说明
userstring否—上游代理的用户名。设置后,每个 CONNECT 请求都会带上由 user:pass 编码而成的 Proxy-Authorization: Basic 头。不设置则不发送凭据。
passstring否""上游代理的密码,只和 user 一起使用:没有 user 时会被忽略;只写 user 不写 pass 时发送空密码。

一个本地 SOCKS 代理,除私有地址段直连外,其余流量都经上游 HTTPS 代理发出:

config.toml
[[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。见传输层。

对于一个到 example.com:443 的流,出站连接上游并发送:

CONNECT example.com:443 HTTP/1.1
Host: example.com:443
Proxy-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 失败。

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" 路由到其他出站