2 个稳定版本
1.1.0 | 2023 年 8 月 3 日 |
---|---|
1.0.2 | 2023 年 7 月 25 日 |
#1574 在 命令行工具
135KB
965 行
ptproxy
动机
这是什么?
这是在敏感网络链路上代理 HTTP/1.1 请求的临时解决方案。
典型使用场景是,当你有一个服务(A)需要消费另一个服务(B)提供的 HTTP API,而这些服务通过一个 敏感网络链路(如公共互联网)隔开(例如,它们位于不同的数据中心)。而不是直接将 A 指向 B,你会在 A 旁边以客户端模式启动一个 ptproxy 实例,并在 B 旁边以服务器模式启动一个 ptproxy 实例。两个 ptproxy 实例都维护持久会话,A 向其 ptproxy 发出 HTTP 请求,ptproxy 将它们发送到 B 的 ptproxy,然后 B 的 ptproxy 在 B 处发出这些请求。
我为什么需要这个?什么是敏感网络链路?
由于简单性和广泛支持,HTTP/1.1 已经成为微服务之间通信的 通用语言。它通常在容器间或数据中心间链路上运行,但根据应用程序,它可能不适合在某些链路上传输(例如公共互联网),原因如下:
-
延迟较大:对于对延迟敏感的应用程序,TCP(例如,在连接建立、流量控制和拥塞控制中)引入的额外 RTT 延迟可能是无法接受的。
-
不安全:跨越不受信任边界的链路(如公共互联网)可能需要请求通过加密和针对中间人攻击者的身份验证进行传输。
-
不稳定:链路可能没有已知或保证的带宽,可能抖动或丢失,或者可能偶尔或频繁地拥塞。
-
带宽/传输较低:链路可能是按传输量计费或带宽较低,因此可以受益于传输效率。
我们用“敏感网络链路”来指代至少具备一些不受欢迎特性的链路。这种情况是否成立通常取决于特定的应用。
VPN 有什么问题?
叠加网络可以在不安全链路上提供安全链接,但它无法消除其他问题(尤其是建立过程中的额外延迟、拥塞控制)。基于请求-响应模型的解决方案在这里更为合适。
HTTP[S] 有什么问题?
实际上并没有。TCP和TLS在理论上已经准备好处理上述挑战(例如,针对延迟有TCP快速打开和TLS 0-RTT),并且有一些技术可以用来提高延迟和稳定性(如连接池、预先建立、HTTP/1.1 keepalive、流水线)。HTTP/2允许在单个连接上复用请求(这提高了拥塞处理能力并消除了许多早期技术的需求),并压缩头部以提高效率。HTTP/3放弃TCP,采用更优的传输层(QUIC),在握手速度、大小、头阻塞和拥塞控制等方面进行了多种改进。
但是,让最终应用(即微服务)直接以这种方式进行通信是不切实际的,因为
-
大多数没有实现所提到的许多协议/技术(通常包括TLS),并且很少公开必要的调整。这通常受到语言或操作系统支持的不足所加剧。
-
即使它们确实实现了,直接在应用中配置必要的调整也是不切实际的:机制、旋钮和单位可能各不相同。
-
当需要多个实例访问链路(即负载均衡或扩展)时,这可能导致不必要的连接,加剧对拥塞的反应。
解决这个问题的方法是在链路的每端使用一个反向代理:这可以将应用部署与基础设施解耦,允许从中央位置管理旋钮,轻松交换服务等。这就是ptproxy所做的事情。
现有的反向代理有什么问题?
大多数反向代理不支持所描述的使用场景(点对点链接)非常好。它们通常充当前端服务器,接受最终用户的请求并通过私有、受控的链路将它们传递到上游。这种设计以几种方式表现出来
-
默认的拥塞控制非常保守,这对于前端服务器来说是不错的,但对于点对点会话来说很糟糕(有时甚至具有阻碍性)。由于对链路有先前的了解,拥塞控制可以相应地放宽,但很少提供调整。
-
一般而言,对HTTP/3的支持也缺乏,即使代理支持它,也可能不会将其作为上游请求的选项(这是我们需要的)。
-
对于此用例的配置不直观,并且通常存在陷阱(例如,nginx默认不验证上游的证书,支持压缩响应但不压缩请求,默认不启用TCP keepalive,需要清除
Connection
头部以使keepalive实际生效,需要在不同模块中添加具有相同名称的指令集,等等)。 -
HTTP会话只有在第一个请求到达之前不会建立,这给那些请求带来了额外的延迟。对于nginx来说,会话不会永久保持活动状态,默认为1小时(可以通过在两端触摸几个设置来将其设置为无限,但这不建议,因为服务器的架构)。
这又是什么?
ptproxy是一种专为点对点链路使用案例设计的反向代理。它旨在便携,提供对传输的良好控制,两端易于设置,相对轻量级(尽管吞吐量不是主要优先事项)。在未来,它可能支持一些利基功能,例如报告已建立会话的指标、不同服务器的会话、在同一个会话中复用不同端点、ACLs...)。
目前,ptproxy 仅支持实例间使用 HTTP/3 作为传输协议。除了上述提到的传输改进外,基于用户空间的设计使我们能够独立于操作系统更好地控制所有调整。一个重要的缺点是,卸载优化(无论是在内核、虚拟机管理程序还是路由器中)在 TCP 方面比 UDP 更加成熟。
ptproxy 使用 Rust 编写
-
可移植性: 由于 OpenSSL 的原因,大陆 Linux 发行版的 HTTP/3 支持很混乱,而 Rust 完全避免了依赖系统库。它还提供了更好的跨平台支持。
-
控制: Rust 的 HTTP/3 生态系统提供了更多的控制,大多数组件都允许依赖注入,这意味着内部旋钮不太可能被隐藏。该堆栈完全异步,并且与自定义事件循环兼容,如果我们未来需要的话。
用法
⚠️ 注意: 虽然可以使用,但这仍然处于原型阶段,并且缺少许多次要和不太次要的功能(如 WebSocket 代理)。请自行承担风险。
先决条件
要安装 ptproxy,请从 发布 部分下载最新的生产二进制文件,并将其放置在例如 /usr/bin
下。该二进制文件仅依赖于 glibc 2.29+,因此具有合理的可移植性。
或者,安装 rustup 并切换到夜间工具链(
rustup default nightly
),克隆此项目并运行cargo build --release
。生成的二进制文件位于target/release/ptproxy
。
ptproxy 对等节点相互验证,因此您将需要一个主机的客户端证书和另一个主机的服务器证书。我建议使用 mkcert 生成这些证书
$ mkcert -client foo.example.org
$ mkcert bar.example.org
初始设置
部署 ptproxy,CA 证书以及相应的证书和密钥到每个端点。然后您需要为每个端点创建一个配置文件。一个最小示例如下
-
客户端(请求发起的地方)
[general] mode = "Client" peer_hostname = "bar.example.org" # where to listen for HTTP/1.1 requests http_bind_address = "127.0.0.1:20080" [tls] # CA to validate the peer against ca = "rootCA.pem" # certificate to present to the other peer cert = "foo.example.org-client.pem" key = "foo.example.org-client-key.pem"
-
服务器端(请求被服务的地方)
[general] mode = "Server" peer_hostname = "foo.example.org" # where to send requests from the peer to http_connect_address = "localhost:8081" [tls] # CA to validate the peer against ca = "rootCA.pem" # certificate to present to the other peer cert = "bar.example.org.pem" key = "bar.example.org-key.pem"
ptproxy 使用端口号 20010 作为对等节点之间的 HTTP/3 隧道,但可以通过设置两端中的 quic_port
参数进行自定义。请确保在服务器端打开此 UDP 端口。然后启动 ptproxy,如果一切正常,您应该看到以下内容
-
客户端:
$ ptproxy --config client.toml 2023-07-24T19:10:23.877447Z INFO ptproxy: started endpoint at [::]:60395 2023-07-24T19:10:23.889674Z INFO ptproxy: connection 94756025388432 established 2023-07-24T19:10:23.892695Z INFO ptproxy: tunnel ready
-
服务器端:
$ ptproxy --config server.toml 2023-07-24T19:10:22.512993Z INFO ptproxy: started endpoint at [::]:20010 2023-07-24T19:10:23.892110Z INFO ptproxy: connection 140126596472240 established ([::ffff:81.135.102.59]:60395)
这意味着两个实例已成功建立 HTTP/3 会话。尝试在客户端向 http://127.0.0.1:20080/
发送一些请求,您应该在另一端看到它们被发送到 http://127.0.0.1:8081/
。如果一个端点掉线并再次上线,它们将在几秒钟内重新连接。请参阅 配置字段 了解更多详细信息。
调整
一旦隧道工作正常,下一步通常是对配置文件中的 [transport]
部分的某些参数进行调整,以满足您的需求。与 TCP 一样,QUIC 的 拥塞控制 具有非常保守的默认值。但鉴于这个会话是在已知链路上进行的,我们可以放宽一些以提供更好的吞吐量/延迟,而无需会话预热。
特别是,您可能希望指定链路的 往返时间 和 初始拥塞窗口,您可以使用以下方式推导出来
$$ \text{cwnd} = \text{保证带宽 (B/sec)} \times \text{rtt (sec)} $$
对于假设提供 30mbps 的 150ms 链路,这意味着在配置文件中添加以下内容
[transport]
# initial estimate of the link's RTT (milliseconds)
initial_rtt = 150
# initial congestion control window (bytes)
initial_window = 487500
通常最好在双方都保持传输参数的一致性。
强烈建议使用像ab这样的压力测试工具来感受隧道的性能。虽然您可能期望一旦正确配置,延迟将等于1 RTT,但它可能更多地由于包速率,这是一个旨在减少数据突发以防止数据丢失的层。它调整流量以符合拥塞控制窗口确定的带宽。
systemd 服务
要将此作为systemd服务部署,建议使用服务模板功能,以便轻松管理多个隧道。创建以下内容的/usr/lib/systemd/system/[email protected]
[Unit]
Description=point-to-point HTTP/1.1 reverse proxy (%I)
After=network.target
After=network-online.target
Wants=network-online.target
Documentation=https://github.com/mildsunrise/ptproxy
[Service]
Type=notify
ExecStart=/usr/bin/ptproxy -c /etc/ptproxy/%i.toml
KillSignal=SIGINT
WatchdogSec=6s
DynamicUser=true
TasksMax=128
RestartSec=5s
Restart=on-failure
[Install]
WantedBy=multi-user.target
然后为ptproxy创建一个系统用户、一个配置目录,并重新加载systemd
useradd --system ptproxy
mkdir /etc/ptproxy
mkdir /etc/ptproxy/private
chown ptproxy:ptproxy /etc/ptproxy/private
chmod og-rx /etc/ptproxy/private
systemctl daemon-reload
将TLS文件和配置放在/etc/ptproxy/
,假设您的配置文件名为foo.toml
,您可以使用以下命令启用并启动隧道:
systemctl enable --now ptproxy@foo
行为
systemd 集成
如果ptproxy从systemd(或支持通知协议的其他服务管理器)启动,它将执行以下操作:
-
相应地发送
READY
和STOPPING
信号。在客户端模式下,默认情况下,
READY
将被推迟,直到第一次连接尝试结束(成功或不成功)。这为隧道在启动相关单元之前建立提供了合理的机会。由于连接尝试时间主要受max_idle_timeout
(见下文)的限制,因此单元不会无限期地保持在'启动'状态。请参阅wait_for_first_attempt
选项。 -
报告服务器状态。在服务器模式下,这相当于服务器是否已启动或正在停止(等待未完成的连接关闭,见下文)。在客户端模式下,ptproxy还报告隧道的状态(如果它已关闭,则报告最近失败的原因)。在出现故障的情况下,错误也会在退出前作为状态报告。
-
如果服务管理器启用了看门狗功能,则发送保持活动状态的ping。这是推荐的设置,以便在出现死锁、无限循环或其他静默故障时重新启动服务。如果发生这种情况,请提交一个错误报告。
保持活动状态的ping目前是从运行主要任务(客户端/服务器循环)的线程发送的,理论上可能会发生死锁仅发生在其他线程上且未被检测到的情况。我们可以通过让主循环检查其他任务来改进这一点,但实际上,这种事情发生的同时防止服务操作的风险非常小。
保持活动状态的ping间隔是从看门狗限制(在
WATCHDOG_USEC
环境变量中给出)除以watchdog_factor
得出的。
生命周期
ptproxy将不断尝试连接到服务器,在连接尝试之间休眠connect_interval
毫秒。
在连接尝试的初始阶段,在与另一端建立联系之前,max_idle_timeout
传输参数决定了在尝试失败(此时ptproxy将休眠并稍后再次尝试)之前需要经过多长时间。一旦连接成功建立,max_idle_timeout
将与另一端的结合来确定在没有流量时要等待多长时间以声明连接已死亡(此时ptproxy将休眠并稍后尝试重新连接)。
当接收到SIGINT信号时,ptproxy停止接受新的连接/请求,并等待当前(进行中)的请求被处理,以及QUIC连接终止,然后关闭。再次接收到SIGINT信号将导致ptproxy立即退出。
代理
消息转发
将客户端-服务器对视为一个黑盒时,它努力实现一个最小但符合HTTP/1.1的逆向代理,这意味着
- 连接/跳段头将被丢弃
- 这表示不支持升级隧道,例如WebSocket。HTTP/3本身不支持此功能,但WebSocket的特定情况可能通过RFC9220在未来实现。
- 如果响应中没有
Date
,则会生成它 - 保留分块传输,但所有分块扩展都将被丢弃
- 当使用分块传输时,将丢弃
Content-Length
由于HTTP/3或依赖性约束,还有一些小的限制
- 如果原始消息未指定
Content-Length
,将使用分块传输 - 请求中需要包含
Host
(根据HTTP/1.1的规定) - 拒绝带有绝对URL(也称为代理请求)的请求
- 将头名称规范化为大写形式
- 可能会重新排序头(但重复的头,即具有相同名称的头,保证不会相对于彼此重新排序)
- 响应的原因短语将丢失,并被标准的一个所取代
- 尚未实现
CONNECT
请求、消息尾和中间响应 - 尚未支持早期响应(在请求体完成流式传输之前)(响应将不会代理,直到请求,可能会因为背压而引起问题)
缓冲
消息体目前以分块的形式流式传输,不进行进一步缓冲,保留分块帧(如果使用分块传输),并在两个方向上应用背压。
错误
如果ptproxy在代理请求时遇到错误,并且尚未发送响应,则将生成一个包含错误消息体的人工响应,其内容为Server: ptproxy client
或Server: ptproxy server
(根据错误来源而定)。
状态码通常是503(如果当时未建立隧道)或502(如果尝试请求代理但失败,或原始响应被拒绝),但在请求由于无效或不支持的数据而被拒绝的情况下,可以是400或其他4xx。
如果已发送响应头(这在响应体流式传输时发生错误时可能发生),则记录错误,并提前关闭HTTP/1.1套接字以传播错误条件。
Forwarded
头
如果启用了add_forwarded
参数,ptproxy将在转发请求之前向请求追加一个符合RFC7239的Forwarded
头,指示客户端地址(for
)、通过该请求接收的协议(proto
)以及ptproxy的面向客户端的地址(by
)。由于Host
头不会被更改,因此目前不包含host
。示例
Forwarded: for="127.0.0.1:35974";by="127.0.0.1:20080";proto=http
该参数可以在客户端和服务器端独立启用。如果双方都启用,则会在请求中添加两个头部。在服务器端启用通常没有太大价值。
现有的 Forwarded
头部将保持不变,并在这之后添加一个新的头部。下游代理或框架可以按照HTTP允许的方式使用逗号(,
)合并值,并且由于逗号本身在单个值内(通过引号字符串)是语法上有效的,恶意客户端可以发送一个格式错误的带有未闭合引号字符串的头部,导致整个值集的解析失败。依赖于 Forwarded
进行安全控制的来源 必须 仔细拒绝带有格式错误值的请求,并确保存在N个尾随值。
依赖项
~22–36MB
~623K SLoC