-- 双端架构:每个代理 TCP 流映射到独立 QUIC 双向流,避免一个应用流的有序传输阻塞其他应用流。
-- 长连接复用:QUIC 会话及其拥塞状态可被后续短连接复用,减少重复传输握手带来的开销。
-- 自动回退:新建流的 QUIC 路径失败后使用独立的 TLS 1.3/TCP 连接,并通过冷却机制避免每条新连接都等待 UDP 超时。
-- 三种本地入口:SOCKS5 CONNECT、HTTP absolute-form HTTP/CONNECT、HTTPS Proxy(代理监听器本身使用 TLS)。
-- 严格安全默认值:TLS 1.3、正常 X.509 主机名校验、无 `skip verify` 开关、禁用 QUIC 0-RTT、可选 mTLS、恒定时间令牌验证。
-- 出口保护:默认阻止环回、私网、链路本地、多播、未指定地址,以及常见 SMTP 提交端口;域名在远端解析并逐个校验,只拨已批准的数字 IP,同时交错竞速 IPv6/IPv4,兼顾 DNS rebinding/SSRF 防护与双栈可用性。
-- 有界资源:协议字段长度、连接数、流数和超时均有限制;中继使用背压而不是无限缓存。
-- 可复现实验:内置上传/下载基准,并提供 Linux `netem` 场景用于直连与隧道的同条件比较。
+AutoCAR 在本地提供 SOCKS5、HTTP 和 HTTPS Proxy,在远端安全地解析域名并连接目标。默认链路采用 Hysteria v2.12.1 的 HTTP/3-over-QUIC 核心;每个方向独立使用真实的 BBRv1,或在双方明确配置带宽后使用 Brutal。UDP 不可达时,新建 TCP 流会自动切换到独立的 TLS 1.3/TCP 回退链路。
-当前版本只代理 TCP。SOCKS5 `BIND`、`UDP ASSOCIATE` 和 QUIC DATAGRAM 尚未实现,收到这些命令会返回标准“不支持”响应。
+它是 split proxy,不把原始 TCP 包套进 UDP,因此不会产生 TCP-over-TCP 的双重可靠传输。目标是改善高 RTT、随机丢包、短连接和多并发场景;稳定、低时延的直连仍可能更快,请始终在真实路径上测量。
-## 数据路径
+## 已实现的加速机制
-```mermaid
-flowchart LR
- A["应用"] --> P["SOCKS5 / HTTP(S)"]
- P --> C["AutoCAR 客户端"]
- C -->|"QUIC + TLS 1.3"| S["AutoCAR 服务端"]
- C -. "TCP + TLS 1.3 回退" .-> S
- S --> D["目标站点"]
-```
+| 来源/目标 | AutoCAR 中的实现 | 边界 |
+| --- | --- | --- |
+| Hysteria v2 | 基于官方 core v2.12.1 的可审计安全加固 fork、HTTP/3 多流、QUIC DATAGRAM、Fast Open、Chrome QUIC 指纹、HTTP/3 cover、可选 Salamander | 不包含实验性的 Gecko、Mimic、端口跳跃或 TUN/TProxy |
+| BBR | delivery-rate 与 min-RTT/BDP 模型、pacing,以及 `STARTUP → DRAIN → PROBE_BW → PROBE_RTT`;支持 conservative/standard/aggressive profile | 这是 Hysteria 的 **BBRv1**,不是 Linux 内核 BBRv2/BBRv3 |
+| ServerSpeeder/LotServer 的公开目标 | 双端独立发送控制、对端 ACK/RTT/loss 反馈、RFC 9002 packet/time threshold、PTO、热连接拥塞状态复用 | 没有复制 Zeta-TCP 的专有逐包概率算法,也不是内核透明 TCP、FEC 或包复制 |
+| 高丢包固定带宽 | 双方协商 `min(发送端上限, 接收端上限)` 后启用 Brutal;根据 ACK/loss 采样补偿并 pacing | 必须显式填准确带宽;会争抢共享链路,默认关闭 |
+
+默认值是 `bbr + standard`,客户端上下行带宽均为 `0`,且服务端默认忽略客户端带宽提示,所以不会无意启用 Brutal。详细机制、参数和诚实的声明边界见 [加速设计](docs/ACCELERATION.md)。
+
+Fast Open 默认关闭;只有显式设置 `--fast-open` 才会让首批应用数据与远端拨号响应重叠。这样能减少一次等待,但目标拒绝等错误可能延迟到第一次读取时才返回。
+
+## 代理与安全
+
+- SOCKS5:CONNECT 和 UDP ASSOCIATE;UDP 通过 QUIC DATAGRAM 双向传输。
+- HTTP Proxy:absolute-form HTTP 和 CONNECT。
+- HTTPS Proxy:本地代理监听器自身使用 TLS 1.3。
+- 隧道安全:TLS 1.3、正常 X.509 SAN/链验证、强制共享令牌、可选 mTLS;不存在 `skip verify` 开关。
+- 远端出口:域名由服务端解析,每个 TCP/UDP 目标都经过端口、CIDR、特殊用途地址及 DNS rebinding/SSRF 检查,只使用已批准的数字 IP。
+- 资源防护:握手期/已接受的 QUIC 连接、TCP handler、UDP session、双向/单向 stream、HTTP 头和出口 socket 均有硬上限;连接、TCP handler 与 UDP session 还具有跨 QUIC 会话的来源配额(IPv4 地址或 IPv6 `/64`)。TLS/TCP 回退从 accept 到中继结束也有独立的全局与来源连接配额。未认证连接与 TCP 请求头有 deadline,恶意 UDP 分片在分配重组状态前即受限。
+- 可达性:QUIC 失败后对新流使用真实 TLS/TCP,并通过熔断冷却避免 UDP 黑洞造成重复等待。
+- 抗主动探测:默认未认证请求表现为普通 HTTP/3 页面,客户端启用 Chrome QUIC 指纹;受限网络可选择 Salamander 包混淆。
-AutoCAR 是 split proxy,而不是把原始 TCP 包再次塞进 UDP。它在本地终止代理连接、通过 QUIC 流传送字节、再从远端建立新的 TCP 连接,因此不会形成 TCP-over-TCP 或双层可靠重传。
+TLS 保护机密性、完整性和服务端身份;应用仍应使用 HTTPS、SSH 等端到端协议,因为中继知道目标地址,也能看到目标侧明文。网络观察者仍可能看到端点 IP、流量大小和时序。HTTP/3 cover、Salamander 与 TCP 回退提高抗误识别和可达性,但项目不承诺“不可检测”或“永不封锁”。
## 快速开始
@@ -40,7 +53,7 @@ cd autocar
go build -trimpath -o autocar ./cmd/autocar
```
-在服务端生成共享令牌和包含真实域名/IP SAN 的证书:
+在服务端生成令牌和包含真实域名/IP SAN 的证书:
```bash
./autocar token --out token
@@ -50,19 +63,20 @@ go build -trimpath -o autocar ./cmd/autocar
--key server.key
```
-将 `token` 和用于信任的 `server.crt` 通过安全的带外通道复制到客户端。私钥 `server.key` 只留在服务端。
+通过可信带外通道把 `token` 与 `server.crt` 复制到客户端;`server.key` 只留在服务端。启动服务端(UDP 与 TCP 可使用相同端口号):
-启动远端。UDP 和 TCP 可以使用同一个端口号:
+`autocar cert` 生成与默认 Chrome QUIC 指纹兼容的 ECDSA P-256 证书。若使用外部证书,应选择 ECDSA P-256/P-384 或 RSA;Ed25519 服务端证书需要所有 Hysteria 客户端显式设置 `--disable-chrome-parrot`,否则 TLS 握手会失败并给出提示。
```bash
./autocar server \
--listen :443 \
+ --tcp-listen :443 \
--cert server.crt \
--key server.key \
--token-file token
```
-启动本地端:
+启动客户端:
```bash
./autocar client \
@@ -71,74 +85,90 @@ go build -trimpath -o autocar ./cmd/autocar
--token-file token
```
-默认监听:
+默认入口:
| 入口 | 地址 | 示例 |
-|---|---:|---|
-| SOCKS5 | `127.0.0.1:1080` | `curl --proxy socks5h://127.0.0.1:1080 https://example.com` |
+| --- | --- | --- |
+| SOCKS5 TCP/UDP | `127.0.0.1:1080` | `curl --proxy socks5h://127.0.0.1:1080 https://example.com` |
| HTTP Proxy | `127.0.0.1:8080` | `curl --proxy http://127.0.0.1:8080 https://example.com` |
-| HTTPS Proxy | 默认关闭 | 使用 `--https`、`--proxy-cert` 和 `--proxy-key` 开启 |
+| HTTPS Proxy | 默认关闭 | 使用 `--https`、`--proxy-cert`、`--proxy-key` 开启 |
-`socks5h` 会把域名交给远端解析。HTTP 访问 HTTPS 目标时使用 CONNECT;AutoCAR 不伪造目标证书,也不解密应用到目标站点之间的 HTTPS。
+`socks5h` 会把域名交给远端解析。AutoCAR 不伪造目标证书,也不解密应用到目标站点之间的 HTTPS。
-## 本地代理认证
+## 选择 BBR 或 Brutal
-本机独占使用时保留环回默认监听即可。多人机器或非环回监听必须设置本地代理认证:
+一般部署直接使用默认 BBR。可按链路偏好选择 profile:
```bash
-export AUTOCAR_PROXY_USER=alice
-export AUTOCAR_PROXY_PASSWORD='replace-with-a-long-random-secret'
+# 共享链路更保守
+./autocar client [其他参数] --bbr-profile conservative
-./autocar client \
- --server relay.example.com:443 \
- --ca server.crt \
- --token-file token \
- --socks 127.0.0.1:1080 \
- --http 127.0.0.1:8080
+# Startup 更激进;必须先在自己的链路做公平性与排队延迟测试
+./autocar client [其他参数] --bbr-profile aggressive
```
-SOCKS5 用户名密码和 HTTP Basic 在本地这一跳本身不加密,因此程序默认拒绝把这两个明文入口绑定到非环回地址。跨主机使用请开启带 TLS 的 HTTPS Proxy;只有在已经存在可信外层网络时,才应显式使用 `--allow-public-plaintext`。
+只有已知真实链路容量时才配置 Brutal。官方客户端会在每个方向取声明值与服务端协商上限中的较小值:
-## 证书与 mTLS
+```bash
+# 服务端:每个认证会话最高上传 100 Mbit/s、下载 300 Mbit/s
+./autocar server [其他参数] \
+ --allow-client-bandwidth \
+ --max-upload-mbps 100 \
+ --max-download-mbps 300
+
+# 客户端:本地链路实测上限
+./autocar client [其他参数] \
+ --upload-mbps 80 \
+ --download-mbps 250
+```
-客户端必须二选一:
+服务端只有显式设置 `--allow-client-bandwidth` 且同时提供两个有限协商上限时才接受 Brutal 提示;默认会强制 BBR/Reno。配置高于实际容量会造成排队、丢包和浪费。上述值是协议协商与 pacing 目标,不是针对恶意客户端的流量整形器;需要不可绕过的限速时,应在主机或云网络层配置 policer。Brutal 不是 Reno/CUBIC 公平模式,共享网络应保留默认 BBR。
-- `--ca `:固定私有 CA/自签名证书,推荐自建部署使用;
-- `--system-roots`:明确使用操作系统信任库,适合公共 CA 证书。
+## HTTP/3 cover 与 Salamander
-证书名称与连接地址不一致时,用 `--server-name` 指定证书 SAN。程序不会提供跳过验证的选项。若需要双向证书认证,服务端设置 `--client-ca`,客户端同时设置 `--client-cert` 与 `--client-key`。共享令牌仍作为每条隧道流的第二层授权。
+默认模式是标准 HTTP/3 cover:错误令牌或普通探测会得到中性网页,客户端模拟 Chrome QUIC 的可见参数。若 UDP 被按 QUIC 特征干扰,可在两端配置同一条独立强密码:
-## 出口策略
+```bash
+./autocar token --out obfs-password
-默认禁止通过中继访问内网地址,防止被滥用为开放代理或 SSRF 跳板。确实需要访问服务端所在私网时,可在充分信任所有客户端后设置 `--allow-private`;环回、链路本地、多播和未指定地址仍然禁止。默认拒绝端口 `25,465,587`,可用 `--deny-ports` 调整;`--deny-cidrs` 可额外封锁云厂商控制面或部署专用网段。
+./autocar server [其他参数] --obfs-password-file obfs-password
+./autocar client [其他参数] --obfs-password-file obfs-password
+```
-任何非环回代理监听都必须配置认证。生产环境还应使用主机防火墙,仅向预期客户端开放 UDP/TCP 端口,并优先启用 mTLS。
+也可通过 `AUTOCAR_OBFS_PASSWORD` 提供。Salamander 只是包级混淆,真正的认证与加密仍由 TLS 1.3 完成。启用后线上形态不再是标准 HTTP/3,因此应在“HTTP/3 cover”和“Salamander”之间按网络环境选择,而不是同时宣传两种外观。
-## 性能验证
+## 本地代理认证与 mTLS
-基准服务默认仅监听 `127.0.0.1:9000`。只在同一主机测试时可直接启动:
+无认证入口只能绑定环回。SOCKS5 用户名密码和 HTTP Basic 在本地这一跳是明文;跨主机使用应开启 HTTPS Proxy,不要把明文入口直接暴露到公网。
```bash
-./autocar bench-server
+export AUTOCAR_PROXY_USER=alice
+export AUTOCAR_PROXY_PASSWORD='replace-with-a-long-random-secret'
+
+./autocar client [中继参数] \
+ --socks 127.0.0.1:1080 \
+ --http 127.0.0.1:8080
```
-跨主机测量必须显式确认非环回监听:
+客户端信任方式必须二选一:`--ca ` 固定私有 CA/自签名证书,或显式使用 `--system-roots`。证书名称与连接地址不一致时设置 `--server-name`。mTLS 使用服务端 `--client-ca` 与客户端 `--client-cert/--client-key`;共享令牌仍保留为第二层授权。
-```bash
-./autocar bench-server \
- --listen 0.0.0.0:9000 \
- --allow-public-benchmark
-```
+## 出口策略
+
+默认拒绝环回、私网、链路本地、多播、未指定地址、IANA 特殊用途地址,以及端口 `25,465,587`。`--allow-private` 仅允许 RFC1918/ULA/CGNAT,仍不会开放环回或云元数据等特殊地址。`--deny-cidrs` 与 `--deny-ports` 可进一步收紧策略。
+
+生产环境还应使用主机/云防火墙限制中继 UDP/TCP 端口,给 UDP 设置每源速率与突发上限,并优先启用 mTLS。完整 systemd、容器、防火墙和升级说明见 [部署指南](docs/DEPLOYMENT.md)。
-> `bench-server` 没有认证,远程请求者可以让它持续发送或接收大量数据。
-> 非环回监听只应在临时、受控的测试窗口使用;同时用主机/云防火墙把
-> 端口 9000 严格限制到预期客户端和中继 IP,并在测量后立即停止服务。
-> CLI 默认还把单次传输和并发传输分别限制为 64 MiB 和 16;公开测试时
-> 只应按实际需要调低或谨慎调高 `--max-bytes` / `--max-connections`。
+## 兼容性
-分别测直连和隧道,确保目标、字节数、次数和链路条件完全相同:
+当前默认 QUIC wire protocol 是 Hysteria v2.12.1。`--transport=hy2` 与 `--transport=quic` 等价;旧 AutoCAR 自定义 QUIC v1 可临时使用客户端 `--transport=legacy-quic` 配合服务端 `--quic-engine=legacy`。TLS/TCP fallback 继续使用 AutoCAR protocol v1。一次 UDP 端口不能同时运行两种 QUIC wire protocol,升级时必须协调两端或使用不同端口。
+
+## 性能验证
+
+内置基准会在相同目标、负载和链路条件下比较 direct、hy2/QUIC 与 TLS:
```bash
+./autocar bench-server
+
./autocar bench-client \
--transport direct \
--target target.example:9000 \
@@ -152,17 +182,18 @@ SOCKS5 用户名密码和 HTTP Basic 在本地这一跳本身不加密,因此
--bytes 8388608 --iterations 7 --warmup 2 --json
```
-最可能受益的是高 RTT、存在随机丢包、多个并发/连续短连接,以及直连路径质量明显差于中继路径的场景。TCP/TLS 回退主要提供可达性,并不声称比直连 TCP 更快。详细方法和 Linux `netem` 脚本见 [基准说明](docs/BENCHMARK.md)。
+Linux `netem` 套件分别验证客户端上传与中继下载在高 RTT/丢包下 BBR 相对 Reno 的收益、Brutal 实际协商/发送、热连接短流收益、错误证书/令牌、UDP→TLS 回退,以及抓包中不存在明文 sentinel:
-## 安全边界与封锁
-
-TLS 1.3 为客户端到中继的载荷提供机密性、完整性和服务端身份验证;禁用 0-RTT 避免 CONNECT 请求被重放。应用自身使用 HTTPS 时,从应用到目标的内容仍保持端到端加密。
+```bash
+make build
+sudo ./scripts/netem-integration.sh ./bin/autocar
+```
-观察者仍能看到端点 IP、端口、包长、时序,以及使用 UDP/TLS 的事实;中继也知道目标地址。不存在能保证永不被网络运营者识别、限速或封锁的传输。AutoCAR 的策略是提供两个标准、安全的承载路径:优先 QUIC,在 UDP 被阻断时回退到 TLS/TCP,而不是声称“不可检测”。完整威胁模型见 [SECURITY.md](SECURITY.md)。
+`make release` 生成四个平台的发布归档;每个归档都同时包含可执行文件、AutoCAR 的 `LICENSE` 和完整的 `THIRD_PARTY_NOTICES.md`,不会发布缺少许可文件的裸二进制。
-回退只适用于尚在建立或之后新建的代理流。已交付给应用的 QUIC 流若在传输中途失去 UDP 路径,无法安全地把任意 TCP 字节无缝重放到另一条 TLS 连接;该流会失败,由应用重试,随后新流在熔断冷却期内走 TLS。
+CI 中的窄场景速度门只证明被测试的机制有效,不代表所有生产网络都会加速。方法、指标和扩展矩阵见 [基准说明](docs/BENCHMARK.md)。
-## 开发与测试
+## 开发
```bash
go test ./...
@@ -170,13 +201,14 @@ go test -race ./...
go vet ./...
```
-协议、部署和设计细节分别见:
-
+- [加速机制与边界](docs/ACCELERATION.md)
+- [架构](docs/ARCHITECTURE.md)
- [Wire protocol](docs/PROTOCOL.md)
-- [Deployment guide](docs/DEPLOYMENT.md)
-- [Architecture](docs/ARCHITECTURE.md)
-- [Benchmark methodology](docs/BENCHMARK.md)
+- [部署指南](docs/DEPLOYMENT.md)
+- [基准方法](docs/BENCHMARK.md)
+- [安全策略](SECURITY.md)
+- [第三方许可](THIRD_PARTY_NOTICES.md)
## License
-MIT,见 [LICENSE](LICENSE)。
+AutoCAR 使用 MIT 许可证,见 [LICENSE](LICENSE)。实际链接的全部 Go 依赖及其根级许可、通知和专利声明见自动生成的 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
diff --git a/SECURITY.md b/SECURITY.md
index 92eeb5e..22d4305 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -28,9 +28,20 @@ and any destination traffic that is itself unencrypted. End-to-end HTTPS
remains encrypted between the application and the destination.
No transport can guarantee that a network operator will not rate-limit or
-block it. AutoCAR provides a standards-compliant TCP/TLS fallback for networks
-where UDP is unavailable; it deliberately does not impersonate unrelated
-protocols or claim to be undetectable.
+block it. AutoCAR's default UDP service is valid HTTP/3 and returns a neutral
+cover page to unauthenticated probes; the client uses Hysteria's Chrome QUIC
+fingerprint. Optional Salamander changes packet appearance with a separate
+pre-shared key, but it is obfuscation rather than encryption. It must never be
+treated as a substitute for TLS certificate verification, the relay token or
+mTLS. When UDP is unavailable, new TCP flows can use the standards-compliant
+TLS/TCP fallback. None of these mechanisms is an undetectability guarantee.
+
+The default Chrome-parroting ClientHello intentionally follows a signature
+scheme list that omits Ed25519. Relay certificates should therefore use ECDSA
+P-256/P-384 (`autocar cert` emits P-256) or RSA. An Ed25519 relay certificate is
+supported only when the client explicitly uses `--disable-chrome-parrot`; a
+matching handshake failure includes this guidance. This switch does not relax
+certificate-chain or hostname verification.
The relay blocks private, loopback, link-local, multicast, and unspecified
destinations by default, and denies common SMTP submission ports. Operators
@@ -38,6 +49,71 @@ should keep these defaults unless they fully trust every authenticated client.
Deployment-specific control-plane and metadata ranges can be added with
`--deny-cidrs`, especially before enabling private destinations.
+The same policy is applied to every UDP datagram destination after remote DNS
+resolution; only the approved numeric address is used for the actual send. A
+logical UDP session remembers at most 256 successfully written numeric
+destinations. Once full, it rejects new destinations without evicting existing
+ones; failed writes never authorize replies. This bounds memory while retaining
+valid delayed-reply filtering semantics.
+The local SOCKS5 UDP relay accepts datagrams only from the IP of its associated
+TCP control connection. A concrete `UDP ASSOCIATE` address must equal that peer;
+a domain is resolved under the dial timeout and must include the peer address.
+The relay pins the requested non-zero port, or the first valid source port when
+the request uses port zero. SOCKS fragmentation is not reassembled and is
+dropped.
+
+The Hysteria core exposes a deliberately smaller TLS configuration surface than
+Go's `tls.Config`. AutoCAR copies server-name/root verification,
+`VerifyPeerCertificate` on the client, certificate selection, strict mTLS, and
+ECH fields that the core supports. It rejects unsupported security-sensitive
+policies before binding or dialing, including `VerifyConnection`, server
+`GetConfigForClient`/`VerifyPeerCertificate`, custom verification clocks or
+curve policies, custom server ticket handling, and client-authentication modes
+other than no certificate or `RequireAndVerifyClientCert`. TLS 1.2-only fields
+such as `CipherSuites` and renegotiation are irrelevant to QUIC/TLS 1.3 and do
+not cause rejection. The client session cache in the shared CLI TLS config is
+used by the per-flow TCP fallback; Hysteria instead keeps a long-lived QUIC
+session.
+
+The relay ignores client-supplied bandwidth hints by default, preventing an
+authenticated client from forcing the relay sender into an unbounded Brutal
+rate. `--allow-client-bandwidth` is an explicit operator opt-in and is rejected
+unless finite upload and download negotiation ceilings are both configured.
+
+Application limits do not replace host-level denial-of-service controls. The
+hardened Hysteria fork caps accepted QUIC sessions globally and per source key,
+including cover traffic, after Retry and before handshake allocation. It also
+caps active TCP handlers globally and per source key across QUIC connections
+before they can wait for a target header. Header
+reads have a finite deadline. Unauthenticated HTTP/3 connections must complete
+authentication within `--handshake-timeout`, and `--max-uni-streams` gives their
+unidirectional control streams a separate small bound. HTTP request headers are
+capped at 16 KiB before allocation. UDP session admission is global and shared
+per authenticated source key across QUIC connections, and occurs before
+allocating defragmentation state;
+fragment count and total reassembled bytes are fixed and bounded.
+`--max-outbound-tcp` and `--max-outbound-udp` separately cap active target
+sockets, while `--max-streams` remains a per-QUIC-connection protocol limit.
+Clients behind one NAT share `--max-client-connections`,
+`--max-client-fallback-connections`, `--max-client-tcp-handlers`, and
+`--max-client-udp-sessions`; the global caps
+remain authoritative if a peer can rotate source addresses.
+Resource keys are an IPv4 address or a masked IPv6 `/64`, so rotating IPv6
+interface identifiers does not create new buckets. A legitimate NAT or routed
+IPv6 `/64` shares its bucket by design.
+The relay requires QUIC Retry source-address validation before allocating a
+bounded handshake slot. Initial packets still consume kernel/network work, so production
+relays should apply firewall rate and burst limits per source, bound file
+descriptors and memory with the service manager, and monitor UDP traffic and
+authentication failures.
+
+On the client, connection setup is single-flight and `--max-pending-opens`
+bounds stream-open workers whose upstream Hysteria API has no context-aware
+variant. Caller deadlines still return immediately; late connections are
+closed, and their slot is retained until the underlying call actually exits.
+
TLS private-key files must be regular files and mode `0600` on Unix. Shared
-relay tokens and local-proxy passwords must contain at least 16 bytes; use the
-bundled `autocar token` command to generate high-entropy values.
+relay tokens, local-proxy passwords and Salamander passwords must contain at
+least 16 bytes; use the bundled `autocar token` command to generate independent
+high-entropy values. Do not reuse the relay authentication token as the
+Salamander password.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..fed26a4
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,1019 @@
+# Third-party notices
+
+> Generated by `go run ./tools/notices`; do not edit by hand. CI verifies this file against the linked build graph.
+
+AutoCAR itself is licensed under the repository's `LICENSE`. The sections below reproduce every root-level license, notice, and patent file from each non-main Go module reached by `go list -deps -json ./cmd/autocar`. A module-level replacement is recorded so the notice always describes the source that is actually compiled.
+
+The SHA-256 value is calculated from the upstream file's original bytes; line endings in the displayed copy are normalized for Markdown.
+
+## `github.com/andybalholm/brotli` `v1.1.0`
+
+### `LICENSE`
+
+SHA-256: `3d180008e36922a4e8daec11c34c7af264fed5962d07924aea928c38e8663c94`
+
+```text
+Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+```
+
+## `github.com/apernet/hysteria/core/v2` `v2.12.1`
+
+Effective source replacement: `./third_party/hysteria-core`.
+
+### `LICENSE.md`
+
+SHA-256: `b279cfdac4db4b077f0660b5d8156d50a8bc7bd410036dc356499af43c4e84f5`
+
+```text
+Copyright 2023 Toby
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+```
+
+## `github.com/apernet/hysteria/extras/v2` `v2.12.1`
+
+### `LICENSE.md`
+
+SHA-256: `b279cfdac4db4b077f0660b5d8156d50a8bc7bd410036dc356499af43c4e84f5`
+
+```text
+Copyright 2023 Toby
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+```
+
+## `github.com/apernet/quic-go` `v0.61.1-0.20260806010916-184d081eef3e`
+
+Effective source replacement: `./third_party/quic-go`.
+
+### `LICENSE`
+
+SHA-256: `77d0b7b53e8abb84cf4dd3f9945a7fdf27044240d2e8023966a721a9a46fe96e`
+
+```text
+MIT License
+
+Copyright (c) 2016 the quic-go authors & Google, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+## `github.com/davecgh/go-spew` `v1.1.1`
+
+### `LICENSE`
+
+SHA-256: `1b93a317849ee09d3d7e4f1d20c2b78ddb230b4becb12d7c224c927b9d470251`
+
+```text
+ISC License
+
+Copyright (c) 2012-2016 Dave Collins
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+```
+
+## `github.com/klauspost/compress` `v1.18.7`
+
+### `LICENSE`
+
+SHA-256: `0d9e582ee4bff57bf1189c9e514e6da7ce277f9cd3bc2d488b22fbb39a6d87cf`
+
+```text
+Copyright (c) 2012 The Go Authors. All rights reserved.
+Copyright (c) 2019 Klaus Post. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+------------------
+
+Files: gzhttp/*
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2016-2017 The New York Times Company
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+------------------
+
+Files: s2/cmd/internal/readahead/*
+
+The MIT License (MIT)
+
+Copyright (c) 2015 Klaus Post
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+---------------------
+Files: snappy/*
+Files: internal/snapref/*
+
+Copyright (c) 2011 The Snappy-Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+-----------------
+
+Files: s2/cmd/internal/filepathx/*
+
+Copyright 2016 The filepathx Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+```
+
+## `github.com/pmezard/go-difflib` `v1.0.0`
+
+### `LICENSE`
+
+SHA-256: `2eb550be6801c1ea434feba53bf6d12e7c71c90253e0a9de4a4f46cf88b56477`
+
+```text
+Copyright (c) 2013, Patrick Mezard
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+ The names of its contributors may not be used to endorse or promote
+products derived from this software without specific prior written
+permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+
+## `github.com/quic-go/qpack` `v0.6.0`
+
+### `LICENSE.md`
+
+SHA-256: `1b6a897efd39b20b3cdce8cd306160d115dbded39d855ceeffe21dc11e4d53df`
+
+```text
+Copyright 2019 Marten Seemann
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+```
+
+## `github.com/quic-go/quic-go` `v0.61.0`
+
+### `LICENSE`
+
+SHA-256: `77d0b7b53e8abb84cf4dd3f9945a7fdf27044240d2e8023966a721a9a46fe96e`
+
+```text
+MIT License
+
+Copyright (c) 2016 the quic-go authors & Google, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+## `github.com/refraction-networking/utls` `v1.8.2`
+
+### `LICENSE`
+
+SHA-256: `2d36597f7117c38b006835ae7f537487207d8ec407aa9d9980794b2030cbc067`
+
+```text
+Copyright (c) 2009 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+
+## `github.com/stretchr/objx` `v0.5.2`
+
+### `LICENSE`
+
+SHA-256: `b2663894033a05fd80261176cd8da1d72546e25842d5c1abcc852ca23b6b61b0`
+
+```text
+The MIT License
+
+Copyright (c) 2014 Stretchr, Inc.
+Copyright (c) 2017-2018 objx contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+## `github.com/stretchr/testify` `v1.11.1`
+
+### `LICENSE`
+
+SHA-256: `f8e536c1c7b695810427095dc85f5f80d44ff7c10535e8a9486cf393e2599189`
+
+```text
+MIT License
+
+Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+## `golang.org/x/crypto` `v0.54.0`
+
+### `LICENSE`
+
+SHA-256: `911f8f5782931320f5b8d1160a76365b83aea6447ee6c04fa6d5591467db9dad`
+
+```text
+Copyright 2009 The Go Authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google LLC nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+### `PATENTS`
+
+SHA-256: `96f408bfae65bf137fc2525d3ecb030271c50c1e90799f87abf8846d8dd505cc`
+
+```text
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the Go project.
+
+Google hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section)
+patent license to make, have made, use, offer to sell, sell, import,
+transfer and otherwise run, modify and propagate the contents of this
+implementation of Go, where such license applies only to those patent
+claims, both currently owned or controlled by Google and acquired in
+the future, licensable by Google that are necessarily infringed by this
+implementation of Go. This grant does not include claims that would be
+infringed only as a consequence of further modification of this
+implementation. If you or your agent or exclusive licensee institute or
+order or agree to the institution of patent litigation against any
+entity (including a cross-claim or counterclaim in a lawsuit) alleging
+that this implementation of Go or any code incorporated within this
+implementation of Go constitutes direct or contributory patent
+infringement, or inducement of patent infringement, then any patent
+rights granted to you under this License for this implementation of Go
+shall terminate as of the date such litigation is filed.
+```
+
+## `golang.org/x/exp` `v0.0.0-20240506185415-9bf2ced13842`
+
+### `LICENSE`
+
+SHA-256: `2d36597f7117c38b006835ae7f537487207d8ec407aa9d9980794b2030cbc067`
+
+```text
+Copyright (c) 2009 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+### `PATENTS`
+
+SHA-256: `96f408bfae65bf137fc2525d3ecb030271c50c1e90799f87abf8846d8dd505cc`
+
+```text
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the Go project.
+
+Google hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section)
+patent license to make, have made, use, offer to sell, sell, import,
+transfer and otherwise run, modify and propagate the contents of this
+implementation of Go, where such license applies only to those patent
+claims, both currently owned or controlled by Google and acquired in
+the future, licensable by Google that are necessarily infringed by this
+implementation of Go. This grant does not include claims that would be
+infringed only as a consequence of further modification of this
+implementation. If you or your agent or exclusive licensee institute or
+order or agree to the institution of patent litigation against any
+entity (including a cross-claim or counterclaim in a lawsuit) alleging
+that this implementation of Go or any code incorporated within this
+implementation of Go constitutes direct or contributory patent
+infringement, or inducement of patent infringement, then any patent
+rights granted to you under this License for this implementation of Go
+shall terminate as of the date such litigation is filed.
+```
+
+## `golang.org/x/net` `v0.57.0`
+
+### `LICENSE`
+
+SHA-256: `911f8f5782931320f5b8d1160a76365b83aea6447ee6c04fa6d5591467db9dad`
+
+```text
+Copyright 2009 The Go Authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google LLC nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+### `PATENTS`
+
+SHA-256: `96f408bfae65bf137fc2525d3ecb030271c50c1e90799f87abf8846d8dd505cc`
+
+```text
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the Go project.
+
+Google hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section)
+patent license to make, have made, use, offer to sell, sell, import,
+transfer and otherwise run, modify and propagate the contents of this
+implementation of Go, where such license applies only to those patent
+claims, both currently owned or controlled by Google and acquired in
+the future, licensable by Google that are necessarily infringed by this
+implementation of Go. This grant does not include claims that would be
+infringed only as a consequence of further modification of this
+implementation. If you or your agent or exclusive licensee institute or
+order or agree to the institution of patent litigation against any
+entity (including a cross-claim or counterclaim in a lawsuit) alleging
+that this implementation of Go or any code incorporated within this
+implementation of Go constitutes direct or contributory patent
+infringement, or inducement of patent infringement, then any patent
+rights granted to you under this License for this implementation of Go
+shall terminate as of the date such litigation is filed.
+```
+
+## `golang.org/x/sys` `v0.47.0`
+
+### `LICENSE`
+
+SHA-256: `911f8f5782931320f5b8d1160a76365b83aea6447ee6c04fa6d5591467db9dad`
+
+```text
+Copyright 2009 The Go Authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google LLC nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+### `PATENTS`
+
+SHA-256: `96f408bfae65bf137fc2525d3ecb030271c50c1e90799f87abf8846d8dd505cc`
+
+```text
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the Go project.
+
+Google hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section)
+patent license to make, have made, use, offer to sell, sell, import,
+transfer and otherwise run, modify and propagate the contents of this
+implementation of Go, where such license applies only to those patent
+claims, both currently owned or controlled by Google and acquired in
+the future, licensable by Google that are necessarily infringed by this
+implementation of Go. This grant does not include claims that would be
+infringed only as a consequence of further modification of this
+implementation. If you or your agent or exclusive licensee institute or
+order or agree to the institution of patent litigation against any
+entity (including a cross-claim or counterclaim in a lawsuit) alleging
+that this implementation of Go or any code incorporated within this
+implementation of Go constitutes direct or contributory patent
+infringement, or inducement of patent infringement, then any patent
+rights granted to you under this License for this implementation of Go
+shall terminate as of the date such litigation is filed.
+```
+
+## `golang.org/x/text` `v0.40.0`
+
+### `LICENSE`
+
+SHA-256: `911f8f5782931320f5b8d1160a76365b83aea6447ee6c04fa6d5591467db9dad`
+
+```text
+Copyright 2009 The Go Authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google LLC nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+### `PATENTS`
+
+SHA-256: `96f408bfae65bf137fc2525d3ecb030271c50c1e90799f87abf8846d8dd505cc`
+
+```text
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the Go project.
+
+Google hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section)
+patent license to make, have made, use, offer to sell, sell, import,
+transfer and otherwise run, modify and propagate the contents of this
+implementation of Go, where such license applies only to those patent
+claims, both currently owned or controlled by Google and acquired in
+the future, licensable by Google that are necessarily infringed by this
+implementation of Go. This grant does not include claims that would be
+infringed only as a consequence of further modification of this
+implementation. If you or your agent or exclusive licensee institute or
+order or agree to the institution of patent litigation against any
+entity (including a cross-claim or counterclaim in a lawsuit) alleging
+that this implementation of Go or any code incorporated within this
+implementation of Go constitutes direct or contributory patent
+infringement, or inducement of patent infringement, then any patent
+rights granted to you under this License for this implementation of Go
+shall terminate as of the date such litigation is filed.
+```
+
+## `gopkg.in/yaml.v3` `v3.0.1`
+
+### `LICENSE`
+
+SHA-256: `d18f6323b71b0b768bb5e9616e36da390fbd39369a81807cca352de4e4e6aa0b`
+
+```text
+
+This project is covered by two different licenses: MIT and Apache.
+
+#### MIT License ####
+
+The following files were ported to Go from C files of libyaml, and thus
+are still covered by their original MIT license, with the additional
+copyright staring in 2011 when the project was ported over:
+
+ apic.go emitterc.go parserc.go readerc.go scannerc.go
+ writerc.go yamlh.go yamlprivateh.go
+
+Copyright (c) 2006-2010 Kirill Simonov
+Copyright (c) 2006-2011 Kirill Simonov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+### Apache License ###
+
+All the remaining project files are covered by the Apache license:
+
+Copyright (c) 2011-2019 Canonical Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+```
+### `NOTICE`
+
+SHA-256: `f6c2dd3a67b576eafb89b80200b8b1627230bf3821a0c14cb99a22ac19107d00`
+
+```text
+Copyright 2011-2016 Canonical Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+```
+
+## Brutal code provenance
+
+AutoCAR's negotiated Brutal controller is provided by the MIT-licensed Hysteria core module identified above. AutoCAR does not vendor, import, or copy the GPL-licensed `tcp-brutal` implementation. Similar terminology describes a traffic-control strategy and does not imply source-code provenance.
diff --git a/cmd/autocar/bench.go b/cmd/autocar/bench.go
index 659ba00..cc74a6d 100644
--- a/cmd/autocar/bench.go
+++ b/cmd/autocar/bench.go
@@ -55,14 +55,21 @@ func ensureSafeBenchmarkListener(address string, allowPublic bool) error {
}
type benchOutput struct {
- Mode string `json:"mode"`
- Transport string `json:"transport"`
- Target string `json:"target"`
- Bytes int64 `json:"bytes_per_iteration"`
- Iterations int `json:"iterations"`
- MedianMbps float64 `json:"median_mbps"`
- P95Mbps float64 `json:"p95_mbps"`
- Results []float64 `json:"results_mbps"`
+ Mode string `json:"mode"`
+ Transport string `json:"transport"`
+ Acceleration string `json:"acceleration,omitempty"`
+ NegotiatedTxBytesSec uint64 `json:"negotiated_tx_bytes_per_second,omitempty"`
+ Target string `json:"target"`
+ Bytes int64 `json:"bytes_per_iteration"`
+ Iterations int `json:"iterations"`
+ MedianMbps float64 `json:"median_mbps"`
+ P95Mbps float64 `json:"p95_mbps"`
+ Results []float64 `json:"results_mbps"`
+}
+
+type accelerationReporter interface {
+ AccelerationMode() string
+ NegotiatedTx() uint64
}
func runBenchClient(parent context.Context, args []string) error {
@@ -138,6 +145,10 @@ func runBenchClient(parent context.Context, args []string) error {
P95Mbps: p95,
Results: results,
}
+ if reporter, ok := dialer.(accelerationReporter); ok {
+ output.Acceleration = reporter.AccelerationMode()
+ output.NegotiatedTxBytesSec = reporter.NegotiatedTx()
+ }
if *jsonOutput {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
diff --git a/cmd/autocar/common.go b/cmd/autocar/common.go
index 76930dd..789dcb4 100644
--- a/cmd/autocar/common.go
+++ b/cmd/autocar/common.go
@@ -6,6 +6,7 @@ import (
"errors"
"flag"
"fmt"
+ "log/slog"
"net"
"net/netip"
"os"
@@ -14,41 +15,60 @@ import (
"time"
"github.com/cppla/autocar/internal/config"
+ "github.com/cppla/autocar/internal/hy2"
"github.com/cppla/autocar/internal/security"
"github.com/cppla/autocar/internal/transport"
"github.com/cppla/autocar/internal/tunnel"
)
type tunnelFlags struct {
- server string
- fallback string
- mode string
- serverName string
- caFile string
- systemRoots bool
- clientCert string
- clientKey string
- tokenFile string
- dialTimeout time.Duration
- primaryTimeout time.Duration
- openTimeout time.Duration
- fallbackTTL time.Duration
+ server string
+ fallback string
+ mode string
+ serverName string
+ caFile string
+ systemRoots bool
+ clientCert string
+ clientKey string
+ tokenFile string
+ dialTimeout time.Duration
+ primaryTimeout time.Duration
+ openTimeout time.Duration
+ fallbackTTL time.Duration
+ congestion string
+ bbrProfile string
+ uploadMbps uint64
+ downloadMbps uint64
+ disableLossCompensation bool
+ fastOpen bool
+ obfsPasswordFile string
+ disableChromeParrot bool
+ maxPendingOpens int
}
func addTunnelFlags(fs *flag.FlagSet, flags *tunnelFlags) {
fs.StringVar(&flags.server, "server", "", "relay host:port (required)")
fs.StringVar(&flags.fallback, "fallback-server", "", "TCP/TLS relay host:port; defaults to --server")
- fs.StringVar(&flags.mode, "transport", "auto", "transport: auto, quic, or tls")
+ fs.StringVar(&flags.mode, "transport", "auto", "transport: auto, hy2 (or quic), legacy-quic, or tls")
fs.StringVar(&flags.serverName, "server-name", "", "TLS certificate DNS name; defaults to relay host")
fs.StringVar(&flags.caFile, "ca", "", "PEM trust anchor for the relay certificate")
fs.BoolVar(&flags.systemRoots, "system-roots", false, "trust the operating-system CA set instead of --ca")
fs.StringVar(&flags.clientCert, "client-cert", "", "optional mTLS client certificate PEM")
fs.StringVar(&flags.clientKey, "client-key", "", "optional mTLS client private key PEM")
fs.StringVar(&flags.tokenFile, "token-file", "", "0600 shared-token file; otherwise AUTOCAR_TOKEN")
- fs.DurationVar(&flags.dialTimeout, "dial-timeout", 5*time.Second, "QUIC/TLS network dial timeout")
+ fs.DurationVar(&flags.dialTimeout, "dial-timeout", 5*time.Second, "legacy QUIC/TLS network dial timeout")
fs.DurationVar(&flags.primaryTimeout, "quic-attempt-timeout", 5*time.Second, "entire QUIC phase budget before auto-mode TLS fallback")
fs.DurationVar(&flags.openTimeout, "open-timeout", 15*time.Second, "overall remote stream open timeout")
fs.DurationVar(&flags.fallbackTTL, "fallback-cooldown", 30*time.Second, "time to prefer TLS after a QUIC path failure")
+ fs.StringVar(&flags.congestion, "congestion", hy2.CongestionBBR, "QUIC congestion controller: bbr or reno; configured bandwidth selects Brutal")
+ fs.StringVar(&flags.bbrProfile, "bbr-profile", hy2.BBRStandard, "BBR profile: conservative, standard, or aggressive")
+ fs.Uint64Var(&flags.uploadMbps, "upload-mbps", 0, "known client upload capacity in Mbit/s; nonzero requests negotiated Brutal")
+ fs.Uint64Var(&flags.downloadMbps, "download-mbps", 0, "known client download capacity in Mbit/s; nonzero requests negotiated Brutal")
+ fs.BoolVar(&flags.disableLossCompensation, "disable-loss-compensation", false, "disable Brutal ACK/loss-rate compensation")
+ fs.BoolVar(&flags.fastOpen, "fast-open", false, "return before the exit dial response (lower setup latency, weaker immediate error reporting)")
+ fs.StringVar(&flags.obfsPasswordFile, "obfs-password-file", "", "0600 Salamander password file; otherwise optional AUTOCAR_OBFS_PASSWORD")
+ fs.BoolVar(&flags.disableChromeParrot, "disable-chrome-parrot", false, "disable Hysteria's Chrome QUIC fingerprint (diagnostics or Ed25519 relay certificates)")
+ fs.IntVar(&flags.maxPendingOpens, "max-pending-opens", 256, "maximum in-flight Hysteria TCP stream opens")
}
type closeDialer interface {
@@ -61,12 +81,15 @@ func buildTunnelDialer(flags tunnelFlags) (closeDialer, error) {
return nil, errors.New("--server is required")
}
mode := strings.ToLower(flags.mode)
- if mode != "auto" && mode != "quic" && mode != "tls" {
- return nil, fmt.Errorf("invalid --transport %q; want auto, quic, or tls", flags.mode)
+ if mode != "auto" && mode != "hy2" && mode != "quic" && mode != "legacy-quic" && mode != "tls" {
+ return nil, fmt.Errorf("invalid --transport %q; want auto, hy2, quic, legacy-quic, or tls", flags.mode)
}
if flags.dialTimeout <= 0 || flags.openTimeout <= 0 {
return nil, errors.New("--dial-timeout and --open-timeout must be positive")
}
+ if flags.maxPendingOpens <= 0 || flags.maxPendingOpens > 65536 {
+ return nil, errors.New("--max-pending-opens must be between 1 and 65536")
+ }
if mode == "auto" && (flags.primaryTimeout <= 0 || flags.primaryTimeout >= flags.openTimeout) {
return nil, errors.New("auto mode requires 0 < --quic-attempt-timeout < --open-timeout so TLS fallback retains time")
}
@@ -122,8 +145,39 @@ func buildTunnelDialer(flags tunnelFlags) (closeDialer, error) {
return nil, err
}
+ upload, err := megabitsToBytesPerSecond(flags.uploadMbps)
+ if err != nil {
+ return nil, fmt.Errorf("--upload-mbps: %w", err)
+ }
+ download, err := megabitsToBytesPerSecond(flags.downloadMbps)
+ if err != nil {
+ return nil, fmt.Errorf("--download-mbps: %w", err)
+ }
+ obfuscationKey, err := loadOptionalSecret(flags.obfsPasswordFile, "AUTOCAR_OBFS_PASSWORD", 16)
+ if err != nil {
+ return nil, fmt.Errorf("load obfuscation password: %w", err)
+ }
+ newAcceleratedClient := func() (*hy2.Client, error) {
+ return hy2.NewClient(hy2.ClientConfig{
+ ServerAddress: flags.server,
+ Token: token,
+ TLSConfig: tlsConfig,
+ Congestion: flags.congestion,
+ BBRProfile: flags.bbrProfile,
+ MaxTx: upload,
+ MaxRx: download,
+ DisableLossCompensation: flags.disableLossCompensation,
+ FastOpen: flags.fastOpen,
+ ObfuscationKey: obfuscationKey,
+ DisableChromeParrot: flags.disableChromeParrot,
+ MaxPendingOpens: flags.maxPendingOpens,
+ })
+ }
+
switch mode {
- case "quic":
+ case "hy2", "quic":
+ return newAcceleratedClient()
+ case "legacy-quic":
return tunnel.NewClient(tunnel.ClientConfig{
ServerAddress: flags.server,
Token: token,
@@ -144,22 +198,65 @@ func buildTunnelDialer(flags tunnelFlags) (closeDialer, error) {
if fallback == "" {
fallback = flags.server
}
- return tunnel.NewClient(tunnel.ClientConfig{
- ServerAddress: flags.server,
- FallbackAddress: fallback,
- Token: token,
- TLSConfig: tlsConfig,
- HandshakeTimeout: flags.openTimeout,
- QUICDialTimeout: flags.dialTimeout,
- PrimaryAttemptTimeout: flags.primaryTimeout,
- TLSDialTimeout: flags.dialTimeout,
- FallbackCooldown: flags.fallbackTTL,
+ primary, err := newAcceleratedClient()
+ if err != nil {
+ return nil, err
+ }
+ fallbackDialer, err := tunnel.NewTLSClient(tunnel.TLSClientConfig{
+ ServerAddress: fallback,
+ Token: token,
+ TLSConfig: tlsConfig,
+ HandshakeTimeout: flags.openTimeout,
+ DialTimeout: flags.dialTimeout,
})
+ if err != nil {
+ _ = primary.Close()
+ return nil, err
+ }
+ auto, err := hy2.NewAutoClient(hy2.AutoConfig{
+ Primary: primary,
+ Fallback: fallbackDialer,
+ AttemptTimeout: flags.primaryTimeout,
+ Cooldown: flags.fallbackTTL,
+ OnFallback: func(primaryErr error) {
+ slog.Warn("QUIC path unavailable; using authenticated TLS fallback",
+ "error", primaryErr,
+ "cooldown", flags.fallbackTTL)
+ },
+ })
+ if err != nil {
+ _ = primary.Close()
+ _ = fallbackDialer.Close()
+ return nil, err
+ }
+ return auto, nil
default:
panic("unreachable transport mode")
}
}
+func megabitsToBytesPerSecond(value uint64) (uint64, error) {
+ const bitsPerMegabit = uint64(1_000_000)
+ if value > ^uint64(0)/bitsPerMegabit {
+ return 0, errors.New("value is too large")
+ }
+ return value * bitsPerMegabit / 8, nil
+}
+
+func loadOptionalSecret(path, envName string, minimumLength int) ([]byte, error) {
+ if path == "" && os.Getenv(envName) == "" {
+ return nil, nil
+ }
+ value, err := config.LoadSecret(path, envName)
+ if err != nil {
+ return nil, err
+ }
+ if len(value) < minimumLength {
+ return nil, fmt.Errorf("secret must be at least %d bytes", minimumLength)
+ }
+ return []byte(value), nil
+}
+
func parseDeniedPorts(value string) ([]uint16, error) {
value = strings.TrimSpace(value)
if value == "" || strings.EqualFold(value, "none") {
diff --git a/cmd/autocar/main.go b/cmd/autocar/main.go
index 623c901..f263023 100644
--- a/cmd/autocar/main.go
+++ b/cmd/autocar/main.go
@@ -58,7 +58,7 @@ func run(ctx context.Context, args []string) (err error) {
}
func printUsage() {
- fmt.Fprintln(os.Stderr, `AutoCAR - authenticated dual-ended TCP acceleration
+ fmt.Fprintln(os.Stderr, `AutoCAR - secure dual-ended TCP/UDP acceleration
Usage:
autocar server [options] run the remote QUIC/TLS relay
diff --git a/cmd/autocar/main_test.go b/cmd/autocar/main_test.go
index aeb02a3..eff13ab 100644
--- a/cmd/autocar/main_test.go
+++ b/cmd/autocar/main_test.go
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "math"
"os"
"path/filepath"
"runtime"
@@ -11,6 +12,39 @@ import (
"github.com/cppla/autocar/internal/security"
)
+func TestMegabitsToBytesPerSecond(t *testing.T) {
+ for value, wanted := range map[uint64]uint64{
+ 0: 0,
+ 1: 125_000,
+ 100: 12_500_000,
+ } {
+ got, err := megabitsToBytesPerSecond(value)
+ if err != nil || got != wanted {
+ t.Fatalf("%d Mbit/s = %d B/s, %v; want %d", value, got, err, wanted)
+ }
+ }
+ if _, err := megabitsToBytesPerSecond(math.MaxUint64); err == nil {
+ t.Fatal("overflowing bandwidth was accepted")
+ }
+}
+
+func TestLoadOptionalSecret(t *testing.T) {
+ t.Setenv("AUTOCAR_TEST_OPTIONAL_SECRET", "")
+ value, err := loadOptionalSecret("", "AUTOCAR_TEST_OPTIONAL_SECRET", 16)
+ if err != nil || value != nil {
+ t.Fatalf("empty optional secret = %q, %v", value, err)
+ }
+ t.Setenv("AUTOCAR_TEST_OPTIONAL_SECRET", "short")
+ if _, err := loadOptionalSecret("", "AUTOCAR_TEST_OPTIONAL_SECRET", 16); err == nil {
+ t.Fatal("short optional secret was accepted")
+ }
+ t.Setenv("AUTOCAR_TEST_OPTIONAL_SECRET", strings.Repeat("x", 16))
+ value, err = loadOptionalSecret("", "AUTOCAR_TEST_OPTIONAL_SECRET", 16)
+ if err != nil || string(value) != strings.Repeat("x", 16) {
+ t.Fatalf("optional secret = %q, %v", value, err)
+ }
+}
+
func TestParseDeniedPorts(t *testing.T) {
ports, err := parseDeniedPorts("25, 443,25")
if err != nil {
@@ -175,6 +209,18 @@ func TestRunHelpAndUnknownCommand(t *testing.T) {
}
}
+func TestServerRejectsFallbackSourceLimitAboveGlobalLimit(t *testing.T) {
+ err := runServer(context.Background(), []string{
+ "--cert", "unused.crt",
+ "--key", "unused.key",
+ "--max-streams", "1",
+ "--max-client-fallback-connections", "2",
+ })
+ if err == nil || !strings.Contains(err.Error(), "--max-client-fallback-connections") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
func TestPercentile(t *testing.T) {
values := []float64{1, 2, 3, 4, 5}
if got := percentile(values, 0.5); got != 3 {
diff --git a/cmd/autocar/server.go b/cmd/autocar/server.go
index 2c8eb96..c6c8926 100644
--- a/cmd/autocar/server.go
+++ b/cmd/autocar/server.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/cppla/autocar/internal/config"
+ "github.com/cppla/autocar/internal/hy2"
"github.com/cppla/autocar/internal/security"
"github.com/cppla/autocar/internal/tunnel"
)
@@ -21,6 +22,7 @@ import (
func runServer(parent context.Context, args []string) error {
fs := flag.NewFlagSet("server", flag.ContinueOnError)
listen := fs.String("listen", ":443", "QUIC UDP listen address")
+ quicEngine := fs.String("quic-engine", "hy2", "UDP engine: hy2 or legacy")
tcpListen := fs.String("tcp-listen", "", "TLS/TCP fallback address; defaults to --listen")
disableFallback := fs.Bool("disable-tcp-fallback", false, "disable the TCP/TLS fallback listener")
certFile := fs.String("cert", "", "server certificate PEM (required)")
@@ -30,10 +32,27 @@ func runServer(parent context.Context, args []string) error {
allowPrivate := fs.Bool("allow-private", false, "allow RFC1918/ULA/CGNAT destinations (loopback remains blocked)")
deniedPortsText := fs.String("deny-ports", "25,465,587", "comma-separated denied destination ports, or none")
deniedCIDRsText := fs.String("deny-cidrs", "", "additional comma-separated denied destination CIDRs/IPs")
- maxStreams := fs.Int("max-streams", 1024, "maximum concurrent streams per transport listener")
- maxConnections := fs.Int("max-connections", 256, "maximum accepted QUIC connections")
+ maxStreams := fs.Int("max-streams", 1024, "maximum incoming streams per QUIC connection (legacy/TLS use a listener-wide bound)")
+ maxUniStreams := fs.Int("max-uni-streams", 8, "maximum incoming unidirectional streams per Hysteria QUIC connection")
+ maxConnections := fs.Int("max-connections", 256, "global maximum accepted QUIC sessions, including unauthenticated cover traffic")
+ maxClientConnections := fs.Int("max-client-connections", 32, "maximum accepted QUIC sessions per source IPv4 or IPv6 /64 across authenticated and cover traffic")
+ maxClientFallbackConnections := fs.Int("max-client-fallback-connections", 0, "maximum TLS fallback connections per source IPv4 or IPv6 /64; zero uses min(32, --max-streams)")
+ maxOutboundTCP := fs.Int("max-outbound-tcp", 1024, "global maximum active Hysteria exit TCP connections")
+ maxOutboundUDP := fs.Int("max-outbound-udp", 256, "global maximum active Hysteria UDP sessions")
+ maxClientTCPHandlers := fs.Int("max-client-tcp-handlers", 128, "maximum Hysteria TCP handlers per source IPv4 or IPv6 /64 across QUIC connections")
+ maxClientUDPSessions := fs.Int("max-client-udp-sessions", 64, "maximum Hysteria UDP sessions per authenticated source IPv4 or IPv6 /64 across QUIC connections")
+ congestion := fs.String("congestion", hy2.CongestionBBR, "QUIC congestion controller: bbr or reno")
+ bbrProfile := fs.String("bbr-profile", hy2.BBRStandard, "BBR profile: conservative, standard, or aggressive")
+ maxUploadMbps := fs.Uint64("max-upload-mbps", 0, "maximum negotiated Brutal upload target in Mbit/s; zero is automatic BBR")
+ maxDownloadMbps := fs.Uint64("max-download-mbps", 0, "maximum negotiated Brutal download target in Mbit/s; zero is automatic BBR")
+ allowClientBandwidth := fs.Bool("allow-client-bandwidth", false, "explicitly allow finite client hints to negotiate Brutal (requires both server ceilings)")
+ disableLossCompensation := fs.Bool("disable-loss-compensation", false, "disable Brutal ACK/loss-rate compensation")
+ disableUDP := fs.Bool("disable-udp", false, "disable QUIC DATAGRAM and SOCKS5 UDP ASSOCIATE")
+ udpIdleTimeout := fs.Duration("udp-idle-timeout", 60*time.Second, "idle timeout for each UDP association")
+ obfsPasswordFile := fs.String("obfs-password-file", "", "0600 Salamander password file; otherwise optional AUTOCAR_OBFS_PASSWORD")
+ masqueradeName := fs.String("masquerade-name", "", "neutral site name returned to unauthenticated HTTP/3 probes")
dialTimeout := fs.Duration("dial-timeout", 4*time.Second, "remote destination dial timeout")
- handshakeTimeout := fs.Duration("handshake-timeout", 10*time.Second, "per-stream authentication/open timeout")
+ handshakeTimeout := fs.Duration("handshake-timeout", 10*time.Second, "authentication and initial stream-open timeout")
if err := fs.Parse(args); err != nil {
return err
}
@@ -43,9 +62,37 @@ func runServer(parent context.Context, args []string) error {
if *maxStreams <= 0 {
return errors.New("--max-streams must be positive")
}
+ if *maxUniStreams < 3 || *maxUniStreams > 1024 {
+ return errors.New("--max-uni-streams must be between 3 and 1024")
+ }
if *maxConnections <= 0 {
return errors.New("--max-connections must be positive")
}
+ if *maxClientConnections <= 0 || *maxClientConnections > *maxConnections {
+ return errors.New("--max-client-connections must be positive and no greater than --max-connections")
+ }
+ if !*disableFallback && (*maxClientFallbackConnections < 0 || *maxClientFallbackConnections > *maxStreams) {
+ return errors.New("--max-client-fallback-connections must be zero or positive and no greater than --max-streams")
+ }
+ if *maxOutboundTCP <= 0 || *maxOutboundUDP <= 0 {
+ return errors.New("--max-outbound-tcp and --max-outbound-udp must be positive")
+ }
+ if *maxClientTCPHandlers <= 0 || *maxClientTCPHandlers > *maxOutboundTCP {
+ return errors.New("--max-client-tcp-handlers must be positive and no greater than --max-outbound-tcp")
+ }
+ if *maxClientUDPSessions <= 0 || *maxClientUDPSessions > *maxOutboundUDP {
+ return errors.New("--max-client-udp-sessions must be positive and no greater than --max-outbound-udp")
+ }
+ if *allowClientBandwidth && (*maxUploadMbps == 0 || *maxDownloadMbps == 0) {
+ return errors.New("--allow-client-bandwidth requires nonzero --max-upload-mbps and --max-download-mbps")
+ }
+ if *udpIdleTimeout < 2*time.Second || *udpIdleTimeout > 10*time.Minute {
+ return errors.New("--udp-idle-timeout must be between 2s and 10m")
+ }
+ engine := strings.ToLower(strings.TrimSpace(*quicEngine))
+ if engine != "hy2" && engine != "legacy" {
+ return errors.New("--quic-engine must be hy2 or legacy")
+ }
if *tcpListen == "" {
*tcpListen = *listen
}
@@ -90,18 +137,74 @@ func runServer(parent context.Context, args []string) error {
},
})
- quicServer, err := tunnel.ListenQUIC(tunnel.QUICServerConfig{
- Address: *listen,
- Token: token,
- TLSConfig: tlsConfig,
- Dialer: safeDialer,
- HandshakeTimeout: *handshakeTimeout,
- DialTimeout: *dialTimeout,
- MaxConcurrentStreams: *maxStreams,
- MaxConnections: *maxConnections,
- })
- if err != nil {
- return err
+ type udpRelay interface {
+ Addr() net.Addr
+ Serve(context.Context) error
+ Close() error
+ }
+ var quicServer udpRelay
+ if engine == "legacy" {
+ if *obfsPasswordFile != "" || os.Getenv("AUTOCAR_OBFS_PASSWORD") != "" {
+ return errors.New("Salamander obfuscation requires --quic-engine=hy2")
+ }
+ legacyServer, listenErr := tunnel.ListenQUIC(tunnel.QUICServerConfig{
+ Address: *listen,
+ Token: token,
+ TLSConfig: tlsConfig,
+ Dialer: safeDialer,
+ HandshakeTimeout: *handshakeTimeout,
+ DialTimeout: *dialTimeout,
+ MaxConcurrentStreams: *maxStreams,
+ MaxConnections: *maxConnections,
+ })
+ if listenErr != nil {
+ return listenErr
+ }
+ quicServer = legacyServer
+ } else {
+ maxUpload, conversionErr := megabitsToBytesPerSecond(*maxUploadMbps)
+ if conversionErr != nil {
+ return fmt.Errorf("--max-upload-mbps: %w", conversionErr)
+ }
+ maxDownload, conversionErr := megabitsToBytesPerSecond(*maxDownloadMbps)
+ if conversionErr != nil {
+ return fmt.Errorf("--max-download-mbps: %w", conversionErr)
+ }
+ obfuscationKey, secretErr := loadOptionalSecret(*obfsPasswordFile, "AUTOCAR_OBFS_PASSWORD", 16)
+ if secretErr != nil {
+ return fmt.Errorf("load obfuscation password: %w", secretErr)
+ }
+ acceleratedServer, listenErr := hy2.Listen(hy2.ServerConfig{
+ Address: *listen,
+ Token: token,
+ TLSConfig: tlsConfig,
+ Dialer: safeDialer,
+ Congestion: *congestion,
+ BBRProfile: *bbrProfile,
+ MaxTx: maxDownload,
+ MaxRx: maxUpload,
+ AllowClientBandwidth: *allowClientBandwidth,
+ DisableLossCompensation: *disableLossCompensation,
+ DisableUDP: *disableUDP,
+ ObfuscationKey: obfuscationKey,
+ UDPIdleTimeout: *udpIdleTimeout,
+ DialTimeout: *dialTimeout,
+ MaxConcurrentStreams: *maxStreams,
+ MaxIncomingUniStreams: *maxUniStreams,
+ MaxConnections: *maxConnections,
+ MaxClientConnections: *maxClientConnections,
+ MaxOutboundTCP: *maxOutboundTCP,
+ MaxOutboundUDP: *maxOutboundUDP,
+ MaxClientTCPHandlers: *maxClientTCPHandlers,
+ MaxClientUDPSessions: *maxClientUDPSessions,
+ TCPRequestTimeout: *handshakeTimeout,
+ AuthenticationTimeout: *handshakeTimeout,
+ MasqueradeHandler: hy2.NewCoverHandler(*masqueradeName),
+ })
+ if listenErr != nil {
+ return listenErr
+ }
+ quicServer = acceleratedServer
}
defer quicServer.Close()
@@ -115,6 +218,7 @@ func runServer(parent context.Context, args []string) error {
HandshakeTimeout: *handshakeTimeout,
DialTimeout: *dialTimeout,
MaxConcurrentStreams: *maxStreams,
+ MaxClientConnections: *maxClientFallbackConnections,
})
if err != nil {
return err
@@ -140,7 +244,7 @@ func runServer(parent context.Context, args []string) error {
}
}()
}
- start("quic", quicServer.Addr().String(), quicServer.Serve)
+ start(engine, quicServer.Addr().String(), quicServer.Serve)
if tlsServer != nil {
start("tls", tlsServer.Addr().String(), tlsServer.Serve)
}
diff --git a/docs/ACCELERATION.md b/docs/ACCELERATION.md
new file mode 100644
index 0000000..690176a
--- /dev/null
+++ b/docs/ACCELERATION.md
@@ -0,0 +1,195 @@
+# Acceleration design
+
+AutoCAR combines three public design families without claiming to be a drop-in
+replacement for any of them:
+
+| Design family | What AutoCAR adopts | What AutoCAR does not claim |
+| --- | --- | --- |
+| Hysteria v2 | HTTP/3 over QUIC, a persistent multiplexed session, Fast Open, negotiated Brutal, QUIC DATAGRAM, Chrome-oriented handshake shaping, HTTP/3 cover handling, and optional Salamander | Port hopping, Mimic, a user-facing ECH setup, or invisibility |
+| BBR | A real userspace BBRv1-derived delivery-rate/minimum-RTT model, BDP-based pacing and congestion window, and the four BBR phases | Linux kernel TCP BBR, BBRv2, or BBRv3 |
+| ServerSpeeder/LotServer/Zeta-TCP objectives | ACK-driven feedback in both directions, paced sending, warm state, standard early loss detection, PTO probes, and independent multiplexed streams | Proprietary prediction, redundant retransmission, FEC, transparent TCP interception, or protocol compatibility |
+
+The implementation comes from an in-tree, security-hardened fork of the pinned
+MIT-licensed Hysteria v2.12.1 core and its QUIC fork. AutoCAR adds admission
+before TCP handlers and UDP defragmentation state, finite fragment bounds, and
+exit policy around it. It does not copy the GPL `tcp-brutal` project. See
+[the patch record](../third_party/hysteria-core/AUTOCAR_PATCHES.md) and
+[THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
+
+## BBR mode (the default)
+
+Leaving client `--upload-mbps=0 --download-mbps=0` selects the configured
+model controller. `--congestion=bbr --bbr-profile=standard` is the default on
+both endpoints.
+
+This is a real BBRv1-derived sender, not the old quic-go default controller
+under a different name. For traffic sent by each endpoint it:
+
+1. samples delivered bytes over send/ACK intervals to estimate bottleneck
+ delivery rate;
+2. tracks the minimum observed RTT, expiring the estimate periodically so the
+ path can be remeasured;
+3. derives the bandwidth-delay product, approximately
+ `delivery_rate * min_rtt`;
+4. applies a pacing gain to the estimated delivery rate; and
+5. bounds in-flight data with a congestion-window gain around the BDP, with
+ loss-recovery limits when packets are declared lost.
+
+The BBR state machine is:
+
+| Phase | Purpose |
+| --- | --- |
+| STARTUP | Increase pacing quickly while delivery bandwidth continues to grow |
+| DRAIN | Pace below the estimate to remove the queue accumulated during STARTUP |
+| PROBE_BW | Cycle pacing gains around the bandwidth estimate to look for new capacity while controlling the queue |
+| PROBE_RTT | Temporarily reduce in-flight data to refresh the minimum-RTT model |
+
+In the pinned implementation, the PROBE_BW gain cycle is `1.25, 0.75, 1, 1,
+1, 1, 1, 1`. The minimum-RTT sample expires after 10 seconds; PROBE_RTT lasts
+at least 200 ms after the in-flight target is reached. These are implementation
+details of the pinned version and may change only with an explicit dependency
+upgrade and review.
+
+### Profiles
+
+Profiles change how quickly BBR probes and how conservatively it handles an
+overshot path. They do not change the Hysteria wire protocol.
+
+| Profile | STARTUP pacing gain | STARTUP CWND gain | Steady CWND gain | Growth rounds | Intended use |
+| --- | ---: | ---: | ---: | ---: | --- |
+| `conservative` | 2.25 | 1.75 | 1.75 | 2 | Shallow buffers, shared access links, or latency-sensitive paths; enables drain-to-target, overshoot detection, and estimate safeguards |
+| `standard` | 2.885 | 2.0 | 2.0 | 3 | General default |
+| `aggressive` | 3.0 | 2.25 | 2.5 | 4 | Controlled high-BDP paths; allows more startup ACK aggregation and queue pressure |
+
+Choose the profile independently on the client and relay because each setting
+controls only that endpoint's sender. `--congestion=reno` is available as a
+diagnostic/fairness baseline. Client bandwidth hints are ignored by default.
+They can select Brutal only after the relay operator explicitly enables
+`--allow-client-bandwidth` with finite ceilings in both directions.
+
+BBR is model-based, not magic. A bad route, insufficient relay capacity,
+policing, CPU saturation, or an already optimal direct route can erase any
+benefit. BBRv1 can also compete aggressively with loss-based flows and can
+build queues on paths where its model is inaccurate.
+
+## Brutal mode (explicit bandwidth only)
+
+Brutal is selected direction by direction when the client provides a non-zero
+capacity and the relay explicitly allows client bandwidth with two finite
+ceilings. AutoCAR deliberately has no "guess a large number" default.
+
+| Traffic direction | Client hint | Relay negotiation ceiling |
+| --- | --- | --- |
+| Client to relay / upload | `--upload-mbps` | `--max-upload-mbps` |
+| Relay to client / download | `--download-mbps` | `--max-download-mbps` |
+
+The relay opt-in rejects a zero ceiling. A zero client hint means unknown
+capacity and therefore keeps BBR/Reno for that direction. With two non-zero
+values, the lower value wins. The negotiated value is a sender pacing target,
+not a throughput guarantee.
+
+Example for a measured 20 Mbit/s upload and 100 Mbit/s download:
+
+```sh
+# Relay policy for each authenticated client
+autocar server [server options] \
+ --allow-client-bandwidth \
+ --max-upload-mbps=20 \
+ --max-download-mbps=100
+
+# Client's measured access-link capacities
+autocar client [client options] \
+ --upload-mbps=20 \
+ --download-mbps=100
+```
+
+The sender keeps five one-second ACK/loss sample slots. After at least 50
+packet samples, it computes `ack_rate = ACKed / (ACKed + lost)` and clamps the
+rate to a minimum of `0.8`. Pacing is approximately
+`negotiated_rate / ack_rate`; therefore loss compensation is capped at about
+`1 / 0.8 = 1.25x`. Its congestion window is approximately two smoothed RTTs
+of that compensated rate. `--disable-loss-compensation` fixes the ACK rate at
+one; set it on both endpoints if compensation must be disabled in both
+directions.
+
+Brutal intentionally keeps sending near the declared rate instead of backing
+off like a conventional congestion-fair controller. It can harm other users,
+trigger policers, and waste bandwidth when the entered value exceeds the real
+bottleneck. Use it only on a link you control or have permission to reserve,
+enter a conservative measured capacity, and configure relay negotiation
+ceilings. These values are not a non-bypassable traffic policer; use host or
+cloud shaping for hard limits. Keep the zero-bandwidth BBR default on shared or
+unknown networks.
+
+## Loss recovery and dual-ended feedback
+
+Congestion control and retransmission are separate layers. BBR and Brutal
+consume the same QUIC ACK/loss events; neither replaces QUIC loss detection.
+The pinned QUIC transport follows RFC 9002 with:
+
+- packet-threshold loss after three newer packet numbers are acknowledged;
+- time-threshold loss at 9/8 of the relevant RTT estimate; and
+- probe timeout (PTO) packets with exponential backoff when acknowledgements
+ stop arriving.
+
+Both client and relay are QUIC senders and receivers. ACKs flowing in each
+direction continuously return RTT, delivery, and loss observations to the
+opposite sender. That is the concrete dual-ended feedback mechanism behind
+AutoCAR's "reverse-control" goal. It is auditable standard QUIC behavior, not
+an assertion that AutoCAR reconstructed Zeta-TCP's private algorithm.
+
+QUIC retransmits lost reliable stream frames, but does not retransmit QUIC
+DATAGRAM payloads. AutoCAR adds no speculative retransmission or FEC. Adding
+redundancy without a measured policy could amplify congestion and would
+require a separate protocol and fairness review.
+
+## Short-flow and multiplexing gains
+
+While the current long-lived QUIC session remains connected, it keeps TLS,
+RTT, path-MTU, and controller state warm. Each new TCP proxy flow opens a
+stream instead of a new end-to-end TCP connection between the AutoCAR
+endpoints. A reconnect creates a fresh session and therefore starts cold; no
+TLS resumption or congestion/PMTU state is claimed across reconnects. Fast
+Open is disabled by default; when explicitly enabled, it lets the first bytes
+be written before the exit-dial response reaches the client.
+
+These mechanisms are most visible for sequential short operations on a
+high-RTT path. Independent QUIC streams also prevent a lost ordered byte in one
+logical flow from imposing TCP-style application head-of-line blocking on all
+other logical flows. They do not remove propagation delay or make the final
+relay-to-destination TCP handshake disappear.
+
+## Hysteria traffic-shaping features
+
+- **HTTP/3 cover:** without packet obfuscation, unauthenticated requests see a
+ neutral HTTP/3 service rather than a distinctive tunnel error.
+- **Chrome parrot:** enabled by default, it selects Hysteria/quic-go handshake
+ traits including Chrome-oriented connection-ID behavior. It is a fingerprint
+ reduction, not proof that all traffic is identical to a browser. Its
+ Chrome-compatible signature list requires an ECDSA P-256/P-384 or RSA relay
+ certificate; Ed25519 requires `--disable-chrome-parrot` on the client.
+- **Salamander:** an optional shared secret wraps UDP packets before QUIC. It
+ changes the observable packet form, so normal HTTP/3 cover probing is no
+ longer available in that mode.
+- **TLS/TCP fallback:** `auto` gives new TCP flows a real encrypted TCP path
+ when UDP is unavailable. UDP associations have no TCP fallback.
+
+AutoCAR does not currently expose port hopping, Hysteria Mimic, or ECH
+provisioning. It cannot promise resistance to endpoint blocking, statistical
+traffic analysis, global observation, or traffic-volume correlation.
+
+## How to verify a deployment
+
+Run the repository's netem suite first, then repeat the matrix in
+[BENCHMARK.md](BENCHMARK.md) on the intended route. At minimum compare:
+
+1. direct, BBR `conservative`, BBR `standard`, and BBR `aggressive` with both
+ bandwidth hints zero;
+2. Brutal with truthful capacities and relay caps;
+3. download and upload, short and bulk payloads, and concurrency greater than
+ one; and
+4. clean, delayed, lossy, and reordered path profiles.
+
+Retain raw results, packet captures without payload secrets, CPU data, and the
+exact build/configuration. A result from one narrow CI profile is evidence for
+that profile only, not a universal acceleration claim.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index fee0f52..29a8c42 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1,66 +1,159 @@
# Architecture
-AutoCAR is a split proxy. It terminates a local proxy connection, carries its
-byte stream over an authenticated tunnel, and creates a new TCP connection at
-the relay. It never encapsulates raw TCP segments, avoiding nested TCP
-retransmission and nested congestion-control loops.
+AutoCAR is a split proxy, not a kernel TCP optimizer. The client terminates a
+local SOCKS5 or HTTP(S) proxy request, carries it through an authenticated
+dual-ended tunnel, and the relay creates a new TCP or UDP flow to the
+destination. Raw TCP segments are never nested inside another TCP stream.
```mermaid
-flowchart LR
- A["Application"] --> P["SOCKS5 / HTTP(S)"]
- P --> C["Tunnel client"]
- C -->|"QUIC streams"| R["Authenticated relay"]
- C -. "TLS/TCP fallback" .-> R
+flowchart TB
+ A["Application"] --> P["SOCKS5 / HTTP(S) proxy"]
+ P --> C["AutoCAR client"]
+ C -->|"Hysteria v2 over HTTP/3 + QUIC"| R["AutoCAR relay"]
+ C -. "TLS 1.3 / TCP fallback" .-> R
R --> D["Destination"]
```
-## Design principles
-
-1. **Standard cryptography.** TLS 1.3, normal X.509 validation, optional mTLS,
- and no custom cipher or certificate-verification bypass.
-2. **One flow, one stream.** Each proxied TCP connection maps to an independent
- QUIC bidirectional stream so loss in one ordered stream does not impose
- application-level head-of-line blocking on every other stream.
-3. **Bounded parsing.** Every variable-size protocol field has a small hard
- limit before allocation. Timeouts, stream limits, and backpressure bound
- resource use.
-4. **Remote resolution with egress policy.** Hostnames are preserved across
- the tunnel. The relay resolves them, filters every resulting address, then
- dials only approved numeric IPs to prevent DNS-rebinding bypasses. Approved
- IPv6 and IPv4 candidates are interleaved and staggered so one blackholed
- address family does not consume the whole destination timeout.
-5. **Correctness before aggression.** The default QUIC congestion controller
- is used until reproducible measurements justify a maintained transport
- fork. The TCP fallback favors reachability, not acceleration.
-6. **Explicit limitations.** AutoCAR cannot remove propagation delay or
- guarantee higher throughput on every path. Stable, clean direct paths may
- be faster because a relay adds work and distance.
-
-## Data plane
-
-The QUIC client maintains a warm authenticated connection. Opening a proxy
-flow creates a bidirectional QUIC stream and sends a bounded request containing
-the command, destination, and authentication proof. The relay validates the
-request and destination policy before dialing. A success response switches the
-stream into opaque byte-forwarding mode. Half-close is propagated in both
-directions.
-
-When QUIC cannot be established, the optional fallback opens one TLS 1.3 TCP
-connection per proxied flow and uses the same request/response framing. This
-avoids building an unsafe custom multiplexer over a single TCP byte stream,
-but it does not provide QUIC's stream independence.
-
-## Control plane
-
-Configuration is local and file/flag based. Tokens should be supplied through
-an environment variable or a permission-restricted file rather than a command
-line visible to other users. Certificates can be public-CA certificates or a
-private certificate generated by the bundled command and distributed out of
-band.
-
-## Future work
-
-- SOCKS5 UDP ASSOCIATE over QUIC DATAGRAM.
-- Separate interactive and bulk QUIC sessions with bounded fair scheduling.
-- Reproducible evaluation of CUBIC or BBRv3-based QUIC congestion control.
-- Multi-relay path measurement and policy-based selection.
+## Components
+
+| Component | Responsibility |
+| --- | --- |
+| Local proxy | SOCKS5 CONNECT and UDP ASSOCIATE, HTTP absolute-form requests, HTTP CONNECT, and an optional TLS-protected HTTPS proxy listener |
+| Hysteria adapter | Reconnecting Hysteria v2.12.1 client/server, HTTP/3 authentication, TCP streams, QUIC DATAGRAM, Fast Open, BBR/Brutal selection, cover handling, and optional Salamander wrapping |
+| Automatic dialer | Tries Hysteria v2 over UDP, opens a bounded circuit breaker after a path failure, and sends new TCP flows through the real TLS/TCP fallback |
+| Legacy tunnel | Preserves AutoCAR wire protocol v1 over legacy QUIC and over the TLS/TCP fallback |
+| Safe outbound | Resolves names at the relay, rejects unsafe results, dials approved numeric addresses, and rechecks every UDP destination |
+
+The default UDP engine is `hy2`. On the client, `--transport=quic` is an alias
+for `--transport=hy2`; it no longer selects AutoCAR's original QUIC protocol.
+The old engine remains available as `--transport=legacy-quic` together with
+server `--quic-engine=legacy`. See [PROTOCOL.md](PROTOCOL.md) for the exact
+compatibility matrix.
+
+## Dual-ended acceleration
+
+One long-lived QUIC connection carries many independent flows. A proxied TCP
+connection maps to one bidirectional QUIC stream; a SOCKS5 UDP association maps
+to a Hysteria UDP session carried by QUIC DATAGRAM frames. This preserves a
+warm RTT and congestion model across short flows while that session remains
+connected, and avoids TCP's
+connection-wide application head-of-line blocking between unrelated streams.
+Reconnection creates a cold QUIC/TLS/controller/PMTU session; state is not
+claimed to survive it.
+
+Each endpoint controls the traffic it sends. QUIC ACKs provide delivery,
+loss, and RTT feedback from the opposite endpoint, so both client-to-relay and
+relay-to-client directions adapt independently:
+
+- with bandwidth hints left at zero, each sender uses real BBRv1-derived model
+ control by default (or Reno when explicitly selected);
+- with a non-zero client bandwidth hint, the corresponding direction
+ negotiates a capped Brutal pacing rate with the relay; and
+- QUIC loss recovery remains the standards-based RFC 9002 packet-threshold,
+ time-threshold, and probe-timeout machinery regardless of congestion mode.
+
+This ACK feedback satisfies the public objective commonly called
+"reverse-control" or dual-ended feedback. It is not the proprietary prediction
+or retransmission algorithm from ServerSpeeder/LotServer/Zeta-TCP, and AutoCAR
+does not claim protocol compatibility with those products. The controller
+details and boundaries are documented in [ACCELERATION.md](ACCELERATION.md).
+
+## TCP path
+
+The Hysteria client maintains a reconnecting authenticated HTTP/3 session.
+Opening a proxy flow creates a bidirectional stream and sends a bounded target
+address request. Fast Open is disabled by default. When explicitly enabled,
+the stream can accept the first application bytes before the relay's
+destination-dial response is read; a refusal is surfaced on the first read.
+Stream limits, open deadlines, QUIC flow control, and operating-system
+backpressure bound resource use. `MaxIncomingStreams` applies per QUIC
+connection; the hardened core also applies listener-wide and per-source-key
+handler gates across QUIC connections before reading a TCP target, plus a
+finite request-header deadline. Separate
+listener-wide TCP and UDP exit gates bound active target sockets across all
+sessions. Accepted-but-unauthenticated HTTP/3 connections have a finite
+authentication lifetime, and incoming unidirectional control streams have a
+separate small per-connection cap. After QUIC Retry proves return-path
+reachability, both global and source connection gates apply before handshake
+state. The source key is an IPv4 address or IPv6 `/64`; clients sharing a NAT
+or prefix intentionally share that budget.
+
+The TLS/TCP fallback has an independent listener-wide connection gate and a
+per-source gate using the same IPv4-address / IPv6-`/64` keying. Both are held
+from acceptance through handshake and for the complete relay lifetime.
+
+In `auto` mode, failure to establish or use the UDP session causes a new TCP
+flow to be opened through one TLS 1.3 connection dedicated to that flow. A
+valid relay-side destination error or authentication rejection is not retried
+through fallback. Existing streams are never replayed or migrated: if UDP
+fails after a stream has been handed to an application, that application must
+retry and the new flow can use TLS.
+
+## UDP path
+
+SOCKS5 UDP ASSOCIATE is exposed only when the selected transport implements
+datagrams. The client validates the UDP source against the SOCKS control
+connection and rejects SOCKS fragmentation. Hysteria assigns a logical session
+and fragments oversized Hysteria datagrams to the negotiated QUIC DATAGRAM
+size. Before any per-session map or fragment slice is allocated, the relay
+enforces global and per-source UDP-session caps across QUIC connections (IPv4
+address or IPv6 `/64`);
+fragment count and reassembled payload size have fixed limits.
+One logical Hysteria UDP payload is limited to 4,096 bytes. The SOCKS frontend
+drops a larger payload before transport serialization without closing the UDP
+association; ordinary Internet-MTU datagrams are unaffected.
+
+At the relay, every requested UDP destination is resolved and checked against
+the same port, CIDR, private-address, and special-use policy as TCP. The
+destination is resolved and filtered again for every outbound write, so DNS
+changes cannot bypass the SSRF boundary. UDP sessions expire after a bounded
+idle period. TLS/TCP fallback deliberately does not emulate UDP because doing
+so would add cross-datagram head-of-line blocking and misleading semantics.
+
+## Congestion and loss recovery
+
+Congestion control chooses how quickly new data is put on the wire; loss
+recovery decides when QUIC retransmits lost frames. They are separate:
+
+- BBR estimates delivered bandwidth and minimum RTT, derives a BDP, and paces
+ through STARTUP, DRAIN, PROBE_BW, and PROBE_RTT.
+- Brutal paces at an explicitly configured rate and can compensate for the
+ measured ACK/loss rate. It is opt-in and is not congestion-fair.
+- The QUIC transport declares loss using RFC 9002 packet and time thresholds
+ and sends PTO probes when ACK feedback stalls. AutoCAR does not replace this
+ with a proprietary predictor and does not add FEC.
+
+The controller runs in userspace on the AutoCAR QUIC connection. It does not
+change the host's Linux `tcp_congestion_control`, accelerate unrelated sockets,
+or act as a transparent TCP interception layer.
+
+## Security and probe behavior
+
+Both data paths use TLS 1.3. Clients must opt into either an explicit trust
+anchor (`--ca`) or the operating-system roots (`--system-roots`), and normal
+chain plus DNS/IP SAN verification is mandatory. A shared token is still
+required and is compared in constant time; optional mTLS adds a client
+certificate factor.
+
+Without Salamander, unauthenticated HTTP/3 requests receive a small neutral
+cover page instead of an AutoCAR-specific error. The client also uses the
+Hysteria transport's Chrome-oriented QUIC handshake fingerprint by default.
+With Salamander, the UDP packet shape is obfuscated before it reaches QUIC;
+ordinary HTTP/3 probes can no longer reach the cover site. Cover mode and
+obfuscated-UDP mode are therefore alternative observable forms, not two layers
+of one indistinguishable web service.
+
+These measures reduce obvious active-probe signatures; they do not make the
+relay invisible. An observer can still see endpoints, timing, volume, packet
+sizes, and TCP/UDP use, and can block or rate-limit the relay IP or all UDP.
+AutoCAR makes no guarantee of being unidentifiable or unblockable.
+
+## Deliberate non-goals
+
+- no kernel-wide or transparent TCP acceleration;
+- no proprietary Zeta-TCP prediction, redundant retransmission, or FEC;
+- no BBRv2 or BBRv3 claim (the implemented model is BBRv1-derived);
+- no port hopping, Mimic, or user-facing ECH configuration;
+- no interception of destination HTTPS and no replacement for application
+ end-to-end encryption; and
+- no promise that a relay improves every route or every workload.
diff --git a/docs/BENCHMARK.md b/docs/BENCHMARK.md
index 9ab8956..aa68ef5 100644
--- a/docs/BENCHMARK.md
+++ b/docs/BENCHMARK.md
@@ -1,118 +1,147 @@
# Benchmarking AutoCAR
-AutoCAR includes a deterministic TCP source/sink so a direct route and the
-dual-ended tunnel can be measured with the same payload. The benchmark reports
-payload goodput in decimal Mbit/s. It is designed for repeatable comparisons,
-not as proof that one transport is faster on every network.
+AutoCAR includes a deterministic TCP source/sink so direct and relayed paths
+can move the same payload. It reports payload goodput in decimal Mbit/s. The
+tool is for repeatable comparisons; no single result proves that a relay or a
+controller is faster on every network.
## Basic comparison
-The benchmark server defaults to `127.0.0.1:9000`. For a same-host test:
+The benchmark server defaults to `127.0.0.1:9000`:
```sh
autocar bench-server
```
-A remote-path comparison needs an explicit non-loopback opt-in:
+A remote-path comparison requires explicit non-loopback opt-in:
```sh
autocar bench-server \
- --listen 0.0.0.0:9000 \
+ --listen=0.0.0.0:9000 \
--allow-public-benchmark
```
`bench-server` has no authentication. A remote caller can make it send or
-receive substantial traffic up to the configured limits. Use a host and cloud
-firewall to allow port 9000 only from the intended client and relay addresses,
-run it only for a controlled test window, and stop it immediately afterward.
-The CLI defaults to at most 64 MiB per transfer and 16 concurrent transfers;
-keep `--max-bytes` and `--max-connections` no higher than the experiment needs.
+receive substantial traffic up to its limits. Restrict port 9000 to the test
+client and relay with host/cloud firewalls, use the smallest practical
+`--max-bytes` and `--max-connections`, and stop it after the test.
-From the client host, measure the direct route:
+Measure the direct route:
```sh
autocar bench-client \
- --transport direct \
- --target bench.example.com:9000 \
- --mode download --bytes 8388608 --warmup 1 --iterations 7 --json
+ --transport=direct \
+ --target=bench.example.com:9000 \
+ --mode=download --bytes=8388608 \
+ --warmup=1 --iterations=7 --json
```
-Then measure through an already running relay:
+Measure the default Hysteria v2/BBR path through an already running relay:
```sh
autocar bench-client \
- --transport quic \
- --server relay.example.com:443 \
- --ca relay-ca.crt \
- --token-file relay-token \
- --target bench.example.com:9000 \
- --mode download --bytes 8388608 --warmup 1 --iterations 7 --json
+ --transport=hy2 \
+ --server=relay.example.com:443 \
+ --ca=relay-ca.crt \
+ --token-file=relay-token \
+ --congestion=bbr --bbr-profile=standard \
+ --upload-mbps=0 --download-mbps=0 \
+ --target=bench.example.com:9000 \
+ --mode=download --bytes=8388608 \
+ --warmup=1 --iterations=7 --json
```
-Repeat with `--mode upload`. Use `--transport=tls` to characterize the TCP/TLS
-fallback separately. For `--transport=auto`, the output transport label
-describes the configured mode, not which path won an individual fallback
-decision; use explicit modes when comparing transports.
+`--transport=quic` is an alias for `hy2`. Use `--transport=tls` to characterize
+the TCP/TLS fallback and `--transport=legacy-quic` only against a relay started
+with `--quic-engine=legacy`. For `auto`, the JSON transport label describes the
+configured mode rather than the path used by each individual flow; select an
+explicit transport for performance comparisons.
-The timer begins after the benchmark request header has been written and ends
-after the payload plus a one-byte completion acknowledgement. Connection and
-tunnel stream setup happen before that timer. For user-perceived latency,
-measure the complete application operation separately.
+Repeat with `--mode=upload`. The timer begins after the benchmark request
+header is written and ends after the payload plus one-byte completion
+acknowledgement. Connection and tunnel-stream setup occur before that timer.
+Measure a complete real application operation separately when user-perceived
+latency matters.
+
+## Controller matrix
+
+Do not compare only one controller on one path. A useful minimum matrix is:
+
+| Mode | Client flags | Relay flags | Question answered |
+| --- | --- | --- | --- |
+| Direct | `--transport=direct` | none | What does the unrelayed route deliver? |
+| BBR conservative | `--congestion=bbr --bbr-profile=conservative`, bandwidths zero | matching BBR/profile, caps zero | Does a cautious model reduce queue/loss cost? |
+| BBR standard | `--congestion=bbr --bbr-profile=standard`, bandwidths zero | matching BBR/profile, caps zero | Default model result |
+| BBR aggressive | `--congestion=bbr --bbr-profile=aggressive`, bandwidths zero | matching BBR/profile, caps zero | Is extra startup pressure useful or harmful? |
+| Brutal | truthful non-zero `--upload-mbps` and `--download-mbps` | `--allow-client-bandwidth` plus explicit non-zero negotiation ceilings | Does a reserved/controlled link benefit from a fixed negotiated rate? |
+| Reno | `--congestion=reno`, bandwidths zero | `--congestion=reno`, caps zero | Loss-based baseline |
+| TLS fallback | `--transport=tls` | TCP listener enabled | What is the reachability path's cost? |
+
+For Brutal, both relay caps and client measurements should be written into the
+result metadata. An inflated capacity is not an optimization: it changes the
+experiment into an unfair overload test. BBR profiles control the sender at
+the endpoint where the flag is set, so record both endpoint configurations.
+
+For every row, exercise at least:
+
+- download and upload;
+- short, medium, and bulk payloads;
+- one flow and several concurrent flows; and
+- clean, high-RTT, random-loss, burst-loss, and reordered profiles.
## Fair-test checklist
-1. Pin the exact AutoCAR build, configuration, client, relay and benchmark
- target for a comparison.
-2. Keep the direct and tunneled destination identical. Document the different
- physical routes and relay placement; a relay can improve routing, add a
- detour, or both.
-3. Run enough iterations in alternating order. Discard a declared number of
- warmups and retain every raw result, not only the best value.
-4. Test multiple payload sizes and concurrency levels. Short flows emphasize
- setup and warm-state behavior; bulk transfers emphasize steady-state
- congestion control.
-5. Record RTT, loss, reordering, MTU, bandwidth, CPU utilization and time of
- day. Confirm neither endpoint is CPU-limited.
-6. Report median and the individual results. The emitted `p95_mbps` is the
- 95th percentile of goodput, where larger is better; it is not a latency
- percentile.
-7. Repeat on the real production path. Emulation is useful for regression
- testing but cannot reproduce every queue, middlebox or competing flow.
-
-Why QUIC can help: many flows reuse one authenticated connection and its
-congestion state, and loss in one ordered QUIC stream does not impose
-application-level head-of-line blocking on other streams. Why it may not help:
-the relay adds processing and distance, a single large clean-path TCP flow can
-already fill the link, and AutoCAR currently uses quic-go's default congestion
-controller rather than claiming a custom BBR implementation.
+1. Pin the AutoCAR commit, Go version, module versions, configuration, client,
+ relay, and benchmark target.
+2. Keep the direct and tunneled destinations identical. Document both physical
+ routes and relay placement; a relay can improve routing or add a detour.
+3. Alternate test order, declare warmups, run enough iterations, and retain
+ every raw result rather than only the best value.
+4. Record RTT, random and burst loss, reordering, MTU, configured link rate,
+ CPU, memory, and time of day. Confirm neither endpoint is CPU-limited.
+5. Report median plus all individual results. The emitted `p95_mbps` is the
+ 95th percentile of goodput, where larger is better; it is not latency p95.
+6. Distinguish a warm shared QUIC connection from fresh direct TCP flows. That
+ is a real short-flow benefit, but it must be stated in the test description.
+7. Repeat on the intended production path. Emulation catches regressions but
+ cannot reproduce every queue, middlebox, policer, or competing flow.
+
+Why Hysteria/QUIC can help: streams reuse a warm authenticated connection and
+its BBR delivery/RTT model; pacing uses the inferred BDP; explicitly enabled
+Fast Open can overlap the target response with initial writes; unrelated
+streams avoid TCP-style cross-flow head-of-line blocking. Why it may not help:
+the relay adds work and distance, the relay-to-destination leg is still a new
+socket, and a clean direct TCP route may already fill the bottleneck.
## Reproducible Linux netem suite
-The repository includes a root-only integration script. It creates isolated
-client and relay network namespaces connected by a veth pair, applies the same
-delay/loss/rate policy in both directions, and runs:
+The root-only integration script creates isolated client and relay network
+namespaces connected by a veth pair. Its current test matrix is:
+
+| Stage | Path profile | Cases | Pass condition |
+| --- | --- | --- | --- |
+| Bulk observation | 35 ms one-way delay on both interfaces, 0.5% independent loss each direction, 50 Mbit/s each direction | direct, Hysteria v2 (`quic` alias), TLS | every median is positive; ratios are retained |
+| Controller gate | same lossy/rate-limited profile, repeated 4 MiB uploads and downloads | client-sender BBR/Reno/negotiated 15 Mbit/s Brutal; separate BBR and Reno relays for the relay sender | both upload and download BBR/Reno median ratios are at least 1.10; modes and negotiation are reported, and Brutal reaches at least 50% of its declared upload target |
+| Cold fallback | same delay/rate, random loss removed, unused UDP port | `auto` Hysteria attempt followed by TLS | first command completes within finite deadlines |
+| Short-flow acceleration gate | same delay/rate, loss-free, sequential 128 KiB downloads | fresh direct TCP vs warm Hysteria v2 connection | Hysteria median/direct median is at least 1.10 |
+| Authentication | controlled namespace path | wrong CA and wrong token | both are rejected for the expected reason |
+| Live UDP failure | first proxy request over Hysteria, then client UDP output is dropped | new TCP proxy flow in `auto` | new flow completes over TLS within the 10-second bound |
+| Confidentiality smoke | pcap of Hysteria and fallback links | unique HTTP plaintext sentinel | sentinel is absent from both captures |
-- direct, QUIC and TLS download measurements;
-- an `auto` connection whose UDP address is initially unavailable, verifying
- cold-start TCP/TLS fallback;
-- an auto-mode proxy request that first succeeds over QUIC, followed by a
- client-side UDP/7443 drop and a second bounded request over TCP/TLS;
-- wrong-CA and wrong-token rejection checks;
-- a controlled warm-QUIC short-flow acceleration profile;
-- an HTTP proxy request containing a unique plaintext sentinel; and
-- a packet capture assertion that the sentinel is absent from the client-relay
- link.
+The source and sink benchmark is TCP. SOCKS5 UDP ASSOCIATE, source validation,
+datagram framing, and policy behavior are covered by Go integration tests; a
+production UDP workload should also be measured with an application-specific
+loss/jitter metric rather than TCP goodput.
-On Linux with `iproute2`, `iptables`, `tcpdump`, `curl` and Python 3 installed:
+Run the suite on Linux with `iproute2`, `iptables`, `tcpdump`, `curl`, Python 3,
+and root privileges:
```sh
make build
sudo ./scripts/netem-integration.sh ./bin/autocar
```
-Defaults are 35 ms one-way delay on each side (approximately 70 ms base RTT),
-0.5% independent loss in each direction, a 50 Mbit/s rate per direction, five
-measured 1 MiB transfers and one warmup. They can be changed explicitly:
+Override the declared profile explicitly:
```sh
sudo env \
@@ -123,31 +152,35 @@ sudo env \
AUTOCAR_BENCH_ITERATIONS=9 \
AUTOCAR_BENCH_WARMUP=2 \
AUTOCAR_SHORT_FLOW_BYTES=131072 \
+ AUTOCAR_SHORT_FLOW_ITERATIONS=9 \
+ AUTOCAR_SHORT_FLOW_WARMUP=3 \
AUTOCAR_MIN_SHORT_FLOW_RATIO=1.10 \
+ AUTOCAR_MIN_BBR_RENO_RATIO=1.10 \
+ AUTOCAR_MIN_BRUTAL_TARGET_RATIO=0.50 \
AUTOCAR_ARTIFACT_DIR="$PWD/artifacts/netem" \
./scripts/netem-integration.sh ./bin/autocar
```
-The script writes raw benchmark JSON, a comparison summary, process logs and
-the pcap under `artifacts/netem`. The GitHub Actions netem workflow publishes
-that directory as an artifact.
-
-The suite's general lossy-path bulk measurements are recorded without a speed
-threshold. It separately applies one intentionally narrow acceptance profile:
-loss-free high RTT, sequential 128 KiB downloads and three declared QUIC
-warmups. Fresh direct TCP connections restart congestion state on every
-iteration, while QUIC streams reuse the warm connection. The default gate
-requires the warm QUIC median to be at least 1.10 times the direct median. This
-demonstrates that the implemented connection-reuse acceleration mechanism is
-effective under its stated conditions; it is not a universal production-speed
-claim. `AUTOCAR_MIN_SHORT_FLOW_RATIO` can change the declared gate for a
-different controlled environment, but a release should not lower it merely to
-hide a regression.
-
-Performance on a shared virtual runner remains noisy. A broader release claim
-should cite retained results from the intended path and configuration, not the
-controlled CI profile alone.
-
-The pcap sentinel check is a useful regression smoke test, not a cryptographic
-proof. The TLS 1.3 implementation, certificate validation and protocol threat
-model remain the security basis.
+The script writes raw JSON, a summary, process logs, and packet captures under
+`artifacts/netem`. The GitHub Actions netem workflow publishes the directory
+even when diagnosis is needed.
+
+## What the CI gate proves
+
+The generic bulk path measurements are observations rather than a universal
+speed claim. Three controller-specific gates and one short-flow gate are narrow
+and declared in advance: on the 0.5% lossy path, both client-side uploads and
+relay-side downloads with BBR must beat their Reno baselines by at least 1.10,
+and negotiated Brutal must deliver at least 50% of its truthful 15 Mbit/s
+upload target; on the loss-free high-RTT path, sequential warm Hysteria
+128 KiB downloads must beat fresh direct TCP by at least 1.10. These checks
+demonstrate the selected mechanisms under those profiles only.
+
+Do not lower `AUTOCAR_MIN_SHORT_FLOW_RATIO` merely to hide a regression, and do
+not publish the CI ratio as a universal production claim. BBR profile quality,
+Brutal fairness, sustained high-loss behavior, and real-route improvement need
+the broader retained matrix above.
+
+The pcap sentinel assertion is a regression smoke test, not a cryptographic
+proof. TLS 1.3, verified X.509, token authentication, optional mTLS, and the
+threat model remain the security basis.
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 8a25d6f..71098bf 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -1,13 +1,13 @@
# Deployment guide
-AutoCAR has two roles: a public relay close to the desired destinations and a
-client-side process that exposes local SOCKS5 and HTTP(S) proxy listeners. The
-preferred data path is QUIC over UDP; TCP with TLS 1.3 can listen on the same
-port as a fallback.
+AutoCAR has two roles: a relay near the desired destinations and a client-side
+process exposing local SOCKS5 and HTTP(S) proxies. The default path is Hysteria
+v2 over HTTP/3/QUIC on UDP. A separate TLS 1.3/TCP listener can use the same
+numeric port for new-flow fallback.
## 1. Build and install
-AutoCAR requires the Go version declared in `go.mod`.
+AutoCAR requires the Go version declared in `go.mod`:
```sh
make check
@@ -15,53 +15,299 @@ make build
sudo install -m 0755 bin/autocar /usr/local/bin/autocar
```
-The binary is statically buildable with `CGO_ENABLED=0`. Cross-platform
-artifacts can be produced with `make cross-build`. Linux is the primary
-deployment target; the local proxy also builds on macOS and Windows.
+The binary is statically buildable with `CGO_ENABLED=0`; `make cross-build`
+produces cross-platform artifacts. Linux is the primary relay target. The
+local proxy also builds on macOS and Windows.
-## 2. Create relay credentials
+## 2. Create credentials
-Create a dedicated account and a private configuration directory. Secret
-files are rejected if they are accessible by group or other users.
+Create a dedicated unprivileged account and a private configuration directory.
+AutoCAR rejects secret/key files that are accessible by group or other users.
```sh
sudo useradd --system --home /var/lib/autocar --shell /usr/sbin/nologin autocar
sudo install -d -o autocar -g autocar -m 0700 /etc/autocar
-sudo -u autocar autocar token --out /etc/autocar/relay-token
+
+sudo -u autocar autocar token --out=/etc/autocar/relay-token
sudo -u autocar autocar cert \
- --hosts relay.example.com,203.0.113.10 \
- --cert /etc/autocar/server.crt \
- --key /etc/autocar/server.key
+ --hosts=relay.example.com,203.0.113.10 \
+ --cert=/etc/autocar/server.crt \
+ --key=/etc/autocar/server.key
```
-The bundled `cert` command creates an ECDSA P-256 self-signed certificate. Its
-certificate file must be copied to each client over a trusted, independent
-channel and used as `--ca`. For a public-CA certificate, clients may explicitly
-select `--system-roots`; AutoCAR never offers an option to skip verification.
+The bundled certificate command creates an ECDSA P-256 self-signed
+certificate. Copy its certificate (never the private key) and the token to
+each client over a trusted independent channel. Use the certificate as `--ca`.
+For a public-CA certificate, clients can explicitly choose `--system-roots`.
+There is no certificate-verification bypass. Default Chrome QUIC fingerprinting
+supports ECDSA P-256/P-384 and RSA relay certificates but intentionally does not
+advertise Ed25519. If an external issuer supplies an Ed25519 leaf, every
+Hysteria client must use `--disable-chrome-parrot`; AutoCAR adds this exact hint
+to a matching TLS handshake failure. Prefer P-256/P-384/RSA so the secure default can
+remain enabled.
-Keep the private key and token on the relay. Back them up as secrets, not in
-the repository or container image.
+Keep token, private key, optional client key, and obfuscation secret out of the
+repository and container image. Give each secret a distinct random value; do
+not reuse the relay token as a Salamander password or local-proxy password.
-## 3. Run the relay
+## 3. Run the relay with default BBR
-For an initial foreground run on an unprivileged high port:
+This is a complete foreground example using the default Hysteria engine,
+BBRv1 `standard`, UDP proxying, neutral HTTP/3 cover, and TLS fallback:
```sh
sudo -u autocar autocar server \
- --listen :8443 \
- --tcp-listen :8443 \
- --cert /etc/autocar/server.crt \
- --key /etc/autocar/server.key \
- --token-file /etc/autocar/relay-token
+ --listen=:8443 \
+ --quic-engine=hy2 \
+ --tcp-listen=:8443 \
+ --cert=/etc/autocar/server.crt \
+ --key=/etc/autocar/server.key \
+ --token-file=/etc/autocar/relay-token \
+ --congestion=bbr \
+ --bbr-profile=standard \
+ --max-upload-mbps=0 \
+ --max-download-mbps=0 \
+ --max-connections=256 \
+ --max-client-connections=32 \
+ --max-client-fallback-connections=32 \
+ --max-streams=1024 \
+ --max-uni-streams=8 \
+ --max-outbound-tcp=1024 \
+ --max-outbound-udp=256 \
+ --max-client-tcp-handlers=128 \
+ --max-client-udp-sessions=64 \
+ --handshake-timeout=10s \
+ --udp-idle-timeout=60s \
+ --masquerade-name="Example Service"
+```
+
+The relay ignores client bandwidth hints by default, so this selects BBR rather
+than a guessed or client-forced Brutal rate. Enabling client hints is a separate
+explicit opt-in described below and requires two finite relay ceilings.
+`--bbr-profile=conservative` is a good first choice on shared/shallow-buffer
+links; `aggressive` should be reserved for measured controlled paths.
+`--congestion=reno` offers a loss-based baseline. Controller settings affect
+only traffic sent by the endpoint on which they are configured.
+
+Production deployments often use port 443. Permit both UDP and TCP. Binding a
+low port as non-root requires a narrow capability such as
+`CAP_NET_BIND_SERVICE`, or one-to-one UDP and TCP port forwarding. A load
+balancer must use UDP/TCP pass-through; terminating TLS or HTTP/3 in front of
+AutoCAR changes the required end-to-end protocol.
+
+### Destination policy
+
+The relay rejects loopback, link-local, multicast, unspecified, RFC1918, ULA,
+CGNAT, translation, documentation, benchmarking, reserved, and other
+special-use destinations by default. It also denies ports 25, 465, and 587.
+
+- `--allow-private` permits RFC1918/ULA/CGNAT but never loopback, link-local, or
+ the built-in special-use denylist. Use it only on a relay dedicated to
+ trusted users.
+- `--deny-cidrs` adds deployment-specific IPs/CIDRs such as cloud control-plane
+ ranges.
+- `--deny-ports=none` clears the port denylist and is a deliberate security
+ policy change.
+- `--disable-udp` disables Hysteria UDP sessions and SOCKS5 UDP ASSOCIATE.
+
+TCP hostnames are resolved once into approved numeric dial candidates. UDP is
+resolved and filtered during the permission check and again on every outbound
+write, preventing DNS rebinding from bypassing the egress policy.
+
+## 4. Run the client
+
+The default `auto` mode first uses Hysteria v2 and falls back to TLS/TCP for new
+TCP flows when UDP is unavailable:
+
+```sh
+autocar client \
+ --server=relay.example.com:443 \
+ --transport=auto \
+ --ca=/etc/autocar/relay-ca.crt \
+ --token-file=/etc/autocar/relay-token \
+ --congestion=bbr \
+ --bbr-profile=standard \
+ --upload-mbps=0 \
+ --download-mbps=0 \
+ --fast-open=false \
+ --max-pending-opens=256 \
+ --socks=127.0.0.1:1080 \
+ --http=127.0.0.1:8080
+```
+
+`--transport=hy2` and its alias `--transport=quic` require UDP and do not fall
+back. `--transport=tls` diagnoses the fallback directly. If TCP uses a
+different address, set `--fallback-server`.
+
+Fast Open defaults to off so a destination refusal is returned before the
+application sees an established proxy connection. `--fast-open=true` can
+reduce a round trip for write-first protocols, but defers the relay dial result
+until the first read and should be enabled only after testing application error
+handling.
+
+`--quic-attempt-timeout` bounds the primary UDP attempt,
+`--fallback-cooldown` controls how long new TCP flows prefer TLS after a UDP
+failure, and `--open-timeout` bounds the whole proxy open. In `auto`, keep
+`0 < --quic-attempt-timeout < --open-timeout` so fallback retains time. A
+relay destination error or authentication failure does not open the fallback
+circuit. `--max-pending-opens` bounds Hysteria core operations that cannot be
+interrupted through its public API; canceled callers return immediately, but
+their late workers retain a slot until the core returns or the session closes.
+
+Fallback is not live migration. A stream already returned to an application
+fails if its QUIC connection becomes unusable; the application must retry, and
+the new TCP flow can use TLS. SOCKS5 UDP has no TLS/TCP fallback.
+
+Application examples:
+
+```sh
+curl --socks5-hostname 127.0.0.1:1080 https://example.com/
+curl --proxy http://127.0.0.1:8080 https://example.com/
+```
+
+The SOCKS5 frontend supports CONNECT and UDP ASSOCIATE, but not BIND. The HTTP
+proxy supports absolute-form `http://` and CONNECT. Absolute-form `https://`
+is rejected; HTTPS destinations use CONNECT, leaving application TLS end to
+end. AutoCAR never installs an interception CA.
+
+## 5. Opt into negotiated Brutal
+
+Use Brutal only after measuring the access link and only where reserving that
+rate is permitted. The client values request the two directional rates; relay
+values set negotiation ceilings for cooperating clients:
+
+```sh
+# Relay: bound negotiated Brutal targets
+autocar server [relay options] \
+ --allow-client-bandwidth \
+ --max-upload-mbps=20 \
+ --max-download-mbps=100
+
+# Client: truthful measured capacities
+autocar client [client options] \
+ --upload-mbps=20 \
+ --download-mbps=100
+```
+
+A zero client value keeps BBR/Reno in that direction. With non-zero client and
+relay values, the lower value wins. Set `--disable-loss-compensation` on both
+roles to disable the five-second ACK/loss compensation in both directions.
+Without `--allow-client-bandwidth`, the server ignores all client hints and
+forces its configured BBR/Reno behavior. The opt-in is rejected unless both
+server ceilings are non-zero.
+
+Brutal can send about 1.25 times the requested rate under measured loss. It is
+not congestion-fair; an exaggerated value can starve other traffic, waste
+capacity, and trigger a provider policer. Relay negotiation ceilings are an
+important guardrail for the official client, but they are not a traffic
+policer: use host or cloud rate limiting when a hard, non-bypassable ceiling is
+required.
+
+## 6. Choose one probe-resistance form
+
+### Plain HTTP/3 cover
+
+With no obfuscation secret, the relay is a valid HTTP/3 endpoint.
+Unauthenticated probes receive the neutral site named by `--masquerade-name`.
+The client uses Hysteria's Chrome-oriented QUIC fingerprint by default;
+normally `--disable-chrome-parrot` is intended for diagnostics. It is also
+required when the relay deliberately uses an Ed25519 certificate; ECDSA P-256
+(including `autocar cert`) and RSA work with the default fingerprint.
+
+### Salamander-obfuscated UDP
+
+Generate a separate secret and install the same 0600 file on both endpoints:
+
+```sh
+sudo -u autocar autocar token --out=/etc/autocar/obfs-password
+
+# Add to both relay and client commands:
+--obfs-password-file=/etc/autocar/obfs-password
```
-Permit both UDP and TCP on the chosen port. Production deployments commonly
-use 443 because restrictive networks are more likely to permit it. Binding a
-port below 1024 as a non-root process requires a narrowly scoped capability
-such as `CAP_NET_BIND_SERVICE`, or a firewall/load-balancer mapping from 443 to
-8443.
+The alternative `AUTOCAR_OBFS_PASSWORD` environment variable is supported but
+must be protected from service-manager and process-environment disclosure.
+Salamander requires the `hy2` engine and changes the outer UDP packet form.
+An ordinary HTTP/3 probe can no longer reach the inner cover page; cover and
+Salamander are alternative observable modes. The TCP fallback is unaffected.
+
+Neither mode guarantees that a network operator cannot classify, rate-limit,
+or block the relay. Endpoint IP, timing, volume, packet sizes, and TCP/UDP use
+remain visible.
-A minimal hardened systemd service is:
+## 7. Firewall and UDP rate limits
+
+Allow both protocols on the relay's selected port, restricted by source ranges
+when possible:
+
+```sh
+sudo ufw allow from 198.51.100.0/24 to any port 443 proto udp
+sudo ufw allow from 198.51.100.0/24 to any port 443 proto tcp
+```
+
+`--max-connections` bounds all accepted QUIC sessions, including
+unauthenticated cover traffic; `--max-client-connections` prevents one
+return-path-validated source key from occupying the whole connection budget.
+The key is one IPv4 address or one IPv6 `/64`, preventing ordinary IPv6 address
+rotation from bypassing the budget.
+`--max-streams` bounds incoming streams per QUIC connection.
+The same value is the global TLS/TCP fallback connection gate, while
+`--max-client-fallback-connections` prevents one IPv4 address or IPv6 `/64`
+from occupying it during the TLS handshake or relay lifetime. Its zero default
+selects the smaller of 32 and `--max-streams`; set a positive value only when
+the measured client concurrency requires a different source budget.
+`--max-uni-streams` separately limits Hysteria/HTTP/3 control
+streams, while `--handshake-timeout` closes an accepted connection that does
+not authenticate in time and also bounds the initial TCP target header. A
+global pending-TCP-handler gate prevents slow streams from bypassing the
+listener-wide `--max-outbound-tcp` backstop. The
+`--max-client-tcp-handlers` source budget is shared across QUIC connections,
+so one authenticated source cannot occupy the complete global gate. HTTP
+request headers are rejected above 16 KiB before body allocation. UDP sessions
+are admitted before fragment state is allocated, with both `--max-outbound-udp` global and
+`--max-client-udp-sessions` per-authenticated-source-key limits shared across
+QUIC connections. IPv4 is keyed per address and IPv6 per `/64`. Clients behind
+the same NAT or routed prefix share all three per-source
+connection/TCP/UDP budgets;
+increase them only after measuring legitimate concurrency, while retaining the
+global caps as protection against source-address rotation.
+Fragment count and reassembled size are also bounded. Capacity is released on
+close. QUIC Retry validates the source address before a bounded handshake slot
+is allocated. Initial packets still consume kernel and link work, so combine
+these controls with a host/cloud UDP rate guard. The following nftables fragment is a template
+for an existing firewall. It drops per-source UDP above 25 MiB/s (about
+210 Mbit/s) with an 8 MiB burst, then leaves the accept/drop policy to the
+site's normal filter chain:
+
+```nft
+table inet autocar_guard {
+ chain input {
+ type filter hook input priority -5; policy accept;
+
+ udp dport 443 meter autocar_udp4 {
+ ip saddr limit rate over 25 mbytes/second burst 8 mbytes
+ } drop
+
+ udp dport 443 meter autocar_udp6 {
+ ip6 saddr limit rate over 25 mbytes/second burst 8 mbytes
+ } drop
+ }
+}
+```
+
+Validate nftables syntax on the target distribution before loading it. Set the
+limit above the largest authorized Brutal sender rate plus protocol overhead;
+a lower firewall ceiling silently invalidates the negotiated rate. Also apply
+provider edge limits because host rules cannot recover bandwidth already
+consumed upstream. Keep SSH/management access in a separately tested rule set.
+
+SOCKS5 UDP ASSOCIATE returns a dynamically allocated local UDP port. If the
+client proxy runs in a container, ordinary fixed TCP port publishing does not
+publish that dynamic UDP endpoint. Use a host-local client process or a
+carefully firewalled host-network deployment for applications that need SOCKS
+UDP.
+
+## 8. Hardened systemd relay
```ini
[Unit]
@@ -72,7 +318,7 @@ Wants=network-online.target
[Service]
User=autocar
Group=autocar
-ExecStart=/usr/local/bin/autocar server --listen=:443 --tcp-listen=:443 --cert=/etc/autocar/server.crt --key=/etc/autocar/server.key --token-file=/etc/autocar/relay-token
+ExecStart=/usr/local/bin/autocar server --listen=:443 --quic-engine=hy2 --tcp-listen=:443 --cert=/etc/autocar/server.crt --key=/etc/autocar/server.key --token-file=/etc/autocar/relay-token --congestion=bbr --bbr-profile=standard --max-upload-mbps=0 --max-download-mbps=0 --masquerade-name=Example-Service
Restart=on-failure
RestartSec=3
AmbientCapabilities=CAP_NET_BIND_SERVICE
@@ -92,168 +338,108 @@ MemoryDenyWriteExecute=true
WantedBy=multi-user.target
```
-The relay rejects loopback, link-local, multicast, unspecified, RFC1918, ULA,
-CGNAT and IANA special-use destinations by default. It also denies ports 25,
-465 and 587. `--allow-private` permits private/ULA/CGNAT targets but never
-loopback, link-local or the explicitly denied special-use prefixes; use it only
-for a relay dedicated to trusted users. `--deny-cidrs` adds deployment-specific
-blocked IPs or CIDRs (for example, a cloud-provider control-plane range).
-`--deny-ports=none` removes the port denylist and should be treated as a
-deliberate security-policy change.
-
-## 4. Run the client
+Run `systemd-analyze security autocar.service`, adapt restrictions to the host,
+then test UDP and TCP separately. A watchdog should probe both because a green
+TCP fallback does not prove that Hysteria UDP is reachable.
-Place the relay certificate and the same token on the client, with the token
-owned by the local AutoCAR account and mode `0600`:
+## 9. Local proxy exposure
-```sh
-autocar client \
- --server relay.example.com:443 \
- --transport auto \
- --ca /etc/autocar/relay-ca.crt \
- --token-file /etc/autocar/relay-token \
- --socks 127.0.0.1:1080 \
- --http 127.0.0.1:8080
-```
-
-`auto` first uses QUIC. If that path fails, it uses the TLS/TCP listener; a
-short circuit-breaker cooldown prevents every new flow from repeatedly
-waiting for an unavailable UDP path. `--transport=quic` and
-`--transport=tls` are useful for diagnosis. If the TCP fallback uses a
-different address, set `--fallback-server`. `--quic-attempt-timeout` bounds
-the whole QUIC phase, while `--dial-timeout` bounds network establishment and
-`--open-timeout` bounds the overall proxy open operation. Keep the overall
-timeout comfortably larger than the QUIC budget so TLS has time to complete;
-the defaults also leave the relay's destination dial timeout inside that
-budget on a normally responsive path.
-
-Fallback is connection-establishment behavior, not live stream migration. If
-UDP disappears after a QUIC stream has already been returned to an
-application, that stream fails and the application must retry; newly opened
-streams use TLS while the QUIC circuit is open. Arbitrary TCP bytes cannot be
-safely replayed onto a different transport without application cooperation.
-
-Application examples:
+Unauthenticated local proxy listeners are restricted to loopback. To expose a
+listener on another interface, configure credentials and explicitly
+acknowledge that SOCKS username/password and HTTP Basic are cleartext on those
+listeners:
```sh
-curl --socks5-hostname 127.0.0.1:1080 https://example.com/
-curl --proxy http://127.0.0.1:8080 https://example.com/
+AUTOCAR_PROXY_USER=alice autocar client [client options] \
+ --proxy-password-file=/etc/autocar/proxy-password \
+ --allow-public-plaintext \
+ --socks=0.0.0.0:1080 \
+ --http=0.0.0.0:8080
```
-The SOCKS5 frontend currently supports CONNECT, not BIND or UDP ASSOCIATE.
-The HTTP proxy supports absolute-form `http://` requests and CONNECT.
-Absolute-form `https://` is rejected: HTTPS destinations must use CONNECT, so
-their application TLS remains end to end between the application and
-destination. AutoCAR does not install a CA or intercept destination TLS.
-
-The optional `--https` listener encrypts the hop from an application to the
-local proxy. It requires `--proxy-cert` and `--proxy-key`:
+Prefer the TLS-protected local HTTPS proxy across an untrusted LAN:
```sh
-autocar cert --hosts localhost,127.0.0.1 --cert proxy.crt --key proxy.key
-autocar client [relay options] \
- --socks= --http= --https 127.0.0.1:8444 \
- --proxy-cert proxy.crt --proxy-key proxy.key
-curl --proxy https://127.0.0.1:8444 --proxy-cacert proxy.crt https://example.com/
-```
+autocar cert \
+ --hosts=localhost,127.0.0.1 \
+ --cert=proxy.crt --key=proxy.key
-Unauthenticated proxy listeners are restricted to loopback. To expose one on
-another interface, set a username and a permission-restricted password file:
+autocar client [client options] \
+ --socks= --http= \
+ --https=127.0.0.1:8444 \
+ --proxy-cert=proxy.crt \
+ --proxy-key=proxy.key
-```sh
-AUTOCAR_PROXY_USER=alice autocar client [relay options] \
- --proxy-password-file /etc/autocar/proxy-password \
- --allow-public-plaintext \
- --socks 0.0.0.0:1080 --http 0.0.0.0:8080
+curl --proxy https://127.0.0.1:8444 \
+ --proxy-cacert proxy.crt https://example.com/
```
-SOCKS5 uses username/password authentication and the HTTP proxy uses Basic
-proxy authentication. Both transmit local-proxy credentials without transport
-encryption, which is why a non-loopback listener requires the explicit
-`--allow-public-plaintext` acknowledgement. These credentials protect the
-local listener; they do not replace the relay token or TLS certificate
-validation. Add host firewall rules even when authentication is enabled, and
-prefer the HTTPS proxy listener across any untrusted local network.
+Local proxy credentials protect the listener; they do not replace the relay
+token or X.509 verification. Use host firewall rules even with authentication.
-## 5. Optional mutual TLS
+## 10. Optional mutual TLS
-The shared token is mandatory. mTLS adds a client-certificate factor. Generate
-a separate client certificate, install its certificate (not its key) as the
-relay client trust anchor, and start the roles with:
+The token remains mandatory. mTLS adds a client-certificate factor:
```sh
# Relay
-autocar server [server options] --client-ca /etc/autocar/client.crt
+autocar server [relay options] \
+ --client-ca=/etc/autocar/client-ca.crt
# Client
autocar client [client options] \
- --client-cert /etc/autocar/client.crt \
- --client-key /etc/autocar/client.key
+ --client-cert=/etc/autocar/client.crt \
+ --client-key=/etc/autocar/client.key
```
-For multiple clients, use a conventional private CA and issue distinct client
-certificates so identities remain auditable and can be migrated independently.
-Protocol v1 does not implement CRL, OCSP, or a certificate denylist; revoking a
-compromised client therefore requires rotating the accepted client CA and the
-remaining client certificates (and rotating the shared token when exposed).
-
-## 6. Containers and Compose
+Issue distinct client certificates from a private CA so identities can be
+audited and rotated independently. AutoCAR has no CRL, OCSP, or certificate
+denylist; revocation requires rotating the accepted client CA/certificates and
+the token if it was exposed.
-The image uses a multi-stage build and a `scratch` runtime. It includes the
-system CA bundle, contains only the binary and CA file, and runs as numeric UID
-and GID 65532 with no Linux capabilities.
+## 11. Wire migration from original AutoCAR QUIC
-`docker-compose.yml` provides separate `server` and `client` profiles. Prepare
-the expected files under `./secrets`, keep token/password/key files at mode
-`0600`, and make them readable by the configured container UID. For example:
+`--transport=quic` now aliases Hysteria v2. It does not speak the original
+AutoCAR v1 QUIC wire. Upgrade UDP client and server together. For temporary
+compatibility, make both selections explicit:
```sh
-mkdir -p secrets
-bin/autocar token --out secrets/relay-token
-bin/autocar cert --hosts relay.example.com \
- --cert secrets/server.crt --key secrets/server.key
-cp secrets/server.crt secrets/relay-ca.crt
-bin/autocar token --out secrets/proxy-password
-sudo chown -R 65532:65532 secrets
-
-docker compose --profile server up --build -d
+# Old v1 UDP engine
+autocar server [relay options] --quic-engine=legacy
+autocar client [client options] --transport=legacy-quic
```
-For a client host, set `AUTOCAR_RELAY`, `AUTOCAR_SERVER_NAME` and a non-default
-`AUTOCAR_PROXY_USER`, then run `docker compose --profile client up --build -d`.
-Compose publishes the local proxy ports only on host loopback, while proxy
-authentication is still mandatory inside the container because its listener
-binds the container interface. The Compose command also sets
-`--allow-public-plaintext` explicitly: SOCKS5 username/password and HTTP Basic
-proxy credentials are not encrypted on that container-side listener. The
-loopback-only host publishing and Docker network boundary are therefore part
-of this example's security model. Do not change those port mappings to a
-public host address; use the local HTTPS proxy listener or another encrypted
-hop if clients must cross an untrusted network.
-
-If host files cannot be owned by UID 65532, set `AUTOCAR_UID` and
-`AUTOCAR_GID` to their owner. The Dockerfile's default process remains
-non-root; do not set the Compose user to root merely to work around secret-file
-permissions.
-
-## 7. Operations and limits
-
-- Rotate a token by updating both ends during a coordinated restart. There is
- no multi-token grace period in protocol v1.
-- The current release emits lifecycle and fatal-command logs, but deliberately
- has no unauthenticated metrics endpoint and does not log every hostile
- request. Monitor restarts with the process supervisor, use firewall counters
- and bounded external probes for reachability, and alert on host CPU, memory,
- file-descriptor and network saturation. Built-in aggregate auth/dial/stream
- metrics remain future work; logs intentionally avoid payload, credentials,
- destinations and internal dial details.
-- Keep Go and module dependencies patched. CI runs unit/race tests and CodeQL;
- those checks complement rather than replace dependency and host patching.
-- Test UDP and TCP reachability independently after every firewall, NAT or
- load-balancer change.
-- A relay sees requested destinations and any destination-side plaintext.
- Continue using HTTPS, SSH or another end-to-end protocol for sensitive data.
-- Observers still see relay IPs, packet sizes, timing and whether UDP/TLS is in
- use. No protocol can promise that a network operator will never rate-limit
- or block it. AutoCAR's TCP/TLS fallback improves reachability but is not an
- undetectability guarantee.
+One address cannot host both UDP engines. For a staged migration, bind Hysteria
+to a second UDP port, move clients, then retire the legacy port. The separate
+TLS/TCP fallback remains AutoCAR v1, so `auto` can still provide new-flow TCP
+reachability during a UDP mismatch. Legacy QUIC has no BBR/Brutal integration
+or UDP ASSOCIATE.
+
+## 12. Containers and operations
+
+The image uses a multi-stage build, a `scratch` runtime, numeric UID/GID 65532,
+and no Linux capabilities. `docker-compose.yml` publishes relay UDP and TCP and
+publishes local proxy TCP listeners only on host loopback. Prepare 0600 files
+under `./secrets`, make them readable by the configured container UID, and use
+the `server` or `client` profile. Do not publish the cleartext client proxy on
+a public host address.
+
+Operational checklist:
+
+- rotate relay token and optional obfuscation secret with a coordinated restart;
+- independently test Hysteria UDP and fallback TCP after every firewall, NAT,
+ certificate, or load-balancer change;
+- retain the controller/capacity configuration with benchmark results and
+ remeasure after route or provider changes;
+- monitor process restarts, CPU, memory, file descriptors, UDP drops, firewall
+ counters, and link saturation;
+- keep Go, modules, the host kernel, and container base/build images patched;
+ and
+- continue using HTTPS, SSH, or another end-to-end application protocol because
+ the relay necessarily sees requested destinations and destination-side
+ plaintext.
+
+AutoCAR currently has no unauthenticated metrics endpoint and intentionally
+does not log payloads or credentials. It also has no port hopping, Mimic,
+user-facing ECH configuration, kernel-transparent TCP mode, or FEC. Do not
+describe it as unidentifiable or unblockable.
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 8f2eb2b..10c8950 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -1,30 +1,160 @@
-# AutoCAR wire protocol v1
+# AutoCAR transports and wire protocols
-This document describes the protocol implemented by `internal/protocol`. It is
-intended to make compatibility and security review possible; it is not a
-promise that every internal Go API is stable.
+AutoCAR currently has two UDP wire protocols plus one TCP fallback protocol.
+The CLI transport name and the bytes on the wire must not be confused.
-## Transport binding
+## Compatibility matrix
+
+| Client selection | Server selection | Carrier | Wire protocol | Datagram proxying |
+| --- | --- | --- | --- | --- |
+| `auto` (default) | `--quic-engine=hy2` (default) | UDP first, TCP fallback | Hysteria v2; AutoCAR v1 on fallback | Yes on the UDP path |
+| `hy2` or `quic` | `--quic-engine=hy2` | UDP | Hysteria v2 | Yes |
+| `legacy-quic` | `--quic-engine=legacy` | UDP | AutoCAR v1 | No |
+| `tls` | either UDP engine | TCP | AutoCAR v1 | No |
+
+`quic` is an alias for `hy2`. It exists for CLI continuity, not wire
+compatibility with the original AutoCAR QUIC engine. A client and server that
+select different UDP engines cannot complete the UDP handshake.
+
+## Default Hysteria v2 wire
+
+The default engine delegates its wire format and state machine to
+[`github.com/apernet/hysteria/core/v2` v2.12.1](https://github.com/apernet/hysteria/tree/14e9fff1d972ab0187ac7fcf75b9514dc8664065/core).
+It is Hysteria v2 over HTTP/3 and QUIC, rather than an AutoCAR-specific framing
+layer. This section records AutoCAR's binding and security policy; the upstream
+[Hysteria v2 protocol documentation](https://github.com/apernet/hysteria/blob/14e9fff1d972ab0187ac7fcf75b9514dc8664065/PROTOCOL.md)
+is the interoperability reference.
+
+### Authentication and transport negotiation
+
+The client establishes HTTP/3 with TLS 1.3 and sends the Hysteria authentication
+request containing the shared token and the client's receive-rate hint. On
+success, the relay returns whether UDP is enabled and its receive-rate
+advertisement. The two advertisements independently select the client-to-relay
+and relay-to-client sender rate.
+
+For each direction:
+
+1. by default the relay ignores all client capacity hints, keeps the configured
+ model controller (BBR by default, or Reno), and reports automatic bandwidth
+ selection;
+2. the operator must explicitly set `--allow-client-bandwidth` and finite
+ non-zero upload/download ceilings before the relay accepts Brutal hints; and
+3. in that opt-in mode, a non-zero client hint selects Brutal at the lower of
+ the client value and corresponding server ceiling, while a zero hint keeps
+ BBR/Reno for that direction.
+
+The rate values on the Hysteria wire are bytes per second. AutoCAR's CLI accepts
+decimal Mbit/s and converts with `Mbit/s * 1,000,000 / 8`.
+
+| Hysteria field | AutoCAR CLI meaning |
+| --- | --- |
+| Client `MaxTx` | `--upload-mbps`, client to relay |
+| Client `MaxRx` | `--download-mbps`, relay to client |
+| Server `MaxTx` | `--max-download-mbps`, relay-to-client Brutal negotiation ceiling |
+| Server `MaxRx` | `--max-upload-mbps`, client-to-relay Brutal negotiation ceiling |
+
+These server values constrain negotiation with the official client; they are
+not a packet policer and do not impose a hard throughput limit on a modified
+or zero-hint client. Enforce non-bypassable limits in the host or cloud network.
+
+Unknown, malformed, and unauthenticated HTTP/3 requests are passed to the
+configured cover handler. AutoCAR's built-in handler returns a neutral page for
+`GET /` and `HEAD /`, with ordinary not-found responses elsewhere. It does not
+reveal whether a supplied token was close to valid.
+
+### TCP streams and Fast Open
+
+Each proxied TCP flow uses a Hysteria bidirectional QUIC stream. Its bounded
+request identifies the destination, and the relay returns success or a generic
+dial error before opaque byte forwarding. Fast Open is disabled by default.
+With client `--fast-open=true`, the connection is returned after the target
+request is written; application writes may proceed while the response is
+deferred until the first read. Fast Open does not bypass TLS, token
+authentication, outbound policy, or the destination dial.
+
+An authenticated relay error is an application result, not evidence that UDP
+is unavailable. `auto` therefore does not duplicate that request through TLS.
+
+### UDP datagrams
+
+SOCKS5 UDP ASSOCIATE creates one Hysteria logical UDP session. Each QUIC
+DATAGRAM carries a session identifier, packet/fragment identifiers, target
+address, and payload. Hysteria fragments messages that exceed the available
+QUIC DATAGRAM payload and reassembles them inside the same logical session.
+This fragmentation is bounded: one logical payload may be at most 4,096 bytes.
+The SOCKS frontend drops a larger payload before calling the Hysteria transport
+and keeps the association alive. DATAGRAM delivery is intentionally unreliable
+and unordered; QUIC does not
+retransmit a lost UDP payload.
+
+The SOCKS request's `DST.ADDR` and `DST.PORT` describe the expected local UDP
+source as specified by RFC 1928. An unspecified address is bound to the TCP
+control peer; a concrete IP must equal that peer, and a domain must resolve to
+it. A non-zero port is enforced. Port zero remains the normal dynamic form and
+is locked to the first valid datagram. The actual packet source is checked even
+after request validation, so DNS cannot authorize a different sender.
+
+The relay bounds session/fragment state before allocation, checks the first
+complete target before creating an outbound socket, and re-resolves plus
+revalidates the destination on every outbound datagram. Replies are accepted
+only from numeric destinations that the session previously approved and wrote
+to successfully. This per-session destination set is capped at 256 entries and
+never evicts an entry: after it fills, existing destinations remain usable but
+new destinations fail closed. A failed socket write does not authorize its
+source address for replies. UDP is unavailable when the relay uses
+`--disable-udp`, when the client
+selects a v1 transport, or when `auto` has only its TCP fallback path available.
+
+### Loss signals are standard QUIC
+
+Hysteria's congestion controller consumes ACKed/lost packet events from the
+QUIC transport. Loss declaration itself follows RFC 9002: a packet-number
+threshold of three, a time threshold of 9/8 of the relevant RTT estimate, and
+PTO probes with exponential backoff. These mechanisms provide rapid standard
+loss recovery; they are not advertised as Zeta-TCP's proprietary prediction or
+reverse-control implementation.
+
+### Cover and Salamander forms
+
+In plain mode, the UDP endpoint is valid HTTP/3 and unauthenticated probes can
+receive the cover site; Hysteria's Chrome-oriented QUIC handshake fingerprint
+is enabled by default. Its signature list supports ECDSA P-256/P-384/RSA relay
+certificates, not Ed25519. `--disable-chrome-parrot` exists for diagnostics and
+is required for an Ed25519 relay certificate; X.509 verification remains
+mandatory either way.
+
+When both endpoints load the same `--obfs-password-file` (or
+`AUTOCAR_OBFS_PASSWORD`), Salamander wraps the UDP packet connection. A normal
+HTTP/3 client then cannot reach the inner cover handler. This is an alternate
+obfuscated packet form, not HTTP/3 masquerading and obfuscation simultaneously
+visible on the network. Salamander does not affect the separate TLS/TCP
+fallback.
+
+## AutoCAR wire protocol v1
+
+Protocol v1 remains the TLS/TCP fallback format and the explicit legacy QUIC
+format. It is implemented by `internal/protocol` and retained for controlled
+migration and fallback, not used by default Hysteria UDP.
+
+### Transport binding
Protocol v1 is carried over either:
-- one bidirectional stream in a QUIC v1 or v2 connection, as negotiated by
- the pinned quic-go transport, or
+- one bidirectional stream in a legacy QUIC v1 or v2 connection, or
- one TLS-over-TCP connection dedicated to a single proxied stream.
-Both transports require TLS 1.3 and negotiate the ALPN value `autocar/1`.
-Normal X.509 chain and DNS/IP SAN verification is mandatory on the client.
-The server can additionally require an mTLS client certificate. QUIC 0-RTT
-and QUIC DATAGRAM are disabled, so a CONNECT request is not sent as replayable
-early data.
+Both require TLS 1.3 and negotiate ALPN `autocar/1`. Normal X.509 chain and
+DNS/IP SAN verification is mandatory. The server can additionally require an
+mTLS client certificate. Legacy QUIC disables 0-RTT and QUIC DATAGRAM.
-Each QUIC stream or fallback TLS connection contains exactly one CONNECT
-exchange followed by an unframed TCP byte stream. Integers are unsigned and
-encoded in network byte order (big endian).
+Each stream or TLS connection contains one CONNECT exchange followed by an
+unframed TCP byte stream. Integers are unsigned and encoded in network byte
+order (big endian).
-## Common header
+### Common header
-Every request and response starts with this 12-byte header:
+Every v1 request and response starts with this 12-byte header:
| Offset | Size | Field | Value |
| ---: | ---: | --- | --- |
@@ -35,42 +165,36 @@ Every request and response starts with this 12-byte header:
| 8 | 2 | Length 1 | Kind-specific body length |
| 10 | 2 | Length 2 | Kind-specific body length or zero |
-Readers reject a bad magic value, unsupported version or unexpected kind.
-Every length is checked against its semantic limit before allocation.
-
-## CONNECT request
+Readers reject a bad magic value, unsupported version, unexpected kind, or a
+length beyond its semantic limit before allocation.
-A request uses kind `1` and this layout:
+### CONNECT request
| Header field | Meaning |
| --- | --- |
| Flags | Network: `1` = `tcp`, `2` = `tcp4`, `3` = `tcp6` |
-| Length 1 | Shared-token byte length, from 16 through 1024 |
-| Length 2 | destination byte length, from 1 through 1024 |
-| Body | token bytes followed immediately by destination bytes |
-
-The destination is a Go `net.SplitHostPort`-compatible `host:port` value.
-IPv6 literals therefore use brackets, for example `[2001:db8::1]:443`. NUL
-bytes are forbidden. Protocol framing does not separately validate UTF-8. A
-hostname is preserved for resolution by the relay.
+| Length 1 | Shared-token byte length, 16 through 1024 |
+| Length 2 | Destination byte length, 1 through 1024 |
+| Body | Token bytes followed immediately by destination bytes |
-The token is transported only after the encrypted channel has been
-established. The relay compares a SHA-256 digest of the presented token with
-the configured token digest using a constant-time comparison. The token is an
-authorization factor, not a replacement for certificate verification.
+The destination is a `host:port` value compatible with Go's
+`net.SplitHostPort`. IPv6 literals use brackets, for example
+`[2001:db8::1]:443`. NUL bytes are forbidden. Hostnames are preserved for
+relay-side resolution.
-## Response
+The token is sent only inside the encrypted channel. The relay compares a
+SHA-256 digest of the presented token with the configured digest using a
+constant-time comparison. It is an authorization factor, not a replacement
+for certificate verification.
-A response uses kind `2` and this layout:
+### Response
| Header field | Meaning |
| --- | --- |
| Flags | Status code |
| Length 1 | Optional human-readable message length, 0 through 1024 |
| Length 2 | Reserved; must be zero |
-| Body | message bytes |
-
-Status values are:
+| Body | Message bytes |
| Value | Name | Meaning |
| ---: | --- | --- |
@@ -81,28 +205,37 @@ Status values are:
| 4 | Busy | Concurrent-stream capacity was exhausted |
| 5 | Internal | Reserved for a relay-side internal failure |
-Relay errors deliberately avoid returning resolver, host-topology or operating
-system details. Clients treat a valid non-OK response as a remote error, not a
-transport outage; it therefore does not trigger a retry through the TCP/TLS
-fallback.
+Relay errors avoid resolver, host-topology, and operating-system details. A
+valid non-OK response is a remote error and does not trigger another transport.
+
+### Relay phase and shutdown
+
+After OK, both sides clear the handshake deadline and copy bytes without more
+application framing. Backpressure comes from the underlying stream. Orderly
+EOF is propagated as a half-close; a hard error aborts both directions.
+
+Legacy QUIC maps each flow to a separate stream in one connection. The TCP
+fallback creates one TLS 1.3 connection per flow and has no custom TCP
+multiplexer. Protocol v1 carries TCP only.
-## Relay phase and shutdown
+## Wire migration
-After an OK response, both sides clear the protocol-handshake deadline and
-copy bytes without further application framing. Backpressure comes from the
-underlying stream. An orderly EOF is propagated as a half-close in the other
-direction, while a hard error aborts both directions.
+Before the Hysteria integration, `--transport=quic` meant AutoCAR v1. It now
+means Hysteria v2. Upgrade both UDP endpoints together, or temporarily pin both
+sides to the legacy names:
-On QUIC, many independent TCP flows share one authenticated QUIC connection,
-with one bidirectional QUIC stream per flow. On the TCP fallback, each flow
-gets a separate TLS 1.3 connection; protocol v1 does not implement a custom
-multiplexer over TCP.
+```sh
+# Relay
+autocar server [common options] --quic-engine=legacy
-## Versioning
+# Client
+autocar client [common options] --transport=legacy-quic
+```
-Any incompatible change requires a new version and a new ALPN value. A v1
-implementation must not silently reinterpret unknown kinds, networks,
-statuses or non-zero reserved fields.
+Only one UDP engine can bind a given address. A staged migration can run the
+new Hysteria engine on a second UDP port, then switch clients and finally
+retire the old port. The TCP/TLS fallback remains protocol v1, so `auto` can
+retain TCP reachability while the UDP endpoints are temporarily mismatched.
-This protocol carries TCP only. SOCKS5 UDP ASSOCIATE and QUIC DATAGRAM are not
-implemented in v1.
+Any incompatible change to AutoCAR v1 requires a new version and ALPN. Hysteria
+wire evolution follows its upstream protocol and the pinned module version.
diff --git a/docs/assets/autocar-logo.svg b/docs/assets/autocar-logo.svg
new file mode 100644
index 0000000..4db3410
--- /dev/null
+++ b/docs/assets/autocar-logo.svg
@@ -0,0 +1,99 @@
+
+
diff --git a/go.mod b/go.mod
index a91f236..5597a7e 100644
--- a/go.mod
+++ b/go.mod
@@ -2,10 +2,34 @@ module github.com/cppla/autocar
go 1.25.0
-require github.com/quic-go/quic-go v0.61.0
+// Local security-hardening fork of Hysteria core v2.12.1. See
+// third_party/hysteria-core/AUTOCAR_PATCHES.md.
+replace github.com/apernet/hysteria/core/v2 => ./third_party/hysteria-core
+
+// Local HTTP/3 pre-read admission hook used by the hardened Hysteria core. See
+// third_party/quic-go/AUTOCAR_PATCHES.md.
+replace github.com/apernet/quic-go => ./third_party/quic-go
+
+require (
+ github.com/apernet/hysteria/core/v2 v2.12.1
+ github.com/apernet/hysteria/extras/v2 v2.12.1
+ github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e
+ github.com/quic-go/quic-go v0.61.0
+)
require (
+ github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/klauspost/compress v1.18.7 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/quic-go/qpack v0.6.0 // indirect
+ github.com/refraction-networking/utls v1.8.2 // indirect
+ github.com/stretchr/objx v0.5.2 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
golang.org/x/crypto v0.54.0 // indirect
- golang.org/x/net v0.56.0 // indirect
+ golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
+ golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index ca322e4..0ac27c4 100644
--- a/go.sum
+++ b/go.sum
@@ -1,20 +1,47 @@
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/apernet/hysteria/extras/v2 v2.12.1 h1:pLtKedlKSUHGCuUxeVaOOI2UUPlj/5DaqXm01FGrF7U=
+github.com/apernet/hysteria/extras/v2 v2.12.1/go.mod h1:QzIFayY1vN8qxX/VpZF+7CGguFpbKDvxbSRp9FmX9V4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
+github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
+github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
+github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
-golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
-golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
+golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/hy2/auto.go b/internal/hy2/auto.go
new file mode 100644
index 0000000..69efb1f
--- /dev/null
+++ b/internal/hy2/auto.go
@@ -0,0 +1,281 @@
+package hy2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/cppla/autocar/internal/transport"
+)
+
+const defaultFallbackCooldown = 30 * time.Second
+
+const (
+ autoRoutePrimary uint32 = iota
+ autoRouteFallback
+)
+
+type closeDialer interface {
+ transport.Dialer
+ Close() error
+}
+
+// AutoConfig configures UDP-first operation with a real TCP/TLS fallback.
+type AutoConfig struct {
+ Primary *Client
+ Fallback closeDialer
+ AttemptTimeout time.Duration
+ Cooldown time.Duration
+ // OnFallback is called once when a healthy/unknown primary circuit first
+ // becomes unavailable. It is intended for concise operational logging and
+ // must not retain secrets or block.
+ OnFallback func(error)
+}
+
+// AutoClient uses Hysteria v2 first and temporarily routes new TCP flows over
+// TLS when the UDP path is unavailable. A valid relay-side destination error
+// and an authentication rejection never open the circuit.
+type AutoClient struct {
+ primary *Client
+ fallback closeDialer
+ timeout time.Duration
+ cooldown time.Duration
+ observer func(error)
+ route atomic.Uint32
+
+ fallbackMu sync.RWMutex
+ lifecycleCtx context.Context
+ lifecycleCancel context.CancelFunc
+
+ mu sync.Mutex
+ failedAt time.Time
+ probing bool
+ closed bool
+ closeErr error
+ closeDone chan struct{}
+}
+
+// NewAutoClient creates an automatic dual-transport dialer.
+func NewAutoClient(config AutoConfig) (*AutoClient, error) {
+ if config.Primary == nil || config.Fallback == nil {
+ return nil, errors.New("hy2: auto mode requires primary and fallback dialers")
+ }
+ if config.AttemptTimeout <= 0 {
+ return nil, errors.New("hy2: auto attempt timeout must be positive")
+ }
+ if config.Cooldown < 0 {
+ return nil, errors.New("hy2: fallback cooldown cannot be negative")
+ }
+ if config.Cooldown == 0 {
+ config.Cooldown = defaultFallbackCooldown
+ }
+ lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background())
+ return &AutoClient{
+ primary: config.Primary,
+ fallback: config.Fallback,
+ timeout: config.AttemptTimeout,
+ cooldown: config.Cooldown,
+ observer: config.OnFallback,
+ lifecycleCtx: lifecycleCtx,
+ lifecycleCancel: lifecycleCancel,
+ closeDone: make(chan struct{}),
+ }, nil
+}
+
+func (c *AutoClient) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ if ctx == nil {
+ return nil, errors.New("hy2: nil dial context")
+ }
+ if c.isClosed() {
+ return nil, net.ErrClosed
+ }
+ if !c.shouldTryPrimary(time.Now()) {
+ // Close may race between the first closed check and the routing
+ // decision. Never invoke an already-closed fallback in that window.
+ if c.isClosed() {
+ return nil, net.ErrClosed
+ }
+ return c.dialFallback(ctx, network, address)
+ }
+ primaryCtx, cancel := context.WithTimeout(ctx, c.timeout)
+ primaryConn, primaryErr := c.primary.DialContext(primaryCtx, network, address)
+ cancel()
+ if primaryErr == nil {
+ c.primarySucceeded()
+ return primaryConn, nil
+ }
+ if callerErr := context.Cause(ctx); callerErr != nil {
+ c.probeFinished()
+ return nil, callerErr
+ }
+ if IsRemoteDialError(primaryErr) || IsAuthenticationError(primaryErr) {
+ c.primarySucceeded()
+ return nil, primaryErr
+ }
+ if c.isClosed() {
+ return nil, net.ErrClosed
+ }
+ if c.primaryFailed(time.Now()) && c.observer != nil {
+ c.observer(primaryErr)
+ }
+ fallbackConn, fallbackErr := c.dialFallback(ctx, network, address)
+ if fallbackErr == nil {
+ return fallbackConn, nil
+ }
+ return nil, errors.Join(
+ fmt.Errorf("hy2 primary: %w", primaryErr),
+ fmt.Errorf("TLS fallback: %w", fallbackErr),
+ )
+}
+
+func (c *AutoClient) dialFallback(ctx context.Context, network, address string) (net.Conn, error) {
+ // The read lock linearizes fallback dispatch with Close: once Close marks
+ // the client closed, no new call can enter the fallback, and an already
+ // running call receives lifecycle cancellation before Close waits for it.
+ c.fallbackMu.RLock()
+ defer c.fallbackMu.RUnlock()
+ if c.isClosed() {
+ return nil, net.ErrClosed
+ }
+ callCtx, cancel := context.WithCancelCause(ctx)
+ var stop func() bool
+ if c.lifecycleCtx != nil {
+ stop = context.AfterFunc(c.lifecycleCtx, func() { cancel(net.ErrClosed) })
+ }
+ defer func() {
+ if stop != nil {
+ stop()
+ }
+ cancel(nil)
+ }()
+ conn, err := c.fallback.DialContext(callCtx, network, address)
+ if err == nil {
+ c.route.Store(autoRouteFallback)
+ }
+ return conn, err
+}
+
+// DialPacket opens an accelerated UDP session. TCP/TLS cannot carry SOCKS5
+// UDP without head-of-line blocking, so datagrams intentionally have no
+// fallback and report a primary-path failure directly.
+func (c *AutoClient) DialPacket(ctx context.Context) (transport.PacketConn, error) {
+ if ctx == nil {
+ return nil, errors.New("hy2: nil packet context")
+ }
+ if c.isClosed() {
+ return nil, net.ErrClosed
+ }
+ packetCtx, cancel := context.WithTimeout(ctx, c.timeout)
+ defer cancel()
+ conn, err := c.primary.DialPacket(packetCtx)
+ if err == nil {
+ c.route.Store(autoRoutePrimary)
+ }
+ return conn, err
+}
+
+// AccelerationMode reports the transport used by the most recent successful
+// flow. A TLS fallback has no QUIC congestion controller.
+func (c *AutoClient) AccelerationMode() string {
+ if c.route.Load() == autoRouteFallback {
+ return "tls-fallback"
+ }
+ return c.primary.AccelerationMode()
+}
+
+// NegotiatedTx reports the primary QUIC path's most recently negotiated
+// client-to-server Brutal rate. It is zero while the TLS fallback, BBR or Reno
+// is active.
+func (c *AutoClient) NegotiatedTx() uint64 {
+ if c.route.Load() == autoRouteFallback {
+ return 0
+ }
+ return c.primary.NegotiatedTx()
+}
+
+func (c *AutoClient) isClosed() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.closed
+}
+
+func (c *AutoClient) shouldTryPrimary(now time.Time) bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.closed {
+ return false
+ }
+ if c.failedAt.IsZero() {
+ return true
+ }
+ if now.Before(c.failedAt.Add(c.cooldown)) || c.probing {
+ return false
+ }
+ c.probing = true
+ return true
+}
+
+func (c *AutoClient) primarySucceeded() {
+ c.mu.Lock()
+ c.failedAt = time.Time{}
+ c.probing = false
+ c.mu.Unlock()
+ c.route.Store(autoRoutePrimary)
+}
+
+func (c *AutoClient) primaryFailed(now time.Time) bool {
+ c.mu.Lock()
+ firstFailure := c.failedAt.IsZero()
+ c.failedAt = now
+ c.probing = false
+ c.mu.Unlock()
+ return firstFailure
+}
+
+func (c *AutoClient) probeFinished() {
+ c.mu.Lock()
+ c.probing = false
+ c.mu.Unlock()
+}
+
+// Close closes both transports.
+func (c *AutoClient) Close() error {
+ c.mu.Lock()
+ if c.closed {
+ done := c.closeDone
+ c.mu.Unlock()
+ if done != nil {
+ <-done
+ }
+ c.mu.Lock()
+ err := c.closeErr
+ c.mu.Unlock()
+ return err
+ }
+ c.closed = true
+ if c.closeDone == nil {
+ c.closeDone = make(chan struct{})
+ }
+ done := c.closeDone
+ lifecycleCancel := c.lifecycleCancel
+ c.mu.Unlock()
+
+ if lifecycleCancel != nil {
+ lifecycleCancel()
+ }
+ c.fallbackMu.Lock()
+ err := errors.Join(c.primary.Close(), c.fallback.Close())
+ c.fallbackMu.Unlock()
+ c.mu.Lock()
+ c.closeErr = err
+ close(done)
+ c.mu.Unlock()
+ return err
+}
+
+var _ transport.Dialer = (*AutoClient)(nil)
+var _ transport.PacketDialer = (*AutoClient)(nil)
diff --git a/internal/hy2/client.go b/internal/hy2/client.go
new file mode 100644
index 0000000..a3ec493
--- /dev/null
+++ b/internal/hy2/client.go
@@ -0,0 +1,543 @@
+// Package hy2 adapts the Hysteria v2 transport core to AutoCAR's proxy
+// interfaces. It provides a long-lived HTTP/3-over-QUIC session, BBR or
+// negotiated Brutal congestion control, QUIC datagrams and optional
+// Salamander packet obfuscation.
+package hy2
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "net"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ hyclient "github.com/apernet/hysteria/core/v2/client"
+ hyerrors "github.com/apernet/hysteria/core/v2/errors"
+ "github.com/apernet/hysteria/extras/v2/obfs"
+ "github.com/cppla/autocar/internal/protocol"
+ "github.com/cppla/autocar/internal/transport"
+)
+
+const (
+ CongestionBBR = "bbr"
+ CongestionReno = "reno"
+
+ BBRConservative = "conservative"
+ BBRStandard = "standard"
+ BBRAggressive = "aggressive"
+
+ minimumBandwidth = 65536
+ maximumBandwidth = 1_000_000_000_000 // 8 Tbit/s; keeps signed QUIC arithmetic safely bounded.
+ minimumObfsKey = 16
+ defaultMaxPendingOpens = 256
+)
+
+// ClientConfig configures the accelerated Hysteria v2 transport. Bandwidth
+// values are bytes per second. Leaving both values at zero selects BBR;
+// setting a value selects negotiated Brutal for that sending direction.
+type ClientConfig struct {
+ ServerAddress string
+ Token string
+ TLSConfig *tls.Config
+
+ Congestion string
+ BBRProfile string
+ MaxTx uint64
+ MaxRx uint64
+
+ DisableLossCompensation bool
+ FastOpen bool
+ ObfuscationKey []byte
+ DisablePathMTUDiscovery bool
+ DisableGSO bool
+ DisableChromeParrot bool
+ MaxIdleTimeout time.Duration
+ KeepAlivePeriod time.Duration
+ MaxPendingOpens int
+}
+
+// Client is a reconnecting Hysteria v2 client. The first request establishes
+// the authenticated HTTP/3 session lazily.
+type Client struct {
+ config ClientConfig
+
+ coreMu sync.Mutex
+ core hyclient.Client
+ attempt *connectAttempt
+ connectFunc func() (hyclient.Client, *hyclient.HandshakeInfo, error)
+
+ closed sync.Once
+ closeErr error
+ closedState atomic.Bool
+ closeCh chan struct{}
+ openSlots chan struct{}
+
+ connections atomic.Uint64
+ negotiated atomic.Uint64
+ udpEnabled atomic.Bool
+}
+
+type connectAttempt struct {
+ done chan struct{}
+ core hyclient.Client
+ err error
+}
+
+// NewClient validates config and creates a lazy reconnecting client.
+func NewClient(config ClientConfig) (*Client, error) {
+ if err := validateClientConfig(config); err != nil {
+ return nil, err
+ }
+ config.Congestion = normalizeCongestion(config.Congestion)
+ config.BBRProfile = normalizeBBRProfile(config.BBRProfile)
+ config.ObfuscationKey = append([]byte(nil), config.ObfuscationKey...)
+ config.TLSConfig = config.TLSConfig.Clone()
+ if config.MaxPendingOpens == 0 {
+ config.MaxPendingOpens = defaultMaxPendingOpens
+ }
+
+ return &Client{
+ config: config,
+ closeCh: make(chan struct{}),
+ openSlots: make(chan struct{}, config.MaxPendingOpens),
+ }, nil
+}
+
+func validateClientConfig(config ClientConfig) error {
+ if config.ServerAddress == "" {
+ return errors.New("hy2: server address is required")
+ }
+ if _, _, err := net.SplitHostPort(config.ServerAddress); err != nil {
+ return fmt.Errorf("hy2: invalid server address %q: %w", config.ServerAddress, err)
+ }
+ if len(config.Token) < protocol.MinTokenLength || len(config.Token) > protocol.MaxTokenLength {
+ return fmt.Errorf("hy2: token length must be between %d and %d bytes", protocol.MinTokenLength, protocol.MaxTokenLength)
+ }
+ if config.TLSConfig == nil {
+ return errors.New("hy2: TLS config is required")
+ }
+ if config.TLSConfig.InsecureSkipVerify {
+ return errors.New("hy2: InsecureSkipVerify is prohibited")
+ }
+ if config.TLSConfig.ServerName == "" || config.TLSConfig.RootCAs == nil {
+ return errors.New("hy2: verified server name and explicit root CAs are required")
+ }
+ if err := validateClientTLSPolicy(config.TLSConfig); err != nil {
+ return err
+ }
+ congestion := normalizeCongestion(config.Congestion)
+ if congestion != CongestionBBR && congestion != CongestionReno {
+ return fmt.Errorf("hy2: unsupported congestion controller %q", config.Congestion)
+ }
+ profile := normalizeBBRProfile(config.BBRProfile)
+ if congestion == CongestionBBR && profile != BBRConservative && profile != BBRStandard && profile != BBRAggressive {
+ return fmt.Errorf("hy2: unsupported BBR profile %q", config.BBRProfile)
+ }
+ for name, bandwidth := range map[string]uint64{"MaxTx": config.MaxTx, "MaxRx": config.MaxRx} {
+ if bandwidth != 0 && bandwidth < minimumBandwidth {
+ return fmt.Errorf("hy2: %s must be zero or at least %d bytes/s", name, minimumBandwidth)
+ }
+ if bandwidth > maximumBandwidth {
+ return fmt.Errorf("hy2: %s must not exceed %d bytes/s", name, maximumBandwidth)
+ }
+ }
+ if len(config.ObfuscationKey) != 0 && len(config.ObfuscationKey) < minimumObfsKey {
+ return fmt.Errorf("hy2: obfuscation key must be at least %d bytes", minimumObfsKey)
+ }
+ if config.MaxIdleTimeout != 0 && (config.MaxIdleTimeout < 4*time.Second || config.MaxIdleTimeout > 120*time.Second) {
+ return errors.New("hy2: maximum idle timeout must be zero or between 4s and 120s")
+ }
+ if config.KeepAlivePeriod != 0 && (config.KeepAlivePeriod < 2*time.Second || config.KeepAlivePeriod > 60*time.Second) {
+ return errors.New("hy2: keepalive period must be zero or between 2s and 60s")
+ }
+ if config.MaxPendingOpens < 0 || config.MaxPendingOpens > 65536 {
+ return errors.New("hy2: maximum pending opens must be zero or at most 65536")
+ }
+ return nil
+}
+
+func normalizeCongestion(value string) string {
+ value = strings.ToLower(strings.TrimSpace(value))
+ if value == "" {
+ return CongestionBBR
+ }
+ return value
+}
+
+func normalizeBBRProfile(value string) string {
+ value = strings.ToLower(strings.TrimSpace(value))
+ if value == "" {
+ return BBRStandard
+ }
+ return value
+}
+
+func (c *Client) newCoreConfig() (*hyclient.Config, error) {
+ serverAddress, err := net.ResolveUDPAddr("udp", c.config.ServerAddress)
+ if err != nil {
+ return nil, fmt.Errorf("hy2: resolve relay: %w", err)
+ }
+ tlsConfig := c.config.TLSConfig.Clone()
+ getClientCertificate := tlsConfig.GetClientCertificate
+ if getClientCertificate == nil && len(tlsConfig.Certificates) != 0 {
+ certificates := append([]tls.Certificate(nil), tlsConfig.Certificates...)
+ getClientCertificate = func(request *tls.CertificateRequestInfo) (*tls.Certificate, error) {
+ for i := range certificates {
+ if err := request.SupportsCertificate(&certificates[i]); err == nil {
+ return &certificates[i], nil
+ }
+ }
+ return &tls.Certificate{}, nil
+ }
+ }
+ return &hyclient.Config{
+ ConnFactory: &packetConnFactory{obfuscationKey: c.config.ObfuscationKey},
+ ServerAddr: serverAddress,
+ Auth: c.config.Token,
+ TLSConfig: hyclient.TLSConfig{
+ ServerName: tlsConfig.ServerName,
+ InsecureSkipVerify: false,
+ VerifyPeerCertificate: tlsConfig.VerifyPeerCertificate,
+ RootCAs: tlsConfig.RootCAs.Clone(),
+ GetClientCertificate: getClientCertificate,
+ ECHConfigList: append([]byte(nil), tlsConfig.EncryptedClientHelloConfigList...),
+ },
+ QUICConfig: hyclient.QUICConfig{
+ MaxIdleTimeout: c.config.MaxIdleTimeout,
+ KeepAlivePeriod: c.config.KeepAlivePeriod,
+ DisablePathMTUDiscovery: c.config.DisablePathMTUDiscovery,
+ DisableGSO: c.config.DisableGSO,
+ DisableChromeParrot: c.config.DisableChromeParrot,
+ },
+ CongestionConfig: hyclient.CongestionConfig{
+ Type: c.config.Congestion,
+ BBRProfile: c.config.BBRProfile,
+ },
+ BandwidthConfig: hyclient.BandwidthConfig{
+ MaxTx: c.config.MaxTx,
+ MaxRx: c.config.MaxRx,
+ DisableLossCompensation: c.config.DisableLossCompensation,
+ },
+ FastOpen: c.config.FastOpen,
+ }, nil
+}
+
+// coreForContext returns the active authenticated session. Connection setup is
+// a context-aware single flight: a UDP black hole can leave at most one
+// bounded upstream handshake running, while all other callers wait without
+// spawning their own reconnect attempts. A successful session is shared by
+// all streams and datagram associations.
+func (c *Client) coreForContext(ctx context.Context) (hyclient.Client, error) {
+ c.coreMu.Lock()
+ if c.closedState.Load() {
+ c.coreMu.Unlock()
+ return nil, net.ErrClosed
+ }
+ if c.core != nil {
+ core := c.core
+ c.coreMu.Unlock()
+ return core, nil
+ }
+ attempt := c.attempt
+ if attempt == nil {
+ attempt = &connectAttempt{done: make(chan struct{})}
+ c.attempt = attempt
+ go c.connect(attempt)
+ }
+ c.coreMu.Unlock()
+
+ select {
+ case <-attempt.done:
+ if attempt.err != nil {
+ return nil, attempt.err
+ }
+ return attempt.core, nil
+ case <-ctx.Done():
+ return nil, context.Cause(ctx)
+ case <-c.closeCh:
+ return nil, net.ErrClosed
+ }
+}
+
+func (c *Client) connect(attempt *connectAttempt) {
+ connect := c.connectFunc
+ if connect == nil {
+ connect = func() (hyclient.Client, *hyclient.HandshakeInfo, error) {
+ config, err := c.newCoreConfig()
+ if err != nil {
+ return nil, nil, err
+ }
+ return hyclient.NewClient(config)
+ }
+ }
+ core, info, err := connect()
+ if err == nil && (core == nil || info == nil) {
+ err = errors.New("hy2: connector returned an incomplete session")
+ }
+ if err != nil {
+ err = c.addChromeCertificateHint(err)
+ err = fmt.Errorf("hy2: establish authenticated session: %w", err)
+ }
+
+ c.coreMu.Lock()
+ if err == nil && c.closedState.Load() {
+ err = net.ErrClosed
+ }
+ if err == nil {
+ c.core = core
+ c.connections.Add(1)
+ c.negotiated.Store(info.Tx)
+ c.udpEnabled.Store(info.UDPEnabled)
+ }
+ attempt.core = core
+ attempt.err = err
+ if c.attempt == attempt {
+ c.attempt = nil
+ }
+ close(attempt.done)
+ c.coreMu.Unlock()
+
+ if err != nil && core != nil {
+ _ = core.Close()
+ }
+}
+
+// addChromeCertificateHint preserves the handshake error while explaining a
+// known compatibility constraint of the Chrome-parroting ClientHello. Chrome's
+// advertised signature schemes intentionally omit Ed25519; an operator can
+// either serve an ECDSA P-256/P-384/RSA certificate or explicitly disable parroting.
+func (c *Client) addChromeCertificateHint(err error) error {
+ if err == nil || c.config.DisableChromeParrot {
+ return err
+ }
+ message := strings.ToLower(err.Error())
+ if !strings.Contains(message, "handshake failure") &&
+ !strings.Contains(message, "signature algorithm") {
+ return err
+ }
+ return fmt.Errorf("%w (Chrome QUIC fingerprinting is enabled; if the relay certificate is Ed25519, use ECDSA P-256/P-384/RSA or pass --disable-chrome-parrot)", err)
+}
+
+func (c *Client) invalidate(core hyclient.Client, err error) {
+ var closedError hyerrors.ClosedError
+ if !errors.As(err, &closedError) {
+ return
+ }
+ c.coreMu.Lock()
+ if c.core != core {
+ c.coreMu.Unlock()
+ return
+ }
+ c.core = nil
+ c.udpEnabled.Store(false)
+ c.negotiated.Store(0)
+ c.coreMu.Unlock()
+ _ = core.Close()
+}
+
+type packetConnFactory struct {
+ obfuscationKey []byte
+}
+
+func (f *packetConnFactory) New(net.Addr) (net.PacketConn, error) {
+ conn, err := net.ListenUDP("udp", nil)
+ if err != nil {
+ return nil, err
+ }
+ if len(f.obfuscationKey) == 0 {
+ return conn, nil
+ }
+ wrapped, err := obfs.WrapPacketConnSalamander(conn, f.obfuscationKey)
+ if err != nil {
+ _ = conn.Close()
+ return nil, err
+ }
+ return wrapped, nil
+}
+
+type tcpResult struct {
+ conn net.Conn
+ err error
+}
+
+// DialContext opens a TCP stream over the authenticated QUIC session.
+func (c *Client) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ if ctx == nil {
+ return nil, errors.New("hy2: nil dial context")
+ }
+ if c.closedState.Load() {
+ return nil, net.ErrClosed
+ }
+ if network != "tcp" {
+ return nil, fmt.Errorf("hy2: unsupported network %q", network)
+ }
+ if _, _, err := net.SplitHostPort(address); err != nil {
+ return nil, fmt.Errorf("hy2: invalid destination %q: %w", address, err)
+ }
+ if err := c.acquireOpen(ctx); err != nil {
+ return nil, err
+ }
+ core, err := c.coreForContext(ctx)
+ if err != nil {
+ c.releaseOpen()
+ return nil, err
+ }
+ result := make(chan tcpResult)
+ go func() {
+ defer c.releaseOpen()
+ conn, err := core.TCP(address)
+ c.invalidate(core, err)
+ value := tcpResult{conn: conn, err: err}
+ select {
+ case result <- value:
+ case <-ctx.Done():
+ if conn != nil {
+ _ = conn.Close()
+ }
+ case <-c.closeCh:
+ if conn != nil {
+ _ = conn.Close()
+ }
+ }
+ }()
+ select {
+ case value := <-result:
+ if value.err != nil {
+ return nil, fmt.Errorf("hy2: open TCP stream: %w", value.err)
+ }
+ if err := context.Cause(ctx); err != nil {
+ _ = value.conn.Close()
+ return nil, err
+ }
+ return value.conn, nil
+ case <-ctx.Done():
+ return nil, context.Cause(ctx)
+ case <-c.closeCh:
+ return nil, net.ErrClosed
+ }
+}
+
+func (c *Client) acquireOpen(ctx context.Context) error {
+ if c.openSlots == nil {
+ return nil
+ }
+ select {
+ case c.openSlots <- struct{}{}:
+ return nil
+ case <-ctx.Done():
+ return context.Cause(ctx)
+ case <-c.closeCh:
+ return net.ErrClosed
+ }
+}
+
+func (c *Client) releaseOpen() {
+ if c.openSlots != nil {
+ <-c.openSlots
+ }
+}
+
+// DialPacket opens one logical UDP session carried by QUIC DATAGRAM frames.
+func (c *Client) DialPacket(ctx context.Context) (transport.PacketConn, error) {
+ if ctx == nil {
+ return nil, errors.New("hy2: nil packet context")
+ }
+ if c.closedState.Load() {
+ return nil, net.ErrClosed
+ }
+ core, err := c.coreForContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ conn, err := core.UDP()
+ c.invalidate(core, err)
+ if err != nil {
+ return nil, fmt.Errorf("hy2: open UDP session: %w", err)
+ }
+ if err := context.Cause(ctx); err != nil {
+ _ = conn.Close()
+ return nil, err
+ }
+ return &packetConn{core: conn}, nil
+}
+
+type packetConn struct {
+ core hyclient.HyUDPConn
+}
+
+func (c *packetConn) Send(payload []byte, address string) error {
+ return c.core.Send(payload, address)
+}
+
+func (c *packetConn) Receive() ([]byte, string, error) {
+ return c.core.Receive()
+}
+
+func (c *packetConn) Close() error { return c.core.Close() }
+
+func (c *packetConn) MaxPayloadSize() int { return hyclient.MaxUDPSize }
+
+// NegotiatedTx reports the most recently negotiated client-to-server Brutal
+// rate in bytes/s. Zero means BBR or Reno is active for that direction.
+func (c *Client) NegotiatedTx() uint64 { return c.negotiated.Load() }
+
+// UDPEnabled reports whether the current server handshake enabled datagrams.
+func (c *Client) UDPEnabled() bool { return c.udpEnabled.Load() }
+
+// ConnectionCount reports successful authenticated session establishments,
+// including reconnects.
+func (c *Client) ConnectionCount() uint64 { return c.connections.Load() }
+
+// AccelerationMode reports the active outbound congestion-control mode. A
+// non-zero negotiated rate always means Brutal; otherwise the configured BBR
+// profile or Reno controls the connection.
+func (c *Client) AccelerationMode() string {
+ if c.negotiated.Load() > 0 {
+ return "brutal"
+ }
+ if normalizeCongestion(c.config.Congestion) == CongestionReno {
+ return CongestionReno
+ }
+ return CongestionBBR + "-" + normalizeBBRProfile(c.config.BBRProfile)
+}
+
+// Close permanently closes the reconnecting client.
+func (c *Client) Close() error {
+ c.closed.Do(func() {
+ c.closedState.Store(true)
+ if c.closeCh != nil {
+ close(c.closeCh)
+ }
+ c.coreMu.Lock()
+ core := c.core
+ c.core = nil
+ c.coreMu.Unlock()
+ if core != nil {
+ c.closeErr = core.Close()
+ }
+ })
+ return c.closeErr
+}
+
+// IsRemoteDialError reports errors returned by an authenticated relay after it
+// attempted the requested target. Auto mode must not retry those requests via
+// another transport, because the primary path itself is healthy.
+func IsRemoteDialError(err error) bool {
+ var dialError hyerrors.DialError
+ return errors.As(err, &dialError)
+}
+
+// IsAuthenticationError reports Hysteria authentication rejection.
+func IsAuthenticationError(err error) bool {
+ var authError hyerrors.AuthError
+ return errors.As(err, &authError)
+}
+
+var _ transport.Dialer = (*Client)(nil)
+var _ transport.PacketDialer = (*Client)(nil)
+var _ transport.PacketConn = (*packetConn)(nil)
+var _ transport.PacketPayloadSizer = (*packetConn)(nil)
diff --git a/internal/hy2/hy2_test.go b/internal/hy2/hy2_test.go
new file mode 100644
index 0000000..6d535ff
--- /dev/null
+++ b/internal/hy2/hy2_test.go
@@ -0,0 +1,1614 @@
+package hy2
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/netip"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ hyclient "github.com/apernet/hysteria/core/v2/client"
+ hyserver "github.com/apernet/hysteria/core/v2/server"
+ "github.com/apernet/quic-go/http3"
+ "github.com/cppla/autocar/internal/security"
+ "github.com/cppla/autocar/internal/transport"
+)
+
+const (
+ testToken = "correct horse battery staple"
+ wrongTestToken = "incorrect horse battery staple"
+)
+
+func TestBBRLoopbackTCPEcho(t *testing.T) {
+ target := startTCPEcho(t)
+ server, clientTLS, outbound := startTestServer(t, nil)
+ client := newTestClient(t, server, clientTLS, nil)
+
+ exchangeTCP(t, client, target, "BBR keeps one authenticated QUIC session hot")
+ if got := client.NegotiatedTx(); got != 0 {
+ t.Fatalf("NegotiatedTx = %d, want 0 while BBR is active", got)
+ }
+ if got := server.admission.lastTx.Load(); got != 0 {
+ t.Fatalf("server negotiated Tx = %d, want 0 while BBR is active", got)
+ }
+ if got := client.ConnectionCount(); got != 1 {
+ t.Fatalf("authenticated connection count = %d, want 1", got)
+ }
+ if got := client.AccelerationMode(); got != "bbr-standard" {
+ t.Fatalf("acceleration mode = %q, want bbr-standard", got)
+ }
+ if got := outbound.tcpDials.Load(); got != 1 {
+ t.Fatalf("target dial count = %d, want 1", got)
+ }
+}
+
+func TestBrutalNegotiatesBothDirectionsAndAppliesServerCaps(t *testing.T) {
+ const (
+ serverMaxTx = 300_000
+ serverMaxRx = 400_000
+ clientMaxTx = 700_000
+ clientMaxRx = 600_000
+ )
+ target := startTCPEcho(t)
+ server, clientTLS, _ := startTestServer(t, func(config *ServerConfig) {
+ config.MaxTx = serverMaxTx
+ config.MaxRx = serverMaxRx
+ config.AllowClientBandwidth = true
+ })
+ client := newTestClient(t, server, clientTLS, func(config *ClientConfig) {
+ config.MaxTx = clientMaxTx
+ config.MaxRx = clientMaxRx
+ })
+
+ exchangeTCP(t, client, target, "Brutal is negotiated independently in both directions")
+ if got := client.NegotiatedTx(); got != serverMaxRx {
+ t.Fatalf("client Tx = %d, want server Rx cap %d", got, serverMaxRx)
+ }
+ if got := server.admission.lastTx.Load(); got != serverMaxTx {
+ t.Fatalf("server Tx = %d, want server Tx cap %d", got, serverMaxTx)
+ }
+ if got := client.AccelerationMode(); got != "brutal" {
+ t.Fatalf("acceleration mode = %q, want brutal", got)
+ }
+}
+
+func TestServerIgnoresClientBandwidthByDefault(t *testing.T) {
+ const requested = 900_000
+ target := startTCPEcho(t)
+ server, clientTLS, _ := startTestServer(t, nil)
+ client := newTestClient(t, server, clientTLS, func(config *ClientConfig) {
+ config.MaxTx = requested
+ config.MaxRx = requested
+ })
+
+ exchangeTCP(t, client, target, "secure default keeps the model controller")
+ if got := client.NegotiatedTx(); got != 0 {
+ t.Fatalf("client Tx = %d, want BBR negotiation value 0", got)
+ }
+ if got := server.admission.lastTx.Load(); got != 0 {
+ t.Fatalf("server Tx = %d, want BBR negotiation value 0", got)
+ }
+ if got := client.AccelerationMode(); got != "bbr-standard" {
+ t.Fatalf("acceleration mode = %q, want bbr-standard", got)
+ }
+}
+
+func TestAccelerationModeLabelsControllerAndProfile(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ config ClientConfig
+ want string
+ }{
+ {name: "default BBR", want: "bbr-standard"},
+ {name: "conservative BBR", config: ClientConfig{Congestion: CongestionBBR, BBRProfile: BBRConservative}, want: "bbr-conservative"},
+ {name: "aggressive BBR", config: ClientConfig{Congestion: CongestionBBR, BBRProfile: BBRAggressive}, want: "bbr-aggressive"},
+ {name: "Reno", config: ClientConfig{Congestion: CongestionReno}, want: "reno"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ client := &Client{config: test.config}
+ if got := client.AccelerationMode(); got != test.want {
+ t.Fatalf("AccelerationMode = %q, want %q", got, test.want)
+ }
+ })
+ }
+ client := &Client{config: ClientConfig{Congestion: CongestionBBR, BBRProfile: BBRStandard}}
+ auto := &AutoClient{primary: client}
+ if got := auto.AccelerationMode(); got != "bbr-standard" {
+ t.Fatalf("AutoClient AccelerationMode = %q", got)
+ }
+ client.negotiated.Store(123_456)
+ if got := auto.NegotiatedTx(); got != 123_456 {
+ t.Fatalf("AutoClient NegotiatedTx = %d, want 123456", got)
+ }
+ auto.route.Store(autoRouteFallback)
+ if got := auto.AccelerationMode(); got != "tls-fallback" {
+ t.Fatalf("fallback AccelerationMode = %q", got)
+ }
+ if got := auto.NegotiatedTx(); got != 0 {
+ t.Fatalf("fallback NegotiatedTx = %d, want 0", got)
+ }
+ auto.primarySucceeded()
+ if got := auto.AccelerationMode(); got != "brutal" {
+ t.Fatalf("restored primary AccelerationMode = %q", got)
+ }
+}
+
+func TestChromeHandshakeFailureIncludesCertificateGuidance(t *testing.T) {
+ client := &Client{
+ config: ClientConfig{},
+ closeCh: make(chan struct{}),
+ connectFunc: func() (hyclient.Client, *hyclient.HandshakeInfo, error) {
+ return nil, nil, errors.New("remote error: tls: handshake failure")
+ },
+ }
+ _, err := client.coreForContext(context.Background())
+ if err == nil || !strings.Contains(err.Error(), "Ed25519") || !strings.Contains(err.Error(), "--disable-chrome-parrot") {
+ t.Fatalf("Chrome handshake guidance error = %v", err)
+ }
+
+ disabled := &Client{
+ config: ClientConfig{DisableChromeParrot: true},
+ closeCh: make(chan struct{}),
+ connectFunc: func() (hyclient.Client, *hyclient.HandshakeInfo, error) {
+ return nil, nil, errors.New("remote error: tls: handshake failure")
+ },
+ }
+ _, err = disabled.coreForContext(context.Background())
+ if err == nil || strings.Contains(err.Error(), "Ed25519") {
+ t.Fatalf("disabled Chrome mode added misleading guidance: %v", err)
+ }
+}
+
+func TestWrongTokenNeverDialsTarget(t *testing.T) {
+ server, clientTLS, outbound := startTestServer(t, nil)
+ client := newTestClient(t, server, clientTLS, func(config *ClientConfig) {
+ config.Token = wrongTestToken
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _, err := client.DialContext(ctx, "tcp", "127.0.0.1:1")
+ if err == nil || !IsAuthenticationError(err) {
+ t.Fatalf("wrong-token error = %v, want authentication rejection", err)
+ }
+ if got := outbound.tcpDials.Load(); got != 0 {
+ t.Fatalf("wrong token caused %d target dials", got)
+ }
+}
+
+func TestWrongCAPreventsSessionAndTargetDial(t *testing.T) {
+ server, _, outbound := startTestServer(t, nil)
+ _, wrongClientTLS := testTLSConfigs(t)
+ client := newTestClient(t, server, wrongClientTLS, nil)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _, err := client.DialContext(ctx, "tcp", "127.0.0.1:1")
+ if err == nil {
+ t.Fatal("relay certificate signed by an untrusted CA was accepted")
+ }
+ if got := outbound.tcpDials.Load(); got != 0 {
+ t.Fatalf("failed TLS verification caused %d target dials", got)
+ }
+}
+
+func TestClientVerifyPeerCertificateCallbackIsPreserved(t *testing.T) {
+ server, clientTLS, outbound := startTestServer(t, nil)
+ var called atomic.Bool
+ client := newTestClient(t, server, clientTLS, func(config *ClientConfig) {
+ config.TLSConfig = config.TLSConfig.Clone()
+ config.TLSConfig.VerifyPeerCertificate = func([][]byte, [][]*x509.Certificate) error {
+ called.Store(true)
+ return errors.New("test certificate policy rejected the relay")
+ }
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if _, err := client.DialContext(ctx, "tcp", "127.0.0.1:1"); err == nil {
+ t.Fatal("custom certificate policy was silently ignored")
+ }
+ if !called.Load() {
+ t.Fatal("VerifyPeerCertificate was not called")
+ }
+ if got := outbound.tcpDials.Load(); got != 0 {
+ t.Fatalf("rejected TLS policy caused %d target dials", got)
+ }
+}
+
+func TestHysteriaStrictMutualTLS(t *testing.T) {
+ target := startTCPEcho(t)
+ var clientCertificate tls.Certificate
+ server, clientTLS, outbound := startTestServer(t, func(config *ServerConfig) {
+ clientCertificate = config.TLSConfig.Certificates[0]
+ leaf, err := x509.ParseCertificate(clientCertificate.Certificate[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ clientCAs := x509.NewCertPool()
+ clientCAs.AddCert(leaf)
+ config.TLSConfig.ClientCAs = clientCAs
+ config.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert
+ })
+
+ withoutCertificate := newTestClient(t, server, clientTLS, nil)
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ _, err := withoutCertificate.DialContext(ctx, "tcp", target)
+ cancel()
+ if err == nil {
+ t.Fatal("strict mTLS accepted a client without a certificate")
+ }
+ if got := outbound.tcpDials.Load(); got != 0 {
+ t.Fatalf("client without an mTLS certificate caused %d target dials", got)
+ }
+
+ withCertificate := newTestClient(t, server, clientTLS, func(config *ClientConfig) {
+ config.TLSConfig = config.TLSConfig.Clone()
+ config.TLSConfig.Certificates = []tls.Certificate{clientCertificate}
+ })
+ exchangeTCP(t, withCertificate, target, "strict Hysteria mutual TLS")
+}
+
+func TestClientRejectsTLSVerificationPoliciesTheAdapterCannotPreserve(t *testing.T) {
+ _, base := testTLSConfigs(t)
+ tests := []struct {
+ name string
+ field string
+ modify func(*tls.Config)
+ }{
+ {
+ name: "VerifyConnection",
+ field: "VerifyConnection",
+ modify: func(config *tls.Config) {
+ config.VerifyConnection = func(tls.ConnectionState) error { return nil }
+ },
+ },
+ {
+ name: "ECH rejection verifier",
+ field: "EncryptedClientHelloRejectionVerify",
+ modify: func(config *tls.Config) {
+ config.EncryptedClientHelloRejectionVerify = func(tls.ConnectionState) error { return nil }
+ },
+ },
+ {name: "custom time", field: "Time", modify: func(config *tls.Config) { config.Time = time.Now }},
+ {name: "custom randomness", field: "Rand", modify: func(config *tls.Config) { config.Rand = rand.Reader }},
+ {
+ name: "curve policy",
+ field: "CurvePreferences",
+ modify: func(config *tls.Config) { config.CurvePreferences = []tls.CurveID{tls.CurveP256} },
+ },
+ {
+ name: "TLS 1.2 maximum",
+ field: "MaxVersion",
+ modify: func(config *tls.Config) { config.MaxVersion = tls.VersionTLS12 },
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ config := base.Clone()
+ test.modify(config)
+ _, err := NewClient(ClientConfig{
+ ServerAddress: "127.0.0.1:443",
+ Token: testToken,
+ TLSConfig: config,
+ })
+ if err == nil || !strings.Contains(err.Error(), test.field) {
+ t.Fatalf("error = %v, want explicit %s rejection", err, test.field)
+ }
+ })
+ }
+}
+
+func TestServerRejectsTLSPoliciesTheAdapterCannotPreserve(t *testing.T) {
+ base, _ := testTLSConfigs(t)
+ tests := []struct {
+ name string
+ field string
+ modify func(*tls.Config)
+ }{
+ {
+ name: "GetConfigForClient",
+ field: "GetConfigForClient",
+ modify: func(config *tls.Config) {
+ config.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { return config, nil }
+ },
+ },
+ {
+ name: "VerifyConnection",
+ field: "VerifyConnection",
+ modify: func(config *tls.Config) {
+ config.VerifyConnection = func(tls.ConnectionState) error { return nil }
+ },
+ },
+ {
+ name: "VerifyPeerCertificate",
+ field: "VerifyPeerCertificate",
+ modify: func(config *tls.Config) {
+ config.VerifyPeerCertificate = func([][]byte, [][]*x509.Certificate) error { return nil }
+ },
+ },
+ {name: "custom time", field: "Time", modify: func(config *tls.Config) { config.Time = time.Now }},
+ {name: "custom randomness", field: "Rand", modify: func(config *tls.Config) { config.Rand = rand.Reader }},
+ {
+ name: "curve policy",
+ field: "CurvePreferences",
+ modify: func(config *tls.Config) { config.CurvePreferences = []tls.CurveID{tls.CurveP256} },
+ },
+ {
+ name: "certificate name map",
+ field: "NameToCertificate",
+ modify: func(config *tls.Config) {
+ config.NameToCertificate = map[string]*tls.Certificate{"relay.example": &config.Certificates[0]}
+ },
+ },
+ {
+ name: "session tickets disabled",
+ field: "SessionTicketsDisabled",
+ modify: func(config *tls.Config) { config.SessionTicketsDisabled = true },
+ },
+ {
+ name: "custom session ticket key",
+ field: "SessionTicketKey",
+ modify: func(config *tls.Config) { config.SessionTicketKey[0] = 1 },
+ },
+ {
+ name: "custom session wrapper",
+ field: "WrapSession",
+ modify: func(config *tls.Config) {
+ config.WrapSession = func(tls.ConnectionState, *tls.SessionState) ([]byte, error) { return nil, nil }
+ },
+ },
+ {
+ name: "custom session unwrapper",
+ field: "UnwrapSession",
+ modify: func(config *tls.Config) {
+ config.UnwrapSession = func([]byte, tls.ConnectionState) (*tls.SessionState, error) { return nil, nil }
+ },
+ },
+ {
+ name: "client auth without CA",
+ field: "ClientAuth",
+ modify: func(config *tls.Config) { config.ClientAuth = tls.RequireAnyClientCert },
+ },
+ {
+ name: "client CA without strict auth",
+ field: "ClientCAs",
+ modify: func(config *tls.Config) { config.ClientCAs = x509.NewCertPool() },
+ },
+ {
+ name: "TLS 1.2 maximum",
+ field: "MaxVersion",
+ modify: func(config *tls.Config) { config.MaxVersion = tls.VersionTLS12 },
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ config := base.Clone()
+ test.modify(config)
+ err := validateServerConfig(ServerConfig{
+ Address: "127.0.0.1:0",
+ Token: testToken,
+ TLSConfig: config,
+ Outbound: &testOutbound{},
+ })
+ if err == nil || !strings.Contains(err.Error(), test.field) {
+ t.Fatalf("error = %v, want explicit %s rejection", err, test.field)
+ }
+ })
+ }
+}
+
+func TestTLSAdapterAllowsTLS13IrrelevantAndForwardedCLIFields(t *testing.T) {
+ serverTLS, clientTLS := testTLSConfigs(t)
+ clientTLS.NextProtos = []string{security.ALPN}
+ clientTLS.CipherSuites = []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}
+ clientTLS.DynamicRecordSizingDisabled = true
+ clientTLS.Renegotiation = tls.RenegotiateFreelyAsClient
+ clientTLS.ClientSessionCache = tls.NewLRUClientSessionCache(8)
+ if _, err := NewClient(ClientConfig{
+ ServerAddress: "127.0.0.1:443",
+ Token: testToken,
+ TLSConfig: clientTLS,
+ }); err != nil {
+ t.Fatalf("ordinary TLS 1.3 client config was rejected: %v", err)
+ }
+
+ serverTLS.NextProtos = []string{security.ALPN}
+ serverTLS.CipherSuites = []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}
+ serverTLS.DynamicRecordSizingDisabled = true
+ serverTLS.Renegotiation = tls.RenegotiateNever
+ if err := validateServerConfig(ServerConfig{
+ Address: "127.0.0.1:0",
+ Token: testToken,
+ TLSConfig: serverTLS,
+ Outbound: &testOutbound{},
+ }); err != nil {
+ t.Fatalf("ordinary TLS 1.3 server config was rejected: %v", err)
+ }
+}
+
+func TestHTTP3CoverLooksLikeOrdinaryWebsite(t *testing.T) {
+ server, clientTLS, _ := startTestServer(t, func(config *ServerConfig) {
+ config.MasqueradeHandler = NewCoverHandler("AutoCAR Edge")
+ })
+ h3 := &http3.Transport{TLSClientConfig: clientTLS.Clone()}
+ t.Cleanup(func() { _ = h3.Close() })
+ httpClient := &http.Client{Transport: h3, Timeout: 3 * time.Second}
+
+ response, err := httpClient.Get("https://" + server.Addr().String() + "/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if response.StatusCode != http.StatusOK || !strings.Contains(string(body), "AutoCAR Edge") {
+ t.Fatalf("cover response status=%d body=%q", response.StatusCode, body)
+ }
+ if got := response.Header.Get("X-Content-Type-Options"); got != "nosniff" {
+ t.Fatalf("X-Content-Type-Options = %q", got)
+ }
+}
+
+func TestHTTP3CoverRejectsOversizedRequestHeaders(t *testing.T) {
+ server, clientTLS, _ := startTestServer(t, nil)
+ h3 := &http3.Transport{TLSClientConfig: clientTLS.Clone()}
+ t.Cleanup(func() { _ = h3.Close() })
+ httpClient := &http.Client{Transport: h3, Timeout: 3 * time.Second}
+ request, err := http.NewRequest(http.MethodGet, "https://"+server.Addr().String()+"/", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ request.Header.Set("X-Oversized", strings.Repeat("a", defaultMaxHTTPHeaderBytes*2))
+ response, err := httpClient.Do(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusRequestHeaderFieldsTooLarge {
+ t.Fatalf("oversized request status = %d, want %d", response.StatusCode, http.StatusRequestHeaderFieldsTooLarge)
+ }
+}
+
+func TestSalamanderMatchingKeyWorksAndWrongKeyFails(t *testing.T) {
+ key := []byte("salamander integration secret")
+ target := startTCPEcho(t)
+ server, clientTLS, _ := startTestServer(t, func(config *ServerConfig) {
+ config.ObfuscationKey = key
+ })
+ client := newTestClient(t, server, clientTLS, func(config *ClientConfig) {
+ config.ObfuscationKey = key
+ })
+ exchangeTCP(t, client, target, "matching Salamander PSKs")
+
+ badClient, err := NewClient(ClientConfig{
+ ServerAddress: server.Addr().String(),
+ Token: testToken,
+ TLSConfig: clientTLS,
+ ObfuscationKey: []byte("different integration secret"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
+ defer cancel()
+ if _, err := badClient.DialContext(ctx, "tcp", target); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("wrong Salamander key error = %v, want deadline exceeded", err)
+ }
+ // The Hysteria core does not expose a handshake context. DialContext still
+ // returns promptly; close asynchronously so the core's bounded handshake
+ // timeout can release its reconnect lock without slowing this test.
+ go func() { _ = badClient.Close() }()
+}
+
+func TestUDPDatagramBidirectionalLoopback(t *testing.T) {
+ target := startUDPEcho(t)
+ server, clientTLS, outbound := startTestServer(t, nil)
+ client := newTestClient(t, server, clientTLS, nil)
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ packet, err := client.DialPacket(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer packet.Close()
+ if !client.UDPEnabled() {
+ t.Fatal("server handshake did not enable QUIC DATAGRAM")
+ }
+
+ payload := []byte("UDP survives without TCP head-of-line blocking")
+ if err := packet.Send(payload, target); err != nil {
+ t.Fatal(err)
+ }
+ type receiveResult struct {
+ payload []byte
+ address string
+ err error
+ }
+ result := make(chan receiveResult, 1)
+ go func() {
+ data, address, err := packet.Receive()
+ result <- receiveResult{payload: data, address: address, err: err}
+ }()
+ select {
+ case value := <-result:
+ if value.err != nil {
+ t.Fatal(value.err)
+ }
+ if string(value.payload) != string(payload) {
+ t.Fatalf("UDP payload = %q, want %q", value.payload, payload)
+ }
+ if value.address != target {
+ t.Fatalf("UDP source = %q, want %q", value.address, target)
+ }
+ case <-ctx.Done():
+ t.Fatalf("UDP receive: %v", context.Cause(ctx))
+ }
+ if outbound.udpChecks.Load() == 0 {
+ t.Fatal("UDP destination policy was not checked")
+ }
+}
+
+func TestSafeUDPOutboundChecksInitialAddressAndRebinding(t *testing.T) {
+ unsafeResolver := &hyRotatingResolver{answers: [][]netip.Addr{{netip.MustParseAddr("169.254.169.254")}}}
+ unsafeDialer := security.NewSafeDialer(security.SafeDialerOptions{Resolver: unsafeResolver})
+ unsafeOutbound := &safeOutbound{dialer: unsafeDialer, timeout: time.Second}
+ if _, err := unsafeOutbound.UDP("metadata.example:53"); !errors.Is(err, security.ErrUnsafeAddress) {
+ t.Fatalf("initial unsafe UDP address error = %v", err)
+ }
+
+ rebindingResolver := &hyRotatingResolver{answers: [][]netip.Addr{
+ {netip.MustParseAddr("8.8.8.8")},
+ {netip.MustParseAddr("127.0.0.1")},
+ }}
+ rebindingDialer := security.NewSafeDialer(security.SafeDialerOptions{Resolver: rebindingResolver})
+ rebindingOutbound := &safeOutbound{dialer: rebindingDialer, timeout: time.Second}
+ conn, err := rebindingOutbound.UDP("rebinding.example:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ if _, err := conn.WriteTo([]byte("must not escape"), "rebinding.example:53"); !errors.Is(err, security.ErrUnsafeAddress) {
+ t.Fatalf("rebound UDP destination error = %v", err)
+ }
+ if got := rebindingResolver.calls.Load(); got != 2 {
+ t.Fatalf("resolver calls = %d, want initial and send-time checks", got)
+ }
+}
+
+func TestSafeOutboundGloballyLimitsTCPAndUDPSessions(t *testing.T) {
+ dialer := security.NewSafeDialer(security.SafeDialerOptions{
+ Dialer: transport.DialFunc(func(context.Context, string, string) (net.Conn, error) {
+ local, peer := net.Pipe()
+ _ = peer.Close()
+ return local, nil
+ }),
+ })
+ outbound := &safeOutbound{
+ dialer: dialer, timeout: time.Second,
+ tcpSlots: make(chan struct{}, 1),
+ udpSlots: make(chan struct{}, 1),
+ }
+
+ tcp, err := outbound.TCP("8.8.8.8:443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := outbound.TCP("8.8.8.8:443"); !errors.Is(err, ErrOutboundCapacity) {
+ t.Fatalf("second TCP error = %v, want capacity rejection", err)
+ }
+ if err := tcp.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := tcp.Close(); err != nil {
+ t.Fatal(err)
+ }
+ tcp, err = outbound.TCP("8.8.8.8:443")
+ if err != nil {
+ t.Fatalf("TCP slot was not released: %v", err)
+ }
+ _ = tcp.Close()
+
+ udp, err := outbound.UDP("8.8.8.8:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := outbound.UDP("8.8.8.8:53"); !errors.Is(err, ErrOutboundCapacity) {
+ t.Fatalf("second UDP error = %v, want capacity rejection", err)
+ }
+ if err := udp.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := udp.Close(); err != nil {
+ t.Fatal(err)
+ }
+ udp, err = outbound.UDP("8.8.8.8:53")
+ if err != nil {
+ t.Fatalf("UDP slot was not released: %v", err)
+ }
+ _ = udp.Close()
+}
+
+func TestSafeUDPConnDropsUnsolicitedSources(t *testing.T) {
+ relay, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer relay.Close()
+ approved, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer approved.Close()
+ unsolicited, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer unsolicited.Close()
+
+ conn := &safeUDPConn{conn: relay}
+ approvedAddress := approved.LocalAddr().(*net.UDPAddr).AddrPort()
+ approvedAddress = netip.AddrPortFrom(approvedAddress.Addr().Unmap(), approvedAddress.Port())
+ if _, err := conn.writeToDestination([]byte("authorize"), approvedAddress); err != nil {
+ t.Fatalf("authorize destination: %v", err)
+ }
+ target := relay.LocalAddr().(*net.UDPAddr).AddrPort()
+ if _, err := unsolicited.WriteToUDPAddrPort([]byte("injected"), target); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := approved.WriteToUDPAddrPort([]byte("approved"), target); err != nil {
+ t.Fatal(err)
+ }
+ if err := relay.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ buffer := make([]byte, 32)
+ n, source, err := conn.ReadFrom(buffer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := string(buffer[:n]); got != "approved" {
+ t.Fatalf("payload = %q, want approved", got)
+ }
+ if source != approvedAddress.String() {
+ t.Fatalf("source = %q, want %q", source, approvedAddress)
+ }
+}
+
+func TestSafeUDPConnDestinationCapacity(t *testing.T) {
+ relay, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer relay.Close()
+ conn := &safeUDPConn{conn: relay}
+ host := netip.MustParseAddr("127.0.0.1")
+
+ for i := range maxUDPAllowedDestinations {
+ destination := netip.AddrPortFrom(host, uint16(20_000+i))
+ if _, err := conn.writeToDestination([]byte("fill"), destination); err != nil {
+ t.Fatalf("authorize destination %d: %v", i, err)
+ }
+ }
+ newDestination := netip.AddrPortFrom(host, 30_000)
+ if _, err := conn.writeToDestination([]byte("reject"), newDestination); !errors.Is(err, ErrUDPDestinationCapacity) {
+ t.Fatalf("new destination after capacity error = %v, want %v", err, ErrUDPDestinationCapacity)
+ }
+ if conn.destinationAllowed(newDestination) {
+ t.Fatal("capacity-rejected destination was authorized")
+ }
+
+ // Filling the set must not revoke destinations that were already approved.
+ existing := netip.AddrPortFrom(host, 20_000)
+ if _, err := conn.writeToDestination([]byte("existing"), existing); err != nil {
+ t.Fatalf("existing destination after capacity: %v", err)
+ }
+ conn.allowedMu.RLock()
+ allowedCount := len(conn.allowed)
+ conn.allowedMu.RUnlock()
+ if allowedCount != maxUDPAllowedDestinations {
+ t.Fatalf("allowed destination count = %d, want %d", allowedCount, maxUDPAllowedDestinations)
+ }
+}
+
+func TestSafeUDPConnFailedWriteDoesNotAuthorize(t *testing.T) {
+ relay, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := relay.Close(); err != nil {
+ t.Fatal(err)
+ }
+ conn := &safeUDPConn{conn: relay}
+ destination := netip.MustParseAddrPort("127.0.0.1:20000")
+ if _, err := conn.writeToDestination([]byte("must fail"), destination); err == nil {
+ t.Fatal("write on closed socket unexpectedly succeeded")
+ }
+ if conn.destinationAllowed(destination) {
+ t.Fatal("failed write authorized its destination")
+ }
+}
+
+func TestSafeUDPConnDestinationSetConcurrent(t *testing.T) {
+ relay, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer relay.Close()
+ conn := &safeUDPConn{conn: relay}
+ host := netip.MustParseAddr("127.0.0.1")
+ const attempts = maxUDPAllowedDestinations + 64
+
+ var wait sync.WaitGroup
+ start := make(chan struct{})
+ errorsByAttempt := make(chan error, attempts)
+ for i := range attempts {
+ destination := netip.AddrPortFrom(host, uint16(31_000+i))
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ <-start
+ _, err := conn.writeToDestination([]byte("concurrent"), destination)
+ _ = conn.destinationAllowed(destination)
+ errorsByAttempt <- err
+ }()
+ }
+ close(start)
+ wait.Wait()
+ close(errorsByAttempt)
+
+ var successes int
+ for err := range errorsByAttempt {
+ switch {
+ case err == nil:
+ successes++
+ case errors.Is(err, ErrUDPDestinationCapacity):
+ default:
+ t.Fatalf("concurrent write error = %v", err)
+ }
+ }
+ if successes != maxUDPAllowedDestinations {
+ t.Fatalf("successful new destinations = %d, want %d", successes, maxUDPAllowedDestinations)
+ }
+ conn.allowedMu.RLock()
+ allowedCount := len(conn.allowed)
+ conn.allowedMu.RUnlock()
+ if allowedCount != maxUDPAllowedDestinations {
+ t.Fatalf("allowed destination count = %d, want %d", allowedCount, maxUDPAllowedDestinations)
+ }
+}
+
+func TestDialContextCancellationClosesLateConnection(t *testing.T) {
+ core := newDelayedCore()
+ client := &Client{core: core}
+ ctx, cancel := context.WithCancel(context.Background())
+ result := make(chan error, 1)
+ go func() {
+ _, err := client.DialContext(ctx, "tcp", "1.1.1.1:443")
+ result <- err
+ }()
+ <-core.started
+ cancel()
+ if err := <-result; !errors.Is(err, context.Canceled) {
+ t.Fatalf("DialContext error = %v, want context canceled", err)
+ }
+ close(core.release)
+ select {
+ case <-core.returned.closed:
+ case <-time.After(time.Second):
+ t.Fatal("connection returned after cancellation was not closed")
+ }
+}
+
+func TestConcurrentCanceledDialsShareOneConnectionAttempt(t *testing.T) {
+ started := make(chan struct{})
+ release := make(chan struct{})
+ var startOnce sync.Once
+ var attempts atomic.Int64
+ core := &instantCore{}
+ client := &Client{
+ connectFunc: func() (hyclient.Client, *hyclient.HandshakeInfo, error) {
+ attempts.Add(1)
+ startOnce.Do(func() { close(started) })
+ <-release
+ return core, &hyclient.HandshakeInfo{UDPEnabled: true}, nil
+ },
+ }
+
+ const callers = 128
+ results := make(chan error, callers)
+ var callersDone sync.WaitGroup
+ for range callers {
+ callersDone.Add(1)
+ go func() {
+ defer callersDone.Done()
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
+ defer cancel()
+ _, err := client.DialContext(ctx, "tcp", "1.1.1.1:443")
+ results <- err
+ }()
+ }
+ <-started
+ callersDone.Wait()
+ close(results)
+ for err := range results {
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("canceled dial error = %v, want deadline exceeded", err)
+ }
+ }
+ if got := attempts.Load(); got != 1 {
+ t.Fatalf("connection attempts = %d, want 1", got)
+ }
+
+ close(release)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ conn, err := client.DialContext(ctx, "tcp", "1.1.1.1:443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = conn.Close()
+ if got := attempts.Load(); got != 1 {
+ t.Fatalf("successful reuse started %d connection attempts, want 1", got)
+ }
+ if err := client.Close(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCanceledTCPDialsKeepUnderlyingWorkersBounded(t *testing.T) {
+ core := newBlockingOpenCore()
+ client := &Client{
+ core: core,
+ closeCh: make(chan struct{}),
+ openSlots: make(chan struct{}, 2),
+ }
+ t.Cleanup(func() { _ = client.Close() })
+
+ for index := 1; index <= 2; index++ {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ _, err := client.DialContext(ctx, "tcp", "1.1.1.1:443")
+ cancel()
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("dial %d error = %v, want deadline", index, err)
+ }
+ }
+ if got := core.calls.Load(); got != 2 {
+ t.Fatalf("underlying open calls = %d, want 2", got)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ _, err := client.DialContext(ctx, "tcp", "1.1.1.1:443")
+ cancel()
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("capacity-waiting dial error = %v, want deadline", err)
+ }
+ if got := core.calls.Load(); got != 2 {
+ t.Fatalf("capacity gate allowed %d underlying opens, want 2", got)
+ }
+
+ if err := client.Close(); err != nil {
+ t.Fatal(err)
+ }
+ deadline := time.Now().Add(time.Second)
+ for len(client.openSlots) != 0 && time.Now().Before(deadline) {
+ time.Sleep(time.Millisecond)
+ }
+ if got := len(client.openSlots); got != 0 {
+ t.Fatalf("worker slots after Close = %d, want 0", got)
+ }
+}
+
+func TestAutoFallsBackAfterPrimaryUDPPathTimeout(t *testing.T) {
+ core := newDelayedCore()
+ primary := &Client{core: core}
+ fallback := &recordingFallback{}
+ var observed atomic.Int64
+ auto, err := NewAutoClient(AutoConfig{
+ Primary: primary, Fallback: fallback,
+ AttemptTimeout: 30 * time.Millisecond,
+ Cooldown: time.Second,
+ OnFallback: func(error) {
+ observed.Add(1)
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = auto.Close() })
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ conn, err := auto.DialContext(ctx, "tcp", "1.1.1.1:443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = conn.Close()
+ if got := fallback.calls.Load(); got != 1 {
+ t.Fatalf("TLS fallback calls = %d, want 1", got)
+ }
+ if got := observed.Load(); got != 1 {
+ t.Fatalf("fallback observations = %d, want 1", got)
+ }
+ if got := auto.AccelerationMode(); got != "tls-fallback" {
+ t.Fatalf("fallback acceleration mode = %q", got)
+ }
+ if got := auto.NegotiatedTx(); got != 0 {
+ t.Fatalf("fallback negotiated rate = %d, want 0", got)
+ }
+ close(core.release)
+ select {
+ case <-core.returned.closed:
+ case <-time.After(time.Second):
+ t.Fatal("timed-out primary returned a connection that was not closed")
+ }
+ if _, err := auto.DialContext(ctx, "tcp", "1.1.1.1:443"); err != nil {
+ t.Fatalf("circuit fallback: %v", err)
+ }
+ if got := fallback.calls.Load(); got != 2 {
+ t.Fatalf("TLS fallback calls in cooldown = %d, want 2", got)
+ }
+ if got := observed.Load(); got != 1 {
+ t.Fatalf("cooldown emitted %d fallback observations, want one", got)
+ }
+}
+
+func TestAdmissionControllerEnforcesMaximumAndReleasesSlot(t *testing.T) {
+ controller := newAdmissionController(testToken, 1)
+ address := netip.MustParseAddrPort("127.0.0.1:12345")
+ firstOK, firstID := controller.Authenticate(net.UDPAddrFromAddrPort(address), testToken, 0)
+ if !firstOK || firstID == "" {
+ t.Fatal("first authenticated connection was rejected")
+ }
+ if ok, _ := controller.Authenticate(net.UDPAddrFromAddrPort(address), testToken, 0); ok {
+ t.Fatal("connection beyond configured maximum was admitted")
+ }
+ if ok, _ := controller.Authenticate(net.UDPAddrFromAddrPort(address), wrongTestToken, 0); ok {
+ t.Fatal("wrong token was admitted")
+ }
+ controller.Disconnect(net.UDPAddrFromAddrPort(address), firstID, nil)
+ if ok, id := controller.Authenticate(net.UDPAddrFromAddrPort(address), testToken, 0); !ok || id == "" {
+ t.Fatal("released admission slot was not reusable")
+ }
+}
+
+func TestClientCloseIsConcurrentIdempotentAndReturnsFirstError(t *testing.T) {
+ want := errors.New("close failure")
+ core := &closeErrorCore{err: want}
+ client := &Client{core: core}
+ const callers = 16
+ errorsSeen := make(chan error, callers)
+ var callersDone sync.WaitGroup
+ for range callers {
+ callersDone.Add(1)
+ go func() {
+ defer callersDone.Done()
+ errorsSeen <- client.Close()
+ }()
+ }
+ callersDone.Wait()
+ close(errorsSeen)
+ for err := range errorsSeen {
+ if !errors.Is(err, want) {
+ t.Fatalf("Close error = %v, want %v", err, want)
+ }
+ }
+ if got := core.calls.Load(); got != 1 {
+ t.Fatalf("core Close calls = %d, want 1", got)
+ }
+ if _, err := client.DialContext(context.Background(), "tcp", "1.1.1.1:443"); !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("TCP dial after Close = %v, want net.ErrClosed", err)
+ }
+ if _, err := client.DialPacket(context.Background()); !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("UDP dial after Close = %v, want net.ErrClosed", err)
+ }
+}
+
+func TestServerServeAndCloseAreOneShotAndConcurrentSafe(t *testing.T) {
+ core := newBlockingServerCore()
+ server := &Server{core: core, address: &net.UDPAddr{}, admission: newAdmissionController(testToken, 1)}
+ ctx, cancel := context.WithCancelCause(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- server.Serve(ctx) }()
+ select {
+ case <-core.started:
+ case <-time.After(time.Second):
+ t.Fatal("core Serve did not start")
+ }
+ if err := server.Serve(context.Background()); err == nil || !strings.Contains(err.Error(), "already serving") {
+ t.Fatalf("second Serve error = %v", err)
+ }
+ cancel(context.Canceled)
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("Serve cancellation error = %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("Serve did not stop after context cancellation")
+ }
+
+ const closers = 16
+ var closeDone sync.WaitGroup
+ for range closers {
+ closeDone.Add(1)
+ go func() {
+ defer closeDone.Done()
+ if err := server.Close(); err != nil {
+ t.Errorf("Close: %v", err)
+ }
+ }()
+ }
+ closeDone.Wait()
+ if got := core.closeCalls.Load(); got != 1 {
+ t.Fatalf("core Close calls = %d, want 1", got)
+ }
+}
+
+func TestAutoRejectsDialsAfterClose(t *testing.T) {
+ core := newDelayedCore()
+ close(core.release)
+ primary := &Client{core: core}
+ fallback := &recordingFallback{}
+ auto, err := NewAutoClient(AutoConfig{
+ Primary: primary, Fallback: fallback,
+ AttemptTimeout: time.Second,
+ Cooldown: time.Second,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := auto.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := auto.DialContext(context.Background(), "tcp", "1.1.1.1:443"); !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("TCP dial after Close = %v, want net.ErrClosed", err)
+ }
+ if _, err := auto.DialPacket(context.Background()); !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("UDP dial after Close = %v, want net.ErrClosed", err)
+ }
+ if got := fallback.calls.Load(); got != 0 {
+ t.Fatalf("closed AutoClient invoked fallback %d times", got)
+ }
+}
+
+func TestAutoCloseIsConcurrentIdempotentAndPreservesErrors(t *testing.T) {
+ primaryErr := errors.New("primary close failure")
+ fallbackErr := errors.New("fallback close failure")
+ primaryCore := &closeErrorCore{err: primaryErr}
+ primary := &Client{core: primaryCore}
+ fallback := &recordingFallback{closeErr: fallbackErr}
+ auto, err := NewAutoClient(AutoConfig{
+ Primary: primary, Fallback: fallback,
+ AttemptTimeout: time.Second,
+ Cooldown: time.Second,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ const callers = 16
+ errorsSeen := make(chan error, callers)
+ var callersDone sync.WaitGroup
+ for range callers {
+ callersDone.Add(1)
+ go func() {
+ defer callersDone.Done()
+ errorsSeen <- auto.Close()
+ }()
+ }
+ callersDone.Wait()
+ close(errorsSeen)
+ for closeErr := range errorsSeen {
+ if !errors.Is(closeErr, primaryErr) || !errors.Is(closeErr, fallbackErr) {
+ t.Fatalf("Close error = %v, want both close failures", closeErr)
+ }
+ }
+ if got := primaryCore.calls.Load(); got != 1 {
+ t.Fatalf("primary Close calls = %d, want 1", got)
+ }
+ if got := fallback.closeCalls.Load(); got != 1 {
+ t.Fatalf("fallback Close calls = %d, want 1", got)
+ }
+}
+
+func TestAutoCloseCancelsActiveFallbackBeforeClosingIt(t *testing.T) {
+ primary := &Client{core: &instantCore{}}
+ fallback := newContextBlockingFallback()
+ auto, err := NewAutoClient(AutoConfig{
+ Primary: primary, Fallback: fallback,
+ AttemptTimeout: time.Second,
+ Cooldown: time.Second,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ dialDone := make(chan error, 1)
+ go func() {
+ _, err := auto.dialFallback(context.Background(), "tcp", "1.1.1.1:443")
+ dialDone <- err
+ }()
+ select {
+ case <-fallback.started:
+ case <-time.After(time.Second):
+ t.Fatal("fallback dial did not start")
+ }
+ if err := auto.Close(); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case err := <-dialDone:
+ if !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("active fallback error = %v, want closed", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("Close did not cancel the active fallback")
+ }
+ if got := fallback.closeCalls.Load(); got != 1 {
+ t.Fatalf("fallback close calls = %d, want 1", got)
+ }
+ if _, err := auto.dialFallback(context.Background(), "tcp", "1.1.1.1:443"); !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("fallback dispatch after Close = %v, want closed", err)
+ }
+ if got := fallback.calls.Load(); got != 1 {
+ t.Fatalf("fallback was dispatched %d times, want one pre-Close call", got)
+ }
+}
+
+func TestClientAndServerRejectCoreInvalidTimeoutsEagerly(t *testing.T) {
+ _, clientTLS := testTLSConfigs(t)
+ for name, modify := range map[string]func(*ClientConfig){
+ "idle too short": func(config *ClientConfig) { config.MaxIdleTimeout = time.Second },
+ "keepalive too short": func(config *ClientConfig) { config.KeepAlivePeriod = time.Second },
+ "idle too long": func(config *ClientConfig) { config.MaxIdleTimeout = 121 * time.Second },
+ } {
+ t.Run("client "+name, func(t *testing.T) {
+ config := ClientConfig{ServerAddress: "127.0.0.1:443", Token: testToken, TLSConfig: clientTLS}
+ modify(&config)
+ if _, err := NewClient(config); err == nil {
+ t.Fatal("invalid core timeout passed eager validation")
+ }
+ })
+ }
+ serverTLS, _ := testTLSConfigs(t)
+ for name, modify := range map[string]func(*ServerConfig){
+ "idle too short": func(config *ServerConfig) { config.MaxIdleTimeout = time.Second },
+ "UDP idle too short": func(config *ServerConfig) { config.UDPIdleTimeout = time.Second },
+ "UDP idle too long": func(config *ServerConfig) { config.UDPIdleTimeout = 601 * time.Second },
+ "authentication too long": func(config *ServerConfig) { config.AuthenticationTimeout = 61 * time.Second },
+ "too few unidirectional": func(config *ServerConfig) { config.MaxIncomingUniStreams = 2 },
+ "too many unidirectional": func(config *ServerConfig) { config.MaxIncomingUniStreams = 1025 },
+ "source connections over global": func(config *ServerConfig) {
+ config.MaxConnections, config.MaxClientConnections = 1, 2
+ },
+ "source TCP over global": func(config *ServerConfig) {
+ config.MaxOutboundTCP, config.MaxClientTCPHandlers = 1, 2
+ },
+ "source UDP over global": func(config *ServerConfig) {
+ config.MaxOutboundUDP, config.MaxClientUDPSessions = 1, 2
+ },
+ "unsafe Brutal opt-in": func(config *ServerConfig) { config.AllowClientBandwidth = true },
+ } {
+ t.Run("server "+name, func(t *testing.T) {
+ config := ServerConfig{
+ Address: "127.0.0.1:0", Token: testToken, TLSConfig: serverTLS,
+ Outbound: &testOutbound{},
+ }
+ modify(&config)
+ if _, err := Listen(config); err == nil {
+ t.Fatal("invalid core timeout passed eager validation")
+ }
+ })
+ }
+}
+
+type testOutbound struct {
+ tcpDials atomic.Int64
+ udpChecks atomic.Int64
+}
+
+type hyRotatingResolver struct {
+ answers [][]netip.Addr
+ calls atomic.Uint64
+}
+
+func (r *hyRotatingResolver) LookupNetIP(context.Context, string, string) ([]netip.Addr, error) {
+ call := r.calls.Add(1)
+ index := int(call - 1)
+ if index >= len(r.answers) {
+ index = len(r.answers) - 1
+ }
+ return append([]netip.Addr(nil), r.answers[index]...), nil
+}
+
+func (o *testOutbound) TCP(address string) (net.Conn, error) {
+ o.tcpDials.Add(1)
+ return net.DialTimeout("tcp", address, 2*time.Second)
+}
+
+func (o *testOutbound) UDP(string) (hyserver.UDPConn, error) {
+ o.udpChecks.Add(1)
+ conn, err := net.ListenUDP("udp", nil)
+ if err != nil {
+ return nil, err
+ }
+ return &testUDPConn{UDPConn: conn}, nil
+}
+
+func (o *testOutbound) CheckUDP(string) error {
+ o.udpChecks.Add(1)
+ return nil
+}
+
+type testUDPConn struct{ *net.UDPConn }
+
+func (c *testUDPConn) ReadFrom(payload []byte) (int, string, error) {
+ n, address, err := c.ReadFromUDPAddrPort(payload)
+ if err != nil {
+ return n, "", err
+ }
+ address = netip.AddrPortFrom(address.Addr().Unmap(), address.Port())
+ return n, address.String(), nil
+}
+
+func (c *testUDPConn) WriteTo(payload []byte, address string) (int, error) {
+ target, err := netip.ParseAddrPort(address)
+ if err != nil {
+ return 0, err
+ }
+ return c.WriteToUDPAddrPort(payload, target)
+}
+
+func startTestServer(t *testing.T, modify func(*ServerConfig)) (*Server, *tls.Config, *testOutbound) {
+ t.Helper()
+ serverTLS, clientTLS := testTLSConfigs(t)
+ outbound := &testOutbound{}
+ config := ServerConfig{
+ Address: "127.0.0.1:0",
+ Token: testToken,
+ TLSConfig: serverTLS,
+ Outbound: outbound,
+ }
+ if modify != nil {
+ modify(&config)
+ }
+ server, err := Listen(config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- server.Serve(ctx) }()
+ t.Cleanup(func() {
+ cancel()
+ _ = server.Close()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Errorf("Serve: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Error("server did not stop")
+ }
+ })
+ return server, clientTLS, outbound
+}
+
+func newTestClient(t *testing.T, server *Server, tlsConfig *tls.Config, modify func(*ClientConfig)) *Client {
+ t.Helper()
+ config := ClientConfig{
+ ServerAddress: server.Addr().String(),
+ Token: testToken,
+ TLSConfig: tlsConfig,
+ }
+ if modify != nil {
+ modify(&config)
+ }
+ client, err := NewClient(config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = client.Close() })
+ return client
+}
+
+func exchangeTCP(t *testing.T, dialer transport.Dialer, address, message string) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ conn, err := dialer.DialContext(ctx, "tcp", address)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ if err := conn.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := io.WriteString(conn, message); err != nil {
+ t.Fatal(err)
+ }
+ response := make([]byte, len(message))
+ if _, err := io.ReadFull(conn, response); err != nil {
+ t.Fatal(err)
+ }
+ if string(response) != message {
+ t.Fatalf("response = %q, want %q", response, message)
+ }
+}
+
+func startTCPEcho(t *testing.T) string {
+ t.Helper()
+ listener, err := net.Listen("tcp4", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var connections sync.WaitGroup
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ connections.Add(1)
+ go func() {
+ defer connections.Done()
+ defer conn.Close()
+ _, _ = io.Copy(conn, conn)
+ }()
+ }
+ }()
+ t.Cleanup(func() {
+ _ = listener.Close()
+ <-done
+ connections.Wait()
+ })
+ return listener.Addr().String()
+}
+
+func startUDPEcho(t *testing.T) string {
+ t.Helper()
+ listener, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ buffer := make([]byte, 64<<10)
+ for {
+ n, address, err := listener.ReadFromUDPAddrPort(buffer)
+ if err != nil {
+ return
+ }
+ _, _ = listener.WriteToUDPAddrPort(buffer[:n], address)
+ }
+ }()
+ t.Cleanup(func() {
+ _ = listener.Close()
+ <-done
+ })
+ return listener.LocalAddr().String()
+}
+
+func testTLSConfigs(t *testing.T) (*tls.Config, *tls.Config) {
+ t.Helper()
+ certPEM, keyPEM, err := security.GenerateSelfSignedCertificate(security.CertificateOptions{
+ Hosts: []string{"127.0.0.1"}, ValidFor: time.Hour,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ certificate, err := tls.X509KeyPair(certPEM, keyPEM)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pool := x509.NewCertPool()
+ if !pool.AppendCertsFromPEM(certPEM) {
+ t.Fatal("test certificate was not accepted as a root")
+ }
+ return &tls.Config{
+ MinVersion: tls.VersionTLS13,
+ MaxVersion: tls.VersionTLS13,
+ Certificates: []tls.Certificate{certificate},
+ }, &tls.Config{
+ MinVersion: tls.VersionTLS13,
+ MaxVersion: tls.VersionTLS13,
+ ServerName: "127.0.0.1",
+ RootCAs: pool,
+ }
+}
+
+type delayedCore struct {
+ started chan struct{}
+ release chan struct{}
+ returned *closeTrackingConn
+ start sync.Once
+ close sync.Once
+}
+
+type blockingOpenCore struct {
+ release chan struct{}
+ close sync.Once
+ calls atomic.Int64
+}
+
+func newBlockingOpenCore() *blockingOpenCore {
+ return &blockingOpenCore{release: make(chan struct{})}
+}
+
+func (c *blockingOpenCore) TCP(string) (net.Conn, error) {
+ c.calls.Add(1)
+ <-c.release
+ return nil, net.ErrClosed
+}
+
+func (c *blockingOpenCore) UDP() (hyclient.HyUDPConn, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (c *blockingOpenCore) Close() error {
+ c.close.Do(func() { close(c.release) })
+ return nil
+}
+
+type closeErrorCore struct {
+ err error
+ calls atomic.Int64
+}
+
+type instantCore struct {
+ closed atomic.Bool
+}
+
+func (c *instantCore) TCP(string) (net.Conn, error) {
+ if c.closed.Load() {
+ return nil, net.ErrClosed
+ }
+ local, peer := net.Pipe()
+ _ = peer.Close()
+ return local, nil
+}
+
+func (c *instantCore) UDP() (hyclient.HyUDPConn, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (c *instantCore) Close() error {
+ c.closed.Store(true)
+ return nil
+}
+
+func (c *closeErrorCore) TCP(string) (net.Conn, error) { return nil, net.ErrClosed }
+func (c *closeErrorCore) UDP() (hyclient.HyUDPConn, error) { return nil, net.ErrClosed }
+func (c *closeErrorCore) Close() error {
+ c.calls.Add(1)
+ return c.err
+}
+
+type blockingServerCore struct {
+ started chan struct{}
+ closed chan struct{}
+ start sync.Once
+ close sync.Once
+ closeCalls atomic.Int64
+}
+
+func newBlockingServerCore() *blockingServerCore {
+ return &blockingServerCore{started: make(chan struct{}), closed: make(chan struct{})}
+}
+
+func (c *blockingServerCore) Serve() error {
+ c.start.Do(func() { close(c.started) })
+ <-c.closed
+ return net.ErrClosed
+}
+
+func (c *blockingServerCore) Close() error {
+ c.closeCalls.Add(1)
+ c.close.Do(func() { close(c.closed) })
+ return nil
+}
+
+func newDelayedCore() *delayedCore {
+ local, peer := net.Pipe()
+ _ = peer.Close()
+ return &delayedCore{
+ started: make(chan struct{}), release: make(chan struct{}),
+ returned: &closeTrackingConn{Conn: local, closed: make(chan struct{})},
+ }
+}
+
+func (c *delayedCore) TCP(string) (net.Conn, error) {
+ c.start.Do(func() { close(c.started) })
+ <-c.release
+ return c.returned, nil
+}
+
+func (c *delayedCore) UDP() (hyclient.HyUDPConn, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (c *delayedCore) Close() error {
+ c.close.Do(func() { _ = c.returned.Close() })
+ return nil
+}
+
+type closeTrackingConn struct {
+ net.Conn
+ closed chan struct{}
+ once sync.Once
+}
+
+func (c *closeTrackingConn) Close() error {
+ err := c.Conn.Close()
+ c.once.Do(func() { close(c.closed) })
+ return err
+}
+
+type recordingFallback struct {
+ calls atomic.Int64
+ closeCalls atomic.Int64
+ closed atomic.Bool
+ closeErr error
+}
+
+type contextBlockingFallback struct {
+ started chan struct{}
+ start sync.Once
+ calls atomic.Int64
+ closeCalls atomic.Int64
+}
+
+func newContextBlockingFallback() *contextBlockingFallback {
+ return &contextBlockingFallback{started: make(chan struct{})}
+}
+
+func (d *contextBlockingFallback) DialContext(ctx context.Context, _, _ string) (net.Conn, error) {
+ d.calls.Add(1)
+ d.start.Do(func() { close(d.started) })
+ <-ctx.Done()
+ return nil, context.Cause(ctx)
+}
+
+func (d *contextBlockingFallback) Close() error {
+ d.closeCalls.Add(1)
+ return nil
+}
+
+func (d *recordingFallback) DialContext(context.Context, string, string) (net.Conn, error) {
+ if d.closed.Load() {
+ return nil, net.ErrClosed
+ }
+ d.calls.Add(1)
+ local, peer := net.Pipe()
+ _ = peer.Close()
+ return local, nil
+}
+
+func (d *recordingFallback) Close() error {
+ d.closeCalls.Add(1)
+ d.closed.Store(true)
+ return d.closeErr
+}
+
+var _ hyserver.Outbound = (*testOutbound)(nil)
+var _ hyserver.UDPConn = (*testUDPConn)(nil)
+var _ hyclient.Client = (*delayedCore)(nil)
+var _ hyclient.Client = (*blockingOpenCore)(nil)
+var _ hyclient.Client = (*closeErrorCore)(nil)
+var _ hyclient.Client = (*instantCore)(nil)
+var _ hyserver.Server = (*blockingServerCore)(nil)
diff --git a/internal/hy2/server.go b/internal/hy2/server.go
new file mode 100644
index 0000000..cf3dfa2
--- /dev/null
+++ b/internal/hy2/server.go
@@ -0,0 +1,622 @@
+package hy2
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/netip"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ hyserver "github.com/apernet/hysteria/core/v2/server"
+ "github.com/apernet/hysteria/extras/v2/obfs"
+ "github.com/cppla/autocar/internal/protocol"
+ "github.com/cppla/autocar/internal/security"
+)
+
+const (
+ defaultMaxConnections = 256
+ defaultMaxClientConnections = 32
+ defaultMaxStreams = 1024
+ defaultMaxUniStreams = 8
+ defaultMaxHTTPHeaderBytes = 16 << 10
+ defaultMaxOutboundTCP = 1024
+ defaultMaxOutboundUDP = 256
+ defaultMaxClientTCP = 128
+ defaultMaxClientUDP = 64
+ maxUDPAllowedDestinations = 256
+ defaultDialTimeout = 4 * time.Second
+ defaultRequestTimeout = 10 * time.Second
+ defaultUDPIdleTimeout = 60 * time.Second
+)
+
+// ServerConfig configures the accelerated HTTP/3 relay.
+type ServerConfig struct {
+ Address string
+ Token string
+ TLSConfig *tls.Config
+ Dialer *security.SafeDialer
+ // Outbound is an optional advanced/test adapter. Production callers should
+ // leave it nil so every destination is enforced by Dialer.
+ Outbound hyserver.Outbound
+
+ Congestion string
+ BBRProfile string
+ MaxTx uint64
+ MaxRx uint64
+
+ AllowClientBandwidth bool
+ DisableLossCompensation bool
+ DisableUDP bool
+ DisablePathMTUDiscovery bool
+ DisableGSO bool
+ ObfuscationKey []byte
+ MaxIdleTimeout time.Duration
+ UDPIdleTimeout time.Duration
+ DialTimeout time.Duration
+ MaxConcurrentStreams int
+ MaxIncomingUniStreams int
+ MaxConnections int
+ MaxClientConnections int
+ MaxOutboundTCP int
+ MaxOutboundUDP int
+ MaxClientTCPHandlers int
+ MaxClientUDPSessions int
+ TCPRequestTimeout time.Duration
+ AuthenticationTimeout time.Duration
+ MasqueradeHandler http.Handler
+}
+
+// Server is an authenticated Hysteria v2 relay.
+type Server struct {
+ core hyserver.Server
+ address net.Addr
+ admission *admissionController
+
+ serveMu sync.Mutex
+ serving bool
+ closed atomic.Bool
+ close sync.Once
+ closeErr error
+}
+
+// Listen binds the UDP socket and constructs the HTTP/3 relay.
+func Listen(config ServerConfig) (*Server, error) {
+ if err := validateServerConfig(config); err != nil {
+ return nil, err
+ }
+ config.Congestion = normalizeCongestion(config.Congestion)
+ config.BBRProfile = normalizeBBRProfile(config.BBRProfile)
+ if config.MaxConnections == 0 {
+ config.MaxConnections = defaultMaxConnections
+ }
+ if config.MaxClientConnections == 0 {
+ config.MaxClientConnections = min(defaultMaxClientConnections, config.MaxConnections)
+ }
+ if config.MaxConcurrentStreams == 0 {
+ config.MaxConcurrentStreams = defaultMaxStreams
+ }
+ if config.MaxIncomingUniStreams == 0 {
+ config.MaxIncomingUniStreams = defaultMaxUniStreams
+ }
+ if config.MaxOutboundTCP == 0 {
+ config.MaxOutboundTCP = defaultMaxOutboundTCP
+ }
+ if config.MaxOutboundUDP == 0 {
+ config.MaxOutboundUDP = defaultMaxOutboundUDP
+ }
+ if config.MaxClientTCPHandlers == 0 {
+ config.MaxClientTCPHandlers = min(defaultMaxClientTCP, config.MaxOutboundTCP)
+ }
+ if config.MaxClientUDPSessions == 0 {
+ config.MaxClientUDPSessions = min(defaultMaxClientUDP, config.MaxOutboundUDP)
+ }
+ if config.DialTimeout == 0 {
+ config.DialTimeout = defaultDialTimeout
+ }
+ if config.TCPRequestTimeout == 0 {
+ config.TCPRequestTimeout = defaultRequestTimeout
+ }
+ if config.AuthenticationTimeout == 0 {
+ config.AuthenticationTimeout = defaultRequestTimeout
+ }
+ if config.UDPIdleTimeout == 0 {
+ config.UDPIdleTimeout = defaultUDPIdleTimeout
+ }
+
+ packetConn, err := net.ListenPacket("udp", config.Address)
+ if err != nil {
+ return nil, fmt.Errorf("hy2: listen UDP: %w", err)
+ }
+ address := packetConn.LocalAddr()
+ if len(config.ObfuscationKey) != 0 {
+ wrapped, wrapErr := obfs.WrapPacketConnSalamander(packetConn, config.ObfuscationKey)
+ if wrapErr != nil {
+ _ = packetConn.Close()
+ return nil, fmt.Errorf("hy2: enable Salamander: %w", wrapErr)
+ }
+ packetConn = wrapped
+ }
+
+ admission := newAdmissionController(config.Token, config.MaxConnections)
+ masquerade := config.MasqueradeHandler
+ if masquerade == nil {
+ masquerade = NewCoverHandler("")
+ }
+ tlsConfig := config.TLSConfig.Clone()
+ outbound := config.Outbound
+ if outbound == nil {
+ outbound = &safeOutbound{
+ dialer: config.Dialer,
+ timeout: config.DialTimeout,
+ tcpSlots: make(chan struct{}, config.MaxOutboundTCP),
+ udpSlots: make(chan struct{}, config.MaxOutboundUDP),
+ }
+ }
+ core, err := hyserver.NewServer(&hyserver.Config{
+ Conn: packetConn,
+ TLSConfig: hyserver.TLSConfig{
+ Certificates: append([]tls.Certificate(nil), tlsConfig.Certificates...),
+ GetCertificate: tlsConfig.GetCertificate,
+ ClientCAs: cloneCertPool(tlsConfig.ClientCAs),
+ ECHKeys: append([]tls.EncryptedClientHelloKey(nil), tlsConfig.EncryptedClientHelloKeys...),
+ GetECHKeys: tlsConfig.GetEncryptedClientHelloKeys,
+ },
+ QUICConfig: hyserver.QUICConfig{
+ MaxIdleTimeout: config.MaxIdleTimeout,
+ MaxIncomingStreams: int64(config.MaxConcurrentStreams),
+ MaxIncomingUniStreams: int64(config.MaxIncomingUniStreams),
+ DisablePathMTUDiscovery: config.DisablePathMTUDiscovery,
+ DisableGSO: config.DisableGSO,
+ },
+ Outbound: outbound,
+ CongestionConfig: hyserver.CongestionConfig{
+ Type: config.Congestion,
+ BBRProfile: config.BBRProfile,
+ },
+ BandwidthConfig: hyserver.BandwidthConfig{
+ MaxTx: config.MaxTx,
+ MaxRx: config.MaxRx,
+ DisableLossCompensation: config.DisableLossCompensation,
+ },
+ IgnoreClientBandwidth: !config.AllowClientBandwidth,
+ DisableUDP: config.DisableUDP,
+ UDPIdleTimeout: config.UDPIdleTimeout,
+ MaxConnections: config.MaxConnections,
+ MaxClientConnections: config.MaxClientConnections,
+ MaxTCPHandlers: config.MaxOutboundTCP,
+ MaxClientTCPHandlers: config.MaxClientTCPHandlers,
+ TCPRequestTimeout: config.TCPRequestTimeout,
+ AuthenticationTimeout: config.AuthenticationTimeout,
+ MaxHTTPHeaderBytes: defaultMaxHTTPHeaderBytes,
+ MaxUDPSessions: config.MaxOutboundUDP,
+ MaxClientUDPSessions: config.MaxClientUDPSessions,
+ Authenticator: admission,
+ EventLogger: admission,
+ MasqHandler: masquerade,
+ })
+ if err != nil {
+ _ = packetConn.Close()
+ return nil, fmt.Errorf("hy2: create server: %w", err)
+ }
+ return &Server{core: core, address: address, admission: admission}, nil
+}
+
+func validateServerConfig(config ServerConfig) error {
+ if config.Address == "" {
+ return errors.New("hy2: listen address is required")
+ }
+ if len(config.Token) < protocol.MinTokenLength || len(config.Token) > protocol.MaxTokenLength {
+ return fmt.Errorf("hy2: token length must be between %d and %d bytes", protocol.MinTokenLength, protocol.MaxTokenLength)
+ }
+ if config.TLSConfig == nil {
+ return errors.New("hy2: server TLS config is required")
+ }
+ if err := validateServerTLSPolicy(config.TLSConfig); err != nil {
+ return err
+ }
+ if len(config.TLSConfig.Certificates) == 0 && config.TLSConfig.GetCertificate == nil {
+ return errors.New("hy2: server TLS certificate is required")
+ }
+ if config.Dialer == nil && config.Outbound == nil {
+ return errors.New("hy2: safe outbound dialer is required")
+ }
+ congestion := normalizeCongestion(config.Congestion)
+ if congestion != CongestionBBR && congestion != CongestionReno {
+ return fmt.Errorf("hy2: unsupported congestion controller %q", config.Congestion)
+ }
+ profile := normalizeBBRProfile(config.BBRProfile)
+ if congestion == CongestionBBR && profile != BBRConservative && profile != BBRStandard && profile != BBRAggressive {
+ return fmt.Errorf("hy2: unsupported BBR profile %q", config.BBRProfile)
+ }
+ for name, bandwidth := range map[string]uint64{"MaxTx": config.MaxTx, "MaxRx": config.MaxRx} {
+ if bandwidth != 0 && bandwidth < minimumBandwidth {
+ return fmt.Errorf("hy2: %s must be zero or at least %d bytes/s", name, minimumBandwidth)
+ }
+ if bandwidth > maximumBandwidth {
+ return fmt.Errorf("hy2: %s must not exceed %d bytes/s", name, maximumBandwidth)
+ }
+ }
+ if config.AllowClientBandwidth && (config.MaxTx == 0 || config.MaxRx == 0) {
+ return errors.New("hy2: allowing client bandwidth requires finite MaxTx and MaxRx ceilings")
+ }
+ if len(config.ObfuscationKey) != 0 && len(config.ObfuscationKey) < minimumObfsKey {
+ return fmt.Errorf("hy2: obfuscation key must be at least %d bytes", minimumObfsKey)
+ }
+ if config.MaxConnections < 0 || config.MaxConnections > 65536 {
+ return errors.New("hy2: maximum connections must be zero or at most 65536")
+ }
+ if config.MaxClientConnections < 0 || config.MaxClientConnections > 65536 {
+ return errors.New("hy2: per-source connection limit must be zero or at most 65536")
+ }
+ if config.MaxClientConnections > 0 && config.MaxConnections > 0 && config.MaxClientConnections > config.MaxConnections {
+ return errors.New("hy2: per-source connection limit cannot exceed the global connection limit")
+ }
+ if config.MaxConcurrentStreams < 0 || config.MaxConcurrentStreams > 65536 || (config.MaxConcurrentStreams > 0 && config.MaxConcurrentStreams < 8) {
+ return errors.New("hy2: maximum streams must be zero or between 8 and 65536")
+ }
+ if config.MaxIncomingUniStreams < 0 || config.MaxIncomingUniStreams > 1024 || (config.MaxIncomingUniStreams > 0 && config.MaxIncomingUniStreams < 3) {
+ return errors.New("hy2: maximum unidirectional streams must be zero or between 3 and 1024")
+ }
+ if config.MaxOutboundTCP < 0 || config.MaxOutboundTCP > 65536 || config.MaxOutboundUDP < 0 || config.MaxOutboundUDP > 65536 {
+ return errors.New("hy2: outbound connection limits must be zero or at most 65536")
+ }
+ if config.MaxClientUDPSessions < 0 || config.MaxClientUDPSessions > 65536 {
+ return errors.New("hy2: per-source UDP session limit must be zero or at most 65536")
+ }
+ if config.MaxClientTCPHandlers < 0 || config.MaxClientTCPHandlers > 65536 {
+ return errors.New("hy2: per-source TCP handler limit must be zero or at most 65536")
+ }
+ if config.MaxClientTCPHandlers > 0 && config.MaxOutboundTCP > 0 && config.MaxClientTCPHandlers > config.MaxOutboundTCP {
+ return errors.New("hy2: per-source TCP handler limit cannot exceed the global TCP limit")
+ }
+ if config.MaxClientUDPSessions > 0 && config.MaxOutboundUDP > 0 && config.MaxClientUDPSessions > config.MaxOutboundUDP {
+ return errors.New("hy2: per-source UDP session limit cannot exceed the global UDP limit")
+ }
+ if config.DialTimeout < 0 {
+ return errors.New("hy2: outbound dial timeout cannot be negative")
+ }
+ if config.TCPRequestTimeout != 0 && (config.TCPRequestTimeout < time.Second || config.TCPRequestTimeout > 60*time.Second) {
+ return errors.New("hy2: TCP request timeout must be zero or between 1s and 60s")
+ }
+ if config.AuthenticationTimeout != 0 && (config.AuthenticationTimeout < time.Second || config.AuthenticationTimeout > 60*time.Second) {
+ return errors.New("hy2: authentication timeout must be zero or between 1s and 60s")
+ }
+ if config.MaxIdleTimeout != 0 && (config.MaxIdleTimeout < 4*time.Second || config.MaxIdleTimeout > 120*time.Second) {
+ return errors.New("hy2: maximum idle timeout must be zero or between 4s and 120s")
+ }
+ if config.UDPIdleTimeout != 0 && (config.UDPIdleTimeout < 2*time.Second || config.UDPIdleTimeout > 600*time.Second) {
+ return errors.New("hy2: UDP idle timeout must be zero or between 2s and 600s")
+ }
+ return nil
+}
+
+func cloneCertPool(pool *x509.CertPool) *x509.CertPool {
+ if pool == nil {
+ return nil
+ }
+ return pool.Clone()
+}
+
+// Addr returns the bound UDP address.
+func (s *Server) Addr() net.Addr { return s.address }
+
+// Serve runs until ctx is canceled, Close is called or the listener fails.
+func (s *Server) Serve(ctx context.Context) error {
+ if ctx == nil {
+ return errors.New("hy2: nil serve context")
+ }
+ s.serveMu.Lock()
+ if s.serving {
+ s.serveMu.Unlock()
+ return errors.New("hy2: server already serving")
+ }
+ s.serving = true
+ s.serveMu.Unlock()
+
+ done := make(chan error, 1)
+ go func() { done <- s.core.Serve() }()
+ select {
+ case err := <-done:
+ if s.closed.Load() || errors.Is(err, net.ErrClosed) {
+ return nil
+ }
+ return fmt.Errorf("hy2: serve: %w", err)
+ case <-ctx.Done():
+ _ = s.Close()
+ <-done
+ if cause := context.Cause(ctx); cause != nil && !errors.Is(cause, context.Canceled) {
+ return cause
+ }
+ return nil
+ }
+}
+
+// Close stops the listener and all active sessions.
+func (s *Server) Close() error {
+ s.close.Do(func() {
+ s.closed.Store(true)
+ s.closeErr = s.core.Close()
+ })
+ return s.closeErr
+}
+
+type admissionController struct {
+ token string
+ slots chan struct{}
+ next atomic.Uint64
+ lastTx atomic.Uint64
+ open sync.Map
+}
+
+func newAdmissionController(token string, maximum int) *admissionController {
+ return &admissionController{token: token, slots: make(chan struct{}, maximum)}
+}
+
+func (a *admissionController) Authenticate(addr net.Addr, provided string, _ uint64) (bool, string) {
+ if !security.VerifyToken(provided, a.token) {
+ return false, ""
+ }
+ select {
+ case a.slots <- struct{}{}:
+ default:
+ return false, ""
+ }
+ id := fmt.Sprintf("%s#%d", addr.String(), a.next.Add(1))
+ a.open.Store(id, struct{}{})
+ return true, id
+}
+
+func (a *admissionController) Connect(addr net.Addr, id string, tx uint64) {
+ a.lastTx.Store(tx)
+ slog.Debug("hy2 session authenticated", "remote", addr.String(), "id", id, "tx_bytes_per_second", tx)
+}
+
+func (a *admissionController) Disconnect(addr net.Addr, id string, err error) {
+ if _, loaded := a.open.LoadAndDelete(id); loaded {
+ <-a.slots
+ }
+ slog.Debug("hy2 session disconnected", "remote", addr.String(), "id", id, "error", err)
+}
+
+func (a *admissionController) TCPRequest(net.Addr, string, string) {}
+func (a *admissionController) TCPError(net.Addr, string, string, error) {}
+func (a *admissionController) UDPRequest(net.Addr, string, uint32, string) {}
+func (a *admissionController) UDPError(net.Addr, string, uint32, error) {}
+
+type safeOutbound struct {
+ dialer *security.SafeDialer
+ timeout time.Duration
+ tcpSlots chan struct{}
+ udpSlots chan struct{}
+}
+
+var (
+ ErrOutboundCapacity = errors.New("hy2: outbound capacity exhausted")
+ ErrUDPDestinationCapacity = errors.New("hy2: UDP destination capacity exhausted")
+)
+
+func (o *safeOutbound) TCP(address string) (net.Conn, error) {
+ if !acquireSlot(o.tcpSlots) {
+ return nil, fmt.Errorf("%w: TCP", ErrOutboundCapacity)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
+ defer cancel()
+ conn, err := o.dialer.DialContext(ctx, "tcp", address)
+ if err != nil {
+ releaseSlot(o.tcpSlots)
+ return nil, err
+ }
+ return &releaseConn{Conn: conn, release: func() { releaseSlot(o.tcpSlots) }}, nil
+}
+
+func (o *safeOutbound) UDP(address string) (hyserver.UDPConn, error) {
+ if !acquireSlot(o.udpSlots) {
+ return nil, fmt.Errorf("%w: UDP", ErrOutboundCapacity)
+ }
+ release := true
+ defer func() {
+ if release {
+ releaseSlot(o.udpSlots)
+ }
+ }()
+ // Hysteria calls UDP directly for the first datagram in a session and uses
+ // CheckUDP only when the destination later changes. Enforce policy here as
+ // well so the initial address cannot bypass the relay's SSRF boundary.
+ ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
+ defer cancel()
+ if _, err := o.dialer.ResolveUDPContext(ctx, address); err != nil {
+ return nil, err
+ }
+ conn, err := net.ListenUDP("udp", nil)
+ if err != nil {
+ return nil, err
+ }
+ release = false
+ return &safeUDPConn{
+ conn: conn, dialer: o.dialer, timeout: o.timeout,
+ release: func() { releaseSlot(o.udpSlots) },
+ }, nil
+}
+
+func (o *safeOutbound) CheckUDP(address string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
+ defer cancel()
+ _, err := o.dialer.ResolveUDPContext(ctx, address)
+ return err
+}
+
+type safeUDPConn struct {
+ conn *net.UDPConn
+ dialer *security.SafeDialer
+ timeout time.Duration
+ allowedMu sync.RWMutex
+ // allowed contains only destinations that passed policy and a successful
+ // socket write. Entries are never evicted: removing one could cause a valid
+ // delayed reply to be mistaken for an unsolicited packet.
+ allowed map[netip.AddrPort]struct{}
+ release func()
+ close sync.Once
+ closeErr error
+}
+
+type releaseConn struct {
+ net.Conn
+ release func()
+ close sync.Once
+ closeErr error
+}
+
+func acquireSlot(slots chan struct{}) bool {
+ if slots == nil {
+ return true
+ }
+ select {
+ case slots <- struct{}{}:
+ return true
+ default:
+ return false
+ }
+}
+
+func releaseSlot(slots chan struct{}) {
+ if slots != nil {
+ <-slots
+ }
+}
+
+func (c *releaseConn) Close() error {
+ c.close.Do(func() {
+ c.closeErr = c.Conn.Close()
+ if c.release != nil {
+ c.release()
+ }
+ })
+ return c.closeErr
+}
+
+func (c *safeUDPConn) ReadFrom(buffer []byte) (int, string, error) {
+ for {
+ n, address, err := c.conn.ReadFromUDPAddrPort(buffer)
+ if err != nil {
+ return n, "", err
+ }
+ address = netip.AddrPortFrom(address.Addr().Unmap(), address.Port())
+ if c.destinationAllowed(address) {
+ return n, address.String(), nil
+ }
+ }
+}
+
+func (c *safeUDPConn) WriteTo(payload []byte, address string) (int, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
+ defer cancel()
+ addresses, err := c.dialer.ResolveUDPContext(ctx, address)
+ if err != nil {
+ return 0, err
+ }
+ var writeErrors []error
+ for _, candidate := range addresses {
+ candidate = netip.AddrPortFrom(candidate.Addr().Unmap(), candidate.Port())
+ n, writeErr := c.writeToDestination(payload, candidate)
+ if writeErr == nil {
+ return n, nil
+ }
+ writeErrors = append(writeErrors, writeErr)
+ }
+ return 0, errors.Join(writeErrors...)
+}
+
+func (c *safeUDPConn) destinationAllowed(address netip.AddrPort) bool {
+ c.allowedMu.RLock()
+ _, ok := c.allowed[address]
+ c.allowedMu.RUnlock()
+ return ok
+}
+
+func (c *safeUDPConn) writeToDestination(payload []byte, address netip.AddrPort) (int, error) {
+ // Existing destinations need no admission change and remain usable even
+ // after the fixed-size set is full.
+ if c.destinationAllowed(address) {
+ return c.conn.WriteToUDPAddrPort(payload, address)
+ }
+
+ // Serialize first writes so concurrent successful sends cannot overfill the
+ // set. Keep the lock through the socket write: a reply read after that write
+ // waits until authorization is recorded, rather than being dropped in the
+ // small interval between the two operations.
+ c.allowedMu.Lock()
+ defer c.allowedMu.Unlock()
+ if _, ok := c.allowed[address]; ok {
+ return c.conn.WriteToUDPAddrPort(payload, address)
+ }
+ if len(c.allowed) >= maxUDPAllowedDestinations {
+ return 0, ErrUDPDestinationCapacity
+ }
+ n, err := c.conn.WriteToUDPAddrPort(payload, address)
+ if err != nil {
+ return n, err
+ }
+ if c.allowed == nil {
+ c.allowed = make(map[netip.AddrPort]struct{}, maxUDPAllowedDestinations)
+ }
+ c.allowed[address] = struct{}{}
+ return n, nil
+}
+
+func (c *safeUDPConn) Close() error {
+ c.close.Do(func() {
+ c.closeErr = c.conn.Close()
+ if c.release != nil {
+ c.release()
+ }
+ })
+ return c.closeErr
+}
+
+// CoverHandler serves a small neutral HTTP site for unauthenticated and
+// ordinary HTTP/3 requests. This makes active probes observe a valid web
+// service instead of an AutoCAR-specific protocol error.
+type CoverHandler struct {
+ serverName string
+ page *template.Template
+}
+
+// NewCoverHandler creates the built-in HTTP/3 cover. serverName is optional
+// and is HTML-escaped by the template package.
+func NewCoverHandler(serverName string) http.Handler {
+ serverName = strings.TrimSpace(serverName)
+ if serverName == "" {
+ serverName = "Service"
+ }
+ page := template.Must(template.New("cover").Parse("{{.}}
{{.}}
The service is online.
"))
+ return &CoverHandler{serverName: serverName, page: page}
+}
+
+func (h *CoverHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
+ response.Header().Set("Cache-Control", "public, max-age=300")
+ response.Header().Set("Content-Type", "text/html; charset=utf-8")
+ response.Header().Set("X-Content-Type-Options", "nosniff")
+ if (request.Method != http.MethodGet && request.Method != http.MethodHead) || request.URL.Path != "/" {
+ http.NotFound(response, request)
+ return
+ }
+ response.WriteHeader(http.StatusOK)
+ if request.Method == http.MethodHead {
+ return
+ }
+ _ = h.page.Execute(response, h.serverName)
+}
+
+var _ hyserver.Authenticator = (*admissionController)(nil)
+var _ hyserver.EventLogger = (*admissionController)(nil)
+var _ hyserver.Outbound = (*safeOutbound)(nil)
+var _ hyserver.UDPConn = (*safeUDPConn)(nil)
diff --git a/internal/hy2/tls_policy.go b/internal/hy2/tls_policy.go
new file mode 100644
index 0000000..b606639
--- /dev/null
+++ b/internal/hy2/tls_policy.go
@@ -0,0 +1,84 @@
+package hy2
+
+import (
+ "crypto/tls"
+ "errors"
+ "fmt"
+)
+
+// The Hysteria core intentionally exposes a smaller TLS surface than
+// crypto/tls.Config. Validate every security policy that cannot be faithfully
+// copied before opening a socket. Silently dropping one of these callbacks can
+// turn an application-specific certificate or resumption policy into the
+// default policy.
+func validateClientTLSPolicy(config *tls.Config) error {
+ if err := validateTLS13Versions(config, "client"); err != nil {
+ return err
+ }
+ switch {
+ case config.VerifyConnection != nil:
+ return errors.New("hy2: client TLS VerifyConnection is unsupported")
+ case config.EncryptedClientHelloRejectionVerify != nil:
+ return errors.New("hy2: client TLS EncryptedClientHelloRejectionVerify is unsupported")
+ case config.Time != nil:
+ return errors.New("hy2: client TLS custom Time is unsupported")
+ case config.Rand != nil:
+ return errors.New("hy2: client TLS custom Rand is unsupported")
+ case len(config.CurvePreferences) != 0:
+ return errors.New("hy2: client TLS custom CurvePreferences are unsupported")
+ }
+ return nil
+}
+
+func validateServerTLSPolicy(config *tls.Config) error {
+ if err := validateTLS13Versions(config, "server"); err != nil {
+ return err
+ }
+ switch {
+ case config.GetConfigForClient != nil:
+ return errors.New("hy2: server TLS GetConfigForClient is unsupported")
+ case config.VerifyConnection != nil:
+ return errors.New("hy2: server TLS VerifyConnection is unsupported")
+ case config.VerifyPeerCertificate != nil:
+ return errors.New("hy2: server TLS VerifyPeerCertificate is unsupported")
+ case config.Time != nil:
+ return errors.New("hy2: server TLS custom Time is unsupported")
+ case config.Rand != nil:
+ return errors.New("hy2: server TLS custom Rand is unsupported")
+ case len(config.CurvePreferences) != 0:
+ return errors.New("hy2: server TLS custom CurvePreferences are unsupported")
+ case config.NameToCertificate != nil:
+ return errors.New("hy2: server TLS NameToCertificate is unsupported; use Certificates or GetCertificate")
+ case config.SessionTicketsDisabled:
+ return errors.New("hy2: server TLS SessionTicketsDisabled is unsupported")
+ case config.SessionTicketKey != ([32]byte{}):
+ return errors.New("hy2: server TLS custom SessionTicketKey is unsupported")
+ case config.WrapSession != nil:
+ return errors.New("hy2: server TLS WrapSession is unsupported")
+ case config.UnwrapSession != nil:
+ return errors.New("hy2: server TLS UnwrapSession is unsupported")
+ }
+
+ // Hysteria exposes client CAs rather than the full ClientAuth enum. Its
+ // exact mapping is either no client certificate, or strict verified mTLS.
+ // Reject every intermediate/custom policy instead of silently strengthening
+ // or weakening it.
+ if config.ClientCAs == nil {
+ if config.ClientAuth != tls.NoClientCert {
+ return errors.New("hy2: server TLS ClientAuth requires ClientCAs and must be RequireAndVerifyClientCert")
+ }
+ } else if config.ClientAuth != tls.RequireAndVerifyClientCert {
+ return errors.New("hy2: server TLS ClientCAs require ClientAuth RequireAndVerifyClientCert")
+ }
+ return nil
+}
+
+func validateTLS13Versions(config *tls.Config, role string) error {
+ if config.MaxVersion != 0 && config.MaxVersion < tls.VersionTLS13 {
+ return fmt.Errorf("hy2: %s TLS MaxVersion excludes TLS 1.3", role)
+ }
+ if config.MinVersion > tls.VersionTLS13 {
+ return fmt.Errorf("hy2: %s TLS MinVersion excludes TLS 1.3", role)
+ }
+ return nil
+}
diff --git a/internal/proxy/config.go b/internal/proxy/config.go
index df3c971..5509f44 100644
--- a/internal/proxy/config.go
+++ b/internal/proxy/config.go
@@ -38,6 +38,7 @@ type Config struct {
type serverConfig struct {
dialer transport.Dialer
+ packetDialer transport.PacketDialer
authenticator Authenticator
handshakeTimeout time.Duration
dialTimeout time.Duration
@@ -67,8 +68,10 @@ func normalizeConfig(cfg Config) (serverConfig, error) {
if cfg.MaxConnections == 0 {
cfg.MaxConnections = 1024
}
+ packetDialer, _ := cfg.Dialer.(transport.PacketDialer)
return serverConfig{
dialer: cfg.Dialer,
+ packetDialer: packetDialer,
authenticator: cfg.Authenticator,
handshakeTimeout: cfg.HandshakeTimeout,
dialTimeout: cfg.DialTimeout,
diff --git a/internal/proxy/socks5.go b/internal/proxy/socks5.go
index 19e99e3..efd76ca 100644
--- a/internal/proxy/socks5.go
+++ b/internal/proxy/socks5.go
@@ -1,6 +1,7 @@
package proxy
import (
+ "bytes"
"context"
"encoding/binary"
"errors"
@@ -8,8 +9,12 @@ import (
"io"
"net"
"os"
+ "strconv"
+ "sync"
"syscall"
"time"
+
+ "github.com/cppla/autocar/internal/transport"
)
const (
@@ -38,10 +43,14 @@ const (
socksReplyAddressUnsupported = 0x08
userPasswordVersion = 0x01
+
+ maxUDPDatagramSize = 65507
)
-// SOCKS5Server is an RFC 1928 CONNECT proxy. BIND and UDP ASSOCIATE receive a
-// standards-compliant "command not supported" response.
+var errSOCKSUDPFragmented = errors.New("socks5: fragmented UDP datagram")
+
+// SOCKS5Server is an RFC 1928 CONNECT and, when the configured transport also
+// implements transport.PacketDialer, UDP ASSOCIATE proxy. BIND is unsupported.
type SOCKS5Server struct {
cfg serverConfig
lifecycle *serverLifecycle
@@ -100,7 +109,15 @@ func (s *SOCKS5Server) serveConn(client net.Conn) {
}
return
}
- if request.command != socksCommandConnect {
+ if request.command == socksCommandBind {
+ _ = writeSOCKSReply(client, socksReplyCommandUnsupported, nil)
+ return
+ }
+ if request.command == socksCommandUDP && s.cfg.packetDialer == nil {
+ _ = writeSOCKSReply(client, socksReplyCommandUnsupported, nil)
+ return
+ }
+ if request.command != socksCommandConnect && request.command != socksCommandUDP {
_ = writeSOCKSReply(client, socksReplyCommandUnsupported, nil)
return
}
@@ -108,7 +125,14 @@ func (s *SOCKS5Server) serveConn(client net.Conn) {
// parsed. Remote dialing has its own timeout and must not accidentally be
// shortened by the handshake deadline.
_ = client.SetDeadline(time.Time{})
+ if request.command == socksCommandUDP {
+ s.serveUDPAssociate(client, request)
+ return
+ }
+ s.serveConnect(client, request)
+}
+func (s *SOCKS5Server) serveConnect(client net.Conn, request socksRequest) {
ctx := context.Background()
cancel := func() {}
if s.cfg.dialTimeout > 0 {
@@ -135,6 +159,356 @@ func (s *SOCKS5Server) serveConn(client net.Conn) {
_ = relay(client, upstream, s.cfg.idleTimeout)
}
+func (s *SOCKS5Server) serveUDPAssociate(client net.Conn, request socksRequest) {
+ ctx := context.Background()
+ cancel := func() {}
+ if s.cfg.dialTimeout > 0 {
+ ctx, cancel = context.WithTimeout(ctx, s.cfg.dialTimeout)
+ }
+ defer cancel()
+
+ peerIP, err := addressIP(client.RemoteAddr())
+ if err != nil {
+ _ = writeSOCKSReply(client, socksReplyGeneralFailure, nil)
+ return
+ }
+ requestedPort, err := validateUDPAssociateRequest(ctx, request, peerIP, net.DefaultResolver.LookupIPAddr)
+ if err != nil {
+ reply := byte(socksReplyGeneralFailure)
+ var protocolErr *socksProtocolError
+ if errors.As(err, &protocolErr) {
+ reply = protocolErr.reply
+ }
+ _ = writeSOCKSReply(client, reply, nil)
+ return
+ }
+
+ udpConn, err := listenSOCKSUDP(client)
+ if err != nil {
+ _ = writeSOCKSReply(client, socksReplyGeneralFailure, nil)
+ return
+ }
+ defer udpConn.Close()
+
+ upstream, err := s.cfg.packetDialer.DialPacket(ctx)
+ if err != nil || upstream == nil {
+ if err == nil {
+ err = errors.New("socks5: packet dialer returned a nil connection")
+ }
+ _ = writeSOCKSReply(client, socksReplyForError(err), nil)
+ return
+ }
+ upstream = &closeOncePacketConn{PacketConn: upstream}
+ defer upstream.Close()
+ cancel()
+
+ if s.cfg.handshakeTimeout > 0 {
+ _ = client.SetWriteDeadline(time.Now().Add(s.cfg.handshakeTimeout))
+ }
+ if err := writeSOCKSReply(client, socksReplySucceeded, udpConn.LocalAddr()); err != nil {
+ return
+ }
+ _ = client.SetWriteDeadline(time.Time{})
+
+ endpoint := &socksUDPClientEndpoint{
+ peerIP: peerIP,
+ requestedPort: requestedPort,
+ }
+ runSOCKSUDPAssociation(client, udpConn, upstream, endpoint, s.cfg.idleTimeout)
+}
+
+type lookupIPFunc func(context.Context, string) ([]net.IPAddr, error)
+
+// validateUDPAssociateRequest applies the RFC 1928 meaning of DST.ADDR and
+// DST.PORT: they describe the endpoint from which the client expects to send
+// UDP packets. An unspecified address selects the TCP control connection's
+// peer. A concrete IP, or the resolved address set for a domain, is accepted
+// only when it includes that same peer. The data path independently checks the
+// actual packet source and pins a zero port on the first valid datagram.
+func validateUDPAssociateRequest(
+ ctx context.Context,
+ request socksRequest,
+ peerIP net.IP,
+ lookupIP lookupIPFunc,
+) (int, error) {
+ if peerIP == nil {
+ return 0, &socksProtocolError{
+ reply: socksReplyGeneralFailure,
+ err: errors.New("socks5: missing UDP control peer IP"),
+ }
+ }
+
+ switch request.addressType {
+ case socksAddressIPv4, socksAddressIPv6:
+ requestedIP := net.ParseIP(request.host)
+ if requestedIP == nil {
+ return 0, &socksProtocolError{
+ reply: socksReplyAddressUnsupported,
+ err: errors.New("socks5: invalid UDP associate IP"),
+ }
+ }
+ if !requestedIP.IsUnspecified() && !requestedIP.Equal(peerIP) {
+ return 0, &socksProtocolError{
+ reply: socksReplyNotAllowed,
+ err: errors.New("socks5: UDP associate address does not match the control peer"),
+ }
+ }
+ case socksAddressDomain:
+ if lookupIP == nil {
+ return 0, &socksProtocolError{
+ reply: socksReplyHostUnreachable,
+ err: errors.New("socks5: no resolver for UDP associate domain"),
+ }
+ }
+ addresses, err := lookupIP(ctx, request.host)
+ if err != nil {
+ return 0, &socksProtocolError{
+ reply: socksReplyHostUnreachable,
+ err: fmt.Errorf("socks5: resolve UDP associate domain: %w", err),
+ }
+ }
+ matched := false
+ for _, address := range addresses {
+ if address.IP.Equal(peerIP) {
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ return 0, &socksProtocolError{
+ reply: socksReplyNotAllowed,
+ err: errors.New("socks5: UDP associate domain does not resolve to the control peer"),
+ }
+ }
+ default:
+ return 0, &socksProtocolError{
+ reply: socksReplyAddressUnsupported,
+ err: errors.New("socks5: unsupported UDP associate address type"),
+ }
+ }
+ return int(request.port), nil
+}
+
+func listenSOCKSUDP(client net.Conn) (*net.UDPConn, error) {
+ localIP, err := addressIP(client.LocalAddr())
+ if err != nil {
+ return nil, err
+ }
+ if ip4 := localIP.To4(); ip4 != nil {
+ return net.ListenUDP("udp4", &net.UDPAddr{IP: ip4})
+ }
+ ip16 := localIP.To16()
+ if ip16 == nil {
+ return nil, errors.New("socks5: TCP listener has no IP address")
+ }
+ return net.ListenUDP("udp6", &net.UDPAddr{IP: ip16})
+}
+
+func addressIP(address net.Addr) (net.IP, error) {
+ switch value := address.(type) {
+ case *net.TCPAddr:
+ if value.IP != nil {
+ return append(net.IP(nil), value.IP...), nil
+ }
+ case *net.UDPAddr:
+ if value.IP != nil {
+ return append(net.IP(nil), value.IP...), nil
+ }
+ }
+ if address == nil {
+ return nil, errors.New("socks5: missing socket address")
+ }
+ host, _, err := net.SplitHostPort(address.String())
+ if err != nil {
+ return nil, err
+ }
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return nil, errors.New("socks5: socket address is not an IP address")
+ }
+ return ip, nil
+}
+
+type socksUDPClientEndpoint struct {
+ mu sync.RWMutex
+ peerIP net.IP
+ requestedPort int
+ address *net.UDPAddr
+}
+
+type closeOncePacketConn struct {
+ transport.PacketConn
+ once sync.Once
+ err error
+}
+
+func (c *closeOncePacketConn) Close() error {
+ c.once.Do(func() { c.err = c.PacketConn.Close() })
+ return c.err
+}
+
+func (c *closeOncePacketConn) MaxPayloadSize() int {
+ if sized, ok := c.PacketConn.(transport.PacketPayloadSizer); ok {
+ return sized.MaxPayloadSize()
+ }
+ return 0
+}
+
+// accept records the first valid source port when the UDP ASSOCIATE request
+// specified port zero. Every datagram must originate from the control TCP
+// connection's peer IP, preventing the relay from becoming an open UDP proxy.
+func (e *socksUDPClientEndpoint) accept(address *net.UDPAddr) bool {
+ if address == nil || !address.IP.Equal(e.peerIP) {
+ return false
+ }
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ if e.requestedPort != 0 && address.Port != e.requestedPort {
+ return false
+ }
+ if e.address == nil {
+ e.address = &net.UDPAddr{
+ IP: append(net.IP(nil), address.IP...),
+ Port: address.Port,
+ Zone: address.Zone,
+ }
+ return true
+ }
+ return address.Port == e.address.Port && address.Zone == e.address.Zone
+}
+
+func (e *socksUDPClientEndpoint) current() *net.UDPAddr {
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+ if e.address == nil {
+ return nil
+ }
+ return &net.UDPAddr{
+ IP: append(net.IP(nil), e.address.IP...),
+ Port: e.address.Port,
+ Zone: e.address.Zone,
+ }
+}
+
+func runSOCKSUDPAssociation(
+ control net.Conn,
+ local *net.UDPConn,
+ upstream transport.PacketConn,
+ endpoint *socksUDPClientEndpoint,
+ idleTimeout time.Duration,
+) {
+ maxPayloadSize := maxUDPDatagramSize
+ if sized, ok := upstream.(transport.PacketPayloadSizer); ok {
+ if limit := sized.MaxPayloadSize(); limit > 0 && limit < maxPayloadSize {
+ maxPayloadSize = limit
+ }
+ }
+ finished := make(chan struct{}, 3)
+ activity := make(chan struct{}, 1)
+ signalActivity := func() {
+ select {
+ case activity <- struct{}{}:
+ default:
+ }
+ }
+ finish := func() { finished <- struct{}{} }
+
+ go func() {
+ defer finish()
+ // One extra byte lets us detect and drop oversized IPv6 UDP payloads
+ // instead of forwarding a silently truncated 65,507-byte prefix.
+ buffer := make([]byte, maxUDPDatagramSize+1)
+ for {
+ n, source, err := local.ReadFromUDP(buffer)
+ if err != nil {
+ return
+ }
+ payload, target, err := parseSOCKSUDPDatagram(buffer[:n])
+ if err != nil || len(payload) > maxPayloadSize || !endpoint.accept(source) {
+ continue
+ }
+ if err := upstream.Send(payload, target); err != nil {
+ return
+ }
+ signalActivity()
+ }
+ }()
+
+ go func() {
+ defer finish()
+ for {
+ payload, source, err := upstream.Receive()
+ if err != nil {
+ return
+ }
+ clientAddress := endpoint.current()
+ if clientAddress == nil {
+ // An unsolicited upstream packet cannot be routed safely before
+ // the client's source endpoint has been validated.
+ continue
+ }
+ packet, err := buildSOCKSUDPDatagram(payload, source)
+ if err != nil {
+ continue
+ }
+ if _, err := local.WriteToUDP(packet, clientAddress); err != nil {
+ return
+ }
+ signalActivity()
+ }
+ }()
+
+ go func() {
+ defer finish()
+ buffer := make([]byte, 1)
+ for {
+ if _, err := control.Read(buffer); err != nil {
+ return
+ }
+ }
+ }()
+
+ var timer *time.Timer
+ var idle <-chan time.Time
+ if idleTimeout > 0 {
+ timer = time.NewTimer(idleTimeout)
+ idle = timer.C
+ defer timer.Stop()
+ }
+ completed := 0
+wait:
+ for {
+ select {
+ case <-finished:
+ completed++
+ break wait
+ case <-activity:
+ if timer != nil {
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(idleTimeout)
+ }
+ case <-idle:
+ break wait
+ }
+ }
+
+ // Closing both packet endpoints interrupts their blocking reads. A read
+ // deadline interrupts the control watcher without removing the connection
+ // from lifecycle tracking before all association goroutines have exited.
+ _ = local.Close()
+ _ = upstream.Close()
+ _ = control.SetReadDeadline(time.Now())
+ for completed < 3 {
+ <-finished
+ completed++
+ }
+}
+
func (s *SOCKS5Server) negotiate(conn net.Conn) error {
methods, err := readSOCKSGreeting(conn)
if err != nil {
@@ -220,8 +594,11 @@ func readUserPasswordRequest(r io.Reader) (string, string, error) {
}
type socksRequest struct {
- command byte
- address string
+ command byte
+ addressType byte
+ host string
+ port uint16
+ address string
}
type socksProtocolError struct {
@@ -259,15 +636,18 @@ func readSOCKSRequest(r io.Reader) (socksRequest, error) {
return socksRequest{}, err
}
port := binary.BigEndian.Uint16(portBytes[:])
- if port == 0 {
+ if port == 0 && header[1] != socksCommandUDP {
return socksRequest{}, &socksProtocolError{
reply: socksReplyAddressUnsupported,
err: errors.New("socks5: zero destination port"),
}
}
return socksRequest{
- command: header[1],
- address: net.JoinHostPort(host, fmt.Sprintf("%d", port)),
+ command: header[1],
+ addressType: header[3],
+ host: host,
+ port: port,
+ address: net.JoinHostPort(host, fmt.Sprintf("%d", port)),
}, nil
}
@@ -317,6 +697,81 @@ func readSOCKSHost(r io.Reader, addressType byte) (string, error) {
}
}
+func parseSOCKSUDPDatagram(packet []byte) ([]byte, string, error) {
+ if len(packet) > maxUDPDatagramSize {
+ return nil, "", errors.New("socks5: UDP datagram exceeds maximum size")
+ }
+ if len(packet) < 4 {
+ return nil, "", io.ErrUnexpectedEOF
+ }
+ if packet[0] != 0 || packet[1] != 0 {
+ return nil, "", errors.New("socks5: nonzero UDP reserved field")
+ }
+ if packet[2] != 0 {
+ return nil, "", errSOCKSUDPFragmented
+ }
+
+ reader := bytes.NewReader(packet[4:])
+ host, err := readSOCKSHost(reader, packet[3])
+ if err != nil {
+ return nil, "", err
+ }
+ portBytes := [2]byte{}
+ if _, err := io.ReadFull(reader, portBytes[:]); err != nil {
+ return nil, "", err
+ }
+ port := binary.BigEndian.Uint16(portBytes[:])
+ if port == 0 {
+ return nil, "", errors.New("socks5: zero UDP destination port")
+ }
+ payloadOffset := len(packet) - reader.Len()
+ return packet[payloadOffset:], net.JoinHostPort(host, strconv.Itoa(int(port))), nil
+}
+
+func buildSOCKSUDPDatagram(payload []byte, address string) ([]byte, error) {
+ host, portString, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, err
+ }
+ port, err := strconv.ParseUint(portString, 10, 16)
+ if err != nil || port == 0 {
+ return nil, errors.New("socks5: invalid UDP source port")
+ }
+
+ packet := make([]byte, 4, 4+net.IPv6len+2+len(payload))
+ if ip := net.ParseIP(host); ip != nil {
+ if ip4 := ip.To4(); ip4 != nil {
+ packet[3] = socksAddressIPv4
+ packet = append(packet, ip4...)
+ } else {
+ ip16 := ip.To16()
+ if ip16 == nil {
+ return nil, errors.New("socks5: invalid UDP source IP")
+ }
+ packet[3] = socksAddressIPv6
+ packet = append(packet, ip16...)
+ }
+ } else {
+ if len(host) == 0 || len(host) > 255 {
+ return nil, errors.New("socks5: invalid UDP source host length")
+ }
+ for _, value := range []byte(host) {
+ if value <= 0x20 || value == 0x7f {
+ return nil, errors.New("socks5: invalid UDP source host")
+ }
+ }
+ packet[3] = socksAddressDomain
+ packet = append(packet, byte(len(host)))
+ packet = append(packet, host...)
+ }
+ packet = binary.BigEndian.AppendUint16(packet, uint16(port))
+ if len(packet)+len(payload) > maxUDPDatagramSize {
+ return nil, errors.New("socks5: UDP datagram exceeds maximum size")
+ }
+ packet = append(packet, payload...)
+ return packet, nil
+}
+
func writeSOCKSReply(w io.Writer, reply byte, address net.Addr) error {
ip := net.IPv4zero
port := 0
diff --git a/internal/proxy/socks5_udp_test.go b/internal/proxy/socks5_udp_test.go
new file mode 100644
index 0000000..028ad4f
--- /dev/null
+++ b/internal/proxy/socks5_udp_test.go
@@ -0,0 +1,642 @@
+package proxy
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/binary"
+ "errors"
+ "io"
+ "net"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/cppla/autocar/internal/transport"
+)
+
+func TestSOCKS5UDPAssociateRoundTrip(t *testing.T) {
+ echoAddress, stopEcho := startUDPEcho(t)
+ defer stopEcho()
+
+ dialer := testPacketDialer{
+ Dialer: directDialer(),
+ dialPacket: func(context.Context) (transport.PacketConn, error) {
+ conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ return nil, err
+ }
+ return &directPacketConn{UDPConn: conn}, nil
+ },
+ }
+ server, proxyAddress, stopProxy := startSOCKS5(t, Config{Dialer: dialer})
+ defer stopProxy(server)
+
+ control := dialTCP(t, proxyAddress)
+ defer control.Close()
+ socksGreeting(t, control, nil)
+ mustWrite(t, control, ipv4SOCKSRequest(socksCommandUDP, net.IPv4zero, 0))
+ reply, relayAddress := readSOCKSReplyAddress(t, control)
+ if reply != socksReplySucceeded {
+ t.Fatalf("reply = %d, want success", reply)
+ }
+ if relayAddress.Port == 0 || !relayAddress.IP.Equal(net.IPv4(127, 0, 0, 1)) {
+ t.Fatalf("UDP relay address = %v, want loopback with a nonzero port", relayAddress)
+ }
+
+ client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+ _ = client.SetDeadline(time.Now().Add(3 * time.Second))
+ payload := []byte("autocar UDP associate")
+ packet, err := buildSOCKSUDPDatagram(payload, echoAddress)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.WriteToUDP(packet, relayAddress); err != nil {
+ t.Fatal(err)
+ }
+
+ buffer := make([]byte, maxUDPDatagramSize)
+ n, source, err := client.ReadFromUDP(buffer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !source.IP.Equal(relayAddress.IP) || source.Port != relayAddress.Port {
+ t.Fatalf("response source = %v, want %v", source, relayAddress)
+ }
+ got, gotSource, err := parseSOCKSUDPDatagram(buffer[:n])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gotSource != echoAddress {
+ t.Fatalf("encapsulated source = %q, want %q", gotSource, echoAddress)
+ }
+ if !bytes.Equal(got, payload) {
+ t.Fatalf("payload = %q, want %q", got, payload)
+ }
+}
+
+func TestSOCKS5UDPAssociateDropsFragmentsAndWrongSourcePort(t *testing.T) {
+ packetConn := newRecordingPacketConn()
+ dialer := testPacketDialer{
+ Dialer: directDialer(),
+ dialPacket: func(context.Context) (transport.PacketConn, error) {
+ return packetConn, nil
+ },
+ }
+ server, proxyAddress, stopProxy := startSOCKS5(t, Config{Dialer: dialer})
+ defer stopProxy(server)
+
+ allowed, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer allowed.Close()
+ wrongPort, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer wrongPort.Close()
+
+ control := dialTCP(t, proxyAddress)
+ defer control.Close()
+ socksGreeting(t, control, nil)
+ mustWrite(t, control, ipv4SOCKSRequest(
+ socksCommandUDP,
+ net.IPv4zero,
+ uint16(allowed.LocalAddr().(*net.UDPAddr).Port),
+ ))
+ reply, relayAddress := readSOCKSReplyAddress(t, control)
+ if reply != socksReplySucceeded {
+ t.Fatalf("reply = %d, want success", reply)
+ }
+
+ valid, err := buildSOCKSUDPDatagram([]byte("accepted"), "example.com:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := wrongPort.WriteToUDP(valid, relayAddress); err != nil {
+ t.Fatal(err)
+ }
+ assertNoPacketSend(t, packetConn.sends)
+
+ fragmented := append([]byte(nil), valid...)
+ fragmented[2] = 1
+ if _, err := allowed.WriteToUDP(fragmented, relayAddress); err != nil {
+ t.Fatal(err)
+ }
+ assertNoPacketSend(t, packetConn.sends)
+
+ if _, err := allowed.WriteToUDP(valid, relayAddress); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case sent := <-packetConn.sends:
+ if sent.address != "example.com:53" || string(sent.payload) != "accepted" {
+ t.Fatalf("upstream send = %#v", sent)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("valid datagram was not sent upstream")
+ }
+
+ packetConn.incoming <- packetRecord{payload: []byte("response"), address: "192.0.2.9:5353"}
+ _ = allowed.SetReadDeadline(time.Now().Add(time.Second))
+ buffer := make([]byte, 128)
+ n, _, err := allowed.ReadFromUDP(buffer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload, source, err := parseSOCKSUDPDatagram(buffer[:n])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(payload) != "response" || source != "192.0.2.9:5353" {
+ t.Fatalf("downstream packet = %q from %q", payload, source)
+ }
+}
+
+func TestSOCKS5UDPAssociateHonorsTransportPayloadLimit(t *testing.T) {
+ recorded := newRecordingPacketConn()
+ upstream := &limitedPacketConn{recordingPacketConn: recorded, limit: 4}
+ dialer := testPacketDialer{
+ Dialer: directDialer(),
+ dialPacket: func(context.Context) (transport.PacketConn, error) {
+ return upstream, nil
+ },
+ }
+ server, proxyAddress, stopProxy := startSOCKS5(t, Config{Dialer: dialer})
+ defer stopProxy(server)
+
+ control := dialTCP(t, proxyAddress)
+ defer control.Close()
+ socksGreeting(t, control, nil)
+ mustWrite(t, control, ipv4SOCKSRequest(socksCommandUDP, net.IPv4zero, 0))
+ reply, relayAddress := readSOCKSReplyAddress(t, control)
+ if reply != socksReplySucceeded {
+ t.Fatalf("reply = %d, want success", reply)
+ }
+ client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+ oversized, err := buildSOCKSUDPDatagram([]byte("12345"), "example.com:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.WriteToUDP(oversized, relayAddress); err != nil {
+ t.Fatal(err)
+ }
+ assertNoPacketSend(t, recorded.sends)
+
+ valid, err := buildSOCKSUDPDatagram([]byte("1234"), "example.com:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.WriteToUDP(valid, relayAddress); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case sent := <-recorded.sends:
+ if string(sent.payload) != "1234" || sent.address != "example.com:53" {
+ t.Fatalf("upstream send = %#v", sent)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("valid datagram was not sent after oversized datagram")
+ }
+}
+
+func TestSOCKS5UDPAssociateRejectsMismatchedRequestedAddressBeforeUpstream(t *testing.T) {
+ dialCalled := make(chan struct{}, 1)
+ dialer := testPacketDialer{
+ Dialer: directDialer(),
+ dialPacket: func(context.Context) (transport.PacketConn, error) {
+ dialCalled <- struct{}{}
+ return newRecordingPacketConn(), nil
+ },
+ }
+ server, proxyAddress, stopProxy := startSOCKS5(t, Config{Dialer: dialer})
+ defer stopProxy(server)
+
+ control := dialTCP(t, proxyAddress)
+ defer control.Close()
+ socksGreeting(t, control, nil)
+ mustWrite(t, control, ipv4SOCKSRequest(socksCommandUDP, net.ParseIP("192.0.2.44"), 5353))
+ reply, _ := readSOCKSReplyAddress(t, control)
+ if reply != socksReplyNotAllowed {
+ t.Fatalf("reply = %d, want not allowed", reply)
+ }
+ select {
+ case <-dialCalled:
+ t.Fatal("invalid UDP ASSOCIATE request allocated an upstream session")
+ default:
+ }
+}
+
+func TestSOCKS5UDPAssociateShutdownClosesPacketConn(t *testing.T) {
+ packetConn := newRecordingPacketConn()
+ dialer := testPacketDialer{
+ Dialer: directDialer(),
+ dialPacket: func(context.Context) (transport.PacketConn, error) {
+ return packetConn, nil
+ },
+ }
+ server, proxyAddress, stopProxy := startSOCKS5(t, Config{Dialer: dialer})
+ defer stopProxy(server)
+ control := dialTCP(t, proxyAddress)
+ defer control.Close()
+ socksGreeting(t, control, nil)
+ mustWrite(t, control, ipv4SOCKSRequest(socksCommandUDP, net.IPv4zero, 0))
+ if reply, _ := readSOCKSReplyAddress(t, control); reply != socksReplySucceeded {
+ t.Fatalf("reply = %d, want success", reply)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
+ defer cancel()
+ if err := server.Shutdown(ctx); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("Shutdown error = %v, want deadline exceeded", err)
+ }
+ select {
+ case <-packetConn.closed:
+ case <-time.After(time.Second):
+ t.Fatal("packet connection was not closed by forced shutdown")
+ }
+ _ = control.SetReadDeadline(time.Now().Add(time.Second))
+ if _, err := control.Read(make([]byte, 1)); err == nil {
+ t.Fatal("control connection remained open after forced shutdown")
+ }
+}
+
+func TestSOCKS5UDPAssociateIdleTimeout(t *testing.T) {
+ packetConn := newRecordingPacketConn()
+ dialer := testPacketDialer{
+ Dialer: directDialer(),
+ dialPacket: func(context.Context) (transport.PacketConn, error) {
+ return packetConn, nil
+ },
+ }
+ server, proxyAddress, stopProxy := startSOCKS5(t, Config{
+ Dialer: dialer,
+ IdleTimeout: 30 * time.Millisecond,
+ })
+ defer stopProxy(server)
+ control := dialTCP(t, proxyAddress)
+ defer control.Close()
+ socksGreeting(t, control, nil)
+ mustWrite(t, control, ipv4SOCKSRequest(socksCommandUDP, net.IPv4zero, 0))
+ if reply, _ := readSOCKSReplyAddress(t, control); reply != socksReplySucceeded {
+ t.Fatalf("reply = %d, want success", reply)
+ }
+ select {
+ case <-packetConn.closed:
+ case <-time.After(time.Second):
+ t.Fatal("idle UDP association did not close")
+ }
+ _ = control.SetReadDeadline(time.Now().Add(time.Second))
+ if _, err := control.Read(make([]byte, 1)); err == nil {
+ t.Fatal("idle UDP control connection remained open")
+ }
+}
+
+func TestSOCKSUDPClientEndpointSourceRestrictions(t *testing.T) {
+ endpoint := &socksUDPClientEndpoint{peerIP: net.ParseIP("192.0.2.1")}
+ if endpoint.accept(&net.UDPAddr{IP: net.ParseIP("192.0.2.2"), Port: 1000}) {
+ t.Fatal("accepted a datagram from a different IP")
+ }
+ if !endpoint.accept(&net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1000}) {
+ t.Fatal("rejected first valid source")
+ }
+ if endpoint.accept(&net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1001}) {
+ t.Fatal("accepted a different source port after locking")
+ }
+}
+
+func TestValidateUDPAssociateRequestAddressAndPortSemantics(t *testing.T) {
+ domainLookup := func(_ context.Context, host string) ([]net.IPAddr, error) {
+ switch host {
+ case "client.example":
+ return []net.IPAddr{
+ {IP: net.ParseIP("2001:db8::99")},
+ {IP: net.ParseIP("192.0.2.10")},
+ }, nil
+ case "other.example":
+ return []net.IPAddr{{IP: net.ParseIP("192.0.2.11")}}, nil
+ default:
+ return nil, &net.DNSError{Name: host, Err: "test lookup failure"}
+ }
+ }
+ tests := []struct {
+ name string
+ request []byte
+ peerIP net.IP
+ wantPort int
+ wantReply byte
+ }{
+ {
+ name: "IPv4 concrete address and port",
+ request: ipv4SOCKSRequest(socksCommandUDP, net.ParseIP("192.0.2.10"), 5300),
+ peerIP: net.ParseIP("192.0.2.10"),
+ wantPort: 5300,
+ },
+ {
+ name: "IPv4 unspecified dynamic port",
+ request: ipv4SOCKSRequest(socksCommandUDP, net.IPv4zero, 0),
+ peerIP: net.ParseIP("192.0.2.10"),
+ wantPort: 0,
+ },
+ {
+ name: "IPv4 address mismatch",
+ request: ipv4SOCKSRequest(socksCommandUDP, net.ParseIP("192.0.2.11"), 5300),
+ peerIP: net.ParseIP("192.0.2.10"),
+ wantReply: socksReplyNotAllowed,
+ },
+ {
+ name: "IPv6 concrete address and port",
+ request: ipv6SOCKSRequest(socksCommandUDP, net.ParseIP("2001:db8::10"), 5353),
+ peerIP: net.ParseIP("2001:db8::10"),
+ wantPort: 5353,
+ },
+ {
+ name: "IPv6 unspecified dynamic port",
+ request: ipv6SOCKSRequest(socksCommandUDP, net.IPv6zero, 0),
+ peerIP: net.ParseIP("2001:db8::10"),
+ wantPort: 0,
+ },
+ {
+ name: "IPv6 address mismatch",
+ request: ipv6SOCKSRequest(socksCommandUDP, net.ParseIP("2001:db8::11"), 5353),
+ peerIP: net.ParseIP("2001:db8::10"),
+ wantReply: socksReplyNotAllowed,
+ },
+ {
+ name: "domain resolves to control peer",
+ request: domainSOCKSRequest(socksCommandUDP, "client.example", 6000),
+ peerIP: net.ParseIP("192.0.2.10"),
+ wantPort: 6000,
+ },
+ {
+ name: "domain resolves elsewhere",
+ request: domainSOCKSRequest(socksCommandUDP, "other.example", 6000),
+ peerIP: net.ParseIP("192.0.2.10"),
+ wantReply: socksReplyNotAllowed,
+ },
+ {
+ name: "domain lookup failure",
+ request: domainSOCKSRequest(socksCommandUDP, "missing.example", 6000),
+ peerIP: net.ParseIP("192.0.2.10"),
+ wantReply: socksReplyHostUnreachable,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ request, err := readSOCKSRequest(bytes.NewReader(test.request))
+ if err != nil {
+ t.Fatal(err)
+ }
+ port, err := validateUDPAssociateRequest(context.Background(), request, test.peerIP, domainLookup)
+ if test.wantReply == 0 {
+ if err != nil || port != test.wantPort {
+ t.Fatalf("port=%d error=%v, want port %d", port, err, test.wantPort)
+ }
+ return
+ }
+ var protocolErr *socksProtocolError
+ if !errors.As(err, &protocolErr) || protocolErr.reply != test.wantReply {
+ t.Fatalf("error = %v, want SOCKS reply %d", err, test.wantReply)
+ }
+ })
+ }
+}
+
+func TestSOCKSUDPDatagramAddressTypesAndLimits(t *testing.T) {
+ for _, address := range []string{"192.0.2.1:53", "[2001:db8::1]:443", "example.com:5353"} {
+ t.Run(address, func(t *testing.T) {
+ packet, err := buildSOCKSUDPDatagram([]byte("data"), address)
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload, gotAddress, err := parseSOCKSUDPDatagram(packet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(payload) != "data" || gotAddress != address {
+ t.Fatalf("round trip = %q to %q", payload, gotAddress)
+ }
+ })
+ }
+
+ maximumPayload := make([]byte, maxUDPDatagramSize-10) // IPv4 header is 10 bytes.
+ if _, err := buildSOCKSUDPDatagram(maximumPayload, "192.0.2.1:53"); err != nil {
+ t.Fatalf("maximum-size datagram: %v", err)
+ }
+ if _, err := buildSOCKSUDPDatagram(append(maximumPayload, 0), "192.0.2.1:53"); err == nil {
+ t.Fatal("oversize datagram was accepted")
+ }
+}
+
+func TestParseSOCKSUDPDatagramRejectsMalformedPackets(t *testing.T) {
+ valid, err := buildSOCKSUDPDatagram([]byte("x"), "192.0.2.1:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ fragmented := append([]byte(nil), valid...)
+ fragmented[2] = 1
+ if _, _, err := parseSOCKSUDPDatagram(fragmented); !errors.Is(err, errSOCKSUDPFragmented) {
+ t.Fatalf("fragment error = %v", err)
+ }
+ for _, packet := range [][]byte{
+ nil,
+ {0, 0, 0},
+ {1, 0, 0, socksAddressIPv4, 127, 0, 0, 1, 0, 53},
+ {0, 0, 0, 0xff, 0, 53},
+ {0, 0, 0, socksAddressIPv4, 127, 0, 0, 1, 0, 0},
+ } {
+ if _, _, err := parseSOCKSUDPDatagram(packet); err == nil {
+ t.Fatalf("malformed packet %v was accepted", packet)
+ }
+ }
+}
+
+func TestReadSOCKSUDPRequestAllowsZeroPort(t *testing.T) {
+ request, err := readSOCKSRequest(bytes.NewReader(ipv4SOCKSRequest(
+ socksCommandUDP,
+ net.IPv4zero,
+ 0,
+ )))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if request.address != "0.0.0.0:0" {
+ t.Fatalf("address = %q", request.address)
+ }
+ if request.addressType != socksAddressIPv4 || request.host != "0.0.0.0" || request.port != 0 {
+ t.Fatalf("parsed UDP endpoint = type %d host %q port %d", request.addressType, request.host, request.port)
+ }
+ if _, err := readSOCKSRequest(bytes.NewReader(ipv4SOCKSRequest(
+ socksCommandConnect,
+ net.IPv4zero,
+ 0,
+ ))); err == nil {
+ t.Fatal("CONNECT with port zero was accepted")
+ }
+}
+
+func ipv6SOCKSRequest(command byte, ip net.IP, port uint16) []byte {
+ request := []byte{socksVersion, command, 0, socksAddressIPv6}
+ request = append(request, ip.To16()...)
+ return binary.BigEndian.AppendUint16(request, port)
+}
+
+func FuzzParseSOCKSUDPDatagram(f *testing.F) {
+ seed, _ := buildSOCKSUDPDatagram([]byte("payload"), "example.com:53")
+ f.Add(seed)
+ f.Add([]byte{0, 0, 0, socksAddressIPv4, 127, 0, 0, 1, 0, 53})
+ f.Fuzz(func(t *testing.T, packet []byte) {
+ _, _, _ = parseSOCKSUDPDatagram(packet)
+ })
+}
+
+type testPacketDialer struct {
+ transport.Dialer
+ dialPacket func(context.Context) (transport.PacketConn, error)
+}
+
+func (d testPacketDialer) DialPacket(ctx context.Context) (transport.PacketConn, error) {
+ return d.dialPacket(ctx)
+}
+
+type directPacketConn struct {
+ *net.UDPConn
+}
+
+func (c *directPacketConn) Send(payload []byte, address string) error {
+ target, err := net.ResolveUDPAddr("udp", address)
+ if err != nil {
+ return err
+ }
+ _, err = c.WriteToUDP(payload, target)
+ return err
+}
+
+func (c *directPacketConn) Receive() ([]byte, string, error) {
+ buffer := make([]byte, maxUDPDatagramSize)
+ n, source, err := c.ReadFromUDP(buffer)
+ if err != nil {
+ return nil, "", err
+ }
+ return buffer[:n], source.String(), nil
+}
+
+type packetRecord struct {
+ payload []byte
+ address string
+}
+
+type recordingPacketConn struct {
+ sends chan packetRecord
+ incoming chan packetRecord
+ closed chan struct{}
+ once sync.Once
+}
+
+type limitedPacketConn struct {
+ *recordingPacketConn
+ limit int
+}
+
+func (c *limitedPacketConn) MaxPayloadSize() int { return c.limit }
+
+func newRecordingPacketConn() *recordingPacketConn {
+ return &recordingPacketConn{
+ sends: make(chan packetRecord, 8),
+ incoming: make(chan packetRecord, 8),
+ closed: make(chan struct{}),
+ }
+}
+
+func (c *recordingPacketConn) Send(payload []byte, address string) error {
+ record := packetRecord{payload: append([]byte(nil), payload...), address: address}
+ select {
+ case c.sends <- record:
+ return nil
+ case <-c.closed:
+ return net.ErrClosed
+ }
+}
+
+func (c *recordingPacketConn) Receive() ([]byte, string, error) {
+ select {
+ case packet := <-c.incoming:
+ return append([]byte(nil), packet.payload...), packet.address, nil
+ case <-c.closed:
+ return nil, "", net.ErrClosed
+ }
+}
+
+func (c *recordingPacketConn) Close() error {
+ c.once.Do(func() { close(c.closed) })
+ return nil
+}
+
+func startUDPEcho(t *testing.T) (string, func()) {
+ t.Helper()
+ conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ buffer := make([]byte, maxUDPDatagramSize)
+ for {
+ n, source, err := conn.ReadFromUDP(buffer)
+ if err != nil {
+ return
+ }
+ _, _ = conn.WriteToUDP(buffer[:n], source)
+ }
+ }()
+ return conn.LocalAddr().String(), func() {
+ _ = conn.Close()
+ <-done
+ }
+}
+
+func ipv4SOCKSRequest(command byte, ip net.IP, port uint16) []byte {
+ request := []byte{socksVersion, command, 0, socksAddressIPv4}
+ request = append(request, ip.To4()...)
+ return append(request, byte(port>>8), byte(port))
+}
+
+func readSOCKSReplyAddress(t *testing.T, reader io.Reader) (byte, *net.UDPAddr) {
+ t.Helper()
+ buffered, ok := reader.(*bufio.Reader)
+ if !ok {
+ buffered = bufio.NewReader(reader)
+ }
+ header := make([]byte, 4)
+ mustReadFull(t, buffered, header)
+ if header[0] != socksVersion || header[2] != 0 {
+ t.Fatalf("invalid SOCKS reply header %v", header)
+ }
+ host, err := readSOCKSHost(buffered, header[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+ portBytes := make([]byte, 2)
+ mustReadFull(t, buffered, portBytes)
+ port := int(portBytes[0])<<8 | int(portBytes[1])
+ return header[1], &net.UDPAddr{IP: net.ParseIP(host), Port: port}
+}
+
+func assertNoPacketSend(t *testing.T, sends <-chan packetRecord) {
+ t.Helper()
+ select {
+ case packet := <-sends:
+ t.Fatalf("unexpected upstream packet %#v", packet)
+ case <-time.After(50 * time.Millisecond):
+ }
+}
diff --git a/internal/security/dialer.go b/internal/security/dialer.go
index cc6a4a0..342faae 100644
--- a/internal/security/dialer.go
+++ b/internal/security/dialer.go
@@ -222,6 +222,68 @@ func (d *SafeDialer) DialContext(ctx context.Context, network, address string) (
return nil, fmt.Errorf("security: %s has no addresses matching %s", host, network)
}
+// ResolveUDPContext resolves a UDP destination and returns only numeric
+// addresses that pass the same port, special-use, private-network and custom
+// CIDR policy enforced by DialContext. Callers must use one of the returned
+// addresses directly and must not resolve the original hostname again. This
+// is used by datagram transports where a connected net.Conn is not suitable.
+func (d *SafeDialer) ResolveUDPContext(ctx context.Context, address string) ([]netip.AddrPort, error) {
+ if d == nil {
+ return nil, errors.New("security: nil SafeDialer")
+ }
+ if ctx == nil {
+ return nil, errors.New("security: nil resolve context")
+ }
+ if err := context.Cause(ctx); err != nil {
+ return nil, err
+ }
+ host, portText, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, fmt.Errorf("security: invalid destination %q: %w", address, err)
+ }
+ if host == "" {
+ return nil, errors.New("security: destination host is required")
+ }
+ portNumber, err := strconv.ParseUint(portText, 10, 16)
+ if err != nil || portNumber == 0 {
+ return nil, fmt.Errorf("security: destination port %q is not a number from 1 to 65535", portText)
+ }
+ port := uint16(portNumber)
+ deniedPorts := d.deniedPorts
+ if deniedPorts == nil {
+ deniedPorts = map[uint16]struct{}{25: {}, 465: {}, 587: {}}
+ }
+ if _, denied := deniedPorts[port]; denied {
+ return nil, fmt.Errorf("%w: %d", ErrDeniedPort, port)
+ }
+
+ addresses, err := d.resolve(ctx, "ip", host)
+ if err != nil {
+ return nil, err
+ }
+ unsafeCount := 0
+ approved := make([]netip.AddrPort, 0, len(addresses))
+ for _, addr := range addresses {
+ addr = addr.Unmap()
+ if err := validateDestinationIP(addr, d.allowPrivate); err != nil {
+ unsafeCount++
+ continue
+ }
+ if matchesDeniedPrefix(addr, d.deniedNets) {
+ unsafeCount++
+ continue
+ }
+ approved = append(approved, netip.AddrPortFrom(addr, port))
+ }
+ if len(approved) != 0 {
+ return approved, nil
+ }
+ if unsafeCount != 0 {
+ return nil, fmt.Errorf("%w: %s resolved only to prohibited addresses", ErrUnsafeAddress, host)
+ }
+ return nil, fmt.Errorf("security: %s has no usable UDP addresses", host)
+}
+
func (d *SafeDialer) resolve(ctx context.Context, network, host string) ([]netip.Addr, error) {
if literal, err := netip.ParseAddr(host); err == nil {
if literal.Zone() != "" {
diff --git a/internal/security/dialer_test.go b/internal/security/dialer_test.go
index 85cbfd9..57b1a30 100644
--- a/internal/security/dialer_test.go
+++ b/internal/security/dialer_test.go
@@ -23,6 +23,23 @@ type fakeResolver struct {
calls []resolverCall
}
+type rotatingResolver struct {
+ mu sync.Mutex
+ answers [][]netip.Addr
+ calls []resolverCall
+}
+
+func (r *rotatingResolver) LookupNetIP(_ context.Context, network, host string) ([]netip.Addr, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.calls = append(r.calls, resolverCall{network: network, host: host})
+ index := len(r.calls) - 1
+ if index >= len(r.answers) {
+ index = len(r.answers) - 1
+ }
+ return append([]netip.Addr(nil), r.answers[index]...), nil
+}
+
func (r *fakeResolver) LookupNetIP(_ context.Context, network, host string) ([]netip.Addr, error) {
r.calls = append(r.calls, resolverCall{network: network, host: host})
return append([]netip.Addr(nil), r.addresses...), r.err
@@ -408,6 +425,89 @@ func TestSafeDialerChecksCanceledContextBeforeDial(t *testing.T) {
}
}
+func TestSafeDialerResolveUDPReturnsOnlyApprovedNumericAddresses(t *testing.T) {
+ resolver := &fakeResolver{addresses: []netip.Addr{
+ netip.MustParseAddr("10.0.0.1"),
+ netip.MustParseAddr("8.8.8.8"),
+ netip.MustParseAddr("2606:4700:4700::1111"),
+ netip.MustParseAddr("8.8.8.8"),
+ }}
+ safe := NewSafeDialer(SafeDialerOptions{Resolver: resolver})
+ addresses, err := safe.ResolveUDPContext(context.Background(), "dns.example:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []netip.AddrPort{
+ netip.MustParseAddrPort("8.8.8.8:53"),
+ netip.MustParseAddrPort("[2606:4700:4700::1111]:53"),
+ }
+ if !reflect.DeepEqual(addresses, want) {
+ t.Fatalf("UDP addresses = %v, want %v", addresses, want)
+ }
+ if !reflect.DeepEqual(resolver.calls, []resolverCall{{network: "ip", host: "dns.example"}}) {
+ t.Fatalf("resolver calls = %v", resolver.calls)
+ }
+}
+
+func TestSafeDialerResolveUDPRejectsDeniedPortBeforeDNS(t *testing.T) {
+ resolver := &fakeResolver{addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}}
+ safe := NewSafeDialer(SafeDialerOptions{Resolver: resolver})
+ if _, err := safe.ResolveUDPContext(context.Background(), "mail.example:465"); !errors.Is(err, ErrDeniedPort) {
+ t.Fatalf("denied UDP port error = %v", err)
+ }
+ if len(resolver.calls) != 0 {
+ t.Fatalf("denied UDP port caused DNS resolution: %v", resolver.calls)
+ }
+}
+
+func TestSafeDialerResolveUDPRejectsUnsafeResolution(t *testing.T) {
+ resolver := &fakeResolver{addresses: []netip.Addr{
+ netip.MustParseAddr("127.0.0.1"),
+ netip.MustParseAddr("169.254.169.254"),
+ }}
+ safe := NewSafeDialer(SafeDialerOptions{Resolver: resolver, AllowPrivate: true})
+ if _, err := safe.ResolveUDPContext(context.Background(), "internal.example:53"); !errors.Is(err, ErrUnsafeAddress) {
+ t.Fatalf("unsafe UDP resolution error = %v", err)
+ }
+}
+
+func TestSafeDialerResolveUDPNumericLiteralSkipsDNS(t *testing.T) {
+ resolver := &fakeResolver{err: errors.New("must not resolve")}
+ safe := NewSafeDialer(SafeDialerOptions{Resolver: resolver})
+ addresses, err := safe.ResolveUDPContext(context.Background(), "[2606:4700:4700::1111]:443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []netip.AddrPort{netip.MustParseAddrPort("[2606:4700:4700::1111]:443")}
+ if !reflect.DeepEqual(addresses, want) {
+ t.Fatalf("literal UDP addresses = %v, want %v", addresses, want)
+ }
+ if len(resolver.calls) != 0 {
+ t.Fatalf("numeric UDP literal caused DNS resolution: %v", resolver.calls)
+ }
+}
+
+func TestSafeDialerResolveUDPFreezesDNSAnswerAgainstRebinding(t *testing.T) {
+ resolver := &rotatingResolver{answers: [][]netip.Addr{
+ {netip.MustParseAddr("8.8.8.8")},
+ {netip.MustParseAddr("127.0.0.1")},
+ }}
+ safe := NewSafeDialer(SafeDialerOptions{Resolver: resolver})
+ addresses, err := safe.ResolveUDPContext(context.Background(), "rebinding.example:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(addresses, []netip.AddrPort{netip.MustParseAddrPort("8.8.8.8:53")}) {
+ t.Fatalf("frozen numeric answer = %v", addresses)
+ }
+ resolver.mu.Lock()
+ calls := append([]resolverCall(nil), resolver.calls...)
+ resolver.mu.Unlock()
+ if len(calls) != 1 {
+ t.Fatalf("ResolveUDPContext performed %d lookups, want exactly 1", len(calls))
+ }
+}
+
func formatPort(port uint16) string {
const digits = "0123456789"
if port == 0 {
diff --git a/internal/transport/transport.go b/internal/transport/transport.go
index 139c38f..4f5f5a5 100644
--- a/internal/transport/transport.go
+++ b/internal/transport/transport.go
@@ -13,6 +13,30 @@ type Dialer interface {
DialContext(ctx context.Context, network, address string) (net.Conn, error)
}
+// PacketDialer is an optional capability implemented by transports that can
+// carry datagrams. Proxy frontends must continue to work with a plain Dialer;
+// datagram commands are advertised only when this interface is available.
+type PacketDialer interface {
+ DialPacket(ctx context.Context) (PacketConn, error)
+}
+
+// PacketConn carries independent datagrams through an authenticated tunnel.
+// Send consumes payload before returning. Close must unblock a concurrent
+// Receive call so proxy shutdown cannot leak goroutines.
+type PacketConn interface {
+ Send(payload []byte, address string) error
+ Receive() (payload []byte, address string, err error)
+ Close() error
+}
+
+// PacketPayloadSizer is an optional capability for packet transports with a
+// logical-message limit below the UDP protocol maximum. Frontends use it to
+// reject an oversized payload without tearing down an otherwise healthy
+// association.
+type PacketPayloadSizer interface {
+ MaxPayloadSize() int
+}
+
// DialFunc adapts a function to Dialer.
type DialFunc func(context.Context, string, string) (net.Conn, error)
diff --git a/internal/tunnel/common.go b/internal/tunnel/common.go
index d849689..4c610c1 100644
--- a/internal/tunnel/common.go
+++ b/internal/tunnel/common.go
@@ -21,10 +21,11 @@ import (
)
const (
- defaultHandshakeTimeout = 10 * time.Second
- defaultDialTimeout = 10 * time.Second
- defaultMaxStreams = 1024
- defaultMaxConnections = 256
+ defaultHandshakeTimeout = 10 * time.Second
+ defaultDialTimeout = 10 * time.Second
+ defaultMaxStreams = 1024
+ defaultMaxConnections = 256
+ defaultMaxClientConnections = 32
)
// RemoteError is returned when the authenticated exit rejects a CONNECT
diff --git a/internal/tunnel/tls.go b/internal/tunnel/tls.go
index 27eb937..f5e05bc 100644
--- a/internal/tunnel/tls.go
+++ b/internal/tunnel/tls.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net"
+ "net/netip"
"sync"
"time"
@@ -23,6 +24,7 @@ type TLSServerConfig struct {
HandshakeTimeout time.Duration
DialTimeout time.Duration
MaxConcurrentStreams int
+ MaxClientConnections int
}
// TLSServer serves one tunneled TCP stream per TLS 1.3 connection. It is a
@@ -31,6 +33,7 @@ type TLSServer struct {
listener net.Listener
tlsConfig *tls.Config
core *serverCore
+ clients *sourceConnectionLimiter
ctx context.Context
cancel context.CancelFunc
@@ -59,6 +62,16 @@ func ListenTLS(config TLSServerConfig) (*TLSServer, error) {
if err != nil {
return nil, err
}
+ maxClientConnections := config.MaxClientConnections
+ if maxClientConnections < 0 {
+ return nil, errors.New("tunnel: maximum TLS client connections cannot be negative")
+ }
+ if maxClientConnections == 0 {
+ maxClientConnections = min(defaultMaxClientConnections, cap(core.sem))
+ }
+ if maxClientConnections > cap(core.sem) {
+ return nil, fmt.Errorf("tunnel: maximum TLS client connections (%d) exceeds maximum concurrent streams (%d)", maxClientConnections, cap(core.sem))
+ }
listener, err := net.Listen("tcp", config.Address)
if err != nil {
return nil, fmt.Errorf("tunnel: listen TLS fallback: %w", err)
@@ -68,6 +81,7 @@ func ListenTLS(config TLSServerConfig) (*TLSServer, error) {
listener: listener,
tlsConfig: tlsConfig,
core: core,
+ clients: newSourceConnectionLimiter(maxClientConnections),
ctx: ctx,
cancel: cancel,
conns: make(map[net.Conn]struct{}),
@@ -121,19 +135,27 @@ func (s *TLSServer) Serve(ctx context.Context) error {
_ = raw.Close()
continue
}
+ sourceKey := tlsSourceKey(raw.RemoteAddr())
+ if !s.clients.acquire(sourceKey) {
+ s.core.release()
+ s.lifecycle.Unlock()
+ _ = raw.Close()
+ continue
+ }
tlsConn := tls.Server(raw, s.tlsConfig)
s.connMu.Lock()
s.conns[tlsConn] = struct{}{}
s.connMu.Unlock()
s.wg.Add(1)
s.lifecycle.Unlock()
- go s.serveTLSConnection(acceptCtx, tlsConn)
+ go s.serveTLSConnection(acceptCtx, tlsConn, sourceKey)
}
}
-func (s *TLSServer) serveTLSConnection(ctx context.Context, conn *tls.Conn) {
+func (s *TLSServer) serveTLSConnection(ctx context.Context, conn *tls.Conn, sourceKey string) {
defer s.wg.Done()
defer s.core.release()
+ defer s.clients.release(sourceKey)
defer func() {
s.connMu.Lock()
delete(s.conns, conn)
@@ -149,6 +171,73 @@ func (s *TLSServer) serveTLSConnection(ctx context.Context, conn *tls.Conn) {
s.core.handleStream(ctx, conn, nil)
}
+type sourceConnectionLimiter struct {
+ mu sync.Mutex
+ limit int
+ active map[string]int
+}
+
+func newSourceConnectionLimiter(limit int) *sourceConnectionLimiter {
+ return &sourceConnectionLimiter{limit: limit, active: make(map[string]int)}
+}
+
+func (l *sourceConnectionLimiter) acquire(key string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.active[key] >= l.limit {
+ return false
+ }
+ l.active[key]++
+ return true
+}
+
+func (l *sourceConnectionLimiter) release(key string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.active[key] <= 1 {
+ delete(l.active, key)
+ return
+ }
+ l.active[key]--
+}
+
+func (l *sourceConnectionLimiter) count(key string) int {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.active[key]
+}
+
+func tlsSourceKey(address net.Addr) string {
+ if address == nil {
+ return ""
+ }
+ if tcpAddress, ok := address.(*net.TCPAddr); ok {
+ if ip, ok := netip.AddrFromSlice(tcpAddress.IP); ok {
+ return sourceIPKey(ip)
+ }
+ }
+ host, _, err := net.SplitHostPort(address.String())
+ if err == nil {
+ if ip, parseErr := netip.ParseAddr(host); parseErr == nil {
+ return sourceIPKey(ip)
+ }
+ }
+ // Unknown address representations share one conservative bucket. Including
+ // an unparsed port here would let a peer obtain a fresh bucket per socket.
+ return ""
+}
+
+func sourceIPKey(ip netip.Addr) string {
+ ip = ip.Unmap().WithZone("")
+ if ip.Is4() {
+ return ip.String()
+ }
+ if ip.Is6() {
+ return netip.PrefixFrom(ip, 64).Masked().String()
+ }
+ return ""
+}
+
// Close stops the listener, closes active connections and waits for relays.
func (s *TLSServer) Close() error {
s.closeOnce.Do(func() {
diff --git a/internal/tunnel/tls_limits_test.go b/internal/tunnel/tls_limits_test.go
new file mode 100644
index 0000000..db6f6c0
--- /dev/null
+++ b/internal/tunnel/tls_limits_test.go
@@ -0,0 +1,233 @@
+package tunnel
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+)
+
+func TestTLSServerClientConnectionLimitValidationAndDefaults(t *testing.T) {
+ serverTLS, _ := testTLSConfigs(t)
+
+ for _, test := range []struct {
+ name string
+ streams int
+ clients int
+ wantClient int
+ }{
+ {name: "default below 32", streams: 2, wantClient: 2},
+ {name: "default capped at 32", streams: 64, wantClient: 32},
+ {name: "explicit", streams: 4, clients: 3, wantClient: 3},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ server, err := ListenTLS(TLSServerConfig{
+ Address: "127.0.0.1:0",
+ Token: testToken,
+ TLSConfig: serverTLS,
+ MaxConcurrentStreams: test.streams,
+ MaxClientConnections: test.clients,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer server.Close()
+ if got := server.clients.limit; got != test.wantClient {
+ t.Fatalf("client connection limit = %d, want %d", got, test.wantClient)
+ }
+ })
+ }
+
+ for _, test := range []struct {
+ name string
+ streams int
+ clients int
+ }{
+ {name: "negative", streams: 4, clients: -1},
+ {name: "exceeds global limit", streams: 2, clients: 3},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ server, err := ListenTLS(TLSServerConfig{
+ Address: "127.0.0.1:0",
+ Token: testToken,
+ TLSConfig: serverTLS,
+ MaxConcurrentStreams: test.streams,
+ MaxClientConnections: test.clients,
+ })
+ if server != nil {
+ _ = server.Close()
+ }
+ if err == nil {
+ t.Fatal("invalid client connection limit was accepted")
+ }
+ })
+ }
+}
+
+func TestTLSSourceConnectionLimiter(t *testing.T) {
+ limiter := newSourceConnectionLimiter(1)
+ first := tlsSourceKey(&net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 1000})
+ same := tlsSourceKey(&net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 2000})
+ other := tlsSourceKey(&net.TCPAddr{IP: net.ParseIP("192.0.2.11"), Port: 1000})
+ if first != same {
+ t.Fatalf("IPv4 source key varies by port: %q != %q", first, same)
+ }
+ if first == other {
+ t.Fatalf("distinct IPv4 sources share key %q", first)
+ }
+ if !limiter.acquire(first) {
+ t.Fatal("first source acquisition failed")
+ }
+ if limiter.acquire(same) {
+ t.Fatal("same source exceeded its limit")
+ }
+ if !limiter.acquire(other) {
+ t.Fatal("different source was incorrectly limited")
+ }
+ if got := limiter.count(first); got != 1 {
+ t.Fatalf("first source count = %d, want 1", got)
+ }
+ limiter.release(first)
+ if got := limiter.count(first); got != 0 {
+ t.Fatalf("released source count = %d, want 0", got)
+ }
+ if !limiter.acquire(first) {
+ t.Fatal("released source slot was not reusable")
+ }
+}
+
+func TestTLSSourceKeyUsesIPv6Prefix(t *testing.T) {
+ first := tlsSourceKey(&net.TCPAddr{IP: net.ParseIP("2001:db8:1:2::1"), Port: 1000})
+ samePrefix := tlsSourceKey(&net.TCPAddr{IP: net.ParseIP("2001:db8:1:2::ffff"), Port: 2000})
+ otherPrefix := tlsSourceKey(&net.TCPAddr{IP: net.ParseIP("2001:db8:1:3::1"), Port: 1000})
+ if first != samePrefix {
+ t.Fatalf("IPv6 addresses in one /64 have different keys: %q != %q", first, samePrefix)
+ }
+ if first == otherPrefix {
+ t.Fatalf("distinct IPv6 /64 prefixes share key %q", first)
+ }
+}
+
+func TestTLSSourceKeyFailsClosed(t *testing.T) {
+ for _, address := range []net.Addr{
+ nil,
+ testTLSAddr("unparseable-one:1234"),
+ testTLSAddr("unparseable-two:5678"),
+ } {
+ if got := tlsSourceKey(address); got != "" {
+ t.Fatalf("unknown address %v received separate source key %q", address, got)
+ }
+ }
+}
+
+type testTLSAddr string
+
+func (a testTLSAddr) Network() string { return "test" }
+func (a testTLSAddr) String() string { return string(a) }
+
+func TestTLSServerClientLimitAcrossAcceptedConnections(t *testing.T) {
+ serverTLS, _ := testTLSConfigs(t)
+ server, err := ListenTLS(TLSServerConfig{
+ Address: "127.0.0.1:0",
+ Token: testToken,
+ TLSConfig: serverTLS,
+ HandshakeTimeout: 30 * time.Second,
+ MaxConcurrentStreams: 2,
+ MaxClientConnections: 1,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ serveDone := make(chan error, 1)
+ go func() { serveDone <- server.Serve(ctx) }()
+ t.Cleanup(func() {
+ cancel()
+ _ = server.Close()
+ select {
+ case err := <-serveDone:
+ if err != nil {
+ t.Errorf("TLS Serve: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Error("TLS Serve did not stop")
+ }
+ })
+
+ dialFrom := func(sourceIP string) net.Conn {
+ t.Helper()
+ dialer := net.Dialer{
+ Timeout: 2 * time.Second,
+ LocalAddr: &net.TCPAddr{IP: net.ParseIP(sourceIP)},
+ }
+ conn, err := dialer.Dial("tcp", server.Addr().String())
+ if err != nil {
+ t.Fatalf("dial from %s: %v", sourceIP, err)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+ return conn
+ }
+
+ first := dialFrom("127.0.0.1")
+ firstKey := tlsSourceKey(first.LocalAddr())
+ waitForTLSLimitState(t, "first connection admission", func() bool {
+ return server.clients.count(firstKey) == 1 && len(server.core.sem) == 1
+ })
+
+ rejected := dialFrom("127.0.0.1")
+ if err := rejected.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ buffer := make([]byte, 1)
+ if _, err := rejected.Read(buffer); err == nil {
+ t.Fatal("second connection from the same source remained open")
+ } else if netError, ok := err.(net.Error); ok && netError.Timeout() {
+ t.Fatal("second connection from the same source was not rejected")
+ }
+ waitForTLSLimitState(t, "same-source rejection rollback", func() bool {
+ return server.clients.count(firstKey) == 1 && len(server.core.sem) == 1
+ })
+
+ different := dialFrom("127.0.0.2")
+ differentKey := tlsSourceKey(different.LocalAddr())
+ if differentKey == firstKey {
+ t.Fatalf("different loopback sources share key %q", firstKey)
+ }
+ waitForTLSLimitState(t, "different-source admission", func() bool {
+ return server.clients.count(differentKey) == 1 && len(server.core.sem) == 2
+ })
+
+ if err := first.Close(); err != nil {
+ t.Fatal(err)
+ }
+ waitForTLSLimitState(t, "first connection release", func() bool {
+ return server.clients.count(firstKey) == 0 && len(server.core.sem) == 1
+ })
+
+ replacement := dialFrom("127.0.0.1")
+ waitForTLSLimitState(t, "released slot reuse", func() bool {
+ return server.clients.count(firstKey) == 1 && len(server.core.sem) == 2
+ })
+
+ if err := replacement.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := different.Close(); err != nil {
+ t.Fatal(err)
+ }
+ waitForTLSLimitState(t, "all connection releases", func() bool {
+ return server.clients.count(firstKey) == 0 &&
+ server.clients.count(differentKey) == 0 && len(server.core.sem) == 0
+ })
+}
+
+func waitForTLSLimitState(t *testing.T, description string, condition func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for !condition() {
+ if time.Now().After(deadline) {
+ t.Fatalf("timed out waiting for %s", description)
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+}
diff --git a/scripts/check-fork-provenance.sh b/scripts/check-fork-provenance.sh
new file mode 100755
index 0000000..f0011a7
--- /dev/null
+++ b/scripts/check-fork-provenance.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env sh
+set -eu
+
+expected_hysteria='v2.12.1'
+expected_quic='v0.61.1-0.20260806010916-184d081eef3e'
+
+module_version() {
+ awk -v module="$2" '
+ $1 == module { print $2; found = 1 }
+ END { if (!found) exit 1 }
+ ' "$1"
+}
+
+replace_target() {
+ awk -v module="$2" '
+ $1 == "replace" && $2 == module && $3 == "=>" { print $4; found = 1 }
+ END { if (!found) exit 1 }
+ ' "$1"
+}
+
+root_hysteria="$(module_version go.mod github.com/apernet/hysteria/core/v2)"
+root_quic="$(module_version go.mod github.com/apernet/quic-go)"
+core_quic="$(module_version third_party/hysteria-core/go.mod github.com/apernet/quic-go)"
+root_hysteria_replace="$(replace_target go.mod github.com/apernet/hysteria/core/v2)"
+root_quic_replace="$(replace_target go.mod github.com/apernet/quic-go)"
+core_quic_replace="$(replace_target third_party/hysteria-core/go.mod github.com/apernet/quic-go)"
+
+if [ "$root_hysteria" != "$expected_hysteria" ]; then
+ echo "local Hysteria fork provenance mismatch: go.mod requires $root_hysteria, source is $expected_hysteria" >&2
+ echo "rebase and re-audit third_party/hysteria-core before changing the requirement" >&2
+ exit 1
+fi
+if [ "$root_quic" != "$expected_quic" ] || [ "$core_quic" != "$expected_quic" ]; then
+ echo "local QUIC fork provenance mismatch: root=$root_quic core=$core_quic source=$expected_quic" >&2
+ echo "rebase and re-audit third_party/quic-go before changing either requirement" >&2
+ exit 1
+fi
+if [ "$root_hysteria_replace" != './third_party/hysteria-core' ] || \
+ [ "$root_quic_replace" != './third_party/quic-go' ] || \
+ [ "$core_quic_replace" != '../quic-go' ]; then
+ echo "local fork replacements are missing or redirected" >&2
+ echo "root Hysteria=$root_hysteria_replace root QUIC=$root_quic_replace core QUIC=$core_quic_replace" >&2
+ exit 1
+fi
+
+grep -Fq "core/v2\` $expected_hysteria" third_party/hysteria-core/AUTOCAR_PATCHES.md
+grep -Fq "\`$expected_quic\`" third_party/quic-go/AUTOCAR_PATCHES.md
diff --git a/scripts/govulncheck.sh b/scripts/govulncheck.sh
new file mode 100755
index 0000000..9051567
--- /dev/null
+++ b/scripts/govulncheck.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# GO-2026-5288 is an automatically generated Go DB entry that currently says
+# all Hysteria v2 versions are affected. The reviewed upstream GHSA limits the
+# vulnerable range to <= 2.8.1, while AutoCAR pins 2.12.1. The vulnerable
+# feature is application-layer protocol sniffing, which this adapter neither
+# imports nor enables. Keep the exception fail-closed: changing the version,
+# introducing sniff hooks, finding another reachable advisory, receiving an
+# incomplete JSON stream, or a scanner execution error all fail this script.
+#
+# https://github.com/advisories/GHSA-9fw6-xgg2-mq9q
+# https://pkg.go.dev/vuln/GO-2026-5288
+
+EXPECTED_HYSTERIA_VERSION=v2.12.1
+ACTUAL_HYSTERIA_VERSION=$(go list -m -f '{{.Version}}' github.com/apernet/hysteria/core/v2)
+if [[ ${ACTUAL_HYSTERIA_VERSION} != "${EXPECTED_HYSTERIA_VERSION}" ]]; then
+ echo "error: review the GO-2026-5288 exception before changing Hysteria (${ACTUAL_HYSTERIA_VERSION})" >&2
+ exit 1
+fi
+
+if find cmd internal -type f -name '*.go' \
+ -exec grep -EHin 'RequestHook[[:space:]]*:|/sniff(["`])' {} +; then
+ echo "error: protocol sniffing is incompatible with the narrow GO-2026-5288 exception" >&2
+ exit 1
+fi
+
+TOOL_DIR=${RUNNER_TEMP:-/tmp}/autocar-govulncheck-v1.7.0
+mkdir -p "${TOOL_DIR}"
+GOBIN="${TOOL_DIR}" go install golang.org/x/vuln/cmd/govulncheck@v1.7.0
+
+set +e
+"${TOOL_DIR}/govulncheck" -format=json ./... | go run ./tools/vulnfilter
+PIPELINE_STATUS=("${PIPESTATUS[@]}")
+set -e
+
+SCANNER_STATUS=${PIPELINE_STATUS[0]}
+FILTER_STATUS=${PIPELINE_STATUS[1]}
+if (( FILTER_STATUS != 0 )); then
+ exit "${FILTER_STATUS}"
+fi
+if (( SCANNER_STATUS != 0 && SCANNER_STATUS != 3 )); then
+ echo "error: govulncheck failed with status ${SCANNER_STATUS}" >&2
+ exit "${SCANNER_STATUS}"
+fi
diff --git a/scripts/netem-integration.sh b/scripts/netem-integration.sh
index ca83260..e8848fc 100755
--- a/scripts/netem-integration.sh
+++ b/scripts/netem-integration.sh
@@ -39,6 +39,7 @@ SERVER_DEV="acs${RUN_ID}"
CLIENT_IP=10.203.0.1
SERVER_IP=10.203.0.2
RELAY_PORT=7443
+RENO_RELAY_PORT=7445
UNUSED_QUIC_PORT=7444
BENCH_PORT=9000
ORIGIN_PORT=9080
@@ -55,6 +56,13 @@ SHORT_FLOW_BYTES=${AUTOCAR_SHORT_FLOW_BYTES:-131072}
SHORT_FLOW_ITERATIONS=${AUTOCAR_SHORT_FLOW_ITERATIONS:-9}
SHORT_FLOW_WARMUP=${AUTOCAR_SHORT_FLOW_WARMUP:-3}
MIN_SHORT_FLOW_RATIO=${AUTOCAR_MIN_SHORT_FLOW_RATIO:-1.10}
+MIN_BBR_RENO_RATIO=${AUTOCAR_MIN_BBR_RENO_RATIO:-1.10}
+MIN_BRUTAL_TARGET_RATIO=${AUTOCAR_MIN_BRUTAL_TARGET_RATIO:-0.50}
+BRUTAL_SERVER_UPLOAD_MBPS=15
+BRUTAL_SERVER_DOWNLOAD_MBPS=15
+BRUTAL_CLIENT_UPLOAD_MBPS=20
+BRUTAL_CLIENT_DOWNLOAD_MBPS=20
+BRUTAL_EXPECTED_TX_BYTES_SEC=1875000
PIDS=()
@@ -174,11 +182,28 @@ start_background "${ARTIFACT_DIR}/relay.log" \
--cert="${WORK_DIR}/server.crt" \
--key="${WORK_DIR}/server.key" \
--token-file="${WORK_DIR}/relay-token" \
+ --max-upload-mbps="${BRUTAL_SERVER_UPLOAD_MBPS}" \
+ --max-download-mbps="${BRUTAL_SERVER_DOWNLOAD_MBPS}" \
+ --allow-client-bandwidth \
--allow-private --deny-ports=none
RELAY_PID=${STARTED_PID}
-wait_for_log "${RELAY_PID}" "${ARTIFACT_DIR}/relay.log" "transport=quic"
+wait_for_log "${RELAY_PID}" "${ARTIFACT_DIR}/relay.log" "transport=hy2"
wait_for_log "${RELAY_PID}" "${ARTIFACT_DIR}/relay.log" "transport=tls"
+# A second, otherwise identical relay makes the download comparison exercise
+# the relay-side sender. Client flags alone only select the client-side sender.
+start_background "${ARTIFACT_DIR}/relay-reno.log" \
+ ip netns exec "${SERVER_NS}" "${AUTOCAR_BIN}" server \
+ --listen="${SERVER_IP}:${RENO_RELAY_PORT}" \
+ --tcp-listen="${SERVER_IP}:${RENO_RELAY_PORT}" \
+ --cert="${WORK_DIR}/server.crt" \
+ --key="${WORK_DIR}/server.key" \
+ --token-file="${WORK_DIR}/relay-token" \
+ --congestion=reno \
+ --allow-private --deny-ports=none
+RENO_RELAY_PID=${STARTED_PID}
+wait_for_log "${RENO_RELAY_PID}" "${ARTIFACT_DIR}/relay-reno.log" "transport=hy2"
+
COMMON_BENCH=(
--target="${SERVER_IP}:${BENCH_PORT}"
--mode=download
@@ -196,21 +221,65 @@ TUNNEL_AUTH=(
--dial-timeout=3s
--open-timeout=5s
)
+TUNNEL_AUTH_RENO=(
+ --server="${SERVER_IP}:${RENO_RELAY_PORT}"
+ --server-name="${SERVER_IP}"
+ --ca="${WORK_DIR}/server.crt"
+ --token-file="${WORK_DIR}/relay-token"
+ --dial-timeout=3s
+ --open-timeout=5s
+)
run_client --transport=direct "${COMMON_BENCH[@]}" >"${ARTIFACT_DIR}/direct.json"
run_client --transport=quic "${TUNNEL_AUTH[@]}" "${COMMON_BENCH[@]}" >"${ARTIFACT_DIR}/quic.json"
run_client --transport=tls "${TUNNEL_AUTH[@]}" "${COMMON_BENCH[@]}" >"${ARTIFACT_DIR}/tls.json"
-run_client --transport=auto \
- --server="${SERVER_IP}:${UNUSED_QUIC_PORT}" \
- --fallback-server="${SERVER_IP}:${RELAY_PORT}" \
- --server-name="${SERVER_IP}" \
- --ca="${WORK_DIR}/server.crt" \
- --token-file="${WORK_DIR}/relay-token" \
- --dial-timeout=1s --quic-attempt-timeout=1s --open-timeout=5s \
- --target="${SERVER_IP}:${BENCH_PORT}" --mode=download \
- --bytes=131072 --iterations=2 --warmup=0 --timeout=20s --json \
- >"${ARTIFACT_DIR}/auto-fallback.json"
+# Prove both controller paths with real authenticated transfers while the
+# original delay, random loss and rate limits are still active. The BBR run
+# declares no bandwidth and therefore must negotiate a zero Tx rate. The
+# Brutal run declares 20 Mbit/s in both directions, while the relay's 15
+# Mbit/s upload cap deterministically limits client-to-relay Tx to 1,875,000
+# bytes/s. Repeated uploads isolate the client-side sender so the congestion
+# flag deterministically selects the controller under comparison.
+MODE_PROOF_BENCH=(
+ --target="${SERVER_IP}:${BENCH_PORT}"
+ --mode=upload
+ --bytes=4194304
+ --iterations=3
+ --warmup=1
+ --timeout=60s
+ --json
+)
+run_client --transport=quic --congestion=bbr --bbr-profile=standard \
+ "${TUNNEL_AUTH[@]}" "${MODE_PROOF_BENCH[@]}" \
+ >"${ARTIFACT_DIR}/bbr.json"
+run_client --transport=quic --congestion=reno \
+ "${TUNNEL_AUTH[@]}" "${MODE_PROOF_BENCH[@]}" \
+ >"${ARTIFACT_DIR}/reno.json"
+run_client --transport=quic --congestion=bbr --bbr-profile=standard \
+ --upload-mbps="${BRUTAL_CLIENT_UPLOAD_MBPS}" \
+ --download-mbps="${BRUTAL_CLIENT_DOWNLOAD_MBPS}" \
+ "${TUNNEL_AUTH[@]}" "${MODE_PROOF_BENCH[@]}" \
+ >"${ARTIFACT_DIR}/brutal.json"
+
+# Repeat the BBR/Reno proof in the opposite direction. Both clients use the
+# same configuration; only the relay sender differs (default BBR vs the
+# explicitly configured Reno relay above).
+MODE_PROOF_DOWNLOAD=(
+ --target="${SERVER_IP}:${BENCH_PORT}"
+ --mode=download
+ --bytes=4194304
+ --iterations=3
+ --warmup=1
+ --timeout=60s
+ --json
+)
+run_client --transport=quic --congestion=bbr --bbr-profile=standard \
+ "${TUNNEL_AUTH[@]}" "${MODE_PROOF_DOWNLOAD[@]}" \
+ >"${ARTIFACT_DIR}/bbr-download.json"
+run_client --transport=quic --congestion=bbr --bbr-profile=standard \
+ "${TUNNEL_AUTH_RENO[@]}" "${MODE_PROOF_DOWNLOAD[@]}" \
+ >"${ARTIFACT_DIR}/reno-download.json"
# This intentionally narrow acceleration profile isolates the benefit of a
# warm, shared congestion-control context. Every direct iteration creates a
@@ -222,6 +291,23 @@ ip netns exec "${CLIENT_NS}" tc qdisc replace dev "${CLIENT_DEV}" root netem \
ip netns exec "${SERVER_NS}" tc qdisc replace dev "${SERVER_DEV}" root netem \
delay "${DELAY_MS}ms" rate "${RATE}" limit 10000
+# Test cold QUIC-to-TLS fallback after the lossy throughput profile has
+# completed. Keeping the latency and rate constraints while removing random
+# loss isolates the fallback state machine from a coincidental dropped TLS
+# handshake. The deadlines remain finite and the command must still succeed
+# on its first attempt.
+sleep 0.25
+run_client --transport=auto \
+ --server="${SERVER_IP}:${UNUSED_QUIC_PORT}" \
+ --fallback-server="${SERVER_IP}:${RELAY_PORT}" \
+ --server-name="${SERVER_IP}" \
+ --ca="${WORK_DIR}/server.crt" \
+ --token-file="${WORK_DIR}/relay-token" \
+ --dial-timeout=3s --quic-attempt-timeout=2s --open-timeout=8s \
+ --target="${SERVER_IP}:${BENCH_PORT}" --mode=download \
+ --bytes=131072 --iterations=2 --warmup=0 --timeout=30s --json \
+ >"${ARTIFACT_DIR}/auto-fallback.json"
+
SHORT_BENCH=(
--target="${SERVER_IP}:${BENCH_PORT}"
--mode=download
@@ -268,7 +354,7 @@ kill -0 "${ORIGIN_PID}"
start_background "${ARTIFACT_DIR}/client-proxy.log" \
ip netns exec "${CLIENT_NS}" "${AUTOCAR_BIN}" client \
--transport=auto "${TUNNEL_AUTH[@]}" \
- --dial-timeout=1s --quic-attempt-timeout=1s --open-timeout=2s \
+ --dial-timeout=2s --quic-attempt-timeout=2s --open-timeout=6s \
--socks= --http="127.0.0.1:${PROXY_PORT}" --https=
PROXY_PID=${STARTED_PID}
wait_for_log "${PROXY_PID}" "${ARTIFACT_DIR}/client-proxy.log" "local proxy started"
@@ -349,26 +435,123 @@ fi
python3 - "${ARTIFACT_DIR}/direct.json" "${ARTIFACT_DIR}/quic.json" \
"${ARTIFACT_DIR}/tls.json" "${ARTIFACT_DIR}/short-direct.json" \
- "${ARTIFACT_DIR}/short-quic.json" "${ARTIFACT_DIR}/summary.json" \
+ "${ARTIFACT_DIR}/short-quic.json" "${ARTIFACT_DIR}/bbr.json" \
+ "${ARTIFACT_DIR}/reno.json" "${ARTIFACT_DIR}/brutal.json" \
+ "${ARTIFACT_DIR}/bbr-download.json" "${ARTIFACT_DIR}/reno-download.json" \
+ "${ARTIFACT_DIR}/summary.json" \
"${DELAY_MS}" "${LOSS}" "${RATE}" "${SHORT_FLOW_BYTES}" \
- "${MIN_SHORT_FLOW_RATIO}" <<'PY'
+ "${MIN_SHORT_FLOW_RATIO}" "${MIN_BBR_RENO_RATIO}" \
+ "${MIN_BRUTAL_TARGET_RATIO}" "${BRUTAL_SERVER_UPLOAD_MBPS}" \
+ "${BRUTAL_SERVER_DOWNLOAD_MBPS}" "${BRUTAL_CLIENT_UPLOAD_MBPS}" \
+ "${BRUTAL_CLIENT_DOWNLOAD_MBPS}" "${BRUTAL_EXPECTED_TX_BYTES_SEC}" <<'PY'
import json
import pathlib
import sys
-direct_path, quic_path, tls_path, short_direct_path, short_quic_path, output_path = map(
- pathlib.Path, sys.argv[1:7]
+(
+ direct_path,
+ quic_path,
+ tls_path,
+ short_direct_path,
+ short_quic_path,
+ bbr_path,
+ reno_path,
+ brutal_path,
+ bbr_download_path,
+ reno_download_path,
+ output_path,
+) = map(
+ pathlib.Path, sys.argv[1:12]
)
-delay_ms, loss, rate, short_flow_bytes, minimum_ratio = sys.argv[7:12]
+(
+ delay_ms,
+ loss,
+ rate,
+ short_flow_bytes,
+ minimum_ratio,
+ minimum_bbr_reno_ratio,
+ minimum_brutal_target_ratio,
+ server_upload_mbps,
+ server_download_mbps,
+ client_upload_mbps,
+ client_download_mbps,
+ expected_brutal_tx,
+) = sys.argv[12:24]
direct = json.loads(direct_path.read_text())
quic = json.loads(quic_path.read_text())
tls = json.loads(tls_path.read_text())
short_direct = json.loads(short_direct_path.read_text())
short_quic = json.loads(short_quic_path.read_text())
+bbr = json.loads(bbr_path.read_text())
+reno = json.loads(reno_path.read_text())
+brutal = json.loads(brutal_path.read_text())
+bbr_download = json.loads(bbr_download_path.read_text())
+reno_download = json.loads(reno_download_path.read_text())
for name, result in (("direct", direct), ("quic", quic), ("tls", tls)):
if result["median_mbps"] <= 0:
raise SystemExit(f"{name} benchmark reported non-positive goodput")
+for name, result in (("bbr", bbr), ("reno", reno), ("brutal", brutal)):
+ if result["median_mbps"] <= 0:
+ raise SystemExit(f"{name} controller proof reported non-positive goodput")
+for name, result in (("bbr download", bbr_download), ("reno download", reno_download)):
+ if result["median_mbps"] <= 0:
+ raise SystemExit(f"{name} controller proof reported non-positive goodput")
+
+if bbr.get("acceleration") != "bbr-standard":
+ raise SystemExit(
+ f"BBR proof reported acceleration={bbr.get('acceleration')!r}, "
+ "want 'bbr-standard'"
+ )
+if bbr.get("negotiated_tx_bytes_per_second", 0) != 0:
+ raise SystemExit(
+ "BBR proof unexpectedly negotiated a non-zero Tx bandwidth: "
+ f"{bbr.get('negotiated_tx_bytes_per_second')!r}"
+ )
+if reno.get("acceleration") != "reno":
+ raise SystemExit(
+ f"Reno proof reported acceleration={reno.get('acceleration')!r}, "
+ "want 'reno'"
+ )
+if reno.get("negotiated_tx_bytes_per_second", 0) != 0:
+ raise SystemExit(
+ "Reno proof unexpectedly negotiated a non-zero Tx bandwidth: "
+ f"{reno.get('negotiated_tx_bytes_per_second')!r}"
+ )
+
+bbr_reno_ratio = bbr["median_mbps"] / reno["median_mbps"]
+if bbr_reno_ratio < float(minimum_bbr_reno_ratio):
+ raise SystemExit(
+ f"lossy BBR/Reno upload ratio {bbr_reno_ratio:.3f} is below "
+ f"the declared acceptance threshold {float(minimum_bbr_reno_ratio):.3f}"
+ )
+
+bbr_reno_download_ratio = bbr_download["median_mbps"] / reno_download["median_mbps"]
+if bbr_reno_download_ratio < float(minimum_bbr_reno_ratio):
+ raise SystemExit(
+ f"lossy BBR/Reno download ratio {bbr_reno_download_ratio:.3f} is below "
+ f"the declared acceptance threshold {float(minimum_bbr_reno_ratio):.3f}"
+ )
+
+expected_brutal_tx = int(expected_brutal_tx)
+if brutal.get("acceleration") != "brutal":
+ raise SystemExit(
+ f"Brutal proof reported acceleration={brutal.get('acceleration')!r}, "
+ "want 'brutal'"
+ )
+if brutal.get("negotiated_tx_bytes_per_second") != expected_brutal_tx:
+ raise SystemExit(
+ "Brutal proof negotiated Tx bandwidth "
+ f"{brutal.get('negotiated_tx_bytes_per_second')!r}, "
+ f"want {expected_brutal_tx} bytes/s"
+ )
+brutal_target_mbps = expected_brutal_tx * 8 / 1_000_000
+brutal_target_ratio = brutal["median_mbps"] / brutal_target_mbps
+if brutal_target_ratio < float(minimum_brutal_target_ratio):
+ raise SystemExit(
+ f"Brutal achieved/target ratio {brutal_target_ratio:.3f} is below "
+ f"the declared acceptance threshold {float(minimum_brutal_target_ratio):.3f}"
+ )
short_ratio = short_quic["median_mbps"] / short_direct["median_mbps"]
if short_ratio < float(minimum_ratio):
@@ -392,6 +575,54 @@ summary = {
"quic": quic["median_mbps"] / direct["median_mbps"],
"tls": tls["median_mbps"] / direct["median_mbps"],
},
+ "controller_proof": {
+ "network_profile": {
+ "one_way_delay_ms": int(delay_ms),
+ "loss_each_direction": loss,
+ "rate_each_direction": rate,
+ },
+ "bbr": {
+ "artifact": bbr_path.name,
+ "acceleration": bbr["acceleration"],
+ "negotiated_tx_bytes_per_second": bbr.get(
+ "negotiated_tx_bytes_per_second", 0
+ ),
+ "median_mbps": bbr["median_mbps"],
+ },
+ "reno": {
+ "artifact": reno_path.name,
+ "acceleration": reno["acceleration"],
+ "negotiated_tx_bytes_per_second": reno.get(
+ "negotiated_tx_bytes_per_second", 0
+ ),
+ "median_mbps": reno["median_mbps"],
+ },
+ "bbr_to_reno_ratio": bbr_reno_ratio,
+ "relay_sender_download": {
+ "bbr_artifact": bbr_download_path.name,
+ "reno_artifact": reno_download_path.name,
+ "bbr_median_mbps": bbr_download["median_mbps"],
+ "reno_median_mbps": reno_download["median_mbps"],
+ "bbr_to_reno_ratio": bbr_reno_download_ratio,
+ "minimum_accepted_bbr_to_reno_ratio": float(minimum_bbr_reno_ratio),
+ },
+ "minimum_accepted_bbr_to_reno_ratio": float(minimum_bbr_reno_ratio),
+ "brutal": {
+ "artifact": brutal_path.name,
+ "acceleration": brutal["acceleration"],
+ "negotiated_tx_bytes_per_second": brutal[
+ "negotiated_tx_bytes_per_second"
+ ],
+ "median_mbps": brutal["median_mbps"],
+ "target_mbps": brutal_target_mbps,
+ "achieved_to_target_ratio": brutal_target_ratio,
+ "minimum_accepted_target_ratio": float(minimum_brutal_target_ratio),
+ "client_upload_mbps": int(client_upload_mbps),
+ "client_download_mbps": int(client_download_mbps),
+ "server_upload_cap_mbps": int(server_upload_mbps),
+ "server_download_cap_mbps": int(server_download_mbps),
+ },
+ },
"acceleration_profile": {
"description": "sequential short downloads over a loss-free high-RTT path; QUIC uses declared warmups",
"payload_bytes": int(short_flow_bytes),
diff --git a/third_party/hysteria-core/AUTOCAR_PATCHES.md b/third_party/hysteria-core/AUTOCAR_PATCHES.md
new file mode 100644
index 0000000..0048c1a
--- /dev/null
+++ b/third_party/hysteria-core/AUTOCAR_PATCHES.md
@@ -0,0 +1,51 @@
+# AutoCAR security hardening
+
+This directory is based on `github.com/apernet/hysteria/core/v2` v2.12.1
+(upstream commit `14e9fff1d972ab0187ac7fcf75b9514dc8664065`) and remains licensed under
+the MIT license in `LICENSE.md`.
+
+AutoCAR keeps the fork intentionally small and auditable. Its server adds:
+
+- process-wide and per-source (IPv4 address or IPv6 `/64`) caps on accepted QUIC connections after Retry
+ address validation and before handshake state;
+- process-wide and per-source caps on active TCP handlers, shared across
+ QUIC connections and held for the complete relay lifetime, plus a deadline
+ for reading the initial TCP request;
+- a finite pre-authentication lifetime and a reduced incoming unidirectional
+ stream budget for unauthenticated HTTP/3 peers;
+- process-wide and per-source UDP session admission, shared across QUIC
+ connections, before allocating defragmentation state; and
+- fixed UDP fragment-count and reassembled-size bounds.
+
+Maximum-size UDP payloads use a framing-aware serialization buffer and every
+serialization/fragmentation overflow returns an error instead of reporting a
+successful silent drop.
+The client package exports the 4,096-byte logical payload ceiling so frontends
+can reject larger payloads without destroying a healthy UDP association.
+
+Authentication state and its identity are read under the same lock used by the
+HTTP authentication handler, preventing dispatch or disconnect accounting from
+observing a partially published authentication result.
+
+The QUIC listener forces Retry address validation and acquires the global
+connection budget before allocating handshake state. Unauthenticated HTTP/3
+request headers are capped at 16 KiB before allocation.
+
+All per-source budgets use one IPv4 address or a masked IPv6 `/64`, preventing
+interface-identifier rotation from bypassing the gates while documenting the
+intentional NAT/prefix sharing tradeoff.
+
+These changes close resource-exhaustion paths that cannot be intercepted by
+the public `server.Outbound` API because incomplete fragments never create an
+outbound socket. The TCP admission ordering relies on the narrow local
+`../quic-go` `StreamAdmission` hook documented in its `AUTOCAR_PATCHES.md`.
+Changes should be rebased and re-audited whenever either pinned upstream
+version changes.
+
+The upstream multi-gigabyte TCP and long lossy-UDP stress cases are opt-in via
+`AUTOCAR_RUN_UPSTREAM_STRESS=1`; normal CI runs deterministic integration and
+network-emulation suites instead of allowing those unbounded cases to consume
+the job timeout.
+
+Integration tests generate an ephemeral ECDSA P-256 certificate in memory;
+the fork does not carry the upstream repository's fixed test private key.
diff --git a/third_party/hysteria-core/LICENSE.md b/third_party/hysteria-core/LICENSE.md
new file mode 100644
index 0000000..208e8f2
--- /dev/null
+++ b/third_party/hysteria-core/LICENSE.md
@@ -0,0 +1,7 @@
+Copyright 2023 Toby
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/third_party/hysteria-core/client/.mockery.yaml b/third_party/hysteria-core/client/.mockery.yaml
new file mode 100644
index 0000000..299e6f9
--- /dev/null
+++ b/third_party/hysteria-core/client/.mockery.yaml
@@ -0,0 +1,9 @@
+with-expecter: true
+inpackage: true
+dir: .
+packages:
+ github.com/apernet/hysteria/core/v2/client:
+ interfaces:
+ udpIO:
+ config:
+ mockname: mockUDPIO
diff --git a/third_party/hysteria-core/client/client.go b/third_party/hysteria-core/client/client.go
new file mode 100644
index 0000000..fe46ee4
--- /dev/null
+++ b/third_party/hysteria-core/client/client.go
@@ -0,0 +1,383 @@
+package client
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "net"
+ "net/http"
+ "net/url"
+ "sync"
+ "time"
+
+ coreErrs "github.com/apernet/hysteria/core/v2/errors"
+ "github.com/apernet/hysteria/core/v2/internal/congestion"
+ "github.com/apernet/hysteria/core/v2/internal/protocol"
+ "github.com/apernet/hysteria/core/v2/internal/utils"
+
+ "github.com/apernet/quic-go"
+ "github.com/apernet/quic-go/http3"
+)
+
+const (
+ closeErrCodeOK = 0x100 // HTTP3 ErrCodeNoError
+ closeErrCodeProtocolError = 0x101 // HTTP3 ErrCodeGeneralProtocolError
+
+ // MaxUDPSize is the largest logical UDP payload carried by one Hysteria
+ // message. Larger messages must be rejected before serialization.
+ MaxUDPSize = protocol.MaxUDPSize
+)
+
+type Client interface {
+ TCP(addr string) (net.Conn, error)
+ UDP() (HyUDPConn, error)
+ Close() error
+}
+
+type HyUDPConn interface {
+ Receive() ([]byte, string, error)
+ Send([]byte, string) error
+ Close() error
+}
+
+type HandshakeInfo struct {
+ UDPEnabled bool
+ Tx uint64 // 0 if using BBR
+ ServerAddr net.Addr
+ ECHAccepted bool
+}
+
+func NewClient(config *Config) (Client, *HandshakeInfo, error) {
+ if err := config.verifyAndFill(); err != nil {
+ return nil, nil, err
+ }
+ c := &clientImpl{
+ config: config,
+ }
+ info, err := c.connect()
+ if err != nil {
+ return nil, nil, err
+ }
+ return c, info, nil
+}
+
+type clientImpl struct {
+ config *Config
+
+ pktConn net.PacketConn
+ tr *quic.Transport
+ conn *quic.Conn
+
+ udpSM *udpSessionManager
+}
+
+func (c *clientImpl) connect() (*HandshakeInfo, error) {
+ pktConn, err := c.config.ConnFactory.New(c.config.ServerAddr)
+ if err != nil {
+ return nil, err
+ }
+ // Convert config to TLS config & QUIC config
+ tlsConfig := &tls.Config{
+ ServerName: c.config.TLSConfig.ServerName,
+ InsecureSkipVerify: c.config.TLSConfig.InsecureSkipVerify,
+ VerifyPeerCertificate: c.config.TLSConfig.VerifyPeerCertificate,
+ RootCAs: c.config.TLSConfig.RootCAs,
+ GetClientCertificate: c.config.TLSConfig.GetClientCertificate,
+ EncryptedClientHelloConfigList: c.config.TLSConfig.ECHConfigList,
+ }
+ quicConfig := &quic.Config{
+ InitialStreamReceiveWindow: c.config.QUICConfig.InitialStreamReceiveWindow,
+ MaxStreamReceiveWindow: c.config.QUICConfig.MaxStreamReceiveWindow,
+ InitialConnectionReceiveWindow: c.config.QUICConfig.InitialConnectionReceiveWindow,
+ MaxConnectionReceiveWindow: c.config.QUICConfig.MaxConnectionReceiveWindow,
+ MaxIdleTimeout: c.config.QUICConfig.MaxIdleTimeout,
+ KeepAlivePeriod: c.config.QUICConfig.KeepAlivePeriod,
+ DisablePathMTUDiscovery: c.config.QUICConfig.DisablePathMTUDiscovery,
+ EnableDatagrams: true,
+ MaxDatagramFrameSize: protocol.MaxDatagramFrameSize,
+ OmitMaxDatagramFrameSize: true,
+ DisablePathManager: true,
+ ChromeParrot: !c.config.QUICConfig.DisableChromeParrot,
+ }
+ tr := &quic.Transport{Conn: pktConn, DisableGSO: c.config.QUICConfig.DisableGSO}
+ if !c.config.QUICConfig.DisableChromeParrot {
+ // Chrome uses a zero-length source connection ID. This has to be set on the
+ // Transport, since it fixes the length at which incoming packets' connection
+ // IDs are parsed; leaving it default yields 4-byte IDs, visible on the wire.
+ tr.ConnectionIDGenerator = quic.ZeroLengthConnectionIDGenerator{}
+ }
+ // Prepare RoundTripper
+ var conn *quic.Conn
+ rt := &http3.Transport{
+ TLSClientConfig: tlsConfig,
+ QUICConfig: quicConfig,
+ Dial: func(ctx context.Context, _ string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
+ qc, err := tr.DialEarly(ctx, c.config.ServerAddr, tlsCfg, cfg)
+ if err != nil {
+ return nil, err
+ }
+ conn = qc
+ return qc, nil
+ },
+ }
+ // Send auth HTTP request
+ req := &http.Request{
+ Method: http.MethodPost,
+ URL: &url.URL{
+ Scheme: "https",
+ Host: protocol.URLHost,
+ Path: protocol.URLPath,
+ },
+ Header: make(http.Header),
+ }
+ protocol.AuthRequestToHeader(req.Header, protocol.AuthRequest{
+ Auth: c.config.Auth,
+ Rx: c.config.BandwidthConfig.MaxRx,
+ })
+ resp, err := rt.RoundTrip(req)
+ if err != nil {
+ if conn != nil {
+ _ = conn.CloseWithError(closeErrCodeProtocolError, "")
+ }
+ _ = tr.Close()
+ _ = pktConn.Close()
+ return nil, coreErrs.ConnectError{Err: err}
+ }
+ if resp.StatusCode != protocol.StatusAuthOK {
+ _ = conn.CloseWithError(closeErrCodeProtocolError, "")
+ _ = tr.Close()
+ _ = pktConn.Close()
+ return nil, coreErrs.AuthError{StatusCode: resp.StatusCode}
+ }
+ // Auth OK
+ authResp := protocol.AuthResponseFromHeader(resp.Header)
+ var actualTx uint64
+ if authResp.RxAuto {
+ // Server asks client to use bandwidth detection,
+ // ignore local bandwidth config and use the configured congestion controller.
+ congestion.UseConfigured(conn, c.config.CongestionConfig.Type, c.config.CongestionConfig.BBRProfile)
+ } else {
+ // actualTx = min(serverRx, clientTx)
+ actualTx = authResp.Rx
+ if actualTx == 0 || actualTx > c.config.BandwidthConfig.MaxTx {
+ // Server doesn't have a limit, or our clientTx is smaller than serverRx
+ actualTx = c.config.BandwidthConfig.MaxTx
+ }
+ if actualTx > 0 {
+ congestion.UseBrutal(conn, actualTx, c.config.BandwidthConfig.DisableLossCompensation)
+ } else {
+ // We don't know our own bandwidth either, use the configured congestion controller.
+ congestion.UseConfigured(conn, c.config.CongestionConfig.Type, c.config.CongestionConfig.BBRProfile)
+ }
+ }
+ _ = resp.Body.Close()
+
+ c.pktConn = pktConn
+ c.tr = tr
+ c.conn = conn
+ if authResp.UDPEnabled {
+ c.udpSM = newUDPSessionManager(&udpIOImpl{Conn: conn})
+ }
+ return &HandshakeInfo{
+ UDPEnabled: authResp.UDPEnabled,
+ Tx: actualTx,
+ ServerAddr: c.config.ServerAddr,
+ ECHAccepted: conn.ConnectionState().TLS.ECHAccepted,
+ }, nil
+}
+
+// openStream wraps the stream with QStream, which handles Close() properly
+func (c *clientImpl) openStream() (*utils.QStream, error) {
+ stream, err := c.conn.OpenStream()
+ if err != nil {
+ return nil, err
+ }
+ return &utils.QStream{Stream: stream}, nil
+}
+
+func (c *clientImpl) TCP(addr string) (net.Conn, error) {
+ stream, err := c.openStream()
+ if err != nil {
+ return nil, wrapIfConnectionClosed(err)
+ }
+ // Send request
+ err = protocol.WriteTCPRequest(stream, addr)
+ if err != nil {
+ _ = stream.Close()
+ return nil, wrapIfConnectionClosed(err)
+ }
+ if c.config.FastOpen {
+ // Don't wait for the response when fast open is enabled.
+ // Return the connection immediately, defer the response handling
+ // to the first Read() call.
+ return &tcpConn{
+ Orig: stream,
+ PseudoLocalAddr: c.conn.LocalAddr(),
+ PseudoRemoteAddr: c.conn.RemoteAddr(),
+ }, nil
+ }
+ // Read response
+ ok, msg, err := protocol.ReadTCPResponse(stream)
+ if err != nil {
+ _ = stream.Close()
+ return nil, wrapIfConnectionClosed(err)
+ }
+ if !ok {
+ _ = stream.Close()
+ return nil, coreErrs.DialError{Message: msg}
+ }
+ return &tcpConn{
+ Orig: stream,
+ PseudoLocalAddr: c.conn.LocalAddr(),
+ PseudoRemoteAddr: c.conn.RemoteAddr(),
+ established: true,
+ }, nil
+}
+
+func (c *clientImpl) UDP() (HyUDPConn, error) {
+ if c.udpSM == nil {
+ return nil, coreErrs.DialError{Message: "UDP not enabled"}
+ }
+ return c.udpSM.NewUDP()
+}
+
+func (c *clientImpl) Close() error {
+ _ = c.conn.CloseWithError(closeErrCodeOK, "")
+ _ = c.tr.Close()
+ _ = c.pktConn.Close()
+ return nil
+}
+
+var nonPermanentErrors = []error{
+ quic.StreamLimitReachedError{},
+}
+
+// wrapIfConnectionClosed checks if the error returned by quic-go
+// is recoverable (listed in nonPermanentErrors) or permanent.
+// Recoverable errors are returned as-is,
+// permanent ones are wrapped as ClosedError.
+func wrapIfConnectionClosed(err error) error {
+ for _, e := range nonPermanentErrors {
+ if errors.Is(err, e) {
+ return err
+ }
+ }
+ return coreErrs.ClosedError{Err: err}
+}
+
+type tcpStream interface {
+ Read([]byte) (int, error)
+ Write([]byte) (int, error)
+ Close() error
+ SetDeadline(time.Time) error
+ SetReadDeadline(time.Time) error
+ SetWriteDeadline(time.Time) error
+}
+
+type tcpConn struct {
+ Orig tcpStream
+ PseudoLocalAddr net.Addr
+ PseudoRemoteAddr net.Addr
+
+ establishMu sync.Mutex
+ established bool
+ establishErr error
+ closeMu sync.Mutex
+ closed bool
+ closeErr error
+}
+
+func (c *tcpConn) Read(b []byte) (n int, err error) {
+ if err := c.ensureEstablished(); err != nil {
+ return 0, err
+ }
+ return c.Orig.Read(b)
+}
+
+func (c *tcpConn) ensureEstablished() error {
+ c.establishMu.Lock()
+ defer c.establishMu.Unlock()
+ if c.established {
+ return nil
+ }
+ if c.establishErr != nil {
+ return c.establishErr
+ }
+ ok, msg, err := protocol.ReadTCPResponse(c.Orig)
+ if err != nil {
+ c.establishErr = err
+ } else if !ok {
+ c.establishErr = coreErrs.DialError{Message: msg}
+ } else {
+ c.established = true
+ return nil
+ }
+ _ = c.closeOrig()
+ return c.establishErr
+}
+
+func (c *tcpConn) Write(b []byte) (n int, err error) {
+ return c.Orig.Write(b)
+}
+
+func (c *tcpConn) Close() error {
+ return c.closeOrig()
+}
+
+func (c *tcpConn) closeOrig() error {
+ c.closeMu.Lock()
+ defer c.closeMu.Unlock()
+ if !c.closed {
+ c.closeErr = c.Orig.Close()
+ c.closed = true
+ }
+ return c.closeErr
+}
+
+func (c *tcpConn) LocalAddr() net.Addr {
+ return c.PseudoLocalAddr
+}
+
+func (c *tcpConn) RemoteAddr() net.Addr {
+ return c.PseudoRemoteAddr
+}
+
+func (c *tcpConn) SetDeadline(t time.Time) error {
+ return c.Orig.SetDeadline(t)
+}
+
+func (c *tcpConn) SetReadDeadline(t time.Time) error {
+ return c.Orig.SetReadDeadline(t)
+}
+
+func (c *tcpConn) SetWriteDeadline(t time.Time) error {
+ return c.Orig.SetWriteDeadline(t)
+}
+
+type udpIOImpl struct {
+ Conn *quic.Conn
+}
+
+func (io *udpIOImpl) ReceiveMessage() (*protocol.UDPMessage, error) {
+ for {
+ msg, err := io.Conn.ReceiveDatagram(context.Background())
+ if err != nil {
+ // Connection error, this will stop the session manager
+ return nil, err
+ }
+ udpMsg, err := protocol.ParseUDPMessage(msg)
+ if err != nil {
+ // Invalid message, this is fine - just wait for the next
+ continue
+ }
+ return udpMsg, nil
+ }
+}
+
+func (io *udpIOImpl) SendMessage(buf []byte, msg *protocol.UDPMessage) error {
+ msgN := msg.Serialize(buf)
+ if msgN < 0 {
+ return coreErrs.ProtocolError{Message: "UDP message exceeds serialization limit"}
+ }
+ return io.Conn.SendDatagram(buf[:msgN])
+}
diff --git a/third_party/hysteria-core/client/config.go b/third_party/hysteria-core/client/config.go
new file mode 100644
index 0000000..131a213
--- /dev/null
+++ b/third_party/hysteria-core/client/config.go
@@ -0,0 +1,136 @@
+package client
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "net"
+ "time"
+
+ "github.com/apernet/hysteria/core/v2/errors"
+ "github.com/apernet/hysteria/core/v2/internal/congestion"
+ "github.com/apernet/hysteria/core/v2/internal/pmtud"
+)
+
+const (
+ defaultStreamReceiveWindow = 8388608 // 8MB
+ defaultConnReceiveWindow = defaultStreamReceiveWindow * 5 / 2 // 20MB
+ defaultMaxIdleTimeout = 30 * time.Second
+ defaultKeepAlivePeriod = 10 * time.Second
+)
+
+type Config struct {
+ ConnFactory ConnFactory
+ ServerAddr net.Addr
+ Auth string
+ TLSConfig TLSConfig
+ QUICConfig QUICConfig
+ CongestionConfig CongestionConfig
+ BandwidthConfig BandwidthConfig
+ FastOpen bool
+
+ filled bool // whether the fields have been verified and filled
+}
+
+// verifyAndFill fills the fields that are not set by the user with default values when possible,
+// and returns an error if the user has not set a required field or has set an invalid value.
+func (c *Config) verifyAndFill() error {
+ if c.filled {
+ return nil
+ }
+ if c.ConnFactory == nil {
+ c.ConnFactory = &udpConnFactory{}
+ }
+ if c.ServerAddr == nil {
+ return errors.ConfigError{Field: "ServerAddr", Reason: "must be set"}
+ }
+ if c.QUICConfig.InitialStreamReceiveWindow == 0 {
+ c.QUICConfig.InitialStreamReceiveWindow = defaultStreamReceiveWindow
+ } else if c.QUICConfig.InitialStreamReceiveWindow < 16384 {
+ return errors.ConfigError{Field: "QUICConfig.InitialStreamReceiveWindow", Reason: "must be at least 16384"}
+ }
+ if c.QUICConfig.MaxStreamReceiveWindow == 0 {
+ c.QUICConfig.MaxStreamReceiveWindow = defaultStreamReceiveWindow
+ } else if c.QUICConfig.MaxStreamReceiveWindow < 16384 {
+ return errors.ConfigError{Field: "QUICConfig.MaxStreamReceiveWindow", Reason: "must be at least 16384"}
+ }
+ if c.QUICConfig.InitialConnectionReceiveWindow == 0 {
+ c.QUICConfig.InitialConnectionReceiveWindow = defaultConnReceiveWindow
+ } else if c.QUICConfig.InitialConnectionReceiveWindow < 16384 {
+ return errors.ConfigError{Field: "QUICConfig.InitialConnectionReceiveWindow", Reason: "must be at least 16384"}
+ }
+ if c.QUICConfig.MaxConnectionReceiveWindow == 0 {
+ c.QUICConfig.MaxConnectionReceiveWindow = defaultConnReceiveWindow
+ } else if c.QUICConfig.MaxConnectionReceiveWindow < 16384 {
+ return errors.ConfigError{Field: "QUICConfig.MaxConnectionReceiveWindow", Reason: "must be at least 16384"}
+ }
+ if c.QUICConfig.MaxIdleTimeout == 0 {
+ c.QUICConfig.MaxIdleTimeout = defaultMaxIdleTimeout
+ } else if c.QUICConfig.MaxIdleTimeout < 4*time.Second || c.QUICConfig.MaxIdleTimeout > 120*time.Second {
+ return errors.ConfigError{Field: "QUICConfig.MaxIdleTimeout", Reason: "must be between 4s and 120s"}
+ }
+ if c.QUICConfig.KeepAlivePeriod == 0 {
+ c.QUICConfig.KeepAlivePeriod = defaultKeepAlivePeriod
+ } else if c.QUICConfig.KeepAlivePeriod < 2*time.Second || c.QUICConfig.KeepAlivePeriod > 60*time.Second {
+ return errors.ConfigError{Field: "QUICConfig.KeepAlivePeriod", Reason: "must be between 2s and 60s"}
+ }
+ c.QUICConfig.DisablePathMTUDiscovery = c.QUICConfig.DisablePathMTUDiscovery || pmtud.DisablePathMTUDiscovery
+ var err error
+ c.CongestionConfig.Type, err = congestion.NormalizeType(c.CongestionConfig.Type)
+ if err != nil {
+ return errors.ConfigError{Field: "CongestionConfig.Type", Reason: err.Error()}
+ }
+ if c.CongestionConfig.Type == congestion.TypeBBR {
+ c.CongestionConfig.BBRProfile, err = congestion.NormalizeBBRProfile(c.CongestionConfig.BBRProfile)
+ if err != nil {
+ return errors.ConfigError{Field: "CongestionConfig.BBRProfile", Reason: err.Error()}
+ }
+ }
+
+ c.filled = true
+ return nil
+}
+
+type ConnFactory interface {
+ New(net.Addr) (net.PacketConn, error)
+}
+
+type udpConnFactory struct{}
+
+func (f *udpConnFactory) New(addr net.Addr) (net.PacketConn, error) {
+ return net.ListenUDP("udp", nil)
+}
+
+// TLSConfig contains the TLS configuration fields that we want to expose to the user.
+type TLSConfig struct {
+ ServerName string
+ InsecureSkipVerify bool
+ VerifyPeerCertificate func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error
+ RootCAs *x509.CertPool
+ GetClientCertificate func(*tls.CertificateRequestInfo) (*tls.Certificate, error)
+ ECHConfigList []byte
+}
+
+// QUICConfig contains the QUIC configuration fields that we want to expose to the user.
+type QUICConfig struct {
+ InitialStreamReceiveWindow uint64
+ MaxStreamReceiveWindow uint64
+ InitialConnectionReceiveWindow uint64
+ MaxConnectionReceiveWindow uint64
+ MaxIdleTimeout time.Duration
+ KeepAlivePeriod time.Duration
+ DisablePathMTUDiscovery bool // The server may still override this to true on unsupported platforms.
+ DisableGSO bool
+ DisableChromeParrot bool // Chrome QUIC fingerprint parroting is on by default.
+}
+
+type CongestionConfig struct {
+ Type string
+ BBRProfile string
+}
+
+// BandwidthConfig describes the maximum bandwidth that the server can use, in bytes per second.
+type BandwidthConfig struct {
+ MaxTx uint64
+ MaxRx uint64
+ DisableLossCompensation bool
+}
diff --git a/third_party/hysteria-core/client/fast_open_test.go b/third_party/hysteria-core/client/fast_open_test.go
new file mode 100644
index 0000000..4f56dd3
--- /dev/null
+++ b/third_party/hysteria-core/client/fast_open_test.go
@@ -0,0 +1,87 @@
+package client
+
+import (
+ "errors"
+ "net"
+ "sort"
+ "sync"
+ "testing"
+ "time"
+
+ coreErrs "github.com/apernet/hysteria/core/v2/errors"
+ "github.com/apernet/hysteria/core/v2/internal/protocol"
+)
+
+func TestFastOpenConcurrentReadsConsumeResponseOnce(t *testing.T) {
+ clientSide, serverSide := net.Pipe()
+ defer serverSide.Close()
+ conn := &tcpConn{Orig: clientSide}
+ go func() {
+ _ = protocol.WriteTCPResponse(serverSide, true, "connected")
+ _, _ = serverSide.Write([]byte("xy"))
+ }()
+
+ start := make(chan struct{})
+ results := make(chan byte, 2)
+ errorsSeen := make(chan error, 2)
+ var wait sync.WaitGroup
+ for range 2 {
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ <-start
+ buffer := make([]byte, 1)
+ _, err := conn.Read(buffer)
+ if err != nil {
+ errorsSeen <- err
+ return
+ }
+ results <- buffer[0]
+ }()
+ }
+ close(start)
+ wait.Wait()
+ close(errorsSeen)
+ for err := range errorsSeen {
+ t.Fatalf("concurrent Read: %v", err)
+ }
+ close(results)
+ var got []byte
+ for result := range results {
+ got = append(got, result)
+ }
+ sort.Slice(got, func(i, j int) bool { return got[i] < got[j] })
+ if string(got) != "xy" {
+ t.Fatalf("concurrent payload = %q, want xy", got)
+ }
+}
+
+func TestFastOpenFailureIsCachedAndClosesStream(t *testing.T) {
+ clientSide, serverSide := net.Pipe()
+ conn := &tcpConn{Orig: clientSide}
+ go func() {
+ _ = protocol.WriteTCPResponse(serverSide, false, "destination denied")
+ _ = serverSide.Close()
+ }()
+
+ buffer := make([]byte, 1)
+ _, firstErr := conn.Read(buffer)
+ var firstDialError coreErrs.DialError
+ if !errors.As(firstErr, &firstDialError) {
+ t.Fatalf("first Read error = %v, want DialError", firstErr)
+ }
+ done := make(chan error, 1)
+ go func() {
+ _, err := conn.Read(buffer)
+ done <- err
+ }()
+ select {
+ case secondErr := <-done:
+ var secondDialError coreErrs.DialError
+ if !errors.As(secondErr, &secondDialError) || secondErr.Error() != firstErr.Error() {
+ t.Fatalf("cached Read error = %v, want %v", secondErr, firstErr)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("second Read retried the consumed Fast Open response")
+ }
+}
diff --git a/third_party/hysteria-core/client/mock_udpIO.go b/third_party/hysteria-core/client/mock_udpIO.go
new file mode 100644
index 0000000..dbff53c
--- /dev/null
+++ b/third_party/hysteria-core/client/mock_udpIO.go
@@ -0,0 +1,139 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package client
+
+import (
+ protocol "github.com/apernet/hysteria/core/v2/internal/protocol"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// mockUDPIO is an autogenerated mock type for the udpIO type
+type mockUDPIO struct {
+ mock.Mock
+}
+
+type mockUDPIO_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *mockUDPIO) EXPECT() *mockUDPIO_Expecter {
+ return &mockUDPIO_Expecter{mock: &_m.Mock}
+}
+
+// ReceiveMessage provides a mock function with no fields
+func (_m *mockUDPIO) ReceiveMessage() (*protocol.UDPMessage, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for ReceiveMessage")
+ }
+
+ var r0 *protocol.UDPMessage
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (*protocol.UDPMessage, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() *protocol.UDPMessage); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*protocol.UDPMessage)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// mockUDPIO_ReceiveMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReceiveMessage'
+type mockUDPIO_ReceiveMessage_Call struct {
+ *mock.Call
+}
+
+// ReceiveMessage is a helper method to define mock.On call
+func (_e *mockUDPIO_Expecter) ReceiveMessage() *mockUDPIO_ReceiveMessage_Call {
+ return &mockUDPIO_ReceiveMessage_Call{Call: _e.mock.On("ReceiveMessage")}
+}
+
+func (_c *mockUDPIO_ReceiveMessage_Call) Run(run func()) *mockUDPIO_ReceiveMessage_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *mockUDPIO_ReceiveMessage_Call) Return(_a0 *protocol.UDPMessage, _a1 error) *mockUDPIO_ReceiveMessage_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *mockUDPIO_ReceiveMessage_Call) RunAndReturn(run func() (*protocol.UDPMessage, error)) *mockUDPIO_ReceiveMessage_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SendMessage provides a mock function with given fields: _a0, _a1
+func (_m *mockUDPIO) SendMessage(_a0 []byte, _a1 *protocol.UDPMessage) error {
+ ret := _m.Called(_a0, _a1)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SendMessage")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func([]byte, *protocol.UDPMessage) error); ok {
+ r0 = rf(_a0, _a1)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// mockUDPIO_SendMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendMessage'
+type mockUDPIO_SendMessage_Call struct {
+ *mock.Call
+}
+
+// SendMessage is a helper method to define mock.On call
+// - _a0 []byte
+// - _a1 *protocol.UDPMessage
+func (_e *mockUDPIO_Expecter) SendMessage(_a0 interface{}, _a1 interface{}) *mockUDPIO_SendMessage_Call {
+ return &mockUDPIO_SendMessage_Call{Call: _e.mock.On("SendMessage", _a0, _a1)}
+}
+
+func (_c *mockUDPIO_SendMessage_Call) Run(run func(_a0 []byte, _a1 *protocol.UDPMessage)) *mockUDPIO_SendMessage_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte), args[1].(*protocol.UDPMessage))
+ })
+ return _c
+}
+
+func (_c *mockUDPIO_SendMessage_Call) Return(_a0 error) *mockUDPIO_SendMessage_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *mockUDPIO_SendMessage_Call) RunAndReturn(run func([]byte, *protocol.UDPMessage) error) *mockUDPIO_SendMessage_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// newMockUDPIO creates a new instance of mockUDPIO. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockUDPIO(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *mockUDPIO {
+ mock := &mockUDPIO{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/third_party/hysteria-core/client/reconnect.go b/third_party/hysteria-core/client/reconnect.go
new file mode 100644
index 0000000..91c7eb4
--- /dev/null
+++ b/third_party/hysteria-core/client/reconnect.go
@@ -0,0 +1,120 @@
+package client
+
+import (
+ "net"
+ "sync"
+
+ coreErrs "github.com/apernet/hysteria/core/v2/errors"
+)
+
+// reconnectableClientImpl is a wrapper of Client, which can reconnect when the connection is closed,
+// except when the caller explicitly calls Close() to permanently close this client.
+type reconnectableClientImpl struct {
+ configFunc func() (*Config, error) // called before connecting
+ connectedFunc func(Client, *HandshakeInfo, int) // called when successfully connected
+ client Client
+ count int
+ m sync.Mutex
+ closed bool // permanent close
+}
+
+// NewReconnectableClient creates a reconnectable client.
+// If lazy is true, the client will not connect until the first call to TCP() or UDP().
+// We use a function for config mainly to delay config evaluation
+// (which involves DNS resolution) until the actual connection attempt.
+func NewReconnectableClient(configFunc func() (*Config, error), connectedFunc func(Client, *HandshakeInfo, int), lazy bool) (Client, error) {
+ rc := &reconnectableClientImpl{
+ configFunc: configFunc,
+ connectedFunc: connectedFunc,
+ }
+ if !lazy {
+ if err := rc.reconnect(); err != nil {
+ return nil, err
+ }
+ }
+ return rc, nil
+}
+
+func (rc *reconnectableClientImpl) reconnect() error {
+ if rc.client != nil {
+ _ = rc.client.Close()
+ }
+ var info *HandshakeInfo
+ config, err := rc.configFunc()
+ if err != nil {
+ return err
+ }
+ rc.client, info, err = NewClient(config)
+ if err != nil {
+ return err
+ } else {
+ rc.count++
+ if rc.connectedFunc != nil {
+ rc.connectedFunc(rc, info, rc.count)
+ }
+ return nil
+ }
+}
+
+// clientDo calls f with the current client.
+// If the client is nil, it will first reconnect.
+// It will also detect if the client is closed, and if so,
+// set it to nil for reconnect next time.
+func (rc *reconnectableClientImpl) clientDo(f func(Client) (interface{}, error)) (interface{}, error) {
+ rc.m.Lock()
+ if rc.closed {
+ rc.m.Unlock()
+ return nil, coreErrs.ClosedError{}
+ }
+ if rc.client == nil {
+ // No active connection, connect first
+ if err := rc.reconnect(); err != nil {
+ rc.m.Unlock()
+ return nil, err
+ }
+ }
+ client := rc.client
+ rc.m.Unlock()
+
+ ret, err := f(client)
+ if _, ok := err.(coreErrs.ClosedError); ok {
+ // Connection closed, set client to nil for reconnect next time
+ rc.m.Lock()
+ if rc.client == client {
+ // This check is in case the client is already changed by another goroutine
+ rc.client = nil
+ }
+ rc.m.Unlock()
+ }
+ return ret, err
+}
+
+func (rc *reconnectableClientImpl) TCP(addr string) (net.Conn, error) {
+ if c, err := rc.clientDo(func(client Client) (interface{}, error) {
+ return client.TCP(addr)
+ }); err != nil {
+ return nil, err
+ } else {
+ return c.(net.Conn), nil
+ }
+}
+
+func (rc *reconnectableClientImpl) UDP() (HyUDPConn, error) {
+ if c, err := rc.clientDo(func(client Client) (interface{}, error) {
+ return client.UDP()
+ }); err != nil {
+ return nil, err
+ } else {
+ return c.(HyUDPConn), nil
+ }
+}
+
+func (rc *reconnectableClientImpl) Close() error {
+ rc.m.Lock()
+ defer rc.m.Unlock()
+ rc.closed = true
+ if rc.client != nil {
+ return rc.client.Close()
+ }
+ return nil
+}
diff --git a/third_party/hysteria-core/client/udp.go b/third_party/hysteria-core/client/udp.go
new file mode 100644
index 0000000..c2e7a30
--- /dev/null
+++ b/third_party/hysteria-core/client/udp.go
@@ -0,0 +1,188 @@
+package client
+
+import (
+ "errors"
+ "io"
+ "math/rand"
+ "sync"
+
+ "github.com/apernet/quic-go"
+
+ coreErrs "github.com/apernet/hysteria/core/v2/errors"
+ "github.com/apernet/hysteria/core/v2/internal/frag"
+ "github.com/apernet/hysteria/core/v2/internal/protocol"
+)
+
+const (
+ udpMessageChanSize = 1024
+)
+
+type udpIO interface {
+ ReceiveMessage() (*protocol.UDPMessage, error)
+ SendMessage([]byte, *protocol.UDPMessage) error
+}
+
+type udpConn struct {
+ ID uint32
+ D *frag.Defragger
+ ReceiveCh chan *protocol.UDPMessage
+ SendBuf []byte
+ SendFunc func([]byte, *protocol.UDPMessage) error
+ CloseFunc func()
+ Closed bool
+}
+
+func (u *udpConn) Receive() ([]byte, string, error) {
+ for {
+ msg := <-u.ReceiveCh
+ if msg == nil {
+ // Closed
+ return nil, "", io.EOF
+ }
+ dfMsg := u.D.Feed(msg)
+ if dfMsg == nil {
+ // Incomplete message, wait for more
+ continue
+ }
+ return dfMsg.Data, dfMsg.Addr, nil
+ }
+}
+
+// Send is not thread-safe, as it uses a shared SendBuf.
+func (u *udpConn) Send(data []byte, addr string) error {
+ // Try no frag first
+ msg := &protocol.UDPMessage{
+ SessionID: u.ID,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: addr,
+ Data: data,
+ }
+ err := u.SendFunc(u.SendBuf, msg)
+ var errTooLarge *quic.DatagramTooLargeError
+ if errors.As(err, &errTooLarge) {
+ // Message too large, try fragmentation
+ msg.PacketID = uint16(rand.Intn(0xFFFF)) + 1
+ fMsgs := frag.FragUDPMessage(msg, int(errTooLarge.MaxDatagramPayloadSize))
+ if len(fMsgs) == 0 {
+ return frag.ErrFragmentationLimit
+ }
+ for _, fMsg := range fMsgs {
+ err := u.SendFunc(u.SendBuf, &fMsg)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ } else {
+ return err
+ }
+}
+
+func (u *udpConn) Close() error {
+ u.CloseFunc()
+ return nil
+}
+
+type udpSessionManager struct {
+ io udpIO
+
+ mutex sync.RWMutex
+ m map[uint32]*udpConn
+ nextID uint32
+
+ closed bool
+}
+
+func newUDPSessionManager(io udpIO) *udpSessionManager {
+ m := &udpSessionManager{
+ io: io,
+ m: make(map[uint32]*udpConn),
+ nextID: 1,
+ }
+ go m.run()
+ return m
+}
+
+func (m *udpSessionManager) run() error {
+ defer m.closeCleanup()
+ for {
+ msg, err := m.io.ReceiveMessage()
+ if err != nil {
+ return err
+ }
+ m.feed(msg)
+ }
+}
+
+func (m *udpSessionManager) closeCleanup() {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+
+ for _, conn := range m.m {
+ m.close(conn)
+ }
+ m.closed = true
+}
+
+func (m *udpSessionManager) feed(msg *protocol.UDPMessage) {
+ m.mutex.RLock()
+ defer m.mutex.RUnlock()
+
+ conn, ok := m.m[msg.SessionID]
+ if !ok {
+ // Ignore message from unknown session
+ return
+ }
+
+ select {
+ case conn.ReceiveCh <- msg:
+ // OK
+ default:
+ // Channel full, drop the message
+ }
+}
+
+// NewUDP creates a new UDP session.
+func (m *udpSessionManager) NewUDP() (HyUDPConn, error) {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+
+ if m.closed {
+ return nil, coreErrs.ClosedError{}
+ }
+
+ id := m.nextID
+ m.nextID++
+
+ conn := &udpConn{
+ ID: id,
+ D: &frag.Defragger{},
+ ReceiveCh: make(chan *protocol.UDPMessage, udpMessageChanSize),
+ SendBuf: make([]byte, protocol.MaxUDPMessageSize),
+ SendFunc: m.io.SendMessage,
+ }
+ conn.CloseFunc = func() {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ m.close(conn)
+ }
+ m.m[id] = conn
+
+ return conn, nil
+}
+
+func (m *udpSessionManager) close(conn *udpConn) {
+ if !conn.Closed {
+ conn.Closed = true
+ close(conn.ReceiveCh)
+ delete(m.m, conn.ID)
+ }
+}
+
+func (m *udpSessionManager) Count() int {
+ m.mutex.RLock()
+ defer m.mutex.RUnlock()
+ return len(m.m)
+}
diff --git a/third_party/hysteria-core/client/udp_test.go b/third_party/hysteria-core/client/udp_test.go
new file mode 100644
index 0000000..d039b5b
--- /dev/null
+++ b/third_party/hysteria-core/client/udp_test.go
@@ -0,0 +1,146 @@
+package client
+
+import (
+ "errors"
+ io2 "io"
+ "testing"
+ "time"
+
+ "github.com/apernet/quic-go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "go.uber.org/goleak"
+
+ coreErrs "github.com/apernet/hysteria/core/v2/errors"
+ "github.com/apernet/hysteria/core/v2/internal/frag"
+ "github.com/apernet/hysteria/core/v2/internal/protocol"
+)
+
+func TestUDPSendReportsFragmentationLimit(t *testing.T) {
+ conn := &udpConn{
+ ID: 1,
+ SendBuf: make([]byte, protocol.MaxUDPSize),
+ SendFunc: func([]byte, *protocol.UDPMessage) error {
+ return &quic.DatagramTooLargeError{MaxDatagramPayloadSize: 1}
+ },
+ }
+ err := conn.Send(make([]byte, protocol.MaxUDPSize), "example.test:443")
+ if !errors.Is(err, frag.ErrFragmentationLimit) {
+ t.Fatalf("Send error = %v, want fragmentation limit", err)
+ }
+}
+
+func TestUDPSerializationOverflowIsReported(t *testing.T) {
+ io := &udpIOImpl{}
+ err := io.SendMessage(make([]byte, 1), &protocol.UDPMessage{Addr: "example.test:443", Data: []byte("payload")})
+ if err == nil {
+ t.Fatal("serialization overflow was silently dropped")
+ }
+}
+
+func TestUDPSessionManager(t *testing.T) {
+ io := newMockUDPIO(t)
+ receiveCh := make(chan *protocol.UDPMessage, 4)
+ io.EXPECT().ReceiveMessage().RunAndReturn(func() (*protocol.UDPMessage, error) {
+ m := <-receiveCh
+ if m == nil {
+ return nil, errors.New("closed")
+ }
+ return m, nil
+ })
+ sm := newUDPSessionManager(io)
+
+ // Test UDP session IO
+ udpConn1, err := sm.NewUDP()
+ assert.NoError(t, err)
+ udpConn2, err := sm.NewUDP()
+ assert.NoError(t, err)
+
+ msg1 := &protocol.UDPMessage{
+ SessionID: 1,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "random.site.com:9000",
+ Data: []byte("hello friend"),
+ }
+ io.EXPECT().SendMessage(mock.Anything, msg1).Return(nil).Once()
+ err = udpConn1.Send(msg1.Data, msg1.Addr)
+ assert.NoError(t, err)
+
+ msg2 := &protocol.UDPMessage{
+ SessionID: 2,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "another.site.org:8000",
+ Data: []byte("mr robot"),
+ }
+ io.EXPECT().SendMessage(mock.Anything, msg2).Return(nil).Once()
+ err = udpConn2.Send(msg2.Data, msg2.Addr)
+ assert.NoError(t, err)
+
+ respMsg1 := &protocol.UDPMessage{
+ SessionID: 1,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: msg1.Addr,
+ Data: []byte("goodbye captain price"),
+ }
+ receiveCh <- respMsg1
+ data, addr, err := udpConn1.Receive()
+ assert.NoError(t, err)
+ assert.Equal(t, data, respMsg1.Data)
+ assert.Equal(t, addr, respMsg1.Addr)
+
+ respMsg2 := &protocol.UDPMessage{
+ SessionID: 2,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: msg2.Addr,
+ Data: []byte("white rose"),
+ }
+ receiveCh <- respMsg2
+ data, addr, err = udpConn2.Receive()
+ assert.NoError(t, err)
+ assert.Equal(t, data, respMsg2.Data)
+ assert.Equal(t, addr, respMsg2.Addr)
+
+ respMsg3 := &protocol.UDPMessage{
+ SessionID: 55, // Bogus session ID that doesn't exist
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "burgerking.com:27017",
+ Data: []byte("impossible whopper"),
+ }
+ receiveCh <- respMsg3
+ // No test for this, just make sure it doesn't panic
+
+ // Test close UDP connection unblocks Receive()
+ errChan := make(chan error, 1)
+ go func() {
+ _, _, err := udpConn1.Receive()
+ errChan <- err
+ }()
+ assert.NoError(t, udpConn1.Close())
+ assert.Equal(t, <-errChan, io2.EOF)
+
+ // Test close IO unblocks Receive() and blocks new UDP creation
+ errChan = make(chan error, 1)
+ go func() {
+ _, _, err := udpConn2.Receive()
+ errChan <- err
+ }()
+ close(receiveCh)
+ assert.Equal(t, <-errChan, io2.EOF)
+ _, err = sm.NewUDP()
+ assert.Equal(t, err, coreErrs.ClosedError{})
+
+ // Leak checks
+ time.Sleep(1 * time.Second)
+ assert.Zero(t, sm.Count(), "session count should be 0")
+ goleak.VerifyNone(t)
+}
diff --git a/third_party/hysteria-core/errors/errors.go b/third_party/hysteria-core/errors/errors.go
new file mode 100644
index 0000000..cb69118
--- /dev/null
+++ b/third_party/hysteria-core/errors/errors.go
@@ -0,0 +1,75 @@
+package errors
+
+import (
+ "fmt"
+ "strconv"
+)
+
+// ConfigError is returned when a configuration field is invalid.
+type ConfigError struct {
+ Field string
+ Reason string
+}
+
+func (c ConfigError) Error() string {
+ return fmt.Sprintf("invalid config: %s: %s", c.Field, c.Reason)
+}
+
+// ConnectError is returned when the client fails to connect to the server.
+type ConnectError struct {
+ Err error
+}
+
+func (c ConnectError) Error() string {
+ return "connect error: " + c.Err.Error()
+}
+
+func (c ConnectError) Unwrap() error {
+ return c.Err
+}
+
+// AuthError is returned when the client fails to authenticate with the server.
+type AuthError struct {
+ StatusCode int
+}
+
+func (a AuthError) Error() string {
+ return "authentication error, HTTP status code: " + strconv.Itoa(a.StatusCode)
+}
+
+// DialError is returned when the server rejects the client's dial request.
+// This applies to both TCP and UDP.
+type DialError struct {
+ Message string
+}
+
+func (c DialError) Error() string {
+ return "dial error: " + c.Message
+}
+
+// ClosedError is returned when the client attempts to use a closed connection.
+type ClosedError struct {
+ Err error // Can be nil
+}
+
+func (c ClosedError) Error() string {
+ if c.Err == nil {
+ return "connection closed"
+ } else {
+ return "connection closed: " + c.Err.Error()
+ }
+}
+
+func (c ClosedError) Unwrap() error {
+ return c.Err
+}
+
+// ProtocolError is returned when the server/client runs into an unexpected
+// or malformed request/response/message.
+type ProtocolError struct {
+ Message string
+}
+
+func (p ProtocolError) Error() string {
+ return "protocol error: " + p.Message
+}
diff --git a/third_party/hysteria-core/go.mod b/third_party/hysteria-core/go.mod
new file mode 100644
index 0000000..98fbf11
--- /dev/null
+++ b/third_party/hysteria-core/go.mod
@@ -0,0 +1,32 @@
+module github.com/apernet/hysteria/core/v2
+
+go 1.25.0
+
+toolchain go1.25.1
+
+replace github.com/apernet/quic-go => ../quic-go
+
+require (
+ github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e
+ github.com/stretchr/testify v1.11.1
+ go.uber.org/goleak v1.3.0
+ golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842
+ golang.org/x/time v0.15.0
+)
+
+require (
+ github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/klauspost/compress v1.18.7 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/quic-go/qpack v0.6.0 // indirect
+ github.com/refraction-networking/utls v1.8.2 // indirect
+ github.com/rogpeppe/go-internal v1.12.0 // indirect
+ github.com/stretchr/objx v0.5.2 // indirect
+ golang.org/x/crypto v0.54.0 // indirect
+ golang.org/x/net v0.57.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/third_party/hysteria-core/go.sum b/third_party/hysteria-core/go.sum
new file mode 100644
index 0000000..effebfa
--- /dev/null
+++ b/third_party/hysteria-core/go.sum
@@ -0,0 +1,46 @@
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
+github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
+github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
+github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
+go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
+golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/third_party/hysteria-core/internal/congestion/bbr/bandwidth.go b/third_party/hysteria-core/internal/congestion/bbr/bandwidth.go
new file mode 100644
index 0000000..52deb24
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/bbr/bandwidth.go
@@ -0,0 +1,27 @@
+package bbr
+
+import (
+ "math"
+ "time"
+
+ "github.com/apernet/quic-go/congestion"
+)
+
+const (
+ infBandwidth = Bandwidth(math.MaxUint64)
+)
+
+// Bandwidth of a connection
+type Bandwidth uint64
+
+const (
+ // BitsPerSecond is 1 bit per second
+ BitsPerSecond Bandwidth = 1
+ // BytesPerSecond is 1 byte per second
+ BytesPerSecond = 8 * BitsPerSecond
+)
+
+// BandwidthFromDelta calculates the bandwidth from a number of bytes and a time delta
+func BandwidthFromDelta(bytes congestion.ByteCount, delta time.Duration) Bandwidth {
+ return Bandwidth(bytes) * Bandwidth(time.Second) / Bandwidth(delta) * BytesPerSecond
+}
diff --git a/third_party/hysteria-core/internal/congestion/bbr/bandwidth_sampler.go b/third_party/hysteria-core/internal/congestion/bbr/bandwidth_sampler.go
new file mode 100644
index 0000000..2bd66d0
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/bbr/bandwidth_sampler.go
@@ -0,0 +1,877 @@
+package bbr
+
+import (
+ "math"
+ "time"
+
+ "github.com/apernet/quic-go/congestion"
+ "github.com/apernet/quic-go/monotime"
+)
+
+const (
+ infRTT = time.Duration(math.MaxInt64)
+ defaultConnectionStateMapQueueSize = 256
+ defaultCandidatesBufferSize = 256
+)
+
+type roundTripCount uint64
+
+// SendTimeState is a subset of ConnectionStateOnSentPacket which is returned
+// to the caller when the packet is acked or lost.
+type sendTimeState struct {
+ // Whether other states in this object is valid.
+ isValid bool
+ // Whether the sender is app limited at the time the packet was sent.
+ // App limited bandwidth sample might be artificially low because the sender
+ // did not have enough data to send in order to saturate the link.
+ isAppLimited bool
+ // Total number of sent bytes at the time the packet was sent.
+ // Includes the packet itself.
+ totalBytesSent congestion.ByteCount
+ // Total number of acked bytes at the time the packet was sent.
+ totalBytesAcked congestion.ByteCount
+ // Total number of lost bytes at the time the packet was sent.
+ totalBytesLost congestion.ByteCount
+ // Total number of inflight bytes at the time the packet was sent.
+ // Includes the packet itself.
+ // It should be equal to |total_bytes_sent| minus the sum of
+ // |total_bytes_acked|, |total_bytes_lost| and total neutered bytes.
+ bytesInFlight congestion.ByteCount
+}
+
+func newSendTimeState(
+ isAppLimited bool,
+ totalBytesSent congestion.ByteCount,
+ totalBytesAcked congestion.ByteCount,
+ totalBytesLost congestion.ByteCount,
+ bytesInFlight congestion.ByteCount,
+) *sendTimeState {
+ return &sendTimeState{
+ isValid: true,
+ isAppLimited: isAppLimited,
+ totalBytesSent: totalBytesSent,
+ totalBytesAcked: totalBytesAcked,
+ totalBytesLost: totalBytesLost,
+ bytesInFlight: bytesInFlight,
+ }
+}
+
+type extraAckedEvent struct {
+ // The excess bytes acknowlwedged in the time delta for this event.
+ extraAcked congestion.ByteCount
+
+ // The bytes acknowledged and time delta from the event.
+ bytesAcked congestion.ByteCount
+ timeDelta time.Duration
+ // The round trip of the event.
+ round roundTripCount
+}
+
+func maxExtraAckedEventFunc(a, b extraAckedEvent) int {
+ if a.extraAcked > b.extraAcked {
+ return 1
+ } else if a.extraAcked < b.extraAcked {
+ return -1
+ }
+ return 0
+}
+
+// BandwidthSample
+type bandwidthSample struct {
+ // The bandwidth at that particular sample. Zero if no valid bandwidth sample
+ // is available.
+ bandwidth Bandwidth
+ // The RTT measurement at this particular sample. Zero if no RTT sample is
+ // available. Does not correct for delayed ack time.
+ rtt time.Duration
+ // |send_rate| is computed from the current packet being acked('P') and an
+ // earlier packet that is acked before P was sent.
+ sendRate Bandwidth
+ // States captured when the packet was sent.
+ stateAtSend sendTimeState
+}
+
+func newBandwidthSample() *bandwidthSample {
+ return &bandwidthSample{
+ sendRate: infBandwidth,
+ }
+}
+
+// MaxAckHeightTracker is part of the BandwidthSampler. It is called after every
+// ack event to keep track the degree of ack aggregation(a.k.a "ack height").
+type maxAckHeightTracker struct {
+ // Tracks the maximum number of bytes acked faster than the estimated
+ // bandwidth.
+ maxAckHeightFilter *WindowedFilter[extraAckedEvent, roundTripCount]
+ // The time this aggregation started and the number of bytes acked during it.
+ aggregationEpochStartTime monotime.Time
+ aggregationEpochBytes congestion.ByteCount
+ // The last sent packet number before the current aggregation epoch started.
+ lastSentPacketNumberBeforeEpoch congestion.PacketNumber
+ // The number of ack aggregation epochs ever started, including the ongoing
+ // one. Stats only.
+ numAckAggregationEpochs uint64
+ ackAggregationBandwidthThreshold float64
+ startNewAggregationEpochAfterFullRound bool
+ reduceExtraAckedOnBandwidthIncrease bool
+}
+
+func newMaxAckHeightTracker(windowLength roundTripCount) *maxAckHeightTracker {
+ return &maxAckHeightTracker{
+ maxAckHeightFilter: NewWindowedFilter(windowLength, maxExtraAckedEventFunc),
+ lastSentPacketNumberBeforeEpoch: invalidPacketNumber,
+ ackAggregationBandwidthThreshold: 1.0,
+ }
+}
+
+func (m *maxAckHeightTracker) Get() congestion.ByteCount {
+ return m.maxAckHeightFilter.GetBest().extraAcked
+}
+
+func (m *maxAckHeightTracker) Update(
+ bandwidthEstimate Bandwidth,
+ isNewMaxBandwidth bool,
+ roundTripCount roundTripCount,
+ lastSentPacketNumber congestion.PacketNumber,
+ lastAckedPacketNumber congestion.PacketNumber,
+ ackTime monotime.Time,
+ bytesAcked congestion.ByteCount,
+) congestion.ByteCount {
+ forceNewEpoch := false
+
+ if m.reduceExtraAckedOnBandwidthIncrease && isNewMaxBandwidth {
+ // Save and clear existing entries.
+ best := m.maxAckHeightFilter.GetBest()
+ secondBest := m.maxAckHeightFilter.GetSecondBest()
+ thirdBest := m.maxAckHeightFilter.GetThirdBest()
+ m.maxAckHeightFilter.Clear()
+
+ // Reinsert the heights into the filter after recalculating.
+ expectedBytesAcked := bytesFromBandwidthAndTimeDelta(bandwidthEstimate, best.timeDelta)
+ if expectedBytesAcked < best.bytesAcked {
+ best.extraAcked = best.bytesAcked - expectedBytesAcked
+ m.maxAckHeightFilter.Update(best, best.round)
+ }
+ expectedBytesAcked = bytesFromBandwidthAndTimeDelta(bandwidthEstimate, secondBest.timeDelta)
+ if expectedBytesAcked < secondBest.bytesAcked {
+ secondBest.extraAcked = secondBest.bytesAcked - expectedBytesAcked
+ m.maxAckHeightFilter.Update(secondBest, secondBest.round)
+ }
+ expectedBytesAcked = bytesFromBandwidthAndTimeDelta(bandwidthEstimate, thirdBest.timeDelta)
+ if expectedBytesAcked < thirdBest.bytesAcked {
+ thirdBest.extraAcked = thirdBest.bytesAcked - expectedBytesAcked
+ m.maxAckHeightFilter.Update(thirdBest, thirdBest.round)
+ }
+ }
+
+ // If any packet sent after the start of the epoch has been acked, start a new
+ // epoch.
+ if m.startNewAggregationEpochAfterFullRound &&
+ m.lastSentPacketNumberBeforeEpoch != invalidPacketNumber &&
+ lastAckedPacketNumber != invalidPacketNumber &&
+ lastAckedPacketNumber > m.lastSentPacketNumberBeforeEpoch {
+ forceNewEpoch = true
+ }
+ if m.aggregationEpochStartTime.IsZero() || forceNewEpoch {
+ m.aggregationEpochBytes = bytesAcked
+ m.aggregationEpochStartTime = ackTime
+ m.lastSentPacketNumberBeforeEpoch = lastSentPacketNumber
+ m.numAckAggregationEpochs++
+ return 0
+ }
+
+ // Compute how many bytes are expected to be delivered, assuming max bandwidth
+ // is correct.
+ aggregationDelta := ackTime.Sub(m.aggregationEpochStartTime)
+ expectedBytesAcked := bytesFromBandwidthAndTimeDelta(bandwidthEstimate, aggregationDelta)
+ // Reset the current aggregation epoch as soon as the ack arrival rate is less
+ // than or equal to the max bandwidth.
+ if m.aggregationEpochBytes <= congestion.ByteCount(m.ackAggregationBandwidthThreshold*float64(expectedBytesAcked)) {
+ // Reset to start measuring a new aggregation epoch.
+ m.aggregationEpochBytes = bytesAcked
+ m.aggregationEpochStartTime = ackTime
+ m.lastSentPacketNumberBeforeEpoch = lastSentPacketNumber
+ m.numAckAggregationEpochs++
+ return 0
+ }
+
+ m.aggregationEpochBytes += bytesAcked
+
+ // Compute how many extra bytes were delivered vs max bandwidth.
+ extraBytesAcked := m.aggregationEpochBytes - expectedBytesAcked
+ newEvent := extraAckedEvent{
+ extraAcked: extraBytesAcked,
+ bytesAcked: m.aggregationEpochBytes,
+ timeDelta: aggregationDelta,
+ }
+ m.maxAckHeightFilter.Update(newEvent, roundTripCount)
+ return extraBytesAcked
+}
+
+func (m *maxAckHeightTracker) SetFilterWindowLength(length roundTripCount) {
+ m.maxAckHeightFilter.SetWindowLength(length)
+}
+
+func (m *maxAckHeightTracker) Reset(newHeight congestion.ByteCount, newTime roundTripCount) {
+ newEvent := extraAckedEvent{
+ extraAcked: newHeight,
+ round: newTime,
+ }
+ m.maxAckHeightFilter.Reset(newEvent, newTime)
+}
+
+func (m *maxAckHeightTracker) SetAckAggregationBandwidthThreshold(threshold float64) {
+ m.ackAggregationBandwidthThreshold = threshold
+}
+
+func (m *maxAckHeightTracker) SetStartNewAggregationEpochAfterFullRound(value bool) {
+ m.startNewAggregationEpochAfterFullRound = value
+}
+
+func (m *maxAckHeightTracker) SetReduceExtraAckedOnBandwidthIncrease(value bool) {
+ m.reduceExtraAckedOnBandwidthIncrease = value
+}
+
+func (m *maxAckHeightTracker) AckAggregationBandwidthThreshold() float64 {
+ return m.ackAggregationBandwidthThreshold
+}
+
+func (m *maxAckHeightTracker) NumAckAggregationEpochs() uint64 {
+ return m.numAckAggregationEpochs
+}
+
+// AckPoint represents a point on the ack line.
+type ackPoint struct {
+ ackTime monotime.Time
+ totalBytesAcked congestion.ByteCount
+}
+
+// RecentAckPoints maintains the most recent 2 ack points at distinct times.
+type recentAckPoints struct {
+ ackPoints [2]ackPoint
+}
+
+func (r *recentAckPoints) Update(ackTime monotime.Time, totalBytesAcked congestion.ByteCount) {
+ if ackTime.Before(r.ackPoints[1].ackTime) {
+ r.ackPoints[1].ackTime = ackTime
+ } else if ackTime.After(r.ackPoints[1].ackTime) {
+ r.ackPoints[0] = r.ackPoints[1]
+ r.ackPoints[1].ackTime = ackTime
+ }
+
+ r.ackPoints[1].totalBytesAcked = totalBytesAcked
+}
+
+func (r *recentAckPoints) Clear() {
+ r.ackPoints[0] = ackPoint{}
+ r.ackPoints[1] = ackPoint{}
+}
+
+func (r *recentAckPoints) MostRecentPoint() *ackPoint {
+ return &r.ackPoints[1]
+}
+
+func (r *recentAckPoints) LessRecentPoint() *ackPoint {
+ if r.ackPoints[0].totalBytesAcked != 0 {
+ return &r.ackPoints[0]
+ }
+
+ return &r.ackPoints[1]
+}
+
+// ConnectionStateOnSentPacket represents the information about a sent packet
+// and the state of the connection at the moment the packet was sent,
+// specifically the information about the most recently acknowledged packet at
+// that moment.
+type connectionStateOnSentPacket struct {
+ // Time at which the packet is sent.
+ sentTime monotime.Time
+ // Size of the packet.
+ size congestion.ByteCount
+ // The value of |totalBytesSentAtLastAckedPacket| at the time the
+ // packet was sent.
+ totalBytesSentAtLastAckedPacket congestion.ByteCount
+ // The value of |lastAckedPacketSentTime| at the time the packet was
+ // sent.
+ lastAckedPacketSentTime monotime.Time
+ // The value of |lastAckedPacketAckTime| at the time the packet was
+ // sent.
+ lastAckedPacketAckTime monotime.Time
+ // Send time states that are returned to the congestion controller when the
+ // packet is acked or lost.
+ sendTimeState sendTimeState
+}
+
+// Snapshot constructor. Records the current state of the bandwidth
+// sampler.
+// |bytes_in_flight| is the bytes in flight right after the packet is sent.
+func newConnectionStateOnSentPacket(
+ sentTime monotime.Time,
+ size congestion.ByteCount,
+ bytesInFlight congestion.ByteCount,
+ sampler *bandwidthSampler,
+) *connectionStateOnSentPacket {
+ return &connectionStateOnSentPacket{
+ sentTime: sentTime,
+ size: size,
+ totalBytesSentAtLastAckedPacket: sampler.totalBytesSentAtLastAckedPacket,
+ lastAckedPacketSentTime: sampler.lastAckedPacketSentTime,
+ lastAckedPacketAckTime: sampler.lastAckedPacketAckTime,
+ sendTimeState: *newSendTimeState(
+ sampler.isAppLimited,
+ sampler.totalBytesSent,
+ sampler.totalBytesAcked,
+ sampler.totalBytesLost,
+ bytesInFlight,
+ ),
+ }
+}
+
+// BandwidthSampler keeps track of sent and acknowledged packets and outputs a
+// bandwidth sample for every packet acknowledged. The samples are taken for
+// individual packets, and are not filtered; the consumer has to filter the
+// bandwidth samples itself. In certain cases, the sampler will locally severely
+// underestimate the bandwidth, hence a maximum filter with a size of at least
+// one RTT is recommended.
+//
+// This class bases its samples on the slope of two curves: the number of bytes
+// sent over time, and the number of bytes acknowledged as received over time.
+// It produces a sample of both slopes for every packet that gets acknowledged,
+// based on a slope between two points on each of the corresponding curves. Note
+// that due to the packet loss, the number of bytes on each curve might get
+// further and further away from each other, meaning that it is not feasible to
+// compare byte values coming from different curves with each other.
+//
+// The obvious points for measuring slope sample are the ones corresponding to
+// the packet that was just acknowledged. Let us denote them as S_1 (point at
+// which the current packet was sent) and A_1 (point at which the current packet
+// was acknowledged). However, taking a slope requires two points on each line,
+// so estimating bandwidth requires picking a packet in the past with respect to
+// which the slope is measured.
+//
+// For that purpose, BandwidthSampler always keeps track of the most recently
+// acknowledged packet, and records it together with every outgoing packet.
+// When a packet gets acknowledged (A_1), it has not only information about when
+// it itself was sent (S_1), but also the information about the latest
+// acknowledged packet right before it was sent (S_0 and A_0).
+//
+// Based on that data, send and ack rate are estimated as:
+//
+// send_rate = (bytes(S_1) - bytes(S_0)) / (time(S_1) - time(S_0))
+// ack_rate = (bytes(A_1) - bytes(A_0)) / (time(A_1) - time(A_0))
+//
+// Here, the ack rate is intuitively the rate we want to treat as bandwidth.
+// However, in certain cases (e.g. ack compression) the ack rate at a point may
+// end up higher than the rate at which the data was originally sent, which is
+// not indicative of the real bandwidth. Hence, we use the send rate as an upper
+// bound, and the sample value is
+//
+// rate_sample = min(send_rate, ack_rate)
+//
+// An important edge case handled by the sampler is tracking the app-limited
+// samples. There are multiple meaning of "app-limited" used interchangeably,
+// hence it is important to understand and to be able to distinguish between
+// them.
+//
+// Meaning 1: connection state. The connection is said to be app-limited when
+// there is no outstanding data to send. This means that certain bandwidth
+// samples in the future would not be an accurate indication of the link
+// capacity, and it is important to inform consumer about that. Whenever
+// connection becomes app-limited, the sampler is notified via OnAppLimited()
+// method.
+//
+// Meaning 2: a phase in the bandwidth sampler. As soon as the bandwidth
+// sampler becomes notified about the connection being app-limited, it enters
+// app-limited phase. In that phase, all *sent* packets are marked as
+// app-limited. Note that the connection itself does not have to be
+// app-limited during the app-limited phase, and in fact it will not be
+// (otherwise how would it send packets?). The boolean flag below indicates
+// whether the sampler is in that phase.
+//
+// Meaning 3: a flag on the sent packet and on the sample. If a sent packet is
+// sent during the app-limited phase, the resulting sample related to the
+// packet will be marked as app-limited.
+//
+// With the terminology issue out of the way, let us consider the question of
+// what kind of situation it addresses.
+//
+// Consider a scenario where we first send packets 1 to 20 at a regular
+// bandwidth, and then immediately run out of data. After a few seconds, we send
+// packets 21 to 60, and only receive ack for 21 between sending packets 40 and
+// 41. In this case, when we sample bandwidth for packets 21 to 40, the S_0/A_0
+// we use to compute the slope is going to be packet 20, a few seconds apart
+// from the current packet, hence the resulting estimate would be extremely low
+// and not indicative of anything. Only at packet 41 the S_0/A_0 will become 21,
+// meaning that the bandwidth sample would exclude the quiescence.
+//
+// Based on the analysis of that scenario, we implement the following rule: once
+// OnAppLimited() is called, all sent packets will produce app-limited samples
+// up until an ack for a packet that was sent after OnAppLimited() was called.
+// Note that while the scenario above is not the only scenario when the
+// connection is app-limited, the approach works in other cases too.
+
+type congestionEventSample struct {
+ // The maximum bandwidth sample from all acked packets.
+ // QuicBandwidth::Zero() if no samples are available.
+ sampleMaxBandwidth Bandwidth
+ // Whether |sample_max_bandwidth| is from a app-limited sample.
+ sampleIsAppLimited bool
+ // The minimum rtt sample from all acked packets.
+ // QuicTime::Delta::Infinite() if no samples are available.
+ sampleRtt time.Duration
+ // For each packet p in acked packets, this is the max value of INFLIGHT(p),
+ // where INFLIGHT(p) is the number of bytes acked while p is inflight.
+ sampleMaxInflight congestion.ByteCount
+ // The send state of the largest packet in acked_packets, unless it is
+ // empty. If acked_packets is empty, it's the send state of the largest
+ // packet in lost_packets.
+ lastPacketSendState sendTimeState
+ // The number of extra bytes acked from this ack event, compared to what is
+ // expected from the flow's bandwidth. Larger value means more ack
+ // aggregation.
+ extraAcked congestion.ByteCount
+}
+
+func newCongestionEventSample() *congestionEventSample {
+ return &congestionEventSample{
+ sampleRtt: infRTT,
+ }
+}
+
+type bandwidthSampler struct {
+ // The total number of congestion controlled bytes sent during the connection.
+ totalBytesSent congestion.ByteCount
+
+ // The total number of congestion controlled bytes which were acknowledged.
+ totalBytesAcked congestion.ByteCount
+
+ // The total number of congestion controlled bytes which were lost.
+ totalBytesLost congestion.ByteCount
+
+ // The total number of congestion controlled bytes which have been neutered.
+ totalBytesNeutered congestion.ByteCount
+
+ // The value of |total_bytes_sent_| at the time the last acknowledged packet
+ // was sent. Valid only when |last_acked_packet_sent_time_| is valid.
+ totalBytesSentAtLastAckedPacket congestion.ByteCount
+
+ // The time at which the last acknowledged packet was sent. Set to
+ // QuicTime::Zero() if no valid timestamp is available.
+ lastAckedPacketSentTime monotime.Time
+
+ // The time at which the most recent packet was acknowledged.
+ lastAckedPacketAckTime monotime.Time
+
+ // The most recently sent packet.
+ lastSentPacket congestion.PacketNumber
+
+ // The most recently acked packet.
+ lastAckedPacket congestion.PacketNumber
+
+ // Indicates whether the bandwidth sampler is currently in an app-limited
+ // phase.
+ isAppLimited bool
+
+ // The packet that will be acknowledged after this one will cause the sampler
+ // to exit the app-limited phase.
+ endOfAppLimitedPhase congestion.PacketNumber
+
+ // Record of the connection state at the point where each packet in flight was
+ // sent, indexed by the packet number.
+ connectionStateMap *packetNumberIndexedQueue[connectionStateOnSentPacket]
+
+ recentAckPoints recentAckPoints
+ a0Candidates RingBuffer[ackPoint]
+
+ // Maximum number of tracked packets.
+ maxTrackedPackets congestion.ByteCount
+
+ maxAckHeightTracker *maxAckHeightTracker
+ totalBytesAckedAfterLastAckEvent congestion.ByteCount
+
+ // True if connection option 'BSAO' is set.
+ overestimateAvoidance bool
+
+ // True if connection option 'BBRB' is set.
+ limitMaxAckHeightTrackerBySendRate bool
+}
+
+func newBandwidthSampler(maxAckHeightTrackerWindowLength roundTripCount) *bandwidthSampler {
+ b := &bandwidthSampler{
+ maxAckHeightTracker: newMaxAckHeightTracker(maxAckHeightTrackerWindowLength),
+ connectionStateMap: newPacketNumberIndexedQueue[connectionStateOnSentPacket](defaultConnectionStateMapQueueSize),
+ lastSentPacket: invalidPacketNumber,
+ lastAckedPacket: invalidPacketNumber,
+ endOfAppLimitedPhase: invalidPacketNumber,
+ }
+
+ b.a0Candidates.Init(defaultCandidatesBufferSize)
+
+ return b
+}
+
+func (b *bandwidthSampler) MaxAckHeight() congestion.ByteCount {
+ return b.maxAckHeightTracker.Get()
+}
+
+func (b *bandwidthSampler) NumAckAggregationEpochs() uint64 {
+ return b.maxAckHeightTracker.NumAckAggregationEpochs()
+}
+
+func (b *bandwidthSampler) SetMaxAckHeightTrackerWindowLength(length roundTripCount) {
+ b.maxAckHeightTracker.SetFilterWindowLength(length)
+}
+
+func (b *bandwidthSampler) ResetMaxAckHeightTracker(newHeight congestion.ByteCount, newTime roundTripCount) {
+ b.maxAckHeightTracker.Reset(newHeight, newTime)
+}
+
+func (b *bandwidthSampler) SetStartNewAggregationEpochAfterFullRound(value bool) {
+ b.maxAckHeightTracker.SetStartNewAggregationEpochAfterFullRound(value)
+}
+
+func (b *bandwidthSampler) SetLimitMaxAckHeightTrackerBySendRate(value bool) {
+ b.limitMaxAckHeightTrackerBySendRate = value
+}
+
+func (b *bandwidthSampler) SetReduceExtraAckedOnBandwidthIncrease(value bool) {
+ b.maxAckHeightTracker.SetReduceExtraAckedOnBandwidthIncrease(value)
+}
+
+func (b *bandwidthSampler) EnableOverestimateAvoidance() {
+ if b.overestimateAvoidance {
+ return
+ }
+
+ b.overestimateAvoidance = true
+ b.maxAckHeightTracker.SetAckAggregationBandwidthThreshold(2.0)
+}
+
+func (b *bandwidthSampler) IsOverestimateAvoidanceEnabled() bool {
+ return b.overestimateAvoidance
+}
+
+func (b *bandwidthSampler) OnPacketSent(
+ sentTime monotime.Time,
+ packetNumber congestion.PacketNumber,
+ bytes congestion.ByteCount,
+ bytesInFlight congestion.ByteCount,
+ isRetransmittable bool,
+) {
+ b.lastSentPacket = packetNumber
+
+ if !isRetransmittable {
+ return
+ }
+
+ b.totalBytesSent += bytes
+
+ // If there are no packets in flight, the time at which the new transmission
+ // opens can be treated as the A_0 point for the purpose of bandwidth
+ // sampling. This underestimates bandwidth to some extent, and produces some
+ // artificially low samples for most packets in flight, but it provides with
+ // samples at important points where we would not have them otherwise, most
+ // importantly at the beginning of the connection.
+ if bytesInFlight == 0 {
+ b.lastAckedPacketAckTime = sentTime
+ if b.overestimateAvoidance {
+ b.recentAckPoints.Clear()
+ b.recentAckPoints.Update(sentTime, b.totalBytesAcked)
+ b.a0Candidates.Clear()
+ b.a0Candidates.PushBack(*b.recentAckPoints.MostRecentPoint())
+ }
+ b.totalBytesSentAtLastAckedPacket = b.totalBytesSent
+
+ // In this situation ack compression is not a concern, set send rate to
+ // effectively infinite.
+ b.lastAckedPacketSentTime = sentTime
+ }
+
+ b.connectionStateMap.Emplace(packetNumber, newConnectionStateOnSentPacket(
+ sentTime,
+ bytes,
+ bytesInFlight+bytes,
+ b,
+ ))
+}
+
+func (b *bandwidthSampler) OnCongestionEvent(
+ ackTime monotime.Time,
+ ackedPackets []congestion.AckedPacketInfo,
+ lostPackets []congestion.LostPacketInfo,
+ maxBandwidth Bandwidth,
+ estBandwidthUpperBound Bandwidth,
+ roundTripCount roundTripCount,
+) congestionEventSample {
+ eventSample := newCongestionEventSample()
+
+ var lastLostPacketSendState sendTimeState
+
+ for _, p := range lostPackets {
+ sendState := b.OnPacketLost(p.PacketNumber, p.BytesLost)
+ if sendState.isValid {
+ lastLostPacketSendState = sendState
+ }
+ }
+
+ if len(ackedPackets) == 0 {
+ // Only populate send state for a loss-only event.
+ eventSample.lastPacketSendState = lastLostPacketSendState
+ return *eventSample
+ }
+
+ var lastAckedPacketSendState sendTimeState
+ var maxSendRate Bandwidth
+
+ for _, p := range ackedPackets {
+ sample := b.onPacketAcknowledged(ackTime, p.PacketNumber)
+ if !sample.stateAtSend.isValid {
+ continue
+ }
+
+ lastAckedPacketSendState = sample.stateAtSend
+
+ if sample.rtt != 0 {
+ eventSample.sampleRtt = min(eventSample.sampleRtt, sample.rtt)
+ }
+ if sample.bandwidth > eventSample.sampleMaxBandwidth {
+ eventSample.sampleMaxBandwidth = sample.bandwidth
+ eventSample.sampleIsAppLimited = sample.stateAtSend.isAppLimited
+ }
+ if sample.sendRate != infBandwidth {
+ maxSendRate = max(maxSendRate, sample.sendRate)
+ }
+ inflightSample := b.totalBytesAcked - lastAckedPacketSendState.totalBytesAcked
+ if inflightSample > eventSample.sampleMaxInflight {
+ eventSample.sampleMaxInflight = inflightSample
+ }
+ }
+
+ if !lastLostPacketSendState.isValid {
+ eventSample.lastPacketSendState = lastAckedPacketSendState
+ } else if !lastAckedPacketSendState.isValid {
+ eventSample.lastPacketSendState = lastLostPacketSendState
+ } else {
+ // If two packets are inflight and an alarm is armed to lose a packet and it
+ // wakes up late, then the first of two in flight packets could have been
+ // acknowledged before the wakeup, which re-evaluates loss detection, and
+ // could declare the later of the two lost.
+ if lostPackets[len(lostPackets)-1].PacketNumber > ackedPackets[len(ackedPackets)-1].PacketNumber {
+ eventSample.lastPacketSendState = lastLostPacketSendState
+ } else {
+ eventSample.lastPacketSendState = lastAckedPacketSendState
+ }
+ }
+
+ isNewMaxBandwidth := eventSample.sampleMaxBandwidth > maxBandwidth
+ maxBandwidth = max(maxBandwidth, eventSample.sampleMaxBandwidth)
+ if b.limitMaxAckHeightTrackerBySendRate {
+ maxBandwidth = max(maxBandwidth, maxSendRate)
+ }
+
+ eventSample.extraAcked = b.onAckEventEnd(min(estBandwidthUpperBound, maxBandwidth), isNewMaxBandwidth, roundTripCount)
+
+ return *eventSample
+}
+
+func (b *bandwidthSampler) OnPacketLost(packetNumber congestion.PacketNumber, bytesLost congestion.ByteCount) (s sendTimeState) {
+ b.totalBytesLost += bytesLost
+ if sentPacketPointer := b.connectionStateMap.GetEntry(packetNumber); sentPacketPointer != nil {
+ sentPacketToSendTimeState(sentPacketPointer, &s)
+ }
+ return s
+}
+
+func (b *bandwidthSampler) OnPacketNeutered(packetNumber congestion.PacketNumber) {
+ b.connectionStateMap.Remove(packetNumber, func(sentPacket connectionStateOnSentPacket) {
+ b.totalBytesNeutered += sentPacket.size
+ })
+}
+
+func (b *bandwidthSampler) OnAppLimited() {
+ b.isAppLimited = true
+ b.endOfAppLimitedPhase = b.lastSentPacket
+}
+
+func (b *bandwidthSampler) RemoveObsoletePackets(leastUnacked congestion.PacketNumber) {
+ // A packet can become obsolete when it is removed from QuicUnackedPacketMap's
+ // view of inflight before it is acked or marked as lost. For example, when
+ // QuicSentPacketManager::RetransmitCryptoPackets retransmits a crypto packet,
+ // the packet is removed from QuicUnackedPacketMap's inflight, but is not
+ // marked as acked or lost in the BandwidthSampler.
+ b.connectionStateMap.RemoveUpTo(leastUnacked)
+}
+
+func (b *bandwidthSampler) TotalBytesSent() congestion.ByteCount {
+ return b.totalBytesSent
+}
+
+func (b *bandwidthSampler) TotalBytesLost() congestion.ByteCount {
+ return b.totalBytesLost
+}
+
+func (b *bandwidthSampler) TotalBytesAcked() congestion.ByteCount {
+ return b.totalBytesAcked
+}
+
+func (b *bandwidthSampler) TotalBytesNeutered() congestion.ByteCount {
+ return b.totalBytesNeutered
+}
+
+func (b *bandwidthSampler) IsAppLimited() bool {
+ return b.isAppLimited
+}
+
+func (b *bandwidthSampler) EndOfAppLimitedPhase() congestion.PacketNumber {
+ return b.endOfAppLimitedPhase
+}
+
+func (b *bandwidthSampler) max_ack_height() congestion.ByteCount {
+ return b.maxAckHeightTracker.Get()
+}
+
+func (b *bandwidthSampler) chooseA0Point(totalBytesAcked congestion.ByteCount, a0 *ackPoint) bool {
+ if b.a0Candidates.Empty() {
+ return false
+ }
+
+ if b.a0Candidates.Len() == 1 {
+ *a0 = *b.a0Candidates.Front()
+ return true
+ }
+
+ for i := 1; i < b.a0Candidates.Len(); i++ {
+ if b.a0Candidates.Offset(i).totalBytesAcked > totalBytesAcked {
+ *a0 = *b.a0Candidates.Offset(i - 1)
+ if i > 1 {
+ for j := 0; j < i-1; j++ {
+ b.a0Candidates.PopFront()
+ }
+ }
+ return true
+ }
+ }
+
+ *a0 = *b.a0Candidates.Back()
+ for k := 0; k < b.a0Candidates.Len()-1; k++ {
+ b.a0Candidates.PopFront()
+ }
+ return true
+}
+
+func (b *bandwidthSampler) onPacketAcknowledged(ackTime monotime.Time, packetNumber congestion.PacketNumber) bandwidthSample {
+ sample := newBandwidthSample()
+ b.lastAckedPacket = packetNumber
+ sentPacketPointer := b.connectionStateMap.GetEntry(packetNumber)
+ if sentPacketPointer == nil {
+ return *sample
+ }
+
+ // OnPacketAcknowledgedInner
+ b.totalBytesAcked += sentPacketPointer.size
+ b.totalBytesSentAtLastAckedPacket = sentPacketPointer.sendTimeState.totalBytesSent
+ b.lastAckedPacketSentTime = sentPacketPointer.sentTime
+ b.lastAckedPacketAckTime = ackTime
+ if b.overestimateAvoidance {
+ b.recentAckPoints.Update(ackTime, b.totalBytesAcked)
+ }
+
+ if b.isAppLimited {
+ // Exit app-limited phase in two cases:
+ // (1) end_of_app_limited_phase_ is not initialized, i.e., so far all
+ // packets are sent while there are buffered packets or pending data.
+ // (2) The current acked packet is after the sent packet marked as the end
+ // of the app limit phase.
+ if b.endOfAppLimitedPhase == invalidPacketNumber ||
+ packetNumber > b.endOfAppLimitedPhase {
+ b.isAppLimited = false
+ }
+ }
+
+ // There might have been no packets acknowledged at the moment when the
+ // current packet was sent. In that case, there is no bandwidth sample to
+ // make.
+ if sentPacketPointer.lastAckedPacketSentTime.IsZero() {
+ return *sample
+ }
+
+ // Infinite rate indicates that the sampler is supposed to discard the
+ // current send rate sample and use only the ack rate.
+ sendRate := infBandwidth
+ if sentPacketPointer.sentTime.After(sentPacketPointer.lastAckedPacketSentTime) {
+ sendRate = BandwidthFromDelta(
+ sentPacketPointer.sendTimeState.totalBytesSent-sentPacketPointer.totalBytesSentAtLastAckedPacket,
+ sentPacketPointer.sentTime.Sub(sentPacketPointer.lastAckedPacketSentTime),
+ )
+ }
+
+ var a0 ackPoint
+ if b.overestimateAvoidance && b.chooseA0Point(sentPacketPointer.sendTimeState.totalBytesAcked, &a0) {
+ } else {
+ a0.ackTime = sentPacketPointer.lastAckedPacketAckTime
+ a0.totalBytesAcked = sentPacketPointer.sendTimeState.totalBytesAcked
+ }
+
+ // During the slope calculation, ensure that ack time of the current packet is
+ // always larger than the time of the previous packet, otherwise division by
+ // zero or integer underflow can occur.
+ if ackTime.Sub(a0.ackTime) <= 0 {
+ return *sample
+ }
+
+ ackRate := BandwidthFromDelta(b.totalBytesAcked-a0.totalBytesAcked, ackTime.Sub(a0.ackTime))
+
+ sample.bandwidth = min(sendRate, ackRate)
+ // Note: this sample does not account for delayed acknowledgement time. This
+ // means that the RTT measurements here can be artificially high, especially
+ // on low bandwidth connections.
+ sample.rtt = ackTime.Sub(sentPacketPointer.sentTime)
+ sample.sendRate = sendRate
+ sentPacketToSendTimeState(sentPacketPointer, &sample.stateAtSend)
+
+ return *sample
+}
+
+func (b *bandwidthSampler) onAckEventEnd(
+ bandwidthEstimate Bandwidth,
+ isNewMaxBandwidth bool,
+ roundTripCount roundTripCount,
+) congestion.ByteCount {
+ newlyAckedBytes := b.totalBytesAcked - b.totalBytesAckedAfterLastAckEvent
+ if newlyAckedBytes == 0 {
+ return 0
+ }
+ b.totalBytesAckedAfterLastAckEvent = b.totalBytesAcked
+ extraAcked := b.maxAckHeightTracker.Update(
+ bandwidthEstimate,
+ isNewMaxBandwidth,
+ roundTripCount,
+ b.lastSentPacket,
+ b.lastAckedPacket,
+ b.lastAckedPacketAckTime,
+ newlyAckedBytes,
+ )
+ // If |extra_acked| is zero, i.e. this ack event marks the start of a new ack
+ // aggregation epoch, save LessRecentPoint, which is the last ack point of the
+ // previous epoch, as a A0 candidate.
+ if b.overestimateAvoidance && extraAcked == 0 {
+ b.a0Candidates.PushBack(*b.recentAckPoints.LessRecentPoint())
+ }
+ return extraAcked
+}
+
+func sentPacketToSendTimeState(sentPacket *connectionStateOnSentPacket, sendTimeState *sendTimeState) {
+ *sendTimeState = sentPacket.sendTimeState
+ sendTimeState.isValid = true
+}
+
+// BytesFromBandwidthAndTimeDelta calculates the bytes
+// from a bandwidth(bits per second) and a time delta
+func bytesFromBandwidthAndTimeDelta(bandwidth Bandwidth, delta time.Duration) congestion.ByteCount {
+ return (congestion.ByteCount(bandwidth) * congestion.ByteCount(delta)) /
+ (congestion.ByteCount(time.Second) * 8)
+}
+
+func timeDeltaFromBytesAndBandwidth(bytes congestion.ByteCount, bandwidth Bandwidth) time.Duration {
+ return time.Duration(bytes*8) * time.Second / time.Duration(bandwidth)
+}
diff --git a/third_party/hysteria-core/internal/congestion/bbr/bbr_sender.go b/third_party/hysteria-core/internal/congestion/bbr/bbr_sender.go
new file mode 100644
index 0000000..4185311
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/bbr/bbr_sender.go
@@ -0,0 +1,1088 @@
+package bbr
+
+import (
+ "fmt"
+ "math/rand"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/apernet/quic-go/congestion"
+ "github.com/apernet/quic-go/monotime"
+
+ "github.com/apernet/hysteria/core/v2/internal/congestion/common"
+)
+
+// BbrSender implements BBR congestion control algorithm. BBR aims to estimate
+// the current available Bottleneck Bandwidth and RTT (hence the name), and
+// regulates the pacing rate and the size of the congestion window based on
+// those signals.
+//
+// BBR relies on pacing in order to function properly. Do not use BBR when
+// pacing is disabled.
+//
+
+const (
+ minBps = 65536 // 64 KB/s
+
+ invalidPacketNumber = -1
+ initialCongestionWindowPackets = 32
+ minCongestionWindowPackets = 4
+
+ // Constants based on TCP defaults.
+ // The minimum CWND to ensure delayed acks don't reduce bandwidth measurements.
+ // Does not inflate the pacing rate.
+ // The gain used for the STARTUP, equal to 2/ln(2).
+ defaultHighGain = 2.885
+ // The newly derived CWND gain for STARTUP, 2.
+ derivedHighCWNDGain = 2.0
+
+ debugEnv = "HYSTERIA_BBR_DEBUG"
+)
+
+// The cycle of gains used during the PROBE_BW stage.
+var pacingGain = [...]float64{1.25, 0.75, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0}
+
+const (
+ // The length of the gain cycle.
+ gainCycleLength = len(pacingGain)
+ // The size of the bandwidth filter window, in round-trips.
+ bandwidthWindowSize = gainCycleLength + 2
+
+ // The time after which the current min_rtt value expires.
+ minRttExpiry = 10 * time.Second
+ // The minimum time the connection can spend in PROBE_RTT mode.
+ probeRttTime = 200 * time.Millisecond
+ // If the bandwidth does not increase by the factor of |kStartupGrowthTarget|
+ // within |kRoundTripsWithoutGrowthBeforeExitingStartup| rounds, the connection
+ // will exit the STARTUP mode.
+ startupGrowthTarget = 1.25
+ roundTripsWithoutGrowthBeforeExitingStartup = int64(3)
+
+ // Flag.
+ defaultStartupFullLossCount = 8
+ quicBbr2DefaultLossThreshold = 0.02
+)
+
+type bbrMode int
+
+const (
+ // Startup phase of the connection.
+ bbrModeStartup = iota
+ // After achieving the highest possible bandwidth during the startup, lower
+ // the pacing rate in order to drain the queue.
+ bbrModeDrain
+ // Cruising mode.
+ bbrModeProbeBw
+ // Temporarily slow down sending in order to empty the buffer and measure
+ // the real minimum RTT.
+ bbrModeProbeRtt
+)
+
+// Indicates how the congestion control limits the amount of bytes in flight.
+type bbrRecoveryState int
+
+const (
+ // Do not limit.
+ bbrRecoveryStateNotInRecovery = iota
+ // Allow an extra outstanding byte for each byte acknowledged.
+ bbrRecoveryStateConservation
+ // Allow two extra outstanding bytes for each byte acknowledged (slow
+ // start).
+ bbrRecoveryStateGrowth
+)
+
+type Profile string
+
+const (
+ ProfileConservative Profile = "conservative"
+ ProfileStandard Profile = "standard"
+ ProfileAggressive Profile = "aggressive"
+)
+
+type profileConfig struct {
+ highGain float64
+ highCwndGain float64
+ congestionWindowGainConstant float64
+ numStartupRtts int64
+ drainToTarget bool
+ detectOvershooting bool
+ bytesLostMultiplier uint8
+ enableAckAggregationStartup bool
+ expireAckAggregationStartup bool
+ enableOverestimateAvoidance bool
+ reduceExtraAckedOnBandwidthIncrease bool
+}
+
+func ParseProfile(profile string) (Profile, error) {
+ switch normalized := strings.ToLower(profile); normalized {
+ case "", string(ProfileStandard):
+ return ProfileStandard, nil
+ case string(ProfileConservative):
+ return ProfileConservative, nil
+ case string(ProfileAggressive):
+ return ProfileAggressive, nil
+ default:
+ return "", fmt.Errorf("unsupported BBR profile %q", profile)
+ }
+}
+
+func configForProfile(profile Profile) profileConfig {
+ switch profile {
+ case ProfileConservative:
+ return profileConfig{
+ highGain: 2.25,
+ highCwndGain: 1.75,
+ congestionWindowGainConstant: 1.75,
+ numStartupRtts: 2,
+ drainToTarget: true,
+ detectOvershooting: true,
+ bytesLostMultiplier: 1,
+ enableOverestimateAvoidance: true,
+ reduceExtraAckedOnBandwidthIncrease: true,
+ }
+ case ProfileAggressive:
+ return profileConfig{
+ highGain: 3.0,
+ highCwndGain: 2.25,
+ congestionWindowGainConstant: 2.5,
+ numStartupRtts: 4,
+ bytesLostMultiplier: 2,
+ enableAckAggregationStartup: true,
+ expireAckAggregationStartup: true,
+ }
+ default:
+ return profileConfig{
+ highGain: defaultHighGain,
+ highCwndGain: derivedHighCWNDGain,
+ congestionWindowGainConstant: 2.0,
+ numStartupRtts: roundTripsWithoutGrowthBeforeExitingStartup,
+ bytesLostMultiplier: 2,
+ }
+ }
+}
+
+type bbrSender struct {
+ rttStats congestion.RTTStatsProvider
+ clock Clock
+ pacer *common.Pacer
+
+ mode bbrMode
+
+ // Bandwidth sampler provides BBR with the bandwidth measurements at
+ // individual points.
+ sampler *bandwidthSampler
+
+ // The number of the round trips that have occurred during the connection.
+ roundTripCount roundTripCount
+
+ // The packet number of the most recently sent packet.
+ lastSentPacket congestion.PacketNumber
+ // Acknowledgement of any packet after |current_round_trip_end_| will cause
+ // the round trip counter to advance.
+ currentRoundTripEnd congestion.PacketNumber
+
+ // Number of congestion events with some losses, in the current round.
+ numLossEventsInRound uint64
+
+ // Number of total bytes lost in the current round.
+ bytesLostInRound congestion.ByteCount
+
+ // The filter that tracks the maximum bandwidth over the multiple recent
+ // round-trips.
+ maxBandwidth *WindowedFilter[Bandwidth, roundTripCount]
+
+ // Minimum RTT estimate. Automatically expires within 10 seconds (and
+ // triggers PROBE_RTT mode) if no new value is sampled during that period.
+ minRtt time.Duration
+ // The time at which the current value of |min_rtt_| was assigned.
+ minRttTimestamp monotime.Time
+
+ // The maximum allowed number of bytes in flight.
+ congestionWindow congestion.ByteCount
+
+ // The initial value of the |congestion_window_|.
+ initialCongestionWindow congestion.ByteCount
+
+ // The largest value the |congestion_window_| can achieve.
+ maxCongestionWindow congestion.ByteCount
+
+ // The smallest value the |congestion_window_| can achieve.
+ minCongestionWindow congestion.ByteCount
+
+ // The BBR profile used by the sender.
+ profile Profile
+
+ // The pacing gain applied during the STARTUP phase.
+ highGain float64
+
+ // The CWND gain applied during the STARTUP phase.
+ highCwndGain float64
+
+ // The pacing gain applied during the DRAIN phase.
+ drainGain float64
+
+ // The current pacing rate of the connection.
+ pacingRate Bandwidth
+
+ // The gain currently applied to the pacing rate.
+ pacingGain float64
+ // The gain currently applied to the congestion window.
+ congestionWindowGain float64
+
+ // The gain used for the congestion window during PROBE_BW. Latched from
+ // quic_bbr_cwnd_gain flag.
+ congestionWindowGainConstant float64
+ // The number of RTTs to stay in STARTUP mode. Defaults to 3.
+ numStartupRtts int64
+
+ // Number of round-trips in PROBE_BW mode, used for determining the current
+ // pacing gain cycle.
+ cycleCurrentOffset int
+ // The time at which the last pacing gain cycle was started.
+ lastCycleStart monotime.Time
+
+ // Indicates whether the connection has reached the full bandwidth mode.
+ isAtFullBandwidth bool
+ // Number of rounds during which there was no significant bandwidth increase.
+ roundsWithoutBandwidthGain int64
+ // The bandwidth compared to which the increase is measured.
+ bandwidthAtLastRound Bandwidth
+
+ // Set to true upon exiting quiescence.
+ exitingQuiescence bool
+
+ // Time at which PROBE_RTT has to be exited. Setting it to zero indicates
+ // that the time is yet unknown as the number of packets in flight has not
+ // reached the required value.
+ exitProbeRttAt monotime.Time
+ // Indicates whether a round-trip has passed since PROBE_RTT became active.
+ probeRttRoundPassed bool
+
+ // Indicates whether the most recent bandwidth sample was marked as
+ // app-limited.
+ lastSampleIsAppLimited bool
+ // Indicates whether any non app-limited samples have been recorded.
+ hasNoAppLimitedSample bool
+
+ // Current state of recovery.
+ recoveryState bbrRecoveryState
+ // Receiving acknowledgement of a packet after |end_recovery_at_| will cause
+ // BBR to exit the recovery mode. A value above zero indicates at least one
+ // loss has been detected, so it must not be set back to zero.
+ endRecoveryAt congestion.PacketNumber
+ // A window used to limit the number of bytes in flight during loss recovery.
+ recoveryWindow congestion.ByteCount
+ // If true, consider all samples in recovery app-limited.
+ isAppLimitedRecovery bool // not used
+
+ // When true, pace at 1.5x and disable packet conservation in STARTUP.
+ slowerStartup bool // not used
+ // When true, disables packet conservation in STARTUP.
+ rateBasedStartup bool // not used
+
+ // When true, add the most recent ack aggregation measurement during STARTUP.
+ enableAckAggregationDuringStartup bool
+ // When true, expire the windowed ack aggregation values in STARTUP when
+ // bandwidth increases more than 25%.
+ expireAckAggregationInStartup bool
+
+ // If true, will not exit low gain mode until bytes_in_flight drops below BDP
+ // or it's time for high gain mode.
+ drainToTarget bool
+
+ // If true, slow down pacing rate in STARTUP when overshooting is detected.
+ detectOvershooting bool
+ // Bytes lost while detect_overshooting_ is true.
+ bytesLostWhileDetectingOvershooting congestion.ByteCount
+ // Slow down pacing rate if
+ // bytes_lost_while_detecting_overshooting_ *
+ // bytes_lost_multiplier_while_detecting_overshooting_ > IW.
+ bytesLostMultiplierWhileDetectingOvershooting uint8
+ // When overshooting is detected, do not drop pacing_rate_ below this value /
+ // min_rtt.
+ cwndToCalculateMinPacingRate congestion.ByteCount
+
+ // Max congestion window when adjusting network parameters.
+ maxCongestionWindowWithNetworkParametersAdjusted congestion.ByteCount // not used
+
+ // Params.
+ maxDatagramSize congestion.ByteCount
+ // Recorded on packet sent. equivalent |unacked_packets_->bytes_in_flight()|
+ bytesInFlight congestion.ByteCount
+
+ debug bool
+}
+
+var _ congestion.CongestionControl = &bbrSender{}
+
+func NewBbrSender(
+ clock Clock,
+ initialMaxDatagramSize congestion.ByteCount,
+ profile Profile,
+) *bbrSender {
+ return newBbrSender(
+ clock,
+ initialMaxDatagramSize,
+ initialCongestionWindowPackets*initialMaxDatagramSize,
+ congestion.MaxCongestionWindowPackets*initialMaxDatagramSize,
+ profile,
+ )
+}
+
+func newBbrSender(
+ clock Clock,
+ initialMaxDatagramSize,
+ initialCongestionWindow,
+ initialMaxCongestionWindow congestion.ByteCount,
+ profile Profile,
+) *bbrSender {
+ debug, _ := strconv.ParseBool(os.Getenv(debugEnv))
+ b := &bbrSender{
+ clock: clock,
+ mode: bbrModeStartup,
+ sampler: newBandwidthSampler(roundTripCount(bandwidthWindowSize)),
+ lastSentPacket: invalidPacketNumber,
+ currentRoundTripEnd: invalidPacketNumber,
+ maxBandwidth: NewWindowedFilter(roundTripCount(bandwidthWindowSize), MaxFilter[Bandwidth]),
+ congestionWindow: initialCongestionWindow,
+ initialCongestionWindow: initialCongestionWindow,
+ maxCongestionWindow: initialMaxCongestionWindow,
+ minCongestionWindow: minCongestionWindowForMaxDatagramSize(initialMaxDatagramSize),
+ profile: ProfileStandard,
+ highGain: defaultHighGain,
+ highCwndGain: derivedHighCWNDGain,
+ drainGain: 1.0 / defaultHighGain,
+ pacingGain: 1.0,
+ congestionWindowGain: 1.0,
+ congestionWindowGainConstant: 2.0,
+ numStartupRtts: roundTripsWithoutGrowthBeforeExitingStartup,
+ recoveryState: bbrRecoveryStateNotInRecovery,
+ endRecoveryAt: invalidPacketNumber,
+ recoveryWindow: initialMaxCongestionWindow,
+ bytesLostMultiplierWhileDetectingOvershooting: 2,
+ cwndToCalculateMinPacingRate: initialCongestionWindow,
+ maxCongestionWindowWithNetworkParametersAdjusted: initialMaxCongestionWindow,
+ maxDatagramSize: initialMaxDatagramSize,
+ debug: debug,
+ }
+ b.pacer = common.NewPacer(b.bandwidthForPacer)
+ b.applyProfile(profile)
+ if b.debug {
+ b.debugPrint("Profile: %s", b.profile)
+ }
+
+ b.enterStartupMode(b.clock.Now())
+
+ return b
+}
+
+func (b *bbrSender) applyProfile(profile Profile) {
+ if profile == "" {
+ profile = ProfileStandard
+ }
+ cfg := configForProfile(profile)
+ b.profile = profile
+ b.highGain = cfg.highGain
+ b.highCwndGain = cfg.highCwndGain
+ b.drainGain = 1.0 / cfg.highGain
+ b.congestionWindowGainConstant = cfg.congestionWindowGainConstant
+ b.numStartupRtts = cfg.numStartupRtts
+ b.drainToTarget = cfg.drainToTarget
+ b.detectOvershooting = cfg.detectOvershooting
+ b.bytesLostMultiplierWhileDetectingOvershooting = cfg.bytesLostMultiplier
+ b.enableAckAggregationDuringStartup = cfg.enableAckAggregationStartup
+ b.expireAckAggregationInStartup = cfg.expireAckAggregationStartup
+ if cfg.enableOverestimateAvoidance {
+ b.sampler.EnableOverestimateAvoidance()
+ }
+ b.sampler.SetReduceExtraAckedOnBandwidthIncrease(cfg.reduceExtraAckedOnBandwidthIncrease)
+}
+
+func minCongestionWindowForMaxDatagramSize(maxDatagramSize congestion.ByteCount) congestion.ByteCount {
+ return minCongestionWindowPackets * maxDatagramSize
+}
+
+func scaleByteWindowForDatagramSize(window, oldMaxDatagramSize, newMaxDatagramSize congestion.ByteCount) congestion.ByteCount {
+ if oldMaxDatagramSize == newMaxDatagramSize {
+ return window
+ }
+ return congestion.ByteCount(uint64(window) * uint64(newMaxDatagramSize) / uint64(oldMaxDatagramSize))
+}
+
+func (b *bbrSender) rescalePacketSizedWindows(maxDatagramSize congestion.ByteCount) {
+ oldMaxDatagramSize := b.maxDatagramSize
+ b.maxDatagramSize = maxDatagramSize
+ b.initialCongestionWindow = scaleByteWindowForDatagramSize(b.initialCongestionWindow, oldMaxDatagramSize, maxDatagramSize)
+ b.maxCongestionWindow = scaleByteWindowForDatagramSize(b.maxCongestionWindow, oldMaxDatagramSize, maxDatagramSize)
+ b.minCongestionWindow = minCongestionWindowForMaxDatagramSize(maxDatagramSize)
+ b.cwndToCalculateMinPacingRate = scaleByteWindowForDatagramSize(b.cwndToCalculateMinPacingRate, oldMaxDatagramSize, maxDatagramSize)
+ b.maxCongestionWindowWithNetworkParametersAdjusted = scaleByteWindowForDatagramSize(
+ b.maxCongestionWindowWithNetworkParametersAdjusted,
+ oldMaxDatagramSize,
+ maxDatagramSize,
+ )
+}
+
+func (b *bbrSender) SetRTTStatsProvider(provider congestion.RTTStatsProvider) {
+ b.rttStats = provider
+}
+
+// TimeUntilSend implements the SendAlgorithm interface.
+func (b *bbrSender) TimeUntilSend(bytesInFlight congestion.ByteCount) monotime.Time {
+ return b.pacer.TimeUntilSend()
+}
+
+// HasPacingBudget implements the SendAlgorithm interface.
+func (b *bbrSender) HasPacingBudget(now monotime.Time) bool {
+ return b.pacer.Budget(now) >= b.maxDatagramSize
+}
+
+// OnPacketSent implements the SendAlgorithm interface.
+func (b *bbrSender) OnPacketSent(
+ sentTime monotime.Time,
+ bytesInFlight congestion.ByteCount,
+ packetNumber congestion.PacketNumber,
+ bytes congestion.ByteCount,
+ isRetransmittable bool,
+) {
+ b.pacer.SentPacket(sentTime, bytes)
+
+ b.lastSentPacket = packetNumber
+ b.bytesInFlight = bytesInFlight
+
+ if bytesInFlight == 0 {
+ b.exitingQuiescence = true
+ }
+
+ b.sampler.OnPacketSent(sentTime, packetNumber, bytes, bytesInFlight, isRetransmittable)
+}
+
+// CanSend implements the SendAlgorithm interface.
+func (b *bbrSender) CanSend(bytesInFlight congestion.ByteCount) bool {
+ return bytesInFlight < b.GetCongestionWindow()
+}
+
+// MaybeExitSlowStart implements the SendAlgorithm interface.
+func (b *bbrSender) MaybeExitSlowStart() {
+ // Do nothing
+}
+
+// OnPacketAcked implements the SendAlgorithm interface.
+func (b *bbrSender) OnPacketAcked(number congestion.PacketNumber, ackedBytes, priorInFlight congestion.ByteCount, eventTime monotime.Time) {
+ // Do nothing.
+}
+
+// OnPacketLost implements the SendAlgorithm interface.
+func (b *bbrSender) OnPacketLost(number congestion.PacketNumber, lostBytes, priorInFlight congestion.ByteCount) {
+ // Do nothing.
+}
+
+// OnRetransmissionTimeout implements the SendAlgorithm interface.
+func (b *bbrSender) OnRetransmissionTimeout(packetsRetransmitted bool) {
+ // Do nothing.
+}
+
+// SetMaxDatagramSize implements the SendAlgorithm interface.
+func (b *bbrSender) SetMaxDatagramSize(s congestion.ByteCount) {
+ if b.debug {
+ b.debugPrint("Max Datagram Size: %d", s)
+ }
+ if s < b.maxDatagramSize {
+ panic(fmt.Sprintf("congestion BUG: decreased max datagram size from %d to %d", b.maxDatagramSize, s))
+ }
+ oldMinCongestionWindow := b.minCongestionWindow
+ oldInitialCongestionWindow := b.initialCongestionWindow
+ b.rescalePacketSizedWindows(s)
+ switch b.congestionWindow {
+ case oldMinCongestionWindow:
+ b.congestionWindow = b.minCongestionWindow
+ case oldInitialCongestionWindow:
+ b.congestionWindow = b.initialCongestionWindow
+ default:
+ b.congestionWindow = min(b.maxCongestionWindow, max(b.congestionWindow, b.minCongestionWindow))
+ }
+ b.recoveryWindow = min(b.maxCongestionWindow, max(b.recoveryWindow, b.minCongestionWindow))
+ b.pacer.SetMaxDatagramSize(s)
+}
+
+// InSlowStart implements the SendAlgorithmWithDebugInfos interface.
+func (b *bbrSender) InSlowStart() bool {
+ return b.mode == bbrModeStartup
+}
+
+// InRecovery implements the SendAlgorithmWithDebugInfos interface.
+func (b *bbrSender) InRecovery() bool {
+ return b.recoveryState != bbrRecoveryStateNotInRecovery
+}
+
+// GetCongestionWindow implements the SendAlgorithmWithDebugInfos interface.
+func (b *bbrSender) GetCongestionWindow() congestion.ByteCount {
+ if b.mode == bbrModeProbeRtt {
+ return b.probeRttCongestionWindow()
+ }
+
+ if b.InRecovery() {
+ return min(b.congestionWindow, b.recoveryWindow)
+ }
+
+ return b.congestionWindow
+}
+
+func (b *bbrSender) OnCongestionEvent(number congestion.PacketNumber, lostBytes, priorInFlight congestion.ByteCount) {
+ // Do nothing.
+}
+
+func (b *bbrSender) OnCongestionEventEx(priorInFlight congestion.ByteCount, eventTime monotime.Time, ackedPackets []congestion.AckedPacketInfo, lostPackets []congestion.LostPacketInfo) {
+ totalBytesAckedBefore := b.sampler.TotalBytesAcked()
+ totalBytesLostBefore := b.sampler.TotalBytesLost()
+
+ var isRoundStart, minRttExpired bool
+ var excessAcked, bytesLost congestion.ByteCount
+
+ // The send state of the largest packet in acked_packets, unless it is
+ // empty. If acked_packets is empty, it's the send state of the largest
+ // packet in lost_packets.
+ var lastPacketSendState sendTimeState
+
+ b.maybeAppLimited(priorInFlight)
+
+ // Update bytesInFlight
+ b.bytesInFlight = priorInFlight
+ for _, p := range ackedPackets {
+ b.bytesInFlight -= p.BytesAcked
+ }
+ for _, p := range lostPackets {
+ b.bytesInFlight -= p.BytesLost
+ }
+
+ if len(ackedPackets) != 0 {
+ lastAckedPacket := ackedPackets[len(ackedPackets)-1].PacketNumber
+ isRoundStart = b.updateRoundTripCounter(lastAckedPacket)
+ b.updateRecoveryState(lastAckedPacket, len(lostPackets) != 0, isRoundStart)
+ }
+
+ sample := b.sampler.OnCongestionEvent(eventTime,
+ ackedPackets, lostPackets, b.maxBandwidth.GetBest(), infBandwidth, b.roundTripCount)
+ if sample.lastPacketSendState.isValid {
+ b.lastSampleIsAppLimited = sample.lastPacketSendState.isAppLimited
+ b.hasNoAppLimitedSample = b.hasNoAppLimitedSample || !b.lastSampleIsAppLimited
+ }
+ // Avoid updating |max_bandwidth_| if a) this is a loss-only event, or b) all
+ // packets in |acked_packets| did not generate valid samples. (e.g. ack of
+ // ack-only packets). In both cases, sampler_.total_bytes_acked() will not
+ // change.
+ if totalBytesAckedBefore != b.sampler.TotalBytesAcked() {
+ if !sample.sampleIsAppLimited || sample.sampleMaxBandwidth > b.maxBandwidth.GetBest() {
+ b.maxBandwidth.Update(sample.sampleMaxBandwidth, b.roundTripCount)
+ }
+ }
+
+ if sample.sampleRtt != infRTT {
+ minRttExpired = b.maybeUpdateMinRtt(eventTime, sample.sampleRtt)
+ }
+ bytesLost = b.sampler.TotalBytesLost() - totalBytesLostBefore
+
+ excessAcked = sample.extraAcked
+ lastPacketSendState = sample.lastPacketSendState
+
+ if len(lostPackets) != 0 {
+ b.numLossEventsInRound++
+ b.bytesLostInRound += bytesLost
+ }
+
+ // Handle logic specific to PROBE_BW mode.
+ if b.mode == bbrModeProbeBw {
+ b.updateGainCyclePhase(eventTime, priorInFlight, len(lostPackets) != 0)
+ }
+
+ // Handle logic specific to STARTUP and DRAIN modes.
+ if isRoundStart && !b.isAtFullBandwidth {
+ b.checkIfFullBandwidthReached(&lastPacketSendState)
+ }
+
+ b.maybeExitStartupOrDrain(eventTime)
+
+ // Handle logic specific to PROBE_RTT.
+ b.maybeEnterOrExitProbeRtt(eventTime, isRoundStart, minRttExpired)
+
+ // Calculate number of packets acked and lost.
+ bytesAcked := b.sampler.TotalBytesAcked() - totalBytesAckedBefore
+
+ // After the model is updated, recalculate the pacing rate and congestion
+ // window.
+ b.calculatePacingRate(bytesLost)
+ b.calculateCongestionWindow(bytesAcked, excessAcked)
+ b.calculateRecoveryWindow(bytesAcked, bytesLost)
+
+ // Cleanup internal state.
+ // This is where we clean up obsolete (acked or lost) packets from the bandwidth sampler.
+ // The "least unacked" should actually be FirstOutstanding, but since we are not passing
+ // that through OnCongestionEventEx, we will only do an estimate using acked/lost packets
+ // for now. Because of fast retransmission, they should differ by no more than 2 packets.
+ // (this is controlled by packetThreshold in quic-go's sentPacketHandler)
+ var leastUnacked congestion.PacketNumber
+ if len(ackedPackets) != 0 {
+ leastUnacked = ackedPackets[len(ackedPackets)-1].PacketNumber - 2
+ } else {
+ leastUnacked = lostPackets[len(lostPackets)-1].PacketNumber + 1
+ }
+ b.sampler.RemoveObsoletePackets(leastUnacked)
+
+ if isRoundStart {
+ b.numLossEventsInRound = 0
+ b.bytesLostInRound = 0
+ }
+}
+
+func (b *bbrSender) PacingRate() Bandwidth {
+ if b.pacingRate == 0 {
+ return Bandwidth(b.highGain * float64(
+ BandwidthFromDelta(b.initialCongestionWindow, b.getMinRtt()),
+ ))
+ }
+
+ return b.pacingRate
+}
+
+// Sets the CWND gain used in STARTUP. Must be greater than 1.
+func (b *bbrSender) setHighCwndGain(highCwndGain float64) {
+ b.highCwndGain = highCwndGain
+ if b.mode == bbrModeStartup {
+ b.congestionWindowGain = highCwndGain
+ }
+}
+
+// Get the current bandwidth estimate. Note that Bandwidth is in bits per second.
+func (b *bbrSender) bandwidthEstimate() Bandwidth {
+ return b.maxBandwidth.GetBest()
+}
+
+func (b *bbrSender) bandwidthForPacer() congestion.ByteCount {
+ bps := congestion.ByteCount(float64(b.PacingRate()) / float64(BytesPerSecond))
+ if bps < minBps {
+ // We need to make sure that the bandwidth value for pacer is never zero,
+ // otherwise it will go into an edge case where HasPacingBudget = false
+ // but TimeUntilSend is before, causing the quic-go send loop to go crazy and get stuck.
+ return minBps
+ }
+ return bps
+}
+
+// Returns the current estimate of the RTT of the connection. Outside of the
+// edge cases, this is minimum RTT.
+func (b *bbrSender) getMinRtt() time.Duration {
+ if b.minRtt != 0 {
+ return b.minRtt
+ }
+ // min_rtt could be available if the handshake packet gets neutered then
+ // gets acknowledged. This could only happen for QUIC crypto where we do not
+ // drop keys.
+ minRtt := b.rttStats.MinRTT()
+ if minRtt == 0 {
+ return 100 * time.Millisecond
+ } else {
+ return minRtt
+ }
+}
+
+// Computes the target congestion window using the specified gain.
+func (b *bbrSender) getTargetCongestionWindow(gain float64) congestion.ByteCount {
+ bdp := bdpFromRttAndBandwidth(b.getMinRtt(), b.bandwidthEstimate())
+ congestionWindow := congestion.ByteCount(gain * float64(bdp))
+
+ // BDP estimate will be zero if no bandwidth samples are available yet.
+ if congestionWindow == 0 {
+ congestionWindow = congestion.ByteCount(gain * float64(b.initialCongestionWindow))
+ }
+
+ return max(congestionWindow, b.minCongestionWindow)
+}
+
+// The target congestion window during PROBE_RTT.
+func (b *bbrSender) probeRttCongestionWindow() congestion.ByteCount {
+ return b.minCongestionWindow
+}
+
+func (b *bbrSender) maybeUpdateMinRtt(now monotime.Time, sampleMinRtt time.Duration) bool {
+ // Do not expire min_rtt if none was ever available.
+ minRttExpired := b.minRtt != 0 && now.After(b.minRttTimestamp.Add(minRttExpiry))
+ if minRttExpired || sampleMinRtt < b.minRtt || b.minRtt == 0 {
+ b.minRtt = sampleMinRtt
+ b.minRttTimestamp = now
+ }
+
+ return minRttExpired
+}
+
+// Enters the STARTUP mode.
+func (b *bbrSender) enterStartupMode(now monotime.Time) {
+ b.mode = bbrModeStartup
+ // b.maybeTraceStateChange(logging.CongestionStateStartup)
+ b.pacingGain = b.highGain
+ b.congestionWindowGain = b.highCwndGain
+
+ if b.debug {
+ b.debugPrint("Phase: STARTUP")
+ }
+}
+
+// Enters the PROBE_BW mode.
+func (b *bbrSender) enterProbeBandwidthMode(now monotime.Time) {
+ b.mode = bbrModeProbeBw
+ // b.maybeTraceStateChange(logging.CongestionStateProbeBw)
+ b.congestionWindowGain = b.congestionWindowGainConstant
+
+ // Pick a random offset for the gain cycle out of {0, 2..7} range. 1 is
+ // excluded because in that case increased gain and decreased gain would not
+ // follow each other.
+ b.cycleCurrentOffset = int(rand.Int31n(congestion.PacketsPerConnectionID)) % (gainCycleLength - 1)
+ if b.cycleCurrentOffset >= 1 {
+ b.cycleCurrentOffset += 1
+ }
+
+ b.lastCycleStart = now
+ b.pacingGain = pacingGain[b.cycleCurrentOffset]
+
+ if b.debug {
+ b.debugPrint("Phase: PROBE_BW")
+ }
+}
+
+// Updates the round-trip counter if a round-trip has passed. Returns true if
+// the counter has been advanced.
+func (b *bbrSender) updateRoundTripCounter(lastAckedPacket congestion.PacketNumber) bool {
+ if b.currentRoundTripEnd == invalidPacketNumber || lastAckedPacket > b.currentRoundTripEnd {
+ b.roundTripCount++
+ b.currentRoundTripEnd = b.lastSentPacket
+ return true
+ }
+ return false
+}
+
+// Updates the current gain used in PROBE_BW mode.
+func (b *bbrSender) updateGainCyclePhase(now monotime.Time, priorInFlight congestion.ByteCount, hasLosses bool) {
+ // In most cases, the cycle is advanced after an RTT passes.
+ shouldAdvanceGainCycling := now.After(b.lastCycleStart.Add(b.getMinRtt()))
+ // If the pacing gain is above 1.0, the connection is trying to probe the
+ // bandwidth by increasing the number of bytes in flight to at least
+ // pacing_gain * BDP. Make sure that it actually reaches the target, as long
+ // as there are no losses suggesting that the buffers are not able to hold
+ // that much.
+ if b.pacingGain > 1.0 && !hasLosses && priorInFlight < b.getTargetCongestionWindow(b.pacingGain) {
+ shouldAdvanceGainCycling = false
+ }
+
+ // If pacing gain is below 1.0, the connection is trying to drain the extra
+ // queue which could have been incurred by probing prior to it. If the number
+ // of bytes in flight falls down to the estimated BDP value earlier, conclude
+ // that the queue has been successfully drained and exit this cycle early.
+ if b.pacingGain < 1.0 && b.bytesInFlight <= b.getTargetCongestionWindow(1) {
+ shouldAdvanceGainCycling = true
+ }
+
+ if shouldAdvanceGainCycling {
+ b.cycleCurrentOffset = (b.cycleCurrentOffset + 1) % gainCycleLength
+ b.lastCycleStart = now
+ // Stay in low gain mode until the target BDP is hit.
+ // Low gain mode will be exited immediately when the target BDP is achieved.
+ if b.drainToTarget && b.pacingGain < 1 &&
+ pacingGain[b.cycleCurrentOffset] == 1 &&
+ b.bytesInFlight > b.getTargetCongestionWindow(1) {
+ return
+ }
+ b.pacingGain = pacingGain[b.cycleCurrentOffset]
+ }
+}
+
+// Tracks for how many round-trips the bandwidth has not increased
+// significantly.
+func (b *bbrSender) checkIfFullBandwidthReached(lastPacketSendState *sendTimeState) {
+ if b.lastSampleIsAppLimited {
+ return
+ }
+
+ target := Bandwidth(float64(b.bandwidthAtLastRound) * startupGrowthTarget)
+ if b.bandwidthEstimate() >= target {
+ b.bandwidthAtLastRound = b.bandwidthEstimate()
+ b.roundsWithoutBandwidthGain = 0
+ if b.expireAckAggregationInStartup {
+ // Expire old excess delivery measurements now that bandwidth increased.
+ b.sampler.ResetMaxAckHeightTracker(0, b.roundTripCount)
+ }
+ return
+ }
+
+ b.roundsWithoutBandwidthGain++
+ if b.roundsWithoutBandwidthGain >= b.numStartupRtts ||
+ b.shouldExitStartupDueToLoss(lastPacketSendState) {
+ b.isAtFullBandwidth = true
+ }
+}
+
+func (b *bbrSender) maybeAppLimited(bytesInFlight congestion.ByteCount) {
+ if bytesInFlight < b.getTargetCongestionWindow(1) {
+ b.sampler.OnAppLimited()
+ }
+}
+
+// Transitions from STARTUP to DRAIN and from DRAIN to PROBE_BW if
+// appropriate.
+func (b *bbrSender) maybeExitStartupOrDrain(now monotime.Time) {
+ if b.mode == bbrModeStartup && b.isAtFullBandwidth {
+ b.mode = bbrModeDrain
+ // b.maybeTraceStateChange(logging.CongestionStateDrain)
+ b.pacingGain = b.drainGain
+ b.congestionWindowGain = b.highCwndGain
+
+ if b.debug {
+ b.debugPrint("Phase: DRAIN")
+ }
+ }
+ if b.mode == bbrModeDrain && b.bytesInFlight <= b.getTargetCongestionWindow(1) {
+ b.enterProbeBandwidthMode(now)
+ }
+}
+
+// Decides whether to enter or exit PROBE_RTT.
+func (b *bbrSender) maybeEnterOrExitProbeRtt(now monotime.Time, isRoundStart, minRttExpired bool) {
+ if minRttExpired && !b.exitingQuiescence && b.mode != bbrModeProbeRtt {
+ b.mode = bbrModeProbeRtt
+ // b.maybeTraceStateChange(logging.CongestionStateProbRtt)
+ b.pacingGain = 1.0
+ // Do not decide on the time to exit PROBE_RTT until the |bytes_in_flight|
+ // is at the target small value.
+ b.exitProbeRttAt = 0
+
+ if b.debug {
+ b.debugPrint("BandwidthEstimate: %s, CongestionWindowGain: %.2f, PacingGain: %.2f, PacingRate: %s",
+ formatSpeed(b.bandwidthEstimate()), b.congestionWindowGain, b.pacingGain, formatSpeed(b.PacingRate()))
+ b.debugPrint("Phase: PROBE_RTT")
+ }
+ }
+
+ if b.mode == bbrModeProbeRtt {
+ b.sampler.OnAppLimited()
+ // b.maybeTraceStateChange(logging.CongestionStateApplicationLimited)
+
+ if b.exitProbeRttAt.IsZero() {
+ // If the window has reached the appropriate size, schedule exiting
+ // PROBE_RTT. The CWND during PROBE_RTT is kMinimumCongestionWindow, but
+ // we allow an extra packet since QUIC checks CWND before sending a
+ // packet.
+ if b.bytesInFlight < b.probeRttCongestionWindow()+congestion.MaxPacketBufferSize {
+ b.exitProbeRttAt = now.Add(probeRttTime)
+ b.probeRttRoundPassed = false
+ }
+ } else {
+ if isRoundStart {
+ b.probeRttRoundPassed = true
+ }
+ if now.Sub(b.exitProbeRttAt) >= 0 && b.probeRttRoundPassed {
+ b.minRttTimestamp = now
+ if b.debug {
+ b.debugPrint("MinRTT: %s", b.getMinRtt())
+ }
+ if !b.isAtFullBandwidth {
+ b.enterStartupMode(now)
+ } else {
+ b.enterProbeBandwidthMode(now)
+ }
+ }
+ }
+ }
+
+ b.exitingQuiescence = false
+}
+
+// Determines whether BBR needs to enter, exit or advance state of the
+// recovery.
+func (b *bbrSender) updateRecoveryState(lastAckedPacket congestion.PacketNumber, hasLosses, isRoundStart bool) {
+ // Disable recovery in startup, if loss-based exit is enabled.
+ if !b.isAtFullBandwidth {
+ return
+ }
+
+ // Exit recovery when there are no losses for a round.
+ if hasLosses {
+ b.endRecoveryAt = b.lastSentPacket
+ }
+
+ switch b.recoveryState {
+ case bbrRecoveryStateNotInRecovery:
+ if hasLosses {
+ b.recoveryState = bbrRecoveryStateConservation
+ // This will cause the |recovery_window_| to be set to the correct
+ // value in CalculateRecoveryWindow().
+ b.recoveryWindow = 0
+ // Since the conservation phase is meant to be lasting for a whole
+ // round, extend the current round as if it were started right now.
+ b.currentRoundTripEnd = b.lastSentPacket
+ }
+ case bbrRecoveryStateConservation:
+ if isRoundStart {
+ b.recoveryState = bbrRecoveryStateGrowth
+ }
+ fallthrough
+ case bbrRecoveryStateGrowth:
+ // Exit recovery if appropriate.
+ if !hasLosses && lastAckedPacket > b.endRecoveryAt {
+ b.recoveryState = bbrRecoveryStateNotInRecovery
+ }
+ }
+}
+
+// Determines the appropriate pacing rate for the connection.
+func (b *bbrSender) calculatePacingRate(bytesLost congestion.ByteCount) {
+ if b.bandwidthEstimate() == 0 {
+ return
+ }
+
+ targetRate := Bandwidth(b.pacingGain * float64(b.bandwidthEstimate()))
+ if b.isAtFullBandwidth {
+ b.pacingRate = targetRate
+ return
+ }
+
+ // Pace at the rate of initial_window / RTT as soon as RTT measurements are
+ // available.
+ if b.pacingRate == 0 && b.rttStats.MinRTT() != 0 {
+ b.pacingRate = BandwidthFromDelta(b.initialCongestionWindow, b.rttStats.MinRTT())
+ return
+ }
+
+ if b.detectOvershooting {
+ b.bytesLostWhileDetectingOvershooting += bytesLost
+ // Check for overshooting with network parameters adjusted when pacing rate
+ // > target_rate and loss has been detected.
+ if b.pacingRate > targetRate && b.bytesLostWhileDetectingOvershooting > 0 {
+ if b.hasNoAppLimitedSample ||
+ b.bytesLostWhileDetectingOvershooting*congestion.ByteCount(b.bytesLostMultiplierWhileDetectingOvershooting) > b.initialCongestionWindow {
+ // We are fairly sure overshoot happens if 1) there is at least one
+ // non app-limited bw sample or 2) half of IW gets lost. Slow pacing
+ // rate.
+ b.pacingRate = max(targetRate, BandwidthFromDelta(b.cwndToCalculateMinPacingRate, b.rttStats.MinRTT()))
+ b.bytesLostWhileDetectingOvershooting = 0
+ b.detectOvershooting = false
+ }
+ }
+ }
+
+ // Do not decrease the pacing rate during startup.
+ b.pacingRate = max(b.pacingRate, targetRate)
+}
+
+// Determines the appropriate congestion window for the connection.
+func (b *bbrSender) calculateCongestionWindow(bytesAcked, excessAcked congestion.ByteCount) {
+ if b.mode == bbrModeProbeRtt {
+ return
+ }
+
+ targetWindow := b.getTargetCongestionWindow(b.congestionWindowGain)
+ if b.isAtFullBandwidth {
+ // Add the max recently measured ack aggregation to CWND.
+ targetWindow += b.sampler.MaxAckHeight()
+ } else if b.enableAckAggregationDuringStartup {
+ // Add the most recent excess acked. Because CWND never decreases in
+ // STARTUP, this will automatically create a very localized max filter.
+ targetWindow += excessAcked
+ }
+
+ // Instead of immediately setting the target CWND as the new one, BBR grows
+ // the CWND towards |target_window| by only increasing it |bytes_acked| at a
+ // time.
+ if b.isAtFullBandwidth {
+ b.congestionWindow = min(targetWindow, b.congestionWindow+bytesAcked)
+ } else if b.congestionWindow < targetWindow ||
+ b.sampler.TotalBytesAcked() < b.initialCongestionWindow {
+ // If the connection is not yet out of startup phase, do not decrease the
+ // window.
+ b.congestionWindow += bytesAcked
+ }
+
+ // Enforce the limits on the congestion window.
+ b.congestionWindow = max(b.congestionWindow, b.minCongestionWindow)
+ b.congestionWindow = min(b.congestionWindow, b.maxCongestionWindow)
+}
+
+// Determines the appropriate window that constrains the in-flight during recovery.
+func (b *bbrSender) calculateRecoveryWindow(bytesAcked, bytesLost congestion.ByteCount) {
+ if b.recoveryState == bbrRecoveryStateNotInRecovery {
+ return
+ }
+
+ // Set up the initial recovery window.
+ if b.recoveryWindow == 0 {
+ b.recoveryWindow = b.bytesInFlight + bytesAcked
+ b.recoveryWindow = max(b.minCongestionWindow, b.recoveryWindow)
+ return
+ }
+
+ // Remove losses from the recovery window, while accounting for a potential
+ // integer underflow.
+ if b.recoveryWindow >= bytesLost {
+ b.recoveryWindow = b.recoveryWindow - bytesLost
+ } else {
+ b.recoveryWindow = b.maxDatagramSize
+ }
+
+ // In CONSERVATION mode, just subtracting losses is sufficient. In GROWTH,
+ // release additional |bytes_acked| to achieve a slow-start-like behavior.
+ if b.recoveryState == bbrRecoveryStateGrowth {
+ b.recoveryWindow += bytesAcked
+ }
+
+ // Always allow sending at least |bytes_acked| in response.
+ b.recoveryWindow = max(b.recoveryWindow, b.bytesInFlight+bytesAcked)
+ b.recoveryWindow = max(b.minCongestionWindow, b.recoveryWindow)
+}
+
+// Return whether we should exit STARTUP due to excessive loss.
+func (b *bbrSender) shouldExitStartupDueToLoss(lastPacketSendState *sendTimeState) bool {
+ if b.numLossEventsInRound < defaultStartupFullLossCount || !lastPacketSendState.isValid {
+ return false
+ }
+
+ inflightAtSend := lastPacketSendState.bytesInFlight
+
+ if inflightAtSend > 0 && b.bytesLostInRound > 0 {
+ if b.bytesLostInRound > congestion.ByteCount(float64(inflightAtSend)*quicBbr2DefaultLossThreshold) {
+ return true
+ }
+ return false
+ }
+ return false
+}
+
+func (b *bbrSender) debugPrint(format string, a ...any) {
+ fmt.Printf("[BBRSender] [%s] %s\n",
+ time.Now().Format("15:04:05"),
+ fmt.Sprintf(format, a...))
+}
+
+func bdpFromRttAndBandwidth(rtt time.Duration, bandwidth Bandwidth) congestion.ByteCount {
+ return congestion.ByteCount(rtt) * congestion.ByteCount(bandwidth) / congestion.ByteCount(BytesPerSecond) / congestion.ByteCount(time.Second)
+}
+
+func GetInitialPacketSize(addr net.Addr) congestion.ByteCount {
+ // If this is not a UDP address, we don't know anything about the MTU.
+ // Use the minimum size of an Initial packet as the max packet size.
+ if _, ok := addr.(*net.UDPAddr); ok {
+ return congestion.InitialPacketSize
+ } else {
+ return congestion.MinInitialPacketSize
+ }
+}
+
+func formatSpeed(bw Bandwidth) string {
+ bwf := float64(bw)
+ units := []string{"bps", "Kbps", "Mbps", "Gbps"}
+ unitIndex := 0
+ for bwf > 1000 && unitIndex < len(units)-1 {
+ bwf /= 1000
+ unitIndex++
+ }
+ return fmt.Sprintf("%.2f %s", bwf, units[unitIndex])
+}
diff --git a/third_party/hysteria-core/internal/congestion/bbr/bbr_sender_test.go b/third_party/hysteria-core/internal/congestion/bbr/bbr_sender_test.go
new file mode 100644
index 0000000..e4f0259
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/bbr/bbr_sender_test.go
@@ -0,0 +1,226 @@
+package bbr
+
+import (
+ "testing"
+ "time"
+
+ "github.com/apernet/quic-go/congestion"
+ "github.com/apernet/quic-go/monotime"
+ "github.com/stretchr/testify/require"
+)
+
+type fixedClock struct{ now monotime.Time }
+
+func (c fixedClock) Now() monotime.Time { return c.now }
+
+type fixedRTTStats struct {
+ min, latest, smoothed, deviation, maxAckDelay time.Duration
+}
+
+func (s *fixedRTTStats) MinRTT() time.Duration { return s.min }
+func (s *fixedRTTStats) LatestRTT() time.Duration { return s.latest }
+func (s *fixedRTTStats) SmoothedRTT() time.Duration { return s.smoothed }
+func (s *fixedRTTStats) MeanDeviation() time.Duration { return s.deviation }
+func (s *fixedRTTStats) MaxAckDelay() time.Duration { return s.maxAckDelay }
+func (s *fixedRTTStats) PTO(bool) time.Duration { return s.smoothed + 4*s.deviation }
+func (s *fixedRTTStats) UpdateRTT(send, _ time.Duration) { s.latest, s.smoothed = send, send }
+func (s *fixedRTTStats) SetMaxAckDelay(delay time.Duration) { s.maxAckDelay = delay }
+func (s *fixedRTTStats) SetInitialRTT(rtt time.Duration) { s.min, s.latest, s.smoothed = rtt, rtt, rtt }
+
+func TestSetMaxDatagramSizeRescalesPacketSizedWindows(t *testing.T) {
+ const oldMaxDatagramSize = congestion.ByteCount(1000)
+ const newMaxDatagramSize = congestion.ByteCount(1400)
+ const initialCongestionWindowPackets = congestion.ByteCount(20)
+ const maxCongestionWindowPackets = congestion.ByteCount(80)
+
+ b := newBbrSender(
+ DefaultClock{},
+ oldMaxDatagramSize,
+ initialCongestionWindowPackets*oldMaxDatagramSize,
+ maxCongestionWindowPackets*oldMaxDatagramSize,
+ ProfileStandard,
+ )
+ b.congestionWindow = b.initialCongestionWindow
+
+ b.SetMaxDatagramSize(newMaxDatagramSize)
+
+ require.Equal(t, initialCongestionWindowPackets*newMaxDatagramSize, b.initialCongestionWindow)
+ require.Equal(t, maxCongestionWindowPackets*newMaxDatagramSize, b.maxCongestionWindow)
+ require.Equal(t, minCongestionWindowPackets*newMaxDatagramSize, b.minCongestionWindow)
+ require.Equal(t, initialCongestionWindowPackets*newMaxDatagramSize, b.congestionWindow)
+}
+
+func TestSetMaxDatagramSizeClampsCongestionWindow(t *testing.T) {
+ const oldMaxDatagramSize = congestion.ByteCount(1000)
+ const newMaxDatagramSize = congestion.ByteCount(1400)
+
+ b := NewBbrSender(DefaultClock{}, oldMaxDatagramSize, ProfileStandard)
+ b.congestionWindow = b.minCongestionWindow + oldMaxDatagramSize
+ b.recoveryWindow = b.minCongestionWindow + oldMaxDatagramSize
+
+ b.SetMaxDatagramSize(newMaxDatagramSize)
+
+ require.Equal(t, b.minCongestionWindow, b.congestionWindow)
+ require.Equal(t, b.minCongestionWindow, b.recoveryWindow)
+}
+
+func TestNewBbrSenderAppliesProfiles(t *testing.T) {
+ testCases := []struct {
+ name string
+ profile Profile
+ highGain float64
+ highCwndGain float64
+ congestionWindowGainConstant float64
+ numStartupRtts int64
+ drainToTarget bool
+ detectOvershooting bool
+ bytesLostMultiplier uint8
+ enableAckAggregationDuringStartup bool
+ expireAckAggregationInStartup bool
+ enableOverestimateAvoidance bool
+ reduceExtraAckedOnBandwidthIncrease bool
+ }{
+ {
+ name: "standard",
+ profile: ProfileStandard,
+ highGain: defaultHighGain,
+ highCwndGain: derivedHighCWNDGain,
+ congestionWindowGainConstant: 2.0,
+ numStartupRtts: roundTripsWithoutGrowthBeforeExitingStartup,
+ bytesLostMultiplier: 2,
+ },
+ {
+ name: "conservative",
+ profile: ProfileConservative,
+ highGain: 2.25,
+ highCwndGain: 1.75,
+ congestionWindowGainConstant: 1.75,
+ numStartupRtts: 2,
+ drainToTarget: true,
+ detectOvershooting: true,
+ bytesLostMultiplier: 1,
+ enableOverestimateAvoidance: true,
+ reduceExtraAckedOnBandwidthIncrease: true,
+ },
+ {
+ name: "aggressive",
+ profile: ProfileAggressive,
+ highGain: 3.0,
+ highCwndGain: 2.25,
+ congestionWindowGainConstant: 2.5,
+ numStartupRtts: 4,
+ bytesLostMultiplier: 2,
+ enableAckAggregationDuringStartup: true,
+ expireAckAggregationInStartup: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ b := NewBbrSender(DefaultClock{}, congestion.InitialPacketSize, tc.profile)
+ require.Equal(t, tc.profile, b.profile)
+ require.Equal(t, tc.highGain, b.highGain)
+ require.Equal(t, tc.highCwndGain, b.highCwndGain)
+ require.Equal(t, tc.congestionWindowGainConstant, b.congestionWindowGainConstant)
+ require.Equal(t, tc.numStartupRtts, b.numStartupRtts)
+ require.Equal(t, tc.drainToTarget, b.drainToTarget)
+ require.Equal(t, tc.detectOvershooting, b.detectOvershooting)
+ require.Equal(t, tc.bytesLostMultiplier, b.bytesLostMultiplierWhileDetectingOvershooting)
+ require.Equal(t, tc.enableAckAggregationDuringStartup, b.enableAckAggregationDuringStartup)
+ require.Equal(t, tc.expireAckAggregationInStartup, b.expireAckAggregationInStartup)
+ require.Equal(t, tc.enableOverestimateAvoidance, b.sampler.IsOverestimateAvoidanceEnabled())
+ require.Equal(t, tc.reduceExtraAckedOnBandwidthIncrease, b.sampler.maxAckHeightTracker.reduceExtraAckedOnBandwidthIncrease)
+ require.Equal(t, b.highGain, b.pacingGain)
+ require.Equal(t, b.highCwndGain, b.congestionWindowGain)
+ })
+ }
+}
+
+func TestParseProfile(t *testing.T) {
+ profile, err := ParseProfile("")
+ require.NoError(t, err)
+ require.Equal(t, ProfileStandard, profile)
+
+ profile, err = ParseProfile("Aggressive")
+ require.NoError(t, err)
+ require.Equal(t, ProfileAggressive, profile)
+
+ _, err = ParseProfile("turbo")
+ require.EqualError(t, err, `unsupported BBR profile "turbo"`)
+}
+
+func TestCongestionEventUpdatesDeliveryRateAndMinimumRTT(t *testing.T) {
+ now := monotime.Now()
+ b := NewBbrSender(fixedClock{now: now}, 1200, ProfileStandard)
+ b.SetRTTStatsProvider(&fixedRTTStats{min: 100 * time.Millisecond, smoothed: 100 * time.Millisecond})
+
+ const packetSize = congestion.ByteCount(1200)
+ b.OnPacketSent(now, 0, 1, packetSize, true)
+ ackedAt := now.Add(100 * time.Millisecond)
+ b.OnCongestionEventEx(packetSize, ackedAt, []congestion.AckedPacketInfo{{
+ PacketNumber: 1,
+ BytesAcked: packetSize,
+ ReceivedTime: ackedAt,
+ }}, nil)
+
+ require.Equal(t, 100*time.Millisecond, b.minRtt)
+ require.Positive(t, b.bandwidthEstimate())
+ require.Equal(t, packetSize, b.sampler.TotalBytesAcked())
+ require.Positive(t, b.PacingRate())
+}
+
+func TestBBRStateMachineStartupDrainProbeBandwidthAndProbeRTT(t *testing.T) {
+ now := monotime.Now()
+ b := NewBbrSender(fixedClock{now: now}, 1200, ProfileStandard)
+ b.SetRTTStatsProvider(&fixedRTTStats{min: 100 * time.Millisecond, smoothed: 100 * time.Millisecond})
+ b.minRtt = 100 * time.Millisecond
+ b.minRttTimestamp = now
+ b.maxBandwidth.Update(BandwidthFromDelta(12000, 100*time.Millisecond), 1)
+ b.isAtFullBandwidth = true
+
+ target := b.getTargetCongestionWindow(1)
+ b.bytesInFlight = target + b.maxDatagramSize
+ b.maybeExitStartupOrDrain(now)
+ require.EqualValues(t, bbrModeDrain, b.mode)
+ require.Equal(t, b.drainGain, b.pacingGain)
+
+ b.bytesInFlight = target
+ b.maybeExitStartupOrDrain(now.Add(time.Millisecond))
+ require.EqualValues(t, bbrModeProbeBw, b.mode)
+ require.Equal(t, b.congestionWindowGainConstant, b.congestionWindowGain)
+
+ b.exitingQuiescence = false
+ b.bytesInFlight = 0
+ probeStart := now.Add(minRttExpiry + time.Second)
+ b.maybeEnterOrExitProbeRtt(probeStart, true, true)
+ require.EqualValues(t, bbrModeProbeRtt, b.mode)
+ require.Equal(t, b.minCongestionWindow, b.GetCongestionWindow())
+ require.Equal(t, probeStart.Add(probeRttTime), b.exitProbeRttAt)
+
+ probeEnd := probeStart.Add(probeRttTime + time.Millisecond)
+ b.maybeEnterOrExitProbeRtt(probeEnd, true, false)
+ require.EqualValues(t, bbrModeProbeBw, b.mode)
+ require.Equal(t, probeEnd, b.minRttTimestamp)
+}
+
+func TestBBRLossRecoveryConservationGrowthAndExit(t *testing.T) {
+ b := NewBbrSender(DefaultClock{}, 1200, ProfileStandard)
+ b.isAtFullBandwidth = true
+ b.lastSentPacket = 20
+ b.bytesInFlight = 12000
+
+ b.updateRecoveryState(10, true, false)
+ require.EqualValues(t, bbrRecoveryStateConservation, b.recoveryState)
+ require.Equal(t, congestion.PacketNumber(20), b.endRecoveryAt)
+ b.calculateRecoveryWindow(1200, 1200)
+ require.GreaterOrEqual(t, b.recoveryWindow, b.minCongestionWindow)
+
+ b.updateRecoveryState(20, false, true)
+ require.EqualValues(t, bbrRecoveryStateGrowth, b.recoveryState)
+ before := b.recoveryWindow
+ b.calculateRecoveryWindow(1200, 0)
+ require.Greater(t, b.recoveryWindow, before)
+
+ b.updateRecoveryState(21, false, false)
+ require.EqualValues(t, bbrRecoveryStateNotInRecovery, b.recoveryState)
+}
diff --git a/third_party/hysteria-core/internal/congestion/bbr/clock.go b/third_party/hysteria-core/internal/congestion/bbr/clock.go
new file mode 100644
index 0000000..541987e
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/bbr/clock.go
@@ -0,0 +1,18 @@
+package bbr
+
+import "github.com/apernet/quic-go/monotime"
+
+// A Clock returns the current time
+type Clock interface {
+ Now() monotime.Time
+}
+
+// DefaultClock implements the Clock interface using the Go stdlib clock.
+type DefaultClock struct{}
+
+var _ Clock = DefaultClock{}
+
+// Now gets the current time
+func (DefaultClock) Now() monotime.Time {
+ return monotime.Now()
+}
diff --git a/third_party/hysteria-core/internal/congestion/bbr/packet_number_indexed_queue.go b/third_party/hysteria-core/internal/congestion/bbr/packet_number_indexed_queue.go
new file mode 100644
index 0000000..08b99de
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/bbr/packet_number_indexed_queue.go
@@ -0,0 +1,199 @@
+package bbr
+
+import (
+ "github.com/apernet/quic-go/congestion"
+)
+
+// packetNumberIndexedQueue is a queue of mostly continuous numbered entries
+// which supports the following operations:
+// - adding elements to the end of the queue, or at some point past the end
+// - removing elements in any order
+// - retrieving elements
+// If all elements are inserted in order, all of the operations above are
+// amortized O(1) time.
+//
+// Internally, the data structure is a deque where each element is marked as
+// present or not. The deque starts at the lowest present index. Whenever an
+// element is removed, it's marked as not present, and the front of the deque is
+// cleared of elements that are not present.
+//
+// The tail of the queue is not cleared due to the assumption of entries being
+// inserted in order, though removing all elements of the queue will return it
+// to its initial state.
+//
+// Note that this data structure is inherently hazardous, since an addition of
+// just two entries will cause it to consume all of the memory available.
+// Because of that, it is not a general-purpose container and should not be used
+// as one.
+
+type entryWrapper[T any] struct {
+ present bool
+ entry T
+}
+
+type packetNumberIndexedQueue[T any] struct {
+ entries RingBuffer[entryWrapper[T]]
+ numberOfPresentEntries int
+ firstPacket congestion.PacketNumber
+}
+
+func newPacketNumberIndexedQueue[T any](size int) *packetNumberIndexedQueue[T] {
+ q := &packetNumberIndexedQueue[T]{
+ firstPacket: invalidPacketNumber,
+ }
+
+ q.entries.Init(size)
+
+ return q
+}
+
+// Emplace inserts data associated |packet_number| into (or past) the end of the
+// queue, filling up the missing intermediate entries as necessary. Returns
+// true if the element has been inserted successfully, false if it was already
+// in the queue or inserted out of order.
+func (p *packetNumberIndexedQueue[T]) Emplace(packetNumber congestion.PacketNumber, entry *T) bool {
+ if packetNumber == invalidPacketNumber || entry == nil {
+ return false
+ }
+
+ if p.IsEmpty() {
+ p.entries.PushBack(entryWrapper[T]{
+ present: true,
+ entry: *entry,
+ })
+ p.numberOfPresentEntries = 1
+ p.firstPacket = packetNumber
+ return true
+ }
+
+ // Do not allow insertion out-of-order.
+ if packetNumber <= p.LastPacket() {
+ return false
+ }
+
+ // Handle potentially missing elements.
+ offset := int(packetNumber - p.FirstPacket())
+ if gap := offset - p.entries.Len(); gap > 0 {
+ for i := 0; i < gap; i++ {
+ p.entries.PushBack(entryWrapper[T]{})
+ }
+ }
+
+ p.entries.PushBack(entryWrapper[T]{
+ present: true,
+ entry: *entry,
+ })
+ p.numberOfPresentEntries++
+ return true
+}
+
+// GetEntry Retrieve the entry associated with the packet number. Returns the pointer
+// to the entry in case of success, or nullptr if the entry does not exist.
+func (p *packetNumberIndexedQueue[T]) GetEntry(packetNumber congestion.PacketNumber) *T {
+ ew := p.getEntryWraper(packetNumber)
+ if ew == nil {
+ return nil
+ }
+
+ return &ew.entry
+}
+
+// Remove, Same as above, but if an entry is present in the queue, also call f(entry)
+// before removing it.
+func (p *packetNumberIndexedQueue[T]) Remove(packetNumber congestion.PacketNumber, f func(T)) bool {
+ ew := p.getEntryWraper(packetNumber)
+ if ew == nil {
+ return false
+ }
+ if f != nil {
+ f(ew.entry)
+ }
+ ew.present = false
+ p.numberOfPresentEntries--
+
+ if packetNumber == p.FirstPacket() {
+ p.clearup()
+ }
+
+ return true
+}
+
+// RemoveUpTo, but not including |packet_number|.
+// Unused slots in the front are also removed, which means when the function
+// returns, |first_packet()| can be larger than |packet_number|.
+func (p *packetNumberIndexedQueue[T]) RemoveUpTo(packetNumber congestion.PacketNumber) {
+ for !p.entries.Empty() &&
+ p.firstPacket != invalidPacketNumber &&
+ p.firstPacket < packetNumber {
+ if p.entries.Front().present {
+ p.numberOfPresentEntries--
+ }
+ p.entries.PopFront()
+ p.firstPacket++
+ }
+ p.clearup()
+
+ return
+}
+
+// IsEmpty return if queue is empty.
+func (p *packetNumberIndexedQueue[T]) IsEmpty() bool {
+ return p.numberOfPresentEntries == 0
+}
+
+// NumberOfPresentEntries returns the number of entries in the queue.
+func (p *packetNumberIndexedQueue[T]) NumberOfPresentEntries() int {
+ return p.numberOfPresentEntries
+}
+
+// EntrySlotsUsed returns the number of entries allocated in the underlying deque. This is
+// proportional to the memory usage of the queue.
+func (p *packetNumberIndexedQueue[T]) EntrySlotsUsed() int {
+ return p.entries.Len()
+}
+
+// FirstPacket returns packet number of the first entry in the queue.
+func (p *packetNumberIndexedQueue[T]) FirstPacket() (packetNumber congestion.PacketNumber) {
+ return p.firstPacket
+}
+
+// LastPacket returns packet number of the last entry ever inserted in the queue. Note that the
+// entry in question may have already been removed. Zero if the queue is
+// empty.
+func (p *packetNumberIndexedQueue[T]) LastPacket() (packetNumber congestion.PacketNumber) {
+ if p.IsEmpty() {
+ return invalidPacketNumber
+ }
+
+ return p.firstPacket + congestion.PacketNumber(p.entries.Len()-1)
+}
+
+func (p *packetNumberIndexedQueue[T]) clearup() {
+ for !p.entries.Empty() && !p.entries.Front().present {
+ p.entries.PopFront()
+ p.firstPacket++
+ }
+ if p.entries.Empty() {
+ p.firstPacket = invalidPacketNumber
+ }
+}
+
+func (p *packetNumberIndexedQueue[T]) getEntryWraper(packetNumber congestion.PacketNumber) *entryWrapper[T] {
+ if packetNumber == invalidPacketNumber ||
+ p.IsEmpty() ||
+ packetNumber < p.firstPacket {
+ return nil
+ }
+
+ offset := int(packetNumber - p.firstPacket)
+ if offset >= p.entries.Len() {
+ return nil
+ }
+
+ ew := p.entries.Offset(offset)
+ if ew == nil || !ew.present {
+ return nil
+ }
+
+ return ew
+}
diff --git a/third_party/hysteria-core/internal/congestion/bbr/ringbuffer.go b/third_party/hysteria-core/internal/congestion/bbr/ringbuffer.go
new file mode 100644
index 0000000..ed92d4c
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/bbr/ringbuffer.go
@@ -0,0 +1,118 @@
+package bbr
+
+// A RingBuffer is a ring buffer.
+// It acts as a heap that doesn't cause any allocations.
+type RingBuffer[T any] struct {
+ ring []T
+ headPos, tailPos int
+ full bool
+}
+
+// Init preallocs a buffer with a certain size.
+func (r *RingBuffer[T]) Init(size int) {
+ r.ring = make([]T, size)
+}
+
+// Len returns the number of elements in the ring buffer.
+func (r *RingBuffer[T]) Len() int {
+ if r.full {
+ return len(r.ring)
+ }
+ if r.tailPos >= r.headPos {
+ return r.tailPos - r.headPos
+ }
+ return r.tailPos - r.headPos + len(r.ring)
+}
+
+// Empty says if the ring buffer is empty.
+func (r *RingBuffer[T]) Empty() bool {
+ return !r.full && r.headPos == r.tailPos
+}
+
+// PushBack adds a new element.
+// If the ring buffer is full, its capacity is increased first.
+func (r *RingBuffer[T]) PushBack(t T) {
+ if r.full || len(r.ring) == 0 {
+ r.grow()
+ }
+ r.ring[r.tailPos] = t
+ r.tailPos++
+ if r.tailPos == len(r.ring) {
+ r.tailPos = 0
+ }
+ if r.tailPos == r.headPos {
+ r.full = true
+ }
+}
+
+// PopFront returns the next element.
+// It must not be called when the buffer is empty, that means that
+// callers might need to check if there are elements in the buffer first.
+func (r *RingBuffer[T]) PopFront() T {
+ if r.Empty() {
+ panic("github.com/quic-go/quic-go/internal/utils/ringbuffer: pop from an empty queue")
+ }
+ r.full = false
+ t := r.ring[r.headPos]
+ r.ring[r.headPos] = *new(T)
+ r.headPos++
+ if r.headPos == len(r.ring) {
+ r.headPos = 0
+ }
+ return t
+}
+
+// Offset returns the offset element.
+// It must not be called when the buffer is empty, that means that
+// callers might need to check if there are elements in the buffer first
+// and check if the index larger than buffer length.
+func (r *RingBuffer[T]) Offset(index int) *T {
+ if r.Empty() || index >= r.Len() {
+ panic("github.com/quic-go/quic-go/internal/utils/ringbuffer: offset from invalid index")
+ }
+ offset := (r.headPos + index) % len(r.ring)
+ return &r.ring[offset]
+}
+
+// Front returns the front element.
+// It must not be called when the buffer is empty, that means that
+// callers might need to check if there are elements in the buffer first.
+func (r *RingBuffer[T]) Front() *T {
+ if r.Empty() {
+ panic("github.com/quic-go/quic-go/internal/utils/ringbuffer: front from an empty queue")
+ }
+ return &r.ring[r.headPos]
+}
+
+// Back returns the back element.
+// It must not be called when the buffer is empty, that means that
+// callers might need to check if there are elements in the buffer first.
+func (r *RingBuffer[T]) Back() *T {
+ if r.Empty() {
+ panic("github.com/quic-go/quic-go/internal/utils/ringbuffer: back from an empty queue")
+ }
+ return r.Offset(r.Len() - 1)
+}
+
+// Grow the maximum size of the queue.
+// This method assume the queue is full.
+func (r *RingBuffer[T]) grow() {
+ oldRing := r.ring
+ newSize := len(oldRing) * 2
+ if newSize == 0 {
+ newSize = 1
+ }
+ r.ring = make([]T, newSize)
+ headLen := copy(r.ring, oldRing[r.headPos:])
+ copy(r.ring[headLen:], oldRing[:r.headPos])
+ r.headPos, r.tailPos, r.full = 0, len(oldRing), false
+}
+
+// Clear removes all elements.
+func (r *RingBuffer[T]) Clear() {
+ var zeroValue T
+ for i := range r.ring {
+ r.ring[i] = zeroValue
+ }
+ r.headPos, r.tailPos, r.full = 0, 0, false
+}
diff --git a/third_party/hysteria-core/internal/congestion/bbr/windowed_filter.go b/third_party/hysteria-core/internal/congestion/bbr/windowed_filter.go
new file mode 100644
index 0000000..4773bce
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/bbr/windowed_filter.go
@@ -0,0 +1,162 @@
+package bbr
+
+import (
+ "golang.org/x/exp/constraints"
+)
+
+// Implements Kathleen Nichols' algorithm for tracking the minimum (or maximum)
+// estimate of a stream of samples over some fixed time interval. (E.g.,
+// the minimum RTT over the past five minutes.) The algorithm keeps track of
+// the best, second best, and third best min (or max) estimates, maintaining an
+// invariant that the measurement time of the n'th best >= n-1'th best.
+
+// The algorithm works as follows. On a reset, all three estimates are set to
+// the same sample. The second best estimate is then recorded in the second
+// quarter of the window, and a third best estimate is recorded in the second
+// half of the window, bounding the worst case error when the true min is
+// monotonically increasing (or true max is monotonically decreasing) over the
+// window.
+//
+// A new best sample replaces all three estimates, since the new best is lower
+// (or higher) than everything else in the window and it is the most recent.
+// The window thus effectively gets reset on every new min. The same property
+// holds true for second best and third best estimates. Specifically, when a
+// sample arrives that is better than the second best but not better than the
+// best, it replaces the second and third best estimates but not the best
+// estimate. Similarly, a sample that is better than the third best estimate
+// but not the other estimates replaces only the third best estimate.
+//
+// Finally, when the best expires, it is replaced by the second best, which in
+// turn is replaced by the third best. The newest sample replaces the third
+// best.
+
+type WindowedFilterValue interface {
+ any
+}
+
+type WindowedFilterTime interface {
+ constraints.Integer | constraints.Float
+}
+
+type WindowedFilter[V WindowedFilterValue, T WindowedFilterTime] struct {
+ // Time length of window.
+ windowLength T
+ estimates []entry[V, T]
+ comparator func(V, V) int
+}
+
+type entry[V WindowedFilterValue, T WindowedFilterTime] struct {
+ sample V
+ time T
+}
+
+// Compares two values and returns true if the first is greater than or equal
+// to the second.
+func MaxFilter[O constraints.Ordered](a, b O) int {
+ if a > b {
+ return 1
+ } else if a < b {
+ return -1
+ }
+ return 0
+}
+
+// Compares two values and returns true if the first is less than or equal
+// to the second.
+func MinFilter[O constraints.Ordered](a, b O) int {
+ if a < b {
+ return 1
+ } else if a > b {
+ return -1
+ }
+ return 0
+}
+
+func NewWindowedFilter[V WindowedFilterValue, T WindowedFilterTime](windowLength T, comparator func(V, V) int) *WindowedFilter[V, T] {
+ return &WindowedFilter[V, T]{
+ windowLength: windowLength,
+ estimates: make([]entry[V, T], 3, 3),
+ comparator: comparator,
+ }
+}
+
+// Changes the window length. Does not update any current samples.
+func (f *WindowedFilter[V, T]) SetWindowLength(windowLength T) {
+ f.windowLength = windowLength
+}
+
+func (f *WindowedFilter[V, T]) GetBest() V {
+ return f.estimates[0].sample
+}
+
+func (f *WindowedFilter[V, T]) GetSecondBest() V {
+ return f.estimates[1].sample
+}
+
+func (f *WindowedFilter[V, T]) GetThirdBest() V {
+ return f.estimates[2].sample
+}
+
+// Updates best estimates with |sample|, and expires and updates best
+// estimates as necessary.
+func (f *WindowedFilter[V, T]) Update(newSample V, newTime T) {
+ // Reset all estimates if they have not yet been initialized, if new sample
+ // is a new best, or if the newest recorded estimate is too old.
+ if f.comparator(f.estimates[0].sample, *new(V)) == 0 ||
+ f.comparator(newSample, f.estimates[0].sample) >= 0 ||
+ newTime-f.estimates[2].time > f.windowLength {
+ f.Reset(newSample, newTime)
+ return
+ }
+
+ if f.comparator(newSample, f.estimates[1].sample) >= 0 {
+ f.estimates[1] = entry[V, T]{newSample, newTime}
+ f.estimates[2] = f.estimates[1]
+ } else if f.comparator(newSample, f.estimates[2].sample) >= 0 {
+ f.estimates[2] = entry[V, T]{newSample, newTime}
+ }
+
+ // Expire and update estimates as necessary.
+ if newTime-f.estimates[0].time > f.windowLength {
+ // The best estimate hasn't been updated for an entire window, so promote
+ // second and third best estimates.
+ f.estimates[0] = f.estimates[1]
+ f.estimates[1] = f.estimates[2]
+ f.estimates[2] = entry[V, T]{newSample, newTime}
+ // Need to iterate one more time. Check if the new best estimate is
+ // outside the window as well, since it may also have been recorded a
+ // long time ago. Don't need to iterate once more since we cover that
+ // case at the beginning of the method.
+ if newTime-f.estimates[0].time > f.windowLength {
+ f.estimates[0] = f.estimates[1]
+ f.estimates[1] = f.estimates[2]
+ }
+ return
+ }
+ if f.comparator(f.estimates[1].sample, f.estimates[0].sample) == 0 &&
+ newTime-f.estimates[1].time > f.windowLength/4 {
+ // A quarter of the window has passed without a better sample, so the
+ // second-best estimate is taken from the second quarter of the window.
+ f.estimates[1] = entry[V, T]{newSample, newTime}
+ f.estimates[2] = f.estimates[1]
+ return
+ }
+
+ if f.comparator(f.estimates[2].sample, f.estimates[1].sample) == 0 &&
+ newTime-f.estimates[2].time > f.windowLength/2 {
+ // We've passed a half of the window without a better estimate, so take
+ // a third-best estimate from the second half of the window.
+ f.estimates[2] = entry[V, T]{newSample, newTime}
+ }
+}
+
+// Resets all estimates to new sample.
+func (f *WindowedFilter[V, T]) Reset(newSample V, newTime T) {
+ f.estimates[2] = entry[V, T]{newSample, newTime}
+ f.estimates[1] = f.estimates[2]
+ f.estimates[0] = f.estimates[1]
+}
+
+func (f *WindowedFilter[V, T]) Clear() {
+ f.estimates = make([]entry[V, T], 3, 3)
+}
diff --git a/third_party/hysteria-core/internal/congestion/brutal/brutal.go b/third_party/hysteria-core/internal/congestion/brutal/brutal.go
new file mode 100644
index 0000000..ec61bf1
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/brutal/brutal.go
@@ -0,0 +1,193 @@
+package brutal
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/apernet/hysteria/core/v2/internal/congestion/common"
+
+ "github.com/apernet/quic-go/congestion"
+ "github.com/apernet/quic-go/monotime"
+)
+
+const (
+ pktInfoSlotCount = 5 // slot index is based on seconds, so this is basically how many seconds we sample
+ minSampleCount = 50
+ minAckRate = 0.8
+ congestionWindowMultiplier = 2
+
+ debugEnv = "HYSTERIA_BRUTAL_DEBUG"
+ debugPrintInterval = 2
+)
+
+var _ congestion.CongestionControl = &BrutalSender{}
+
+type BrutalSender struct {
+ rttStats congestion.RTTStatsProvider
+ bps congestion.ByteCount
+ maxDatagramSize congestion.ByteCount
+ pacer *common.Pacer
+
+ pktInfoSlots [pktInfoSlotCount]pktInfo
+ ackRate float64
+
+ disableLossCompensation bool
+
+ debug bool
+ lastAckPrintTimestamp int64
+}
+
+type pktInfo struct {
+ Timestamp int64
+ AckCount uint64
+ LossCount uint64
+}
+
+func NewBrutalSender(bps uint64, disableLossCompensation bool) *BrutalSender {
+ debug, _ := strconv.ParseBool(os.Getenv(debugEnv))
+ bs := &BrutalSender{
+ bps: congestion.ByteCount(bps),
+ maxDatagramSize: congestion.InitialPacketSize,
+ ackRate: 1,
+ disableLossCompensation: disableLossCompensation,
+ debug: debug,
+ }
+ bs.pacer = common.NewPacer(func() congestion.ByteCount {
+ return congestion.ByteCount(float64(bs.bps) / bs.ackRate)
+ })
+ return bs
+}
+
+func (b *BrutalSender) SetRTTStatsProvider(rttStats congestion.RTTStatsProvider) {
+ b.rttStats = rttStats
+}
+
+func (b *BrutalSender) TimeUntilSend(bytesInFlight congestion.ByteCount) monotime.Time {
+ return b.pacer.TimeUntilSend()
+}
+
+func (b *BrutalSender) HasPacingBudget(now monotime.Time) bool {
+ return b.pacer.Budget(now) >= b.maxDatagramSize
+}
+
+func (b *BrutalSender) CanSend(bytesInFlight congestion.ByteCount) bool {
+ return bytesInFlight <= b.GetCongestionWindow()
+}
+
+func (b *BrutalSender) GetCongestionWindow() congestion.ByteCount {
+ rtt := b.rttStats.SmoothedRTT()
+ if rtt <= 0 {
+ return 10240
+ }
+ cwnd := congestion.ByteCount(float64(b.bps) * rtt.Seconds() * congestionWindowMultiplier / b.ackRate)
+ if cwnd < b.maxDatagramSize {
+ cwnd = b.maxDatagramSize
+ }
+ return cwnd
+}
+
+func (b *BrutalSender) OnPacketSent(sentTime monotime.Time, bytesInFlight congestion.ByteCount,
+ packetNumber congestion.PacketNumber, bytes congestion.ByteCount, isRetransmittable bool,
+) {
+ b.pacer.SentPacket(sentTime, bytes)
+}
+
+func (b *BrutalSender) OnPacketAcked(number congestion.PacketNumber, ackedBytes congestion.ByteCount,
+ priorInFlight congestion.ByteCount, eventTime monotime.Time,
+) {
+ // Stub
+}
+
+func (b *BrutalSender) OnCongestionEvent(number congestion.PacketNumber, lostBytes congestion.ByteCount,
+ priorInFlight congestion.ByteCount,
+) {
+ // Stub
+}
+
+func (b *BrutalSender) OnCongestionEventEx(priorInFlight congestion.ByteCount, eventTime monotime.Time, ackedPackets []congestion.AckedPacketInfo, lostPackets []congestion.LostPacketInfo) {
+ currentTimestamp := int64(time.Duration(eventTime) / time.Second)
+ slot := currentTimestamp % pktInfoSlotCount
+ if b.pktInfoSlots[slot].Timestamp == currentTimestamp {
+ b.pktInfoSlots[slot].LossCount += uint64(len(lostPackets))
+ b.pktInfoSlots[slot].AckCount += uint64(len(ackedPackets))
+ } else {
+ // uninitialized slot or too old, reset
+ b.pktInfoSlots[slot].Timestamp = currentTimestamp
+ b.pktInfoSlots[slot].AckCount = uint64(len(ackedPackets))
+ b.pktInfoSlots[slot].LossCount = uint64(len(lostPackets))
+ }
+ b.updateAckRate(currentTimestamp)
+}
+
+func (b *BrutalSender) SetMaxDatagramSize(size congestion.ByteCount) {
+ b.maxDatagramSize = size
+ b.pacer.SetMaxDatagramSize(size)
+ if b.debug {
+ b.debugPrint("SetMaxDatagramSize: %d", size)
+ }
+}
+
+func (b *BrutalSender) updateAckRate(currentTimestamp int64) {
+ if b.disableLossCompensation {
+ b.ackRate = 1
+ return
+ }
+ minTimestamp := currentTimestamp - pktInfoSlotCount
+ var ackCount, lossCount uint64
+ for _, info := range b.pktInfoSlots {
+ if info.Timestamp < minTimestamp {
+ continue
+ }
+ ackCount += info.AckCount
+ lossCount += info.LossCount
+ }
+ if ackCount+lossCount < minSampleCount {
+ b.ackRate = 1
+ if b.canPrintAckRate(currentTimestamp) {
+ b.lastAckPrintTimestamp = currentTimestamp
+ b.debugPrint("Not enough samples (total=%d, ack=%d, loss=%d, rtt=%d)",
+ ackCount+lossCount, ackCount, lossCount, b.rttStats.SmoothedRTT().Milliseconds())
+ }
+ return
+ }
+ rate := float64(ackCount) / float64(ackCount+lossCount)
+ if rate < minAckRate {
+ b.ackRate = minAckRate
+ if b.canPrintAckRate(currentTimestamp) {
+ b.lastAckPrintTimestamp = currentTimestamp
+ b.debugPrint("ACK rate too low: %.2f, clamped to %.2f (total=%d, ack=%d, loss=%d, rtt=%d)",
+ rate, minAckRate, ackCount+lossCount, ackCount, lossCount, b.rttStats.SmoothedRTT().Milliseconds())
+ }
+ return
+ }
+ b.ackRate = rate
+ if b.canPrintAckRate(currentTimestamp) {
+ b.lastAckPrintTimestamp = currentTimestamp
+ b.debugPrint("ACK rate: %.2f (total=%d, ack=%d, loss=%d, rtt=%d)",
+ rate, ackCount+lossCount, ackCount, lossCount, b.rttStats.SmoothedRTT().Milliseconds())
+ }
+}
+
+func (b *BrutalSender) InSlowStart() bool {
+ return false
+}
+
+func (b *BrutalSender) InRecovery() bool {
+ return false
+}
+
+func (b *BrutalSender) MaybeExitSlowStart() {}
+
+func (b *BrutalSender) OnRetransmissionTimeout(packetsRetransmitted bool) {}
+
+func (b *BrutalSender) canPrintAckRate(currentTimestamp int64) bool {
+ return b.debug && currentTimestamp-b.lastAckPrintTimestamp >= debugPrintInterval
+}
+
+func (b *BrutalSender) debugPrint(format string, a ...any) {
+ fmt.Printf("[BrutalSender] [%s] %s\n",
+ time.Now().Format("15:04:05"),
+ fmt.Sprintf(format, a...))
+}
diff --git a/third_party/hysteria-core/internal/congestion/brutal/brutal_test.go b/third_party/hysteria-core/internal/congestion/brutal/brutal_test.go
new file mode 100644
index 0000000..2565beb
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/brutal/brutal_test.go
@@ -0,0 +1,45 @@
+package brutal
+
+import (
+ "testing"
+ "time"
+
+ "github.com/apernet/quic-go/congestion"
+ "github.com/apernet/quic-go/monotime"
+)
+
+// feedAckRate drives a single sampling slot with the given number of acked and
+// lost packets and returns the resulting ackRate.
+func feedAckRate(disableLossCompensation bool, ackCount, lossCount int) float64 {
+ b := NewBrutalSender(1000000, disableLossCompensation)
+ acked := make([]congestion.AckedPacketInfo, ackCount)
+ lost := make([]congestion.LostPacketInfo, lossCount)
+ // eventTime lands in a fixed slot; a single event carries enough samples.
+ b.OnCongestionEventEx(0, monotime.Time(5*time.Second), acked, lost)
+ return b.ackRate
+}
+
+func TestBrutalLossCompensation(t *testing.T) {
+ tests := []struct {
+ name string
+ ack, loss int
+ want float64 // expected ackRate when compensation is ENABLED
+ }{
+ {"no loss", 100, 0, 1.0},
+ {"20% loss", 80, 20, 0.8},
+ {"50% loss clamps to floor", 50, 50, minAckRate}, // 0.5 clamped up to 0.8
+ {"few samples stays 1", 10, 5, 1.0}, // below minSampleCount
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Compensation enabled (default behavior): ackRate reacts to loss.
+ if got := feedAckRate(false, tt.ack, tt.loss); got != tt.want {
+ t.Errorf("compensation on: ackRate = %v, want %v", got, tt.want)
+ }
+ // Compensation disabled: ackRate must stay pinned at 1 regardless.
+ if got := feedAckRate(true, tt.ack, tt.loss); got != 1.0 {
+ t.Errorf("compensation off: ackRate = %v, want 1.0", got)
+ }
+ })
+ }
+}
diff --git a/third_party/hysteria-core/internal/congestion/common/pacer.go b/third_party/hysteria-core/internal/congestion/common/pacer.go
new file mode 100644
index 0000000..e0bddbe
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/common/pacer.go
@@ -0,0 +1,80 @@
+package common
+
+import (
+ "time"
+
+ "github.com/apernet/quic-go/congestion"
+ "github.com/apernet/quic-go/monotime"
+)
+
+const (
+ maxBurstPackets = 10
+ maxBurstPacingDelayMultiplier = 4
+)
+
+// Pacer implements a token bucket pacing algorithm.
+type Pacer struct {
+ budgetAtLastSent congestion.ByteCount
+ maxDatagramSize congestion.ByteCount
+ lastSentTime monotime.Time
+ getBandwidth func() congestion.ByteCount // in bytes/s
+}
+
+func NewPacer(getBandwidth func() congestion.ByteCount) *Pacer {
+ p := &Pacer{
+ budgetAtLastSent: maxBurstPackets * congestion.InitialPacketSize,
+ maxDatagramSize: congestion.InitialPacketSize,
+ getBandwidth: getBandwidth,
+ }
+ return p
+}
+
+func (p *Pacer) SentPacket(sendTime monotime.Time, size congestion.ByteCount) {
+ budget := p.Budget(sendTime)
+ if size > budget {
+ p.budgetAtLastSent = 0
+ } else {
+ p.budgetAtLastSent = budget - size
+ }
+ p.lastSentTime = sendTime
+}
+
+func (p *Pacer) Budget(now monotime.Time) congestion.ByteCount {
+ if p.lastSentTime.IsZero() {
+ return p.maxBurstSize()
+ }
+ budget := p.budgetAtLastSent + (p.getBandwidth()*congestion.ByteCount(now.Sub(p.lastSentTime).Nanoseconds()))/1e9
+ if budget < 0 { // protect against overflows
+ budget = congestion.ByteCount(1<<62 - 1)
+ }
+ return min(p.maxBurstSize(), budget)
+}
+
+func (p *Pacer) maxBurstSize() congestion.ByteCount {
+ return max(
+ congestion.ByteCount((maxBurstPacingDelayMultiplier*congestion.MinPacingDelay).Nanoseconds())*p.getBandwidth()/1e9,
+ maxBurstPackets*p.maxDatagramSize,
+ )
+}
+
+// TimeUntilSend returns when the next packet should be sent.
+// It returns the zero value if a packet can be sent immediately.
+func (p *Pacer) TimeUntilSend() monotime.Time {
+ if p.budgetAtLastSent >= p.maxDatagramSize {
+ return 0
+ }
+ diff := 1e9 * uint64(p.maxDatagramSize-p.budgetAtLastSent)
+ bw := uint64(p.getBandwidth())
+ // We might need to round up this value.
+ // Otherwise, we might have a budget (slightly) smaller than the datagram size when the timer expires.
+ d := diff / bw
+ // this is effectively a math.Ceil, but using only integer math
+ if diff%bw > 0 {
+ d++
+ }
+ return p.lastSentTime.Add(max(congestion.MinPacingDelay, time.Duration(d)*time.Nanosecond))
+}
+
+func (p *Pacer) SetMaxDatagramSize(s congestion.ByteCount) {
+ p.maxDatagramSize = s
+}
diff --git a/third_party/hysteria-core/internal/congestion/utils.go b/third_party/hysteria-core/internal/congestion/utils.go
new file mode 100644
index 0000000..1d72811
--- /dev/null
+++ b/third_party/hysteria-core/internal/congestion/utils.go
@@ -0,0 +1,72 @@
+package congestion
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/apernet/hysteria/core/v2/internal/congestion/bbr"
+ "github.com/apernet/hysteria/core/v2/internal/congestion/brutal"
+ "github.com/apernet/quic-go"
+ "github.com/apernet/quic-go/congestion"
+)
+
+const (
+ TypeBBR = "bbr"
+ TypeReno = "reno"
+)
+
+func NormalizeType(congestionType string) (string, error) {
+ switch normalized := strings.ToLower(congestionType); normalized {
+ case "", TypeBBR:
+ return TypeBBR, nil
+ case TypeReno:
+ return TypeReno, nil
+ default:
+ return "", fmt.Errorf("unsupported congestion type %q", congestionType)
+ }
+}
+
+func NormalizeBBRProfile(profile string) (string, error) {
+ normalized, err := bbr.ParseProfile(profile)
+ if err != nil {
+ return "", err
+ }
+ return string(normalized), nil
+}
+
+func UseBBR(conn *quic.Conn, profile bbr.Profile) {
+ conn.SetCongestionControl(bbr.NewBbrSender(
+ bbr.DefaultClock{},
+ seedPacketSize(conn.InitialPacketSize(), bbr.GetInitialPacketSize(conn.RemoteAddr())),
+ profile,
+ ))
+}
+
+// seedPacketSize picks the datagram size to seed a replacement congestion
+// controller with, given the size QUIC itself starts at and the guess derived
+// from the remote address.
+//
+// The seed must not exceed what QUIC actually starts at. If it does, the first
+// path MTU probe can land between the two: QUIC sees an increase and reports
+// it, but the controller sees a decrease, which it cannot represent. Taking the
+// smaller of the two keeps the address-based guess as a floor for connections
+// whose path we can't reason about, while never seeding above QUIC.
+func seedPacketSize(quicSize, byAddr congestion.ByteCount) congestion.ByteCount {
+ if quicSize <= 0 {
+ return byAddr
+ }
+ return min(quicSize, byAddr)
+}
+
+func UseBrutal(conn *quic.Conn, tx uint64, disableLossCompensation bool) {
+ conn.SetCongestionControl(brutal.NewBrutalSender(tx, disableLossCompensation))
+}
+
+func UseConfigured(conn *quic.Conn, congestionType, bbrProfile string) {
+ switch congestionType {
+ case TypeReno:
+ return
+ default:
+ UseBBR(conn, bbr.Profile(bbrProfile))
+ }
+}
diff --git a/third_party/hysteria-core/internal/frag/frag.go b/third_party/hysteria-core/internal/frag/frag.go
new file mode 100644
index 0000000..e7520d9
--- /dev/null
+++ b/third_party/hysteria-core/internal/frag/frag.go
@@ -0,0 +1,110 @@
+package frag
+
+import (
+ "errors"
+
+ "github.com/apernet/hysteria/core/v2/internal/protocol"
+)
+
+// MaxFragments bounds attacker-controlled defragmentation state. A maximum
+// Hysteria UDP payload is 4096 bytes, so 16 fragments leaves ample headroom
+// above the normal four-to-five fragments while preventing 255-slot abuse.
+const MaxFragments = 16
+
+// ErrFragmentationLimit reports that a datagram cannot fit within the bounded
+// fragment count. Callers must surface this instead of silently sending zero
+// fragments.
+var ErrFragmentationLimit = errors.New("UDP message exceeds fragmentation limit")
+
+func FragUDPMessage(m *protocol.UDPMessage, maxSize int) []protocol.UDPMessage {
+ if m.Size() <= maxSize {
+ return []protocol.UDPMessage{*m}
+ }
+ fullPayload := m.Data
+ maxPayloadSize := maxSize - m.HeaderSize()
+ if maxPayloadSize <= 0 {
+ return nil
+ }
+ off := 0
+ fragID := uint8(0)
+ count := (len(fullPayload) + maxPayloadSize - 1) / maxPayloadSize // round up
+ if count > MaxFragments {
+ return nil
+ }
+ fragCount := uint8(count)
+ frags := make([]protocol.UDPMessage, fragCount)
+ for off < len(fullPayload) {
+ payloadSize := len(fullPayload) - off
+ if payloadSize > maxPayloadSize {
+ payloadSize = maxPayloadSize
+ }
+ frag := *m
+ frag.FragID = fragID
+ frag.FragCount = fragCount
+ frag.Data = fullPayload[off : off+payloadSize]
+ frags[fragID] = frag
+ off += payloadSize
+ fragID++
+ }
+ return frags
+}
+
+// Defragger handles the defragmentation of UDP messages.
+// The current implementation can only handle one packet ID at a time.
+// If another packet arrives before a packet has received all fragments
+// in their entirety, any previous state is discarded.
+type Defragger struct {
+ pktID uint16
+ frags []*protocol.UDPMessage
+ count uint8
+ size int // data size
+}
+
+func (d *Defragger) Feed(m *protocol.UDPMessage) *protocol.UDPMessage {
+ if m.FragCount <= 1 {
+ if len(m.Data) > protocol.MaxUDPSize {
+ return nil
+ }
+ return m
+ }
+ if m.FragID >= m.FragCount || m.FragCount > MaxFragments {
+ // wtf is this?
+ return nil
+ }
+ if m.PacketID != d.pktID || m.FragCount != uint8(len(d.frags)) {
+ // new message, clear previous state
+ d.pktID = m.PacketID
+ d.frags = make([]*protocol.UDPMessage, m.FragCount)
+ d.frags[m.FragID] = m
+ d.count = 1
+ d.size = len(m.Data)
+ if d.size > protocol.MaxUDPSize {
+ d.frags = nil
+ d.count = 0
+ d.size = 0
+ }
+ } else if d.frags[m.FragID] == nil {
+ if d.size+len(m.Data) > protocol.MaxUDPSize {
+ d.frags = nil
+ d.count = 0
+ d.size = 0
+ return nil
+ }
+ d.frags[m.FragID] = m
+ d.count++
+ d.size += len(m.Data)
+ if int(d.count) == len(d.frags) {
+ // all fragments received, assemble
+ data := make([]byte, d.size)
+ off := 0
+ for _, frag := range d.frags {
+ off += copy(data[off:], frag.Data)
+ }
+ m.Data = data
+ m.FragID = 0
+ m.FragCount = 1
+ return m
+ }
+ }
+ return nil
+}
diff --git a/third_party/hysteria-core/internal/frag/frag_test.go b/third_party/hysteria-core/internal/frag/frag_test.go
new file mode 100644
index 0000000..3f2eb1d
--- /dev/null
+++ b/third_party/hysteria-core/internal/frag/frag_test.go
@@ -0,0 +1,385 @@
+package frag
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/apernet/hysteria/core/v2/internal/protocol"
+)
+
+func TestFragUDPMessage(t *testing.T) {
+ type args struct {
+ m *protocol.UDPMessage
+ maxSize int
+ }
+ tests := []struct {
+ name string
+ args args
+ want []protocol.UDPMessage
+ }{
+ {
+ "no frag",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 123,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "test:123",
+ Data: []byte("hello"),
+ },
+ 100,
+ },
+ []protocol.UDPMessage{
+ {
+ SessionID: 123,
+ PacketID: 123,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "test:123",
+ Data: []byte("hello"),
+ },
+ },
+ },
+ {
+ "2 frags",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 123,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "test:123",
+ Data: []byte("hello"),
+ },
+ 20,
+ },
+ []protocol.UDPMessage{
+ {
+ SessionID: 123,
+ PacketID: 123,
+ FragID: 0,
+ FragCount: 2,
+ Addr: "test:123",
+ Data: []byte("hel"),
+ },
+ {
+ SessionID: 123,
+ PacketID: 123,
+ FragID: 1,
+ FragCount: 2,
+ Addr: "test:123",
+ Data: []byte("lo"),
+ },
+ },
+ },
+ {
+ "4 frags",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 123,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "test:123",
+ Data: []byte("abcdefgh"),
+ },
+ 19,
+ },
+ []protocol.UDPMessage{
+ {
+ SessionID: 123,
+ PacketID: 123,
+ FragID: 0,
+ FragCount: 4,
+ Addr: "test:123",
+ Data: []byte("ab"),
+ },
+ {
+ SessionID: 123,
+ PacketID: 123,
+ FragID: 1,
+ FragCount: 4,
+ Addr: "test:123",
+ Data: []byte("cd"),
+ },
+ {
+ SessionID: 123,
+ PacketID: 123,
+ FragID: 2,
+ FragCount: 4,
+ Addr: "test:123",
+ Data: []byte("ef"),
+ },
+ {
+ SessionID: 123,
+ PacketID: 123,
+ FragID: 3,
+ FragCount: 4,
+ Addr: "test:123",
+ Data: []byte("gh"),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := FragUDPMessage(tt.args.m, tt.args.maxSize); !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("FragUDPMessage() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDefragger(t *testing.T) {
+ type args struct {
+ m *protocol.UDPMessage
+ }
+ tests := []struct {
+ name string
+ args args
+ want *protocol.UDPMessage
+ }{
+ {
+ "no frag",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 987,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "test:123",
+ Data: []byte("hello"),
+ },
+ },
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 987,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "test:123",
+ Data: []byte("hello"),
+ },
+ },
+ {
+ "frag 0 - 1/2",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 987,
+ FragID: 0,
+ FragCount: 2,
+ Addr: "test:123",
+ Data: []byte("hello "),
+ },
+ },
+ nil,
+ },
+ {
+ "frag 0 - 2/2",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 987,
+ FragID: 1,
+ FragCount: 2,
+ Addr: "test:123",
+ Data: []byte("moto"),
+ },
+ },
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 987,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "test:123",
+ Data: []byte("hello moto"),
+ },
+ },
+ {
+ "frag 1 - 1/3",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 987,
+ FragID: 0,
+ FragCount: 3,
+ Addr: "test:123",
+ Data: []byte("deco"),
+ },
+ },
+ nil,
+ },
+ {
+ "frag 1 - 2/3",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 987,
+ FragID: 1,
+ FragCount: 3,
+ Addr: "test:123",
+ Data: []byte("*"),
+ },
+ },
+ nil,
+ },
+ {
+ "frag 1 - 3/3",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 987,
+ FragID: 2,
+ FragCount: 3,
+ Addr: "test:123",
+ Data: []byte("27"),
+ },
+ },
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 987,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "test:123",
+ Data: []byte("deco*27"),
+ },
+ },
+ {
+ "frag 2 - 1/2",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 233,
+ FragID: 1,
+ FragCount: 2,
+ Addr: "test:123",
+ Data: []byte("shinsekai"),
+ },
+ },
+ nil,
+ },
+ {
+ "frag 3 - 2/2",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 244,
+ FragID: 1,
+ FragCount: 2,
+ Addr: "test:123",
+ Data: []byte("what???"),
+ },
+ },
+ nil,
+ },
+ {
+ "frag 2 - 2/2",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 233,
+ FragID: 1,
+ FragCount: 2,
+ Addr: "test:123",
+ Data: []byte(" annaijo"),
+ },
+ },
+ nil,
+ },
+ {
+ "invalid id",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 233,
+ FragID: 88,
+ FragCount: 2,
+ Addr: "test:123",
+ Data: []byte("shinsekai"),
+ },
+ },
+ nil,
+ },
+ {
+ "frag 2 - 1/2 re",
+ args{
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 233,
+ FragID: 0,
+ FragCount: 2,
+ Addr: "test:123",
+ Data: []byte("shinsekai"),
+ },
+ },
+ &protocol.UDPMessage{
+ SessionID: 123,
+ PacketID: 233,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "test:123",
+ Data: []byte("shinsekai annaijo"),
+ },
+ },
+ }
+
+ d := &Defragger{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := d.Feed(tt.args.m); !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("Feed() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDefraggerBoundsAttackerControlledState(t *testing.T) {
+ d := &Defragger{}
+ if got := d.Feed(&protocol.UDPMessage{
+ SessionID: 1,
+ PacketID: 1,
+ FragID: 0,
+ FragCount: MaxFragments + 1,
+ Data: []byte("partial"),
+ }); got != nil {
+ t.Fatal("excessive fragment count was accepted")
+ }
+ if len(d.frags) != 0 {
+ t.Fatalf("excessive fragment count allocated %d slots", len(d.frags))
+ }
+
+ first := make([]byte, protocol.MaxUDPSize-1)
+ if got := d.Feed(&protocol.UDPMessage{
+ SessionID: 2,
+ PacketID: 2,
+ FragID: 0,
+ FragCount: 2,
+ Data: first,
+ }); got != nil {
+ t.Fatal("incomplete message unexpectedly assembled")
+ }
+ if got := d.Feed(&protocol.UDPMessage{
+ SessionID: 2,
+ PacketID: 2,
+ FragID: 1,
+ FragCount: 2,
+ Data: []byte("too large"),
+ }); got != nil {
+ t.Fatal("oversized reassembled message was accepted")
+ }
+ if len(d.frags) != 0 || d.size != 0 || d.count != 0 {
+ t.Fatalf("oversized state was retained: fragments=%d size=%d count=%d", len(d.frags), d.size, d.count)
+ }
+}
+
+func TestFragUDPMessageRejectsExcessiveFragmentCount(t *testing.T) {
+ message := &protocol.UDPMessage{Data: make([]byte, protocol.MaxUDPSize)}
+ // A deliberately tiny payload budget used to overflow uint8 when the
+ // fragment count was calculated before validation.
+ maxSize := message.HeaderSize() + 1
+ if got := FragUDPMessage(message, maxSize); got != nil {
+ t.Fatalf("created %d fragments, want bounded rejection", len(got))
+ }
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/.mockery.yaml b/third_party/hysteria-core/internal/integration_tests/.mockery.yaml
new file mode 100644
index 0000000..550a725
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/.mockery.yaml
@@ -0,0 +1,29 @@
+with-expecter: true
+dir: mocks
+outpkg: mocks
+packages:
+ net:
+ interfaces:
+ Conn:
+ config:
+ mockname: MockConn
+ github.com/apernet/hysteria/core/v2/server:
+ interfaces:
+ Outbound:
+ config:
+ mockname: MockOutbound
+ UDPConn:
+ config:
+ mockname: MockUDPConn
+ Authenticator:
+ config:
+ mockname: MockAuthenticator
+ EventLogger:
+ config:
+ mockname: MockEventLogger
+ TrafficLogger:
+ config:
+ mockname: MockTrafficLogger
+ RequestHook:
+ config:
+ mockname: MockRequestHook
\ No newline at end of file
diff --git a/third_party/hysteria-core/internal/integration_tests/chrome_parrot_test.go b/third_party/hysteria-core/internal/integration_tests/chrome_parrot_test.go
new file mode 100644
index 0000000..ea57b0f
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/chrome_parrot_test.go
@@ -0,0 +1,70 @@
+package integration_tests
+
+import (
+ "io"
+ "net"
+ "testing"
+
+ "github.com/apernet/hysteria/core/v2/client"
+ "github.com/apernet/hysteria/core/v2/internal/integration_tests/mocks"
+ "github.com/apernet/hysteria/core/v2/server"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+// TestClientServerChromeParrot runs a real Hysteria client/server pair both with
+// the Chrome handshake fingerprint (the default) and with it turned off, proving
+// each works end to end through Hysteria's own config plumbing rather than only
+// at the quic-go layer.
+func TestClientServerChromeParrot(t *testing.T) {
+ tests := []struct {
+ name string
+ quicConfig client.QUICConfig
+ }{
+ {"default", client.QUICConfig{}},
+ {"disabled", client.QUICConfig{DisableChromeParrot: true}},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ echoAddr := "127.0.0.1:22444"
+ echoListener, err := net.Listen("tcp", echoAddr)
+ assert.NoError(t, err)
+ echoServer := &tcpEchoServer{Listener: echoListener}
+ defer echoServer.Close()
+ go echoServer.Serve()
+
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ QUICConfig: test.quicConfig,
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ conn, err := c.TCP(echoAddr)
+ assert.NoError(t, err)
+ defer conn.Close()
+
+ sData := []byte("hello from a chrome-shaped handshake")
+ _, err = conn.Write(sData)
+ assert.NoError(t, err)
+ rData := make([]byte, len(sData))
+ _, err = io.ReadFull(conn, rData)
+ assert.NoError(t, err)
+ assert.Equal(t, sData, rData)
+ })
+ }
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/close_test.go b/third_party/hysteria-core/internal/integration_tests/close_test.go
new file mode 100644
index 0000000..ac7f84b
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/close_test.go
@@ -0,0 +1,252 @@
+package integration_tests
+
+import (
+ "io"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+
+ "github.com/apernet/hysteria/core/v2/client"
+ "github.com/apernet/hysteria/core/v2/errors"
+ "github.com/apernet/hysteria/core/v2/internal/integration_tests/mocks"
+ "github.com/apernet/hysteria/core/v2/server"
+)
+
+// TestClientServerTCPClose tests whether the client/server propagates the close of a connection correctly.
+// Closing one side of the connection should close the other side as well.
+func TestClientServerTCPClose(t *testing.T) {
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ serverOb := mocks.NewMockOutbound(t)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Outbound: serverOb,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ addr := "hi-and-goodbye:2333"
+
+ // Test close from client side:
+ // Client creates a connection, writes something, then closes it.
+ // Server outbound connection should write the same thing, then close.
+ sobConn := mocks.NewMockConn(t)
+ sobConnCh := make(chan struct{}) // For close signal only
+ sobConnChCloseFunc := sync.OnceFunc(func() { close(sobConnCh) })
+ sobConn.EXPECT().Read(mock.Anything).RunAndReturn(func(bs []byte) (int, error) {
+ <-sobConnCh
+ return 0, io.EOF
+ })
+ sobConn.EXPECT().Write([]byte("happy")).Return(5, nil)
+ sobConn.EXPECT().Close().RunAndReturn(func() error {
+ sobConnChCloseFunc()
+ return nil
+ })
+ serverOb.EXPECT().TCP(addr).Return(sobConn, nil).Once()
+ conn, err := c.TCP(addr)
+ assert.NoError(t, err)
+ _, err = conn.Write([]byte("happy"))
+ assert.NoError(t, err)
+ err = conn.Close()
+ assert.NoError(t, err)
+ time.Sleep(1 * time.Second)
+ mock.AssertExpectationsForObjects(t, sobConn, serverOb)
+
+ // Test close from server side:
+ // Client creates a connection.
+ // Server outbound connection reads something, then closes.
+ // Client connection should read the same thing, then close.
+ sobConn = mocks.NewMockConn(t)
+ sobConnCh2 := make(chan []byte, 1)
+ sobConn.EXPECT().Read(mock.Anything).RunAndReturn(func(bs []byte) (int, error) {
+ d := <-sobConnCh2
+ if d == nil {
+ return 0, io.EOF
+ } else {
+ return copy(bs, d), nil
+ }
+ })
+ sobConn.EXPECT().Close().Return(nil)
+ serverOb.EXPECT().TCP(addr).Return(sobConn, nil).Once()
+ conn, err = c.TCP(addr)
+ assert.NoError(t, err)
+ sobConnCh2 <- []byte("happy")
+ close(sobConnCh2)
+ bs, err := io.ReadAll(conn)
+ assert.NoError(t, err)
+ assert.Equal(t, "happy", string(bs))
+}
+
+// TestClientServerUDPIdleTimeout tests whether the server's UDP idle timeout works correctly.
+func TestClientServerUDPIdleTimeout(t *testing.T) {
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ serverOb := mocks.NewMockOutbound(t)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ eventLogger := mocks.NewMockEventLogger(t)
+ eventLogger.EXPECT().Connect(mock.Anything, "nobody", mock.Anything).Once()
+ eventLogger.EXPECT().Disconnect(mock.Anything, "nobody", mock.Anything).Maybe() // Depends on the timing, don't care
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Outbound: serverOb,
+ UDPIdleTimeout: 2 * time.Second,
+ Authenticator: auth,
+ EventLogger: eventLogger,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ addr := "spy.x.family:2023"
+
+ // On the client side, create a UDP session and send a packet every 1 second,
+ // 4 packets in total. The server should have one UDP session and receive all
+ // 4 packets. Then the UDP connection on the server side will receive a packet
+ // every 1 second, 4 packets in total. The client session should receive all
+ // 4 packets. Then the session will be idle for 3 seconds - should be enough
+ // to trigger the server's UDP idle timeout.
+ sobConn := mocks.NewMockUDPConn(t)
+ sobConnCh := make(chan []byte, 1)
+ sobConnChCloseFunc := sync.OnceFunc(func() { close(sobConnCh) })
+ sobConn.EXPECT().ReadFrom(mock.Anything).RunAndReturn(func(bs []byte) (int, string, error) {
+ d := <-sobConnCh
+ if d == nil {
+ return 0, "", io.EOF
+ } else {
+ return copy(bs, d), addr, nil
+ }
+ })
+ sobConn.EXPECT().WriteTo([]byte("happy"), addr).Return(5, nil).Times(4)
+ serverOb.EXPECT().UDP(addr).Return(sobConn, nil).Once()
+ eventLogger.EXPECT().UDPRequest(mock.Anything, mock.Anything, uint32(1), addr).Once()
+ cu, err := c.UDP()
+ assert.NoError(t, err)
+ // Client sends 4 packets
+ for i := 0; i < 4; i++ {
+ err = cu.Send([]byte("happy"), addr)
+ assert.NoError(t, err)
+ time.Sleep(1 * time.Second)
+ }
+ // Client receives 4 packets
+ go func() {
+ for i := 0; i < 4; i++ {
+ sobConnCh <- []byte("sad")
+ time.Sleep(1 * time.Second)
+ }
+ }()
+ for i := 0; i < 4; i++ {
+ bs, rAddr, err := cu.Receive()
+ assert.NoError(t, err)
+ assert.Equal(t, "sad", string(bs))
+ assert.Equal(t, addr, rAddr)
+ }
+ // Now we wait for 3 seconds, the server should close the UDP session.
+ sobConn.EXPECT().Close().RunAndReturn(func() error {
+ sobConnChCloseFunc()
+ return nil
+ })
+ eventLogger.EXPECT().UDPError(mock.Anything, mock.Anything, uint32(1), nil).Once()
+ time.Sleep(3 * time.Second)
+}
+
+// TestClientServerClientShutdown tests whether the server can handle the client's shutdown correctly.
+func TestClientServerClientShutdown(t *testing.T) {
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ eventLogger := mocks.NewMockEventLogger(t)
+ eventLogger.EXPECT().Connect(mock.Anything, "nobody", mock.Anything).Once()
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Authenticator: auth,
+ EventLogger: eventLogger,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+
+ // Close the client - expect disconnect event on the server side.
+ // Since client.Close() sends HTTP3 ErrCodeNoError, the error should be nil.
+ eventLogger.EXPECT().Disconnect(mock.Anything, "nobody", nil).Once()
+ _ = c.Close()
+ time.Sleep(1 * time.Second)
+}
+
+// TestClientServerServerShutdown tests whether the client can handle the server's shutdown correctly.
+func TestClientServerServerShutdown(t *testing.T) {
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ go s.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ QUICConfig: client.QUICConfig{
+ MaxIdleTimeout: 4 * time.Second,
+ },
+ })
+ assert.NoError(t, err)
+
+ // Close the server - expect the client to return ClosedError for both TCP & UDP calls.
+ _ = s.Close()
+
+ _, err = c.TCP("whatever")
+ _, ok := err.(errors.ClosedError)
+ assert.True(t, ok)
+
+ time.Sleep(1 * time.Second) // Allow some time for the error to be propagated to the UDP session manager
+
+ _, err = c.UDP()
+ _, ok = err.(errors.ClosedError)
+ assert.True(t, ok)
+
+ assert.NoError(t, c.Close())
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/hook_test.go b/third_party/hysteria-core/internal/integration_tests/hook_test.go
new file mode 100644
index 0000000..db95995
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/hook_test.go
@@ -0,0 +1,146 @@
+package integration_tests
+
+import (
+ "io"
+ "net"
+ "testing"
+
+ "github.com/apernet/hysteria/core/v2/client"
+ "github.com/apernet/hysteria/core/v2/internal/integration_tests/mocks"
+ "github.com/apernet/hysteria/core/v2/server"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestClientServerHookTCP(t *testing.T) {
+ fakeEchoAddr := "hahanope:6666"
+ realEchoAddr := "127.0.0.1:22333"
+
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ hook := mocks.NewMockRequestHook(t)
+ hook.EXPECT().Check(false, fakeEchoAddr).Return(true).Once()
+ hook.EXPECT().TCP(mock.Anything, mock.Anything).RunAndReturn(func(stream server.HyStream, s *string) ([]byte, error) {
+ assert.Equal(t, fakeEchoAddr, *s)
+ // Change the address
+ *s = realEchoAddr
+ // Read the first 5 bytes and replace them with "byeee"
+ data := make([]byte, 5)
+ _, err := io.ReadFull(stream, data)
+ if err != nil {
+ return nil, err
+ }
+ assert.Equal(t, []byte("hello"), data)
+ return []byte("byeee"), nil
+ }).Once()
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ RequestHook: hook,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create TCP echo server
+ echoListener, err := net.Listen("tcp", realEchoAddr)
+ assert.NoError(t, err)
+ echoServer := &tcpEchoServer{Listener: echoListener}
+ defer echoServer.Close()
+ go echoServer.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ // Dial TCP
+ conn, err := c.TCP(fakeEchoAddr)
+ assert.NoError(t, err)
+ defer conn.Close()
+
+ // Send and receive data
+ sData := []byte("hello world")
+ _, err = conn.Write(sData)
+ assert.NoError(t, err)
+ rData := make([]byte, len(sData))
+ _, err = io.ReadFull(conn, rData)
+ assert.NoError(t, err)
+ assert.Equal(t, []byte("byeee world"), rData)
+}
+
+func TestClientServerHookUDP(t *testing.T) {
+ fakeEchoAddr := "hahanope:6666"
+ realEchoAddr := "127.0.0.1:22333"
+
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ hook := mocks.NewMockRequestHook(t)
+ hook.EXPECT().Check(true, fakeEchoAddr).Return(true).Once()
+ hook.EXPECT().UDP(mock.Anything, mock.Anything).RunAndReturn(func(bytes []byte, s *string) error {
+ assert.Equal(t, fakeEchoAddr, *s)
+ assert.Equal(t, []byte("hello world"), bytes)
+ // Change the address
+ *s = realEchoAddr
+ return nil
+ }).Once()
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ RequestHook: hook,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create UDP echo server
+ echoConn, err := net.ListenPacket("udp", realEchoAddr)
+ assert.NoError(t, err)
+ echoServer := &udpEchoServer{Conn: echoConn}
+ defer echoServer.Close()
+ go echoServer.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ // Listen UDP
+ conn, err := c.UDP()
+ assert.NoError(t, err)
+ defer conn.Close()
+
+ // Send and receive data
+ sData := []byte("hello world")
+ err = conn.Send(sData, fakeEchoAddr)
+ assert.NoError(t, err)
+ rData, rAddr, err := conn.Receive()
+ assert.NoError(t, err)
+ assert.Equal(t, sData, rData)
+ // Hook address change is transparent,
+ // the client should still see the fake echo address it sent packets to
+ assert.Equal(t, fakeEchoAddr, rAddr)
+
+ // Subsequent packets should also be sent to the real echo server
+ sData = []byte("never stop fighting")
+ err = conn.Send(sData, fakeEchoAddr)
+ assert.NoError(t, err)
+ rData, rAddr, err = conn.Receive()
+ assert.NoError(t, err)
+ assert.Equal(t, sData, rData)
+ assert.Equal(t, fakeEchoAddr, rAddr)
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/masq_test.go b/third_party/hysteria-core/internal/integration_tests/masq_test.go
new file mode 100644
index 0000000..584e2f1
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/masq_test.go
@@ -0,0 +1,93 @@
+package integration_tests
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+ "net/http"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+
+ "github.com/apernet/hysteria/core/v2/internal/integration_tests/mocks"
+ "github.com/apernet/hysteria/core/v2/internal/protocol"
+ "github.com/apernet/hysteria/core/v2/server"
+
+ "github.com/apernet/quic-go"
+ "github.com/apernet/quic-go/http3"
+)
+
+// TestServerMasquerade is a test to ensure that the server behaves as a normal
+// HTTP/3 server when dealing with an unauthenticated client. This is mainly to
+// confirm that the server does not expose itself to active probing.
+func TestServerMasquerade(t *testing.T) {
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, "", uint64(0)).Return(false, "").Once()
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // QUIC connection & RoundTripper
+ var conn *quic.Conn
+ rt := &http3.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ Dial: func(ctx context.Context, _ string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
+ qc, err := quic.DialAddrEarly(ctx, udpAddr.String(), tlsCfg, cfg)
+ if err != nil {
+ return nil, err
+ }
+ conn = qc
+ return qc, nil
+ },
+ }
+ defer rt.Close() // This will close the QUIC connection
+
+ // Send the bogus request
+ // We expect 404 (from the default handler)
+ req := &http.Request{
+ Method: http.MethodPost,
+ URL: &url.URL{
+ Scheme: "https",
+ Host: protocol.URLHost,
+ Path: protocol.URLPath,
+ },
+ Header: make(http.Header),
+ }
+ resp, err := rt.RoundTrip(req)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
+ for k := range resp.Header {
+ // Make sure no strange headers are sent by the server
+ assert.NotContains(t, k, "Hysteria")
+ }
+
+ buf := make([]byte, 1024)
+
+ // We send a TCP request anyway, see if we get a response
+ tcpStream, err := conn.OpenStream()
+ assert.NoError(t, err)
+ defer tcpStream.Close()
+ err = protocol.WriteTCPRequest(tcpStream, "www.google.com:443")
+ assert.NoError(t, err)
+
+ // We should receive nothing
+ _ = tcpStream.SetReadDeadline(time.Now().Add(2 * time.Second))
+ n, err := tcpStream.Read(buf)
+ assert.Equal(t, 0, n)
+ nErr, ok := err.(net.Error)
+ assert.True(t, ok)
+ assert.True(t, nErr.Timeout())
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/mocks/mock_Authenticator.go b/third_party/hysteria-core/internal/integration_tests/mocks/mock_Authenticator.go
new file mode 100644
index 0000000..018b499
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/mocks/mock_Authenticator.go
@@ -0,0 +1,94 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package mocks
+
+import (
+ net "net"
+
+ mock "github.com/stretchr/testify/mock"
+)
+
+// MockAuthenticator is an autogenerated mock type for the Authenticator type
+type MockAuthenticator struct {
+ mock.Mock
+}
+
+type MockAuthenticator_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockAuthenticator) EXPECT() *MockAuthenticator_Expecter {
+ return &MockAuthenticator_Expecter{mock: &_m.Mock}
+}
+
+// Authenticate provides a mock function with given fields: addr, auth, tx
+func (_m *MockAuthenticator) Authenticate(addr net.Addr, auth string, tx uint64) (bool, string) {
+ ret := _m.Called(addr, auth, tx)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Authenticate")
+ }
+
+ var r0 bool
+ var r1 string
+ if rf, ok := ret.Get(0).(func(net.Addr, string, uint64) (bool, string)); ok {
+ return rf(addr, auth, tx)
+ }
+ if rf, ok := ret.Get(0).(func(net.Addr, string, uint64) bool); ok {
+ r0 = rf(addr, auth, tx)
+ } else {
+ r0 = ret.Get(0).(bool)
+ }
+
+ if rf, ok := ret.Get(1).(func(net.Addr, string, uint64) string); ok {
+ r1 = rf(addr, auth, tx)
+ } else {
+ r1 = ret.Get(1).(string)
+ }
+
+ return r0, r1
+}
+
+// MockAuthenticator_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate'
+type MockAuthenticator_Authenticate_Call struct {
+ *mock.Call
+}
+
+// Authenticate is a helper method to define mock.On call
+// - addr net.Addr
+// - auth string
+// - tx uint64
+func (_e *MockAuthenticator_Expecter) Authenticate(addr interface{}, auth interface{}, tx interface{}) *MockAuthenticator_Authenticate_Call {
+ return &MockAuthenticator_Authenticate_Call{Call: _e.mock.On("Authenticate", addr, auth, tx)}
+}
+
+func (_c *MockAuthenticator_Authenticate_Call) Run(run func(addr net.Addr, auth string, tx uint64)) *MockAuthenticator_Authenticate_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(net.Addr), args[1].(string), args[2].(uint64))
+ })
+ return _c
+}
+
+func (_c *MockAuthenticator_Authenticate_Call) Return(ok bool, id string) *MockAuthenticator_Authenticate_Call {
+ _c.Call.Return(ok, id)
+ return _c
+}
+
+func (_c *MockAuthenticator_Authenticate_Call) RunAndReturn(run func(net.Addr, string, uint64) (bool, string)) *MockAuthenticator_Authenticate_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockAuthenticator creates a new instance of MockAuthenticator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockAuthenticator(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockAuthenticator {
+ mock := &MockAuthenticator{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/mocks/mock_Conn.go b/third_party/hysteria-core/internal/integration_tests/mocks/mock_Conn.go
new file mode 100644
index 0000000..d068033
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/mocks/mock_Conn.go
@@ -0,0 +1,426 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package mocks
+
+import (
+ net "net"
+ time "time"
+
+ mock "github.com/stretchr/testify/mock"
+)
+
+// MockConn is an autogenerated mock type for the Conn type
+type MockConn struct {
+ mock.Mock
+}
+
+type MockConn_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockConn) EXPECT() *MockConn_Expecter {
+ return &MockConn_Expecter{mock: &_m.Mock}
+}
+
+// Close provides a mock function with no fields
+func (_m *MockConn) Close() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Close")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockConn_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
+type MockConn_Close_Call struct {
+ *mock.Call
+}
+
+// Close is a helper method to define mock.On call
+func (_e *MockConn_Expecter) Close() *MockConn_Close_Call {
+ return &MockConn_Close_Call{Call: _e.mock.On("Close")}
+}
+
+func (_c *MockConn_Close_Call) Run(run func()) *MockConn_Close_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockConn_Close_Call) Return(_a0 error) *MockConn_Close_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_Close_Call) RunAndReturn(run func() error) *MockConn_Close_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// LocalAddr provides a mock function with no fields
+func (_m *MockConn) LocalAddr() net.Addr {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for LocalAddr")
+ }
+
+ var r0 net.Addr
+ if rf, ok := ret.Get(0).(func() net.Addr); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(net.Addr)
+ }
+ }
+
+ return r0
+}
+
+// MockConn_LocalAddr_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LocalAddr'
+type MockConn_LocalAddr_Call struct {
+ *mock.Call
+}
+
+// LocalAddr is a helper method to define mock.On call
+func (_e *MockConn_Expecter) LocalAddr() *MockConn_LocalAddr_Call {
+ return &MockConn_LocalAddr_Call{Call: _e.mock.On("LocalAddr")}
+}
+
+func (_c *MockConn_LocalAddr_Call) Run(run func()) *MockConn_LocalAddr_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockConn_LocalAddr_Call) Return(_a0 net.Addr) *MockConn_LocalAddr_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_LocalAddr_Call) RunAndReturn(run func() net.Addr) *MockConn_LocalAddr_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Read provides a mock function with given fields: b
+func (_m *MockConn) Read(b []byte) (int, error) {
+ ret := _m.Called(b)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Read")
+ }
+
+ var r0 int
+ var r1 error
+ if rf, ok := ret.Get(0).(func([]byte) (int, error)); ok {
+ return rf(b)
+ }
+ if rf, ok := ret.Get(0).(func([]byte) int); ok {
+ r0 = rf(b)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+
+ if rf, ok := ret.Get(1).(func([]byte) error); ok {
+ r1 = rf(b)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockConn_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
+type MockConn_Read_Call struct {
+ *mock.Call
+}
+
+// Read is a helper method to define mock.On call
+// - b []byte
+func (_e *MockConn_Expecter) Read(b interface{}) *MockConn_Read_Call {
+ return &MockConn_Read_Call{Call: _e.mock.On("Read", b)}
+}
+
+func (_c *MockConn_Read_Call) Run(run func(b []byte)) *MockConn_Read_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte))
+ })
+ return _c
+}
+
+func (_c *MockConn_Read_Call) Return(n int, err error) *MockConn_Read_Call {
+ _c.Call.Return(n, err)
+ return _c
+}
+
+func (_c *MockConn_Read_Call) RunAndReturn(run func([]byte) (int, error)) *MockConn_Read_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// RemoteAddr provides a mock function with no fields
+func (_m *MockConn) RemoteAddr() net.Addr {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for RemoteAddr")
+ }
+
+ var r0 net.Addr
+ if rf, ok := ret.Get(0).(func() net.Addr); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(net.Addr)
+ }
+ }
+
+ return r0
+}
+
+// MockConn_RemoteAddr_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoteAddr'
+type MockConn_RemoteAddr_Call struct {
+ *mock.Call
+}
+
+// RemoteAddr is a helper method to define mock.On call
+func (_e *MockConn_Expecter) RemoteAddr() *MockConn_RemoteAddr_Call {
+ return &MockConn_RemoteAddr_Call{Call: _e.mock.On("RemoteAddr")}
+}
+
+func (_c *MockConn_RemoteAddr_Call) Run(run func()) *MockConn_RemoteAddr_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockConn_RemoteAddr_Call) Return(_a0 net.Addr) *MockConn_RemoteAddr_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_RemoteAddr_Call) RunAndReturn(run func() net.Addr) *MockConn_RemoteAddr_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetDeadline provides a mock function with given fields: t
+func (_m *MockConn) SetDeadline(t time.Time) error {
+ ret := _m.Called(t)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetDeadline")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(time.Time) error); ok {
+ r0 = rf(t)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockConn_SetDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDeadline'
+type MockConn_SetDeadline_Call struct {
+ *mock.Call
+}
+
+// SetDeadline is a helper method to define mock.On call
+// - t time.Time
+func (_e *MockConn_Expecter) SetDeadline(t interface{}) *MockConn_SetDeadline_Call {
+ return &MockConn_SetDeadline_Call{Call: _e.mock.On("SetDeadline", t)}
+}
+
+func (_c *MockConn_SetDeadline_Call) Run(run func(t time.Time)) *MockConn_SetDeadline_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(time.Time))
+ })
+ return _c
+}
+
+func (_c *MockConn_SetDeadline_Call) Return(_a0 error) *MockConn_SetDeadline_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_SetDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetDeadline_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetReadDeadline provides a mock function with given fields: t
+func (_m *MockConn) SetReadDeadline(t time.Time) error {
+ ret := _m.Called(t)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetReadDeadline")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(time.Time) error); ok {
+ r0 = rf(t)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockConn_SetReadDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetReadDeadline'
+type MockConn_SetReadDeadline_Call struct {
+ *mock.Call
+}
+
+// SetReadDeadline is a helper method to define mock.On call
+// - t time.Time
+func (_e *MockConn_Expecter) SetReadDeadline(t interface{}) *MockConn_SetReadDeadline_Call {
+ return &MockConn_SetReadDeadline_Call{Call: _e.mock.On("SetReadDeadline", t)}
+}
+
+func (_c *MockConn_SetReadDeadline_Call) Run(run func(t time.Time)) *MockConn_SetReadDeadline_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(time.Time))
+ })
+ return _c
+}
+
+func (_c *MockConn_SetReadDeadline_Call) Return(_a0 error) *MockConn_SetReadDeadline_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_SetReadDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetReadDeadline_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetWriteDeadline provides a mock function with given fields: t
+func (_m *MockConn) SetWriteDeadline(t time.Time) error {
+ ret := _m.Called(t)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetWriteDeadline")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(time.Time) error); ok {
+ r0 = rf(t)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockConn_SetWriteDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWriteDeadline'
+type MockConn_SetWriteDeadline_Call struct {
+ *mock.Call
+}
+
+// SetWriteDeadline is a helper method to define mock.On call
+// - t time.Time
+func (_e *MockConn_Expecter) SetWriteDeadline(t interface{}) *MockConn_SetWriteDeadline_Call {
+ return &MockConn_SetWriteDeadline_Call{Call: _e.mock.On("SetWriteDeadline", t)}
+}
+
+func (_c *MockConn_SetWriteDeadline_Call) Run(run func(t time.Time)) *MockConn_SetWriteDeadline_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(time.Time))
+ })
+ return _c
+}
+
+func (_c *MockConn_SetWriteDeadline_Call) Return(_a0 error) *MockConn_SetWriteDeadline_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_SetWriteDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetWriteDeadline_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Write provides a mock function with given fields: b
+func (_m *MockConn) Write(b []byte) (int, error) {
+ ret := _m.Called(b)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Write")
+ }
+
+ var r0 int
+ var r1 error
+ if rf, ok := ret.Get(0).(func([]byte) (int, error)); ok {
+ return rf(b)
+ }
+ if rf, ok := ret.Get(0).(func([]byte) int); ok {
+ r0 = rf(b)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+
+ if rf, ok := ret.Get(1).(func([]byte) error); ok {
+ r1 = rf(b)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockConn_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
+type MockConn_Write_Call struct {
+ *mock.Call
+}
+
+// Write is a helper method to define mock.On call
+// - b []byte
+func (_e *MockConn_Expecter) Write(b interface{}) *MockConn_Write_Call {
+ return &MockConn_Write_Call{Call: _e.mock.On("Write", b)}
+}
+
+func (_c *MockConn_Write_Call) Run(run func(b []byte)) *MockConn_Write_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte))
+ })
+ return _c
+}
+
+func (_c *MockConn_Write_Call) Return(n int, err error) *MockConn_Write_Call {
+ _c.Call.Return(n, err)
+ return _c
+}
+
+func (_c *MockConn_Write_Call) RunAndReturn(run func([]byte) (int, error)) *MockConn_Write_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockConn creates a new instance of MockConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockConn(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockConn {
+ mock := &MockConn{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/mocks/mock_EventLogger.go b/third_party/hysteria-core/internal/integration_tests/mocks/mock_EventLogger.go
new file mode 100644
index 0000000..14f2175
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/mocks/mock_EventLogger.go
@@ -0,0 +1,249 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package mocks
+
+import (
+ net "net"
+
+ mock "github.com/stretchr/testify/mock"
+)
+
+// MockEventLogger is an autogenerated mock type for the EventLogger type
+type MockEventLogger struct {
+ mock.Mock
+}
+
+type MockEventLogger_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockEventLogger) EXPECT() *MockEventLogger_Expecter {
+ return &MockEventLogger_Expecter{mock: &_m.Mock}
+}
+
+// Connect provides a mock function with given fields: addr, id, tx
+func (_m *MockEventLogger) Connect(addr net.Addr, id string, tx uint64) {
+ _m.Called(addr, id, tx)
+}
+
+// MockEventLogger_Connect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Connect'
+type MockEventLogger_Connect_Call struct {
+ *mock.Call
+}
+
+// Connect is a helper method to define mock.On call
+// - addr net.Addr
+// - id string
+// - tx uint64
+func (_e *MockEventLogger_Expecter) Connect(addr interface{}, id interface{}, tx interface{}) *MockEventLogger_Connect_Call {
+ return &MockEventLogger_Connect_Call{Call: _e.mock.On("Connect", addr, id, tx)}
+}
+
+func (_c *MockEventLogger_Connect_Call) Run(run func(addr net.Addr, id string, tx uint64)) *MockEventLogger_Connect_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(net.Addr), args[1].(string), args[2].(uint64))
+ })
+ return _c
+}
+
+func (_c *MockEventLogger_Connect_Call) Return() *MockEventLogger_Connect_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockEventLogger_Connect_Call) RunAndReturn(run func(net.Addr, string, uint64)) *MockEventLogger_Connect_Call {
+ _c.Run(run)
+ return _c
+}
+
+// Disconnect provides a mock function with given fields: addr, id, err
+func (_m *MockEventLogger) Disconnect(addr net.Addr, id string, err error) {
+ _m.Called(addr, id, err)
+}
+
+// MockEventLogger_Disconnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Disconnect'
+type MockEventLogger_Disconnect_Call struct {
+ *mock.Call
+}
+
+// Disconnect is a helper method to define mock.On call
+// - addr net.Addr
+// - id string
+// - err error
+func (_e *MockEventLogger_Expecter) Disconnect(addr interface{}, id interface{}, err interface{}) *MockEventLogger_Disconnect_Call {
+ return &MockEventLogger_Disconnect_Call{Call: _e.mock.On("Disconnect", addr, id, err)}
+}
+
+func (_c *MockEventLogger_Disconnect_Call) Run(run func(addr net.Addr, id string, err error)) *MockEventLogger_Disconnect_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(net.Addr), args[1].(string), args[2].(error))
+ })
+ return _c
+}
+
+func (_c *MockEventLogger_Disconnect_Call) Return() *MockEventLogger_Disconnect_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockEventLogger_Disconnect_Call) RunAndReturn(run func(net.Addr, string, error)) *MockEventLogger_Disconnect_Call {
+ _c.Run(run)
+ return _c
+}
+
+// TCPError provides a mock function with given fields: addr, id, reqAddr, err
+func (_m *MockEventLogger) TCPError(addr net.Addr, id string, reqAddr string, err error) {
+ _m.Called(addr, id, reqAddr, err)
+}
+
+// MockEventLogger_TCPError_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TCPError'
+type MockEventLogger_TCPError_Call struct {
+ *mock.Call
+}
+
+// TCPError is a helper method to define mock.On call
+// - addr net.Addr
+// - id string
+// - reqAddr string
+// - err error
+func (_e *MockEventLogger_Expecter) TCPError(addr interface{}, id interface{}, reqAddr interface{}, err interface{}) *MockEventLogger_TCPError_Call {
+ return &MockEventLogger_TCPError_Call{Call: _e.mock.On("TCPError", addr, id, reqAddr, err)}
+}
+
+func (_c *MockEventLogger_TCPError_Call) Run(run func(addr net.Addr, id string, reqAddr string, err error)) *MockEventLogger_TCPError_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(net.Addr), args[1].(string), args[2].(string), args[3].(error))
+ })
+ return _c
+}
+
+func (_c *MockEventLogger_TCPError_Call) Return() *MockEventLogger_TCPError_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockEventLogger_TCPError_Call) RunAndReturn(run func(net.Addr, string, string, error)) *MockEventLogger_TCPError_Call {
+ _c.Run(run)
+ return _c
+}
+
+// TCPRequest provides a mock function with given fields: addr, id, reqAddr
+func (_m *MockEventLogger) TCPRequest(addr net.Addr, id string, reqAddr string) {
+ _m.Called(addr, id, reqAddr)
+}
+
+// MockEventLogger_TCPRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TCPRequest'
+type MockEventLogger_TCPRequest_Call struct {
+ *mock.Call
+}
+
+// TCPRequest is a helper method to define mock.On call
+// - addr net.Addr
+// - id string
+// - reqAddr string
+func (_e *MockEventLogger_Expecter) TCPRequest(addr interface{}, id interface{}, reqAddr interface{}) *MockEventLogger_TCPRequest_Call {
+ return &MockEventLogger_TCPRequest_Call{Call: _e.mock.On("TCPRequest", addr, id, reqAddr)}
+}
+
+func (_c *MockEventLogger_TCPRequest_Call) Run(run func(addr net.Addr, id string, reqAddr string)) *MockEventLogger_TCPRequest_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(net.Addr), args[1].(string), args[2].(string))
+ })
+ return _c
+}
+
+func (_c *MockEventLogger_TCPRequest_Call) Return() *MockEventLogger_TCPRequest_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockEventLogger_TCPRequest_Call) RunAndReturn(run func(net.Addr, string, string)) *MockEventLogger_TCPRequest_Call {
+ _c.Run(run)
+ return _c
+}
+
+// UDPError provides a mock function with given fields: addr, id, sessionID, err
+func (_m *MockEventLogger) UDPError(addr net.Addr, id string, sessionID uint32, err error) {
+ _m.Called(addr, id, sessionID, err)
+}
+
+// MockEventLogger_UDPError_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UDPError'
+type MockEventLogger_UDPError_Call struct {
+ *mock.Call
+}
+
+// UDPError is a helper method to define mock.On call
+// - addr net.Addr
+// - id string
+// - sessionID uint32
+// - err error
+func (_e *MockEventLogger_Expecter) UDPError(addr interface{}, id interface{}, sessionID interface{}, err interface{}) *MockEventLogger_UDPError_Call {
+ return &MockEventLogger_UDPError_Call{Call: _e.mock.On("UDPError", addr, id, sessionID, err)}
+}
+
+func (_c *MockEventLogger_UDPError_Call) Run(run func(addr net.Addr, id string, sessionID uint32, err error)) *MockEventLogger_UDPError_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(net.Addr), args[1].(string), args[2].(uint32), args[3].(error))
+ })
+ return _c
+}
+
+func (_c *MockEventLogger_UDPError_Call) Return() *MockEventLogger_UDPError_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockEventLogger_UDPError_Call) RunAndReturn(run func(net.Addr, string, uint32, error)) *MockEventLogger_UDPError_Call {
+ _c.Run(run)
+ return _c
+}
+
+// UDPRequest provides a mock function with given fields: addr, id, sessionID, reqAddr
+func (_m *MockEventLogger) UDPRequest(addr net.Addr, id string, sessionID uint32, reqAddr string) {
+ _m.Called(addr, id, sessionID, reqAddr)
+}
+
+// MockEventLogger_UDPRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UDPRequest'
+type MockEventLogger_UDPRequest_Call struct {
+ *mock.Call
+}
+
+// UDPRequest is a helper method to define mock.On call
+// - addr net.Addr
+// - id string
+// - sessionID uint32
+// - reqAddr string
+func (_e *MockEventLogger_Expecter) UDPRequest(addr interface{}, id interface{}, sessionID interface{}, reqAddr interface{}) *MockEventLogger_UDPRequest_Call {
+ return &MockEventLogger_UDPRequest_Call{Call: _e.mock.On("UDPRequest", addr, id, sessionID, reqAddr)}
+}
+
+func (_c *MockEventLogger_UDPRequest_Call) Run(run func(addr net.Addr, id string, sessionID uint32, reqAddr string)) *MockEventLogger_UDPRequest_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(net.Addr), args[1].(string), args[2].(uint32), args[3].(string))
+ })
+ return _c
+}
+
+func (_c *MockEventLogger_UDPRequest_Call) Return() *MockEventLogger_UDPRequest_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockEventLogger_UDPRequest_Call) RunAndReturn(run func(net.Addr, string, uint32, string)) *MockEventLogger_UDPRequest_Call {
+ _c.Run(run)
+ return _c
+}
+
+// NewMockEventLogger creates a new instance of MockEventLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockEventLogger(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockEventLogger {
+ mock := &MockEventLogger{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/mocks/mock_Outbound.go b/third_party/hysteria-core/internal/integration_tests/mocks/mock_Outbound.go
new file mode 100644
index 0000000..6fda640
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/mocks/mock_Outbound.go
@@ -0,0 +1,199 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package mocks
+
+import (
+ net "net"
+
+ server "github.com/apernet/hysteria/core/v2/server"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// MockOutbound is an autogenerated mock type for the Outbound type
+type MockOutbound struct {
+ mock.Mock
+}
+
+type MockOutbound_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockOutbound) EXPECT() *MockOutbound_Expecter {
+ return &MockOutbound_Expecter{mock: &_m.Mock}
+}
+
+// CheckUDP provides a mock function with given fields: reqAddr
+func (_m *MockOutbound) CheckUDP(reqAddr string) error {
+ ret := _m.Called(reqAddr)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CheckUDP")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string) error); ok {
+ r0 = rf(reqAddr)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockOutbound_CheckUDP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckUDP'
+type MockOutbound_CheckUDP_Call struct {
+ *mock.Call
+}
+
+// CheckUDP is a helper method to define mock.On call
+// - reqAddr string
+func (_e *MockOutbound_Expecter) CheckUDP(reqAddr interface{}) *MockOutbound_CheckUDP_Call {
+ return &MockOutbound_CheckUDP_Call{Call: _e.mock.On("CheckUDP", reqAddr)}
+}
+
+func (_c *MockOutbound_CheckUDP_Call) Run(run func(reqAddr string)) *MockOutbound_CheckUDP_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockOutbound_CheckUDP_Call) Return(_a0 error) *MockOutbound_CheckUDP_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockOutbound_CheckUDP_Call) RunAndReturn(run func(string) error) *MockOutbound_CheckUDP_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// TCP provides a mock function with given fields: reqAddr
+func (_m *MockOutbound) TCP(reqAddr string) (net.Conn, error) {
+ ret := _m.Called(reqAddr)
+
+ if len(ret) == 0 {
+ panic("no return value specified for TCP")
+ }
+
+ var r0 net.Conn
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (net.Conn, error)); ok {
+ return rf(reqAddr)
+ }
+ if rf, ok := ret.Get(0).(func(string) net.Conn); ok {
+ r0 = rf(reqAddr)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(net.Conn)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(reqAddr)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockOutbound_TCP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TCP'
+type MockOutbound_TCP_Call struct {
+ *mock.Call
+}
+
+// TCP is a helper method to define mock.On call
+// - reqAddr string
+func (_e *MockOutbound_Expecter) TCP(reqAddr interface{}) *MockOutbound_TCP_Call {
+ return &MockOutbound_TCP_Call{Call: _e.mock.On("TCP", reqAddr)}
+}
+
+func (_c *MockOutbound_TCP_Call) Run(run func(reqAddr string)) *MockOutbound_TCP_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockOutbound_TCP_Call) Return(_a0 net.Conn, _a1 error) *MockOutbound_TCP_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockOutbound_TCP_Call) RunAndReturn(run func(string) (net.Conn, error)) *MockOutbound_TCP_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// UDP provides a mock function with given fields: reqAddr
+func (_m *MockOutbound) UDP(reqAddr string) (server.UDPConn, error) {
+ ret := _m.Called(reqAddr)
+
+ if len(ret) == 0 {
+ panic("no return value specified for UDP")
+ }
+
+ var r0 server.UDPConn
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (server.UDPConn, error)); ok {
+ return rf(reqAddr)
+ }
+ if rf, ok := ret.Get(0).(func(string) server.UDPConn); ok {
+ r0 = rf(reqAddr)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(server.UDPConn)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(reqAddr)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockOutbound_UDP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UDP'
+type MockOutbound_UDP_Call struct {
+ *mock.Call
+}
+
+// UDP is a helper method to define mock.On call
+// - reqAddr string
+func (_e *MockOutbound_Expecter) UDP(reqAddr interface{}) *MockOutbound_UDP_Call {
+ return &MockOutbound_UDP_Call{Call: _e.mock.On("UDP", reqAddr)}
+}
+
+func (_c *MockOutbound_UDP_Call) Run(run func(reqAddr string)) *MockOutbound_UDP_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockOutbound_UDP_Call) Return(_a0 server.UDPConn, _a1 error) *MockOutbound_UDP_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockOutbound_UDP_Call) RunAndReturn(run func(string) (server.UDPConn, error)) *MockOutbound_UDP_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockOutbound creates a new instance of MockOutbound. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockOutbound(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockOutbound {
+ mock := &MockOutbound{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/mocks/mock_RequestHook.go b/third_party/hysteria-core/internal/integration_tests/mocks/mock_RequestHook.go
new file mode 100644
index 0000000..49e8c6c
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/mocks/mock_RequestHook.go
@@ -0,0 +1,188 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package mocks
+
+import (
+ server "github.com/apernet/hysteria/core/v2/server"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// MockRequestHook is an autogenerated mock type for the RequestHook type
+type MockRequestHook struct {
+ mock.Mock
+}
+
+type MockRequestHook_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockRequestHook) EXPECT() *MockRequestHook_Expecter {
+ return &MockRequestHook_Expecter{mock: &_m.Mock}
+}
+
+// Check provides a mock function with given fields: isUDP, reqAddr
+func (_m *MockRequestHook) Check(isUDP bool, reqAddr string) bool {
+ ret := _m.Called(isUDP, reqAddr)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Check")
+ }
+
+ var r0 bool
+ if rf, ok := ret.Get(0).(func(bool, string) bool); ok {
+ r0 = rf(isUDP, reqAddr)
+ } else {
+ r0 = ret.Get(0).(bool)
+ }
+
+ return r0
+}
+
+// MockRequestHook_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check'
+type MockRequestHook_Check_Call struct {
+ *mock.Call
+}
+
+// Check is a helper method to define mock.On call
+// - isUDP bool
+// - reqAddr string
+func (_e *MockRequestHook_Expecter) Check(isUDP interface{}, reqAddr interface{}) *MockRequestHook_Check_Call {
+ return &MockRequestHook_Check_Call{Call: _e.mock.On("Check", isUDP, reqAddr)}
+}
+
+func (_c *MockRequestHook_Check_Call) Run(run func(isUDP bool, reqAddr string)) *MockRequestHook_Check_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(bool), args[1].(string))
+ })
+ return _c
+}
+
+func (_c *MockRequestHook_Check_Call) Return(_a0 bool) *MockRequestHook_Check_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockRequestHook_Check_Call) RunAndReturn(run func(bool, string) bool) *MockRequestHook_Check_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// TCP provides a mock function with given fields: stream, reqAddr
+func (_m *MockRequestHook) TCP(stream server.HyStream, reqAddr *string) ([]byte, error) {
+ ret := _m.Called(stream, reqAddr)
+
+ if len(ret) == 0 {
+ panic("no return value specified for TCP")
+ }
+
+ var r0 []byte
+ var r1 error
+ if rf, ok := ret.Get(0).(func(server.HyStream, *string) ([]byte, error)); ok {
+ return rf(stream, reqAddr)
+ }
+ if rf, ok := ret.Get(0).(func(server.HyStream, *string) []byte); ok {
+ r0 = rf(stream, reqAddr)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]byte)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(server.HyStream, *string) error); ok {
+ r1 = rf(stream, reqAddr)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockRequestHook_TCP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TCP'
+type MockRequestHook_TCP_Call struct {
+ *mock.Call
+}
+
+// TCP is a helper method to define mock.On call
+// - stream server.HyStream
+// - reqAddr *string
+func (_e *MockRequestHook_Expecter) TCP(stream interface{}, reqAddr interface{}) *MockRequestHook_TCP_Call {
+ return &MockRequestHook_TCP_Call{Call: _e.mock.On("TCP", stream, reqAddr)}
+}
+
+func (_c *MockRequestHook_TCP_Call) Run(run func(stream server.HyStream, reqAddr *string)) *MockRequestHook_TCP_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(server.HyStream), args[1].(*string))
+ })
+ return _c
+}
+
+func (_c *MockRequestHook_TCP_Call) Return(_a0 []byte, _a1 error) *MockRequestHook_TCP_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockRequestHook_TCP_Call) RunAndReturn(run func(server.HyStream, *string) ([]byte, error)) *MockRequestHook_TCP_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// UDP provides a mock function with given fields: data, reqAddr
+func (_m *MockRequestHook) UDP(data []byte, reqAddr *string) error {
+ ret := _m.Called(data, reqAddr)
+
+ if len(ret) == 0 {
+ panic("no return value specified for UDP")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func([]byte, *string) error); ok {
+ r0 = rf(data, reqAddr)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockRequestHook_UDP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UDP'
+type MockRequestHook_UDP_Call struct {
+ *mock.Call
+}
+
+// UDP is a helper method to define mock.On call
+// - data []byte
+// - reqAddr *string
+func (_e *MockRequestHook_Expecter) UDP(data interface{}, reqAddr interface{}) *MockRequestHook_UDP_Call {
+ return &MockRequestHook_UDP_Call{Call: _e.mock.On("UDP", data, reqAddr)}
+}
+
+func (_c *MockRequestHook_UDP_Call) Run(run func(data []byte, reqAddr *string)) *MockRequestHook_UDP_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte), args[1].(*string))
+ })
+ return _c
+}
+
+func (_c *MockRequestHook_UDP_Call) Return(_a0 error) *MockRequestHook_UDP_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockRequestHook_UDP_Call) RunAndReturn(run func([]byte, *string) error) *MockRequestHook_UDP_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockRequestHook creates a new instance of MockRequestHook. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockRequestHook(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockRequestHook {
+ mock := &MockRequestHook{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/mocks/mock_TrafficLogger.go b/third_party/hysteria-core/internal/integration_tests/mocks/mock_TrafficLogger.go
new file mode 100644
index 0000000..92ed6ed
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/mocks/mock_TrafficLogger.go
@@ -0,0 +1,184 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package mocks
+
+import (
+ server "github.com/apernet/hysteria/core/v2/server"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// MockTrafficLogger is an autogenerated mock type for the TrafficLogger type
+type MockTrafficLogger struct {
+ mock.Mock
+}
+
+type MockTrafficLogger_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockTrafficLogger) EXPECT() *MockTrafficLogger_Expecter {
+ return &MockTrafficLogger_Expecter{mock: &_m.Mock}
+}
+
+// LogOnlineState provides a mock function with given fields: id, online
+func (_m *MockTrafficLogger) LogOnlineState(id string, online bool) {
+ _m.Called(id, online)
+}
+
+// MockTrafficLogger_LogOnlineState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogOnlineState'
+type MockTrafficLogger_LogOnlineState_Call struct {
+ *mock.Call
+}
+
+// LogOnlineState is a helper method to define mock.On call
+// - id string
+// - online bool
+func (_e *MockTrafficLogger_Expecter) LogOnlineState(id interface{}, online interface{}) *MockTrafficLogger_LogOnlineState_Call {
+ return &MockTrafficLogger_LogOnlineState_Call{Call: _e.mock.On("LogOnlineState", id, online)}
+}
+
+func (_c *MockTrafficLogger_LogOnlineState_Call) Run(run func(id string, online bool)) *MockTrafficLogger_LogOnlineState_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string), args[1].(bool))
+ })
+ return _c
+}
+
+func (_c *MockTrafficLogger_LogOnlineState_Call) Return() *MockTrafficLogger_LogOnlineState_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockTrafficLogger_LogOnlineState_Call) RunAndReturn(run func(string, bool)) *MockTrafficLogger_LogOnlineState_Call {
+ _c.Run(run)
+ return _c
+}
+
+// LogTraffic provides a mock function with given fields: id, tx, rx
+func (_m *MockTrafficLogger) LogTraffic(id string, tx uint64, rx uint64) bool {
+ ret := _m.Called(id, tx, rx)
+
+ if len(ret) == 0 {
+ panic("no return value specified for LogTraffic")
+ }
+
+ var r0 bool
+ if rf, ok := ret.Get(0).(func(string, uint64, uint64) bool); ok {
+ r0 = rf(id, tx, rx)
+ } else {
+ r0 = ret.Get(0).(bool)
+ }
+
+ return r0
+}
+
+// MockTrafficLogger_LogTraffic_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogTraffic'
+type MockTrafficLogger_LogTraffic_Call struct {
+ *mock.Call
+}
+
+// LogTraffic is a helper method to define mock.On call
+// - id string
+// - tx uint64
+// - rx uint64
+func (_e *MockTrafficLogger_Expecter) LogTraffic(id interface{}, tx interface{}, rx interface{}) *MockTrafficLogger_LogTraffic_Call {
+ return &MockTrafficLogger_LogTraffic_Call{Call: _e.mock.On("LogTraffic", id, tx, rx)}
+}
+
+func (_c *MockTrafficLogger_LogTraffic_Call) Run(run func(id string, tx uint64, rx uint64)) *MockTrafficLogger_LogTraffic_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string), args[1].(uint64), args[2].(uint64))
+ })
+ return _c
+}
+
+func (_c *MockTrafficLogger_LogTraffic_Call) Return(ok bool) *MockTrafficLogger_LogTraffic_Call {
+ _c.Call.Return(ok)
+ return _c
+}
+
+func (_c *MockTrafficLogger_LogTraffic_Call) RunAndReturn(run func(string, uint64, uint64) bool) *MockTrafficLogger_LogTraffic_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// TraceStream provides a mock function with given fields: stream, stats
+func (_m *MockTrafficLogger) TraceStream(stream server.HyStream, stats *server.StreamStats) {
+ _m.Called(stream, stats)
+}
+
+// MockTrafficLogger_TraceStream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TraceStream'
+type MockTrafficLogger_TraceStream_Call struct {
+ *mock.Call
+}
+
+// TraceStream is a helper method to define mock.On call
+// - stream server.HyStream
+// - stats *server.StreamStats
+func (_e *MockTrafficLogger_Expecter) TraceStream(stream interface{}, stats interface{}) *MockTrafficLogger_TraceStream_Call {
+ return &MockTrafficLogger_TraceStream_Call{Call: _e.mock.On("TraceStream", stream, stats)}
+}
+
+func (_c *MockTrafficLogger_TraceStream_Call) Run(run func(stream server.HyStream, stats *server.StreamStats)) *MockTrafficLogger_TraceStream_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(server.HyStream), args[1].(*server.StreamStats))
+ })
+ return _c
+}
+
+func (_c *MockTrafficLogger_TraceStream_Call) Return() *MockTrafficLogger_TraceStream_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockTrafficLogger_TraceStream_Call) RunAndReturn(run func(server.HyStream, *server.StreamStats)) *MockTrafficLogger_TraceStream_Call {
+ _c.Run(run)
+ return _c
+}
+
+// UntraceStream provides a mock function with given fields: stream
+func (_m *MockTrafficLogger) UntraceStream(stream server.HyStream) {
+ _m.Called(stream)
+}
+
+// MockTrafficLogger_UntraceStream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UntraceStream'
+type MockTrafficLogger_UntraceStream_Call struct {
+ *mock.Call
+}
+
+// UntraceStream is a helper method to define mock.On call
+// - stream server.HyStream
+func (_e *MockTrafficLogger_Expecter) UntraceStream(stream interface{}) *MockTrafficLogger_UntraceStream_Call {
+ return &MockTrafficLogger_UntraceStream_Call{Call: _e.mock.On("UntraceStream", stream)}
+}
+
+func (_c *MockTrafficLogger_UntraceStream_Call) Run(run func(stream server.HyStream)) *MockTrafficLogger_UntraceStream_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(server.HyStream))
+ })
+ return _c
+}
+
+func (_c *MockTrafficLogger_UntraceStream_Call) Return() *MockTrafficLogger_UntraceStream_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockTrafficLogger_UntraceStream_Call) RunAndReturn(run func(server.HyStream)) *MockTrafficLogger_UntraceStream_Call {
+ _c.Run(run)
+ return _c
+}
+
+// NewMockTrafficLogger creates a new instance of MockTrafficLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockTrafficLogger(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockTrafficLogger {
+ mock := &MockTrafficLogger{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/mocks/mock_UDPConn.go b/third_party/hysteria-core/internal/integration_tests/mocks/mock_UDPConn.go
new file mode 100644
index 0000000..965edc0
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/mocks/mock_UDPConn.go
@@ -0,0 +1,197 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package mocks
+
+import mock "github.com/stretchr/testify/mock"
+
+// MockUDPConn is an autogenerated mock type for the UDPConn type
+type MockUDPConn struct {
+ mock.Mock
+}
+
+type MockUDPConn_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockUDPConn) EXPECT() *MockUDPConn_Expecter {
+ return &MockUDPConn_Expecter{mock: &_m.Mock}
+}
+
+// Close provides a mock function with no fields
+func (_m *MockUDPConn) Close() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Close")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockUDPConn_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
+type MockUDPConn_Close_Call struct {
+ *mock.Call
+}
+
+// Close is a helper method to define mock.On call
+func (_e *MockUDPConn_Expecter) Close() *MockUDPConn_Close_Call {
+ return &MockUDPConn_Close_Call{Call: _e.mock.On("Close")}
+}
+
+func (_c *MockUDPConn_Close_Call) Run(run func()) *MockUDPConn_Close_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockUDPConn_Close_Call) Return(_a0 error) *MockUDPConn_Close_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockUDPConn_Close_Call) RunAndReturn(run func() error) *MockUDPConn_Close_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ReadFrom provides a mock function with given fields: b
+func (_m *MockUDPConn) ReadFrom(b []byte) (int, string, error) {
+ ret := _m.Called(b)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ReadFrom")
+ }
+
+ var r0 int
+ var r1 string
+ var r2 error
+ if rf, ok := ret.Get(0).(func([]byte) (int, string, error)); ok {
+ return rf(b)
+ }
+ if rf, ok := ret.Get(0).(func([]byte) int); ok {
+ r0 = rf(b)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+
+ if rf, ok := ret.Get(1).(func([]byte) string); ok {
+ r1 = rf(b)
+ } else {
+ r1 = ret.Get(1).(string)
+ }
+
+ if rf, ok := ret.Get(2).(func([]byte) error); ok {
+ r2 = rf(b)
+ } else {
+ r2 = ret.Error(2)
+ }
+
+ return r0, r1, r2
+}
+
+// MockUDPConn_ReadFrom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadFrom'
+type MockUDPConn_ReadFrom_Call struct {
+ *mock.Call
+}
+
+// ReadFrom is a helper method to define mock.On call
+// - b []byte
+func (_e *MockUDPConn_Expecter) ReadFrom(b interface{}) *MockUDPConn_ReadFrom_Call {
+ return &MockUDPConn_ReadFrom_Call{Call: _e.mock.On("ReadFrom", b)}
+}
+
+func (_c *MockUDPConn_ReadFrom_Call) Run(run func(b []byte)) *MockUDPConn_ReadFrom_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte))
+ })
+ return _c
+}
+
+func (_c *MockUDPConn_ReadFrom_Call) Return(_a0 int, _a1 string, _a2 error) *MockUDPConn_ReadFrom_Call {
+ _c.Call.Return(_a0, _a1, _a2)
+ return _c
+}
+
+func (_c *MockUDPConn_ReadFrom_Call) RunAndReturn(run func([]byte) (int, string, error)) *MockUDPConn_ReadFrom_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// WriteTo provides a mock function with given fields: b, addr
+func (_m *MockUDPConn) WriteTo(b []byte, addr string) (int, error) {
+ ret := _m.Called(b, addr)
+
+ if len(ret) == 0 {
+ panic("no return value specified for WriteTo")
+ }
+
+ var r0 int
+ var r1 error
+ if rf, ok := ret.Get(0).(func([]byte, string) (int, error)); ok {
+ return rf(b, addr)
+ }
+ if rf, ok := ret.Get(0).(func([]byte, string) int); ok {
+ r0 = rf(b, addr)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+
+ if rf, ok := ret.Get(1).(func([]byte, string) error); ok {
+ r1 = rf(b, addr)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockUDPConn_WriteTo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteTo'
+type MockUDPConn_WriteTo_Call struct {
+ *mock.Call
+}
+
+// WriteTo is a helper method to define mock.On call
+// - b []byte
+// - addr string
+func (_e *MockUDPConn_Expecter) WriteTo(b interface{}, addr interface{}) *MockUDPConn_WriteTo_Call {
+ return &MockUDPConn_WriteTo_Call{Call: _e.mock.On("WriteTo", b, addr)}
+}
+
+func (_c *MockUDPConn_WriteTo_Call) Run(run func(b []byte, addr string)) *MockUDPConn_WriteTo_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte), args[1].(string))
+ })
+ return _c
+}
+
+func (_c *MockUDPConn_WriteTo_Call) Return(_a0 int, _a1 error) *MockUDPConn_WriteTo_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockUDPConn_WriteTo_Call) RunAndReturn(run func([]byte, string) (int, error)) *MockUDPConn_WriteTo_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockUDPConn creates a new instance of MockUDPConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockUDPConn(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockUDPConn {
+ mock := &MockUDPConn{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/smoke_test.go b/third_party/hysteria-core/internal/integration_tests/smoke_test.go
new file mode 100644
index 0000000..ef14991
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/smoke_test.go
@@ -0,0 +1,283 @@
+package integration_tests
+
+import (
+ "io"
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+
+ "github.com/apernet/hysteria/core/v2/client"
+ coreErrs "github.com/apernet/hysteria/core/v2/errors"
+ "github.com/apernet/hysteria/core/v2/internal/integration_tests/mocks"
+ "github.com/apernet/hysteria/core/v2/server"
+)
+
+// Smoke tests that act as a sanity check for client & server to ensure they can talk to each other correctly.
+
+// TestClientNoServer tests how the client handles a server address it cannot connect to.
+// NewClient should return a ConnectError.
+func TestClientNoServer(t *testing.T) {
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 55666},
+ })
+ assert.Nil(t, c)
+ _, ok := err.(coreErrs.ConnectError)
+ assert.True(t, ok)
+}
+
+// TestClientServerBadAuth tests two things:
+// - The server uses Authenticator when a client connects.
+// - How the client handles failed authentication.
+func TestClientServerBadAuth(t *testing.T) {
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, "badpassword", uint64(0)).Return(false, "").Once()
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ Auth: "badpassword",
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.Nil(t, c)
+ _, ok := err.(coreErrs.AuthError)
+ assert.True(t, ok)
+}
+
+// TestClientServerUDPDisabled tests how the client handles a server that does not support UDP.
+// UDP should return a DialError.
+func TestClientServerUDPDisabled(t *testing.T) {
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ DisableUDP: true,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ conn, err := c.UDP()
+ assert.Nil(t, conn)
+ _, ok := err.(coreErrs.DialError)
+ assert.True(t, ok)
+}
+
+// TestClientServerTCPEcho tests TCP forwarding using a TCP echo server.
+func TestClientServerTCPEcho(t *testing.T) {
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create TCP echo server
+ echoAddr := "127.0.0.1:22333"
+ echoListener, err := net.Listen("tcp", echoAddr)
+ assert.NoError(t, err)
+ echoServer := &tcpEchoServer{Listener: echoListener}
+ defer echoServer.Close()
+ go echoServer.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ // Dial TCP
+ conn, err := c.TCP(echoAddr)
+ assert.NoError(t, err)
+ defer conn.Close()
+
+ // Send and receive data
+ sData := []byte("hello world")
+ _, err = conn.Write(sData)
+ assert.NoError(t, err)
+ rData := make([]byte, len(sData))
+ _, err = io.ReadFull(conn, rData)
+ assert.NoError(t, err)
+ assert.Equal(t, sData, rData)
+}
+
+// TestClientServerUDPEcho tests UDP forwarding using a UDP echo server.
+func TestClientServerUDPEcho(t *testing.T) {
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create UDP echo server
+ echoAddr := "127.0.0.1:22333"
+ echoConn, err := net.ListenPacket("udp", echoAddr)
+ assert.NoError(t, err)
+ echoServer := &udpEchoServer{Conn: echoConn}
+ defer echoServer.Close()
+ go echoServer.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ // Listen UDP
+ conn, err := c.UDP()
+ assert.NoError(t, err)
+ defer conn.Close()
+
+ // Send and receive data
+ sData := []byte("hello world")
+ err = conn.Send(sData, echoAddr)
+ assert.NoError(t, err)
+ rData, rAddr, err := conn.Receive()
+ assert.NoError(t, err)
+ assert.Equal(t, sData, rData)
+ assert.Equal(t, echoAddr, rAddr)
+}
+
+// TestClientServerHandshakeInfo tests that the client returns the correct handshake info.
+func TestClientServerHandshakeInfo(t *testing.T) {
+ // Create server 1, UDP enabled, unlimited bandwidth
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ go s.Serve()
+
+ // Create client 1, with specified tx bandwidth
+ c, info, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ BandwidthConfig: client.BandwidthConfig{
+ MaxTx: 123456,
+ },
+ })
+ assert.NoError(t, err)
+ assert.Equal(t, &client.HandshakeInfo{
+ UDPEnabled: true,
+ Tx: 123456,
+ ServerAddr: udpAddr,
+ }, info)
+
+ // Close server 1 and client 1
+ _ = s.Close()
+ _ = c.Close()
+
+ // Create server 2, UDP disabled, limited rx bandwidth
+ udpConn, udpAddr, err = serverConn()
+ assert.NoError(t, err)
+ s, err = server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ BandwidthConfig: server.BandwidthConfig{
+ MaxRx: 100000,
+ },
+ DisableUDP: true,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ go s.Serve()
+
+ // Create client 2, with specified tx bandwidth
+ c, info, err = client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ BandwidthConfig: client.BandwidthConfig{
+ MaxTx: 123456,
+ },
+ })
+ assert.NoError(t, err)
+ assert.Equal(t, &client.HandshakeInfo{
+ UDPEnabled: false,
+ Tx: 100000,
+ ServerAddr: udpAddr,
+ }, info)
+
+ // Close server 2 and client 2
+ _ = s.Close()
+ _ = c.Close()
+
+ // Create server 3, UDP enabled, ignore client bandwidth
+ udpConn, udpAddr, err = serverConn()
+ assert.NoError(t, err)
+ s, err = server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ IgnoreClientBandwidth: true,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ go s.Serve()
+
+ // Create client 3, with specified tx bandwidth
+ c, info, err = client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ BandwidthConfig: client.BandwidthConfig{
+ MaxTx: 123456,
+ },
+ })
+ assert.NoError(t, err)
+ assert.Equal(t, &client.HandshakeInfo{
+ UDPEnabled: true,
+ Tx: 0,
+ ServerAddr: udpAddr,
+ }, info)
+
+ // Close server 3 and client 3
+ _ = s.Close()
+ _ = c.Close()
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/stress_test.go b/third_party/hysteria-core/internal/integration_tests/stress_test.go
new file mode 100644
index 0000000..fc98847
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/stress_test.go
@@ -0,0 +1,263 @@
+package integration_tests
+
+import (
+ "context"
+ "crypto/rand"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "golang.org/x/time/rate"
+
+ "github.com/apernet/hysteria/core/v2/client"
+ "github.com/apernet/hysteria/core/v2/internal/integration_tests/mocks"
+ "github.com/apernet/hysteria/core/v2/server"
+)
+
+type tcpStressor struct {
+ DialFunc func() (net.Conn, error)
+ Size int
+ Parallel int
+ Iterations int
+}
+
+func (s *tcpStressor) Run(t *testing.T) {
+ // Make some random data
+ sData := make([]byte, s.Size)
+ _, err := rand.Read(sData)
+ assert.NoError(t, err)
+
+ // Run iterations
+ for i := 0; i < s.Iterations; i++ {
+ var wg sync.WaitGroup
+ errChan := make(chan error, s.Parallel)
+ for j := 0; j < s.Parallel; j++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ conn, err := s.DialFunc()
+ if err != nil {
+ errChan <- err
+ return
+ }
+ defer conn.Close()
+ go conn.Write(sData)
+
+ rData := make([]byte, len(sData))
+ _, err = io.ReadFull(conn, rData)
+ if err != nil {
+ errChan <- err
+ return
+ }
+ }()
+ }
+ wg.Wait()
+
+ assert.Empty(t, errChan)
+ }
+}
+
+type udpStressor struct {
+ ListenFunc func() (client.HyUDPConn, error)
+ ServerAddr string
+ Size int
+ Count int
+ Parallel int
+ Iterations int
+}
+
+func (s *udpStressor) Run(t *testing.T) {
+ // Make some random data
+ sData := make([]byte, s.Size)
+ _, err := rand.Read(sData)
+ assert.NoError(t, err)
+
+ // Due to UDP's unreliability, we need to limit the rate of sending
+ // to reduce packet loss. This is hardcoded to 1 MiB/s for now.
+ limiter := rate.NewLimiter(1048576, 1048576)
+
+ // Run iterations
+ for i := 0; i < s.Iterations; i++ {
+ var wg sync.WaitGroup
+ errChan := make(chan error, s.Parallel)
+ for j := 0; j < s.Parallel; j++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ conn, err := s.ListenFunc()
+ if err != nil {
+ errChan <- err
+ return
+ }
+ defer conn.Close()
+ go func() {
+ // Sending routine
+ for i := 0; i < s.Count; i++ {
+ _ = limiter.WaitN(context.Background(), len(sData))
+ _ = conn.Send(sData, s.ServerAddr)
+ }
+ }()
+
+ minCount := s.Count * 8 / 10 // Tolerate 20% packet loss
+ for i := 0; i < minCount; i++ {
+ rData, _, err := conn.Receive()
+ if err != nil {
+ errChan <- err
+ return
+ }
+ if len(rData) != len(sData) {
+ errChan <- fmt.Errorf("incomplete data received: %d/%d bytes", len(rData), len(sData))
+ return
+ }
+ }
+ }()
+ }
+ wg.Wait()
+
+ assert.Empty(t, errChan)
+ }
+}
+
+func TestClientServerTCPStress(t *testing.T) {
+ if os.Getenv("AUTOCAR_RUN_UPSTREAM_STRESS") != "1" {
+ t.Skip("set AUTOCAR_RUN_UPSTREAM_STRESS=1 to run multi-gigabyte upstream stress cases")
+ }
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ MaxTCPHandlers: 1024,
+ MaxClientTCPHandlers: 1024,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create TCP echo server
+ echoAddr := "127.0.0.1:22333"
+ echoListener, err := net.Listen("tcp", echoAddr)
+ assert.NoError(t, err)
+ echoServer := &tcpEchoServer{Listener: echoListener}
+ defer echoServer.Close()
+ go echoServer.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ dialFunc := func() (net.Conn, error) {
+ return c.TCP(echoAddr)
+ }
+
+ t.Run("Single 500m", (&tcpStressor{DialFunc: dialFunc, Size: 524288000, Parallel: 1, Iterations: 1}).Run)
+
+ t.Run("Sequential 1000x1m", (&tcpStressor{DialFunc: dialFunc, Size: 1048576, Parallel: 1, Iterations: 1000}).Run)
+ t.Run("Sequential 10000x100k", (&tcpStressor{DialFunc: dialFunc, Size: 102400, Parallel: 1, Iterations: 10000}).Run)
+
+ t.Run("Parallel 100x10m", (&tcpStressor{DialFunc: dialFunc, Size: 10485760, Parallel: 100, Iterations: 1}).Run)
+ t.Run("Parallel 1000x1m", (&tcpStressor{DialFunc: dialFunc, Size: 1048576, Parallel: 1000, Iterations: 1}).Run)
+}
+
+func TestClientServerUDPStress(t *testing.T) {
+ if os.Getenv("AUTOCAR_RUN_UPSTREAM_STRESS") != "1" {
+ t.Skip("set AUTOCAR_RUN_UPSTREAM_STRESS=1 to run long lossy-UDP upstream stress cases")
+ }
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ MaxUDPSessions: 1024,
+ MaxClientUDPSessions: 1024,
+ Authenticator: auth,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create UDP echo server
+ echoAddr := "127.0.0.1:22333"
+ echoConn, err := net.ListenPacket("udp", echoAddr)
+ assert.NoError(t, err)
+ echoServer := &udpEchoServer{Conn: echoConn}
+ defer echoServer.Close()
+ go echoServer.Serve()
+
+ // Create client
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ t.Run("Single 1000x100b", (&udpStressor{
+ ListenFunc: c.UDP,
+ ServerAddr: echoAddr,
+ Size: 100,
+ Count: 1000,
+ Parallel: 1,
+ Iterations: 1,
+ }).Run)
+ t.Run("Single 1000x3k", (&udpStressor{
+ ListenFunc: c.UDP,
+ ServerAddr: echoAddr,
+ Size: 3000,
+ Count: 1000,
+ Parallel: 1,
+ Iterations: 1,
+ }).Run)
+
+ t.Run("5 Sequential 1000x100b", (&udpStressor{
+ ListenFunc: c.UDP,
+ ServerAddr: echoAddr,
+ Size: 100,
+ Count: 1000,
+ Parallel: 1,
+ Iterations: 5,
+ }).Run)
+ t.Run("5 Sequential 200x3k", (&udpStressor{
+ ListenFunc: c.UDP,
+ ServerAddr: echoAddr,
+ Size: 3000,
+ Count: 200,
+ Parallel: 1,
+ Iterations: 5,
+ }).Run)
+
+ t.Run("2 Sequential 5 Parallel 1000x100b", (&udpStressor{
+ ListenFunc: c.UDP,
+ ServerAddr: echoAddr,
+ Size: 100,
+ Count: 1000,
+ Parallel: 5,
+ Iterations: 2,
+ }).Run)
+ t.Run("2 Sequential 5 Parallel 200x3k", (&udpStressor{
+ ListenFunc: c.UDP,
+ ServerAddr: echoAddr,
+ Size: 3000,
+ Count: 200,
+ Parallel: 5,
+ Iterations: 2,
+ }).Run)
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/trafficlogger_test.go b/third_party/hysteria-core/internal/integration_tests/trafficlogger_test.go
new file mode 100644
index 0000000..841f4ff
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/trafficlogger_test.go
@@ -0,0 +1,180 @@
+package integration_tests
+
+import (
+ "io"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+
+ "github.com/apernet/hysteria/core/v2/client"
+ "github.com/apernet/hysteria/core/v2/internal/integration_tests/mocks"
+ "github.com/apernet/hysteria/core/v2/server"
+)
+
+// TestClientServerTrafficLoggerTCP tests that the traffic logger is correctly called for TCP connections,
+// and that the client is disconnected when the traffic logger returns false.
+func TestClientServerTrafficLoggerTCP(t *testing.T) {
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ serverOb := mocks.NewMockOutbound(t)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ trafficLogger := mocks.NewMockTrafficLogger(t)
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Outbound: serverOb,
+ Authenticator: auth,
+ TrafficLogger: trafficLogger,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create client
+ trafficLogger.EXPECT().LogOnlineState("nobody", true).Return().Once()
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ addr := "dontcare.cc:4455"
+
+ sobConn := mocks.NewMockConn(t)
+ sobConnCh := make(chan []byte, 1)
+ sobConnChCloseFunc := sync.OnceFunc(func() { close(sobConnCh) })
+ sobConn.EXPECT().Read(mock.Anything).RunAndReturn(func(bs []byte) (int, error) {
+ b := <-sobConnCh
+ if b == nil {
+ return 0, io.EOF
+ } else {
+ return copy(bs, b), nil
+ }
+ })
+ sobConn.EXPECT().Close().RunAndReturn(func() error {
+ sobConnChCloseFunc()
+ return nil
+ })
+ serverOb.EXPECT().TCP(addr).Return(sobConn, nil).Once()
+ trafficLogger.EXPECT().TraceStream(mock.Anything, mock.Anything).Return().Once()
+
+ conn, err := c.TCP(addr)
+ assert.NoError(t, err)
+
+ // Client reads from server
+ trafficLogger.EXPECT().LogTraffic("nobody", uint64(0), uint64(11)).Return(true).Once()
+ sobConnCh <- []byte("knock knock")
+ buf := make([]byte, 100)
+ n, err := conn.Read(buf)
+ assert.NoError(t, err)
+ assert.Equal(t, 11, n)
+ assert.Equal(t, "knock knock", string(buf[:n]))
+
+ // Client writes to server
+ trafficLogger.EXPECT().LogTraffic("nobody", uint64(12), uint64(0)).Return(true).Once()
+ sobConn.EXPECT().Write([]byte("who is there")).Return(12, nil).Once()
+ n, err = conn.Write([]byte("who is there"))
+ assert.NoError(t, err)
+ assert.Equal(t, 12, n)
+ time.Sleep(1 * time.Second) // Need some time for the server to receive the data
+
+ // Client reads from server again but blocked
+ trafficLogger.EXPECT().UntraceStream(mock.Anything).Return().Once()
+ trafficLogger.EXPECT().LogTraffic("nobody", uint64(0), uint64(4)).Return(false).Once()
+ trafficLogger.EXPECT().LogOnlineState("nobody", false).Return().Once()
+ sobConnCh <- []byte("nope")
+ n, err = conn.Read(buf)
+ assert.Zero(t, n)
+ assert.Error(t, err)
+
+ // The client should be disconnected
+ _, err = c.TCP("whatever")
+ assert.Error(t, err)
+}
+
+// TestClientServerTrafficLoggerUDP tests that the traffic logger is correctly called for UDP sessions,
+// and that the client is disconnected when the traffic logger returns false.
+func TestClientServerTrafficLoggerUDP(t *testing.T) {
+ // Create server
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ serverOb := mocks.NewMockOutbound(t)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ trafficLogger := mocks.NewMockTrafficLogger(t)
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Outbound: serverOb,
+ Authenticator: auth,
+ TrafficLogger: trafficLogger,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ // Create client
+ trafficLogger.EXPECT().LogOnlineState("nobody", true).Return().Once()
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ addr := "shady.org:43211"
+
+ sobConn := mocks.NewMockUDPConn(t)
+ sobConnCh := make(chan []byte, 1)
+ sobConnChCloseFunc := sync.OnceFunc(func() { close(sobConnCh) })
+ sobConn.EXPECT().ReadFrom(mock.Anything).RunAndReturn(func(bs []byte) (int, string, error) {
+ b := <-sobConnCh
+ if b == nil {
+ return 0, "", io.EOF
+ } else {
+ return copy(bs, b), addr, nil
+ }
+ })
+ sobConn.EXPECT().Close().RunAndReturn(func() error {
+ sobConnChCloseFunc()
+ return nil
+ })
+ serverOb.EXPECT().UDP(addr).Return(sobConn, nil).Once()
+
+ conn, err := c.UDP()
+ assert.NoError(t, err)
+
+ // Client writes to server
+ trafficLogger.EXPECT().LogTraffic("nobody", uint64(9), uint64(0)).Return(true).Once()
+ sobConn.EXPECT().WriteTo([]byte("small sad"), addr).Return(9, nil).Once()
+ err = conn.Send([]byte("small sad"), addr)
+ assert.NoError(t, err)
+ time.Sleep(1 * time.Second) // Need some time for the server to receive the data
+
+ // Client reads from server
+ trafficLogger.EXPECT().LogTraffic("nobody", uint64(0), uint64(7)).Return(true).Once()
+ sobConnCh <- []byte("big mad")
+ bs, rAddr, err := conn.Receive()
+ assert.NoError(t, err)
+ assert.Equal(t, rAddr, addr)
+ assert.Equal(t, "big mad", string(bs))
+
+ // Client reads from server again but blocked
+ trafficLogger.EXPECT().LogTraffic("nobody", uint64(0), uint64(4)).Return(false).Once()
+ trafficLogger.EXPECT().LogOnlineState("nobody", false).Return().Once()
+ sobConnCh <- []byte("nope")
+ bs, rAddr, err = conn.Receive()
+ assert.Equal(t, err, io.EOF)
+ assert.Empty(t, rAddr)
+ assert.Empty(t, bs)
+
+ // The client should be disconnected
+ _, err = c.UDP()
+ assert.Error(t, err)
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/udp_acl_test.go b/third_party/hysteria-core/internal/integration_tests/udp_acl_test.go
new file mode 100644
index 0000000..b8bda01
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/udp_acl_test.go
@@ -0,0 +1,177 @@
+package integration_tests
+
+import (
+ "errors"
+ "net"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/apernet/hysteria/core/v2/client"
+ "github.com/apernet/hysteria/core/v2/internal/integration_tests/mocks"
+ "github.com/apernet/hysteria/core/v2/server"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+type gatedOutbound struct {
+ blocked string
+ checkCalls atomic.Int32
+ dialedAddrs atomic.Int32
+}
+
+func (o *gatedOutbound) TCP(reqAddr string) (net.Conn, error) {
+ return net.Dial("tcp", reqAddr)
+}
+
+func (o *gatedOutbound) UDP(reqAddr string) (server.UDPConn, error) {
+ if reqAddr == o.blocked {
+ return nil, errors.New("rejected")
+ }
+ o.dialedAddrs.Add(1)
+ c, err := net.ListenUDP("udp", nil)
+ if err != nil {
+ return nil, err
+ }
+ return &gatedUDPConn{UDPConn: c}, nil
+}
+
+func (o *gatedOutbound) CheckUDP(reqAddr string) error {
+ o.checkCalls.Add(1)
+ if reqAddr == o.blocked {
+ return errors.New("rejected")
+ }
+ return nil
+}
+
+type gatedUDPConn struct {
+ *net.UDPConn
+}
+
+func (c *gatedUDPConn) ReadFrom(b []byte) (int, string, error) {
+ n, addr, err := c.UDPConn.ReadFrom(b)
+ if addr != nil {
+ return n, addr.String(), err
+ }
+ return n, "", err
+}
+
+func (c *gatedUDPConn) WriteTo(b []byte, addr string) (int, error) {
+ uAddr, err := net.ResolveUDPAddr("udp", addr)
+ if err != nil {
+ return 0, err
+ }
+ return c.UDPConn.WriteTo(b, uAddr)
+}
+
+func TestClientServerUDPACLBypass(t *testing.T) {
+ const allowed, blocked = "127.0.0.1:22444", "127.0.0.1:22445"
+ ob := &gatedOutbound{blocked: blocked}
+
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Authenticator: auth,
+ Outbound: ob,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ allowedConn, err := net.ListenPacket("udp", allowed)
+ assert.NoError(t, err)
+ defer allowedConn.Close()
+ go (&udpEchoServer{Conn: allowedConn}).Serve()
+
+ blockedConn, err := net.ListenPacket("udp", blocked)
+ assert.NoError(t, err)
+ defer blockedConn.Close()
+ go (&udpEchoServer{Conn: blockedConn}).Serve()
+
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ conn, err := c.UDP()
+ assert.NoError(t, err)
+ defer conn.Close()
+
+ assert.NoError(t, conn.Send([]byte("hello"), allowed))
+ rData, rAddr, err := conn.Receive()
+ assert.NoError(t, err)
+ assert.Equal(t, []byte("hello"), rData)
+ assert.Equal(t, allowed, rAddr)
+
+ assert.NoError(t, conn.Send([]byte("ssrf"), blocked))
+
+ done := make(chan struct{})
+ var leakedAddr string
+ go func() {
+ _, addr, err := conn.Receive()
+ if err == nil {
+ leakedAddr = addr
+ }
+ close(done)
+ }()
+ select {
+ case <-done:
+ assert.NotEqual(t, blocked, leakedAddr, "ACL bypass: blocked destination relayed")
+ case <-time.After(500 * time.Millisecond):
+ }
+
+ assert.GreaterOrEqual(t, ob.checkCalls.Load(), int32(1), "CheckUDP not invoked for subsequent packet")
+ assert.Equal(t, int32(1), ob.dialedAddrs.Load(), "outbound dial must happen only on first allowed destination")
+}
+
+func TestClientServerUDPACLMultiDestAllowed(t *testing.T) {
+ const dest1, dest2 = "127.0.0.1:22448", "127.0.0.1:22449"
+ ob := &gatedOutbound{blocked: ""}
+
+ udpConn, udpAddr, err := serverConn()
+ assert.NoError(t, err)
+ auth := mocks.NewMockAuthenticator(t)
+ auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
+ s, err := server.NewServer(&server.Config{
+ TLSConfig: serverTLSConfig(),
+ Conn: udpConn,
+ Authenticator: auth,
+ Outbound: ob,
+ })
+ assert.NoError(t, err)
+ defer s.Close()
+ go s.Serve()
+
+ for _, addr := range []string{dest1, dest2} {
+ ec, err := net.ListenPacket("udp", addr)
+ assert.NoError(t, err)
+ defer ec.Close()
+ go (&udpEchoServer{Conn: ec}).Serve()
+ }
+
+ c, _, err := client.NewClient(&client.Config{
+ ServerAddr: udpAddr,
+ TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
+ })
+ assert.NoError(t, err)
+ defer c.Close()
+
+ conn, err := c.UDP()
+ assert.NoError(t, err)
+ defer conn.Close()
+
+ for _, addr := range []string{dest1, dest2} {
+ assert.NoError(t, conn.Send([]byte("hi"), addr))
+ rData, rAddr, err := conn.Receive()
+ assert.NoError(t, err)
+ assert.Equal(t, []byte("hi"), rData)
+ assert.Equal(t, addr, rAddr)
+ }
+}
diff --git a/third_party/hysteria-core/internal/integration_tests/utils_test.go b/third_party/hysteria-core/internal/integration_tests/utils_test.go
new file mode 100644
index 0000000..bea8985
--- /dev/null
+++ b/third_party/hysteria-core/internal/integration_tests/utils_test.go
@@ -0,0 +1,104 @@
+package integration_tests
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "io"
+ "math/big"
+ "net"
+ "sync"
+ "time"
+
+ "github.com/apernet/hysteria/core/v2/server"
+)
+
+// This file provides utilities for the integration tests.
+
+var testCertificate = sync.OnceValue(func() tls.Certificate {
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ panic(err)
+ }
+ now := time.Now()
+ template := &x509.Certificate{
+ SerialNumber: big.NewInt(now.UnixNano()),
+ Subject: pkix.Name{CommonName: "localhost"},
+ NotBefore: now.Add(-time.Minute),
+ NotAfter: now.Add(24 * time.Hour),
+ KeyUsage: x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ BasicConstraintsValid: true,
+ DNSNames: []string{"localhost"},
+ }
+ der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
+ if err != nil {
+ panic(err)
+ }
+ return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
+})
+
+func serverTLSConfig() server.TLSConfig {
+ return server.TLSConfig{
+ Certificates: []tls.Certificate{testCertificate()},
+ }
+}
+
+func serverConn() (net.PacketConn, net.Addr, error) {
+ udpAddr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 14514}
+ udpConn, err := net.ListenUDP("udp", udpAddr)
+ if err != nil {
+ return nil, nil, err
+ }
+ return udpConn, udpAddr, nil
+}
+
+// tcpEchoServer is a TCP server that echoes what it reads from the connection.
+// It will never actively close the connection.
+type tcpEchoServer struct {
+ Listener net.Listener
+}
+
+func (s *tcpEchoServer) Serve() error {
+ for {
+ conn, err := s.Listener.Accept()
+ if err != nil {
+ return err
+ }
+ go func() {
+ _, _ = io.Copy(conn, conn)
+ _ = conn.Close()
+ }()
+ }
+}
+
+func (s *tcpEchoServer) Close() error {
+ return s.Listener.Close()
+}
+
+// udpEchoServer is a UDP server that echoes what it reads from the connection.
+// It will never actively close the connection.
+type udpEchoServer struct {
+ Conn net.PacketConn
+}
+
+func (s *udpEchoServer) Serve() error {
+ buf := make([]byte, 65536)
+ for {
+ n, addr, err := s.Conn.ReadFrom(buf)
+ if err != nil {
+ return err
+ }
+ _, err = s.Conn.WriteTo(buf[:n], addr)
+ if err != nil {
+ return err
+ }
+ }
+}
+
+func (s *udpEchoServer) Close() error {
+ return s.Conn.Close()
+}
diff --git a/third_party/hysteria-core/internal/pmtud/avail.go b/third_party/hysteria-core/internal/pmtud/avail.go
new file mode 100644
index 0000000..cd7afd0
--- /dev/null
+++ b/third_party/hysteria-core/internal/pmtud/avail.go
@@ -0,0 +1,7 @@
+//go:build linux || windows || darwin
+
+package pmtud
+
+const (
+ DisablePathMTUDiscovery = false
+)
diff --git a/third_party/hysteria-core/internal/pmtud/unavail.go b/third_party/hysteria-core/internal/pmtud/unavail.go
new file mode 100644
index 0000000..917b973
--- /dev/null
+++ b/third_party/hysteria-core/internal/pmtud/unavail.go
@@ -0,0 +1,13 @@
+//go:build !linux && !windows && !darwin
+
+package pmtud
+
+// quic-go's MTU detection is enabled by default on all platforms.
+// However, it only actually sets the DF bit on 3 supported platforms (Windows, macOS, Linux).
+// As a result, on other platforms, probe packets that should never be fragmented will still
+// be fragmented and transmitted. So we're only enabling it for platforms where we've verified
+// its functionality for now.
+
+const (
+ DisablePathMTUDiscovery = true
+)
diff --git a/third_party/hysteria-core/internal/protocol/http.go b/third_party/hysteria-core/internal/protocol/http.go
new file mode 100644
index 0000000..abcc1a4
--- /dev/null
+++ b/third_party/hysteria-core/internal/protocol/http.go
@@ -0,0 +1,68 @@
+package protocol
+
+import (
+ "net/http"
+ "strconv"
+)
+
+const (
+ URLHost = "hysteria"
+ URLPath = "/auth"
+
+ RequestHeaderAuth = "Hysteria-Auth"
+ ResponseHeaderUDPEnabled = "Hysteria-UDP"
+ CommonHeaderCCRX = "Hysteria-CC-RX"
+ CommonHeaderPadding = "Hysteria-Padding"
+
+ StatusAuthOK = 233
+)
+
+// AuthRequest is what client sends to server for authentication.
+type AuthRequest struct {
+ Auth string
+ Rx uint64 // 0 = unknown, client asks server to use bandwidth detection
+}
+
+// AuthResponse is what server sends to client when authentication is passed.
+type AuthResponse struct {
+ UDPEnabled bool
+ Rx uint64 // 0 = unlimited
+ RxAuto bool // true = server asks client to use bandwidth detection
+}
+
+func AuthRequestFromHeader(h http.Header) AuthRequest {
+ rx, _ := strconv.ParseUint(h.Get(CommonHeaderCCRX), 10, 64)
+ return AuthRequest{
+ Auth: h.Get(RequestHeaderAuth),
+ Rx: rx,
+ }
+}
+
+func AuthRequestToHeader(h http.Header, req AuthRequest) {
+ h.Set(RequestHeaderAuth, req.Auth)
+ h.Set(CommonHeaderCCRX, strconv.FormatUint(req.Rx, 10))
+ h.Set(CommonHeaderPadding, authRequestPadding.String())
+}
+
+func AuthResponseFromHeader(h http.Header) AuthResponse {
+ resp := AuthResponse{}
+ resp.UDPEnabled, _ = strconv.ParseBool(h.Get(ResponseHeaderUDPEnabled))
+ rxStr := h.Get(CommonHeaderCCRX)
+ if rxStr == "auto" {
+ // Special case for server requesting client to use bandwidth detection
+ resp.RxAuto = true
+ } else {
+ resp.Rx, _ = strconv.ParseUint(rxStr, 10, 64)
+ }
+ return resp
+}
+
+func AuthResponseToHeader(h http.Header, resp AuthResponse) {
+ h.Set(ResponseHeaderUDPEnabled, strconv.FormatBool(resp.UDPEnabled))
+ if resp.RxAuto {
+ h.Set(CommonHeaderCCRX, "auto")
+ } else {
+ h.Set(CommonHeaderCCRX, strconv.FormatUint(resp.Rx, 10))
+ }
+ h.Set(CommonHeaderPadding, authResponsePadding.String())
+}
diff --git a/third_party/hysteria-core/internal/protocol/padding.go b/third_party/hysteria-core/internal/protocol/padding.go
new file mode 100644
index 0000000..9895cdc
--- /dev/null
+++ b/third_party/hysteria-core/internal/protocol/padding.go
@@ -0,0 +1,31 @@
+package protocol
+
+import (
+ "math/rand"
+)
+
+const (
+ paddingChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+)
+
+// padding specifies a half-open range [Min, Max).
+type padding struct {
+ Min int
+ Max int
+}
+
+func (p padding) String() string {
+ n := p.Min + rand.Intn(p.Max-p.Min)
+ bs := make([]byte, n)
+ for i := range bs {
+ bs[i] = paddingChars[rand.Intn(len(paddingChars))]
+ }
+ return string(bs)
+}
+
+var (
+ authRequestPadding = padding{Min: 256, Max: 2048}
+ authResponsePadding = padding{Min: 256, Max: 2048}
+ tcpRequestPadding = padding{Min: 64, Max: 512}
+ tcpResponsePadding = padding{Min: 128, Max: 1024}
+)
diff --git a/third_party/hysteria-core/internal/protocol/proxy.go b/third_party/hysteria-core/internal/protocol/proxy.go
new file mode 100644
index 0000000..8448511
--- /dev/null
+++ b/third_party/hysteria-core/internal/protocol/proxy.go
@@ -0,0 +1,261 @@
+package protocol
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "io"
+
+ "github.com/apernet/hysteria/core/v2/errors"
+
+ "github.com/apernet/quic-go/quicvarint"
+)
+
+const (
+ FrameTypeTCPRequest = 0x401
+
+ // Max length values are for preventing DoS attacks
+
+ MaxAddressLength = 2048
+ MaxMessageLength = 2048
+ MaxPaddingLength = 4096
+
+ MaxDatagramFrameSize = 1200
+ MaxUDPSize = 4096
+ // MaxUDPMessageSize includes the largest accepted payload, address and
+ // fixed/varint framing. Send buffers must use this size so a maximum UDP
+ // payload reaches QUIC, which can then return its real datagram limit and
+ // trigger fragmentation instead of being dropped during serialization.
+ MaxUDPMessageSize = 8 + 2 + MaxAddressLength + MaxUDPSize
+
+ maxVarInt1 = 63
+ maxVarInt2 = 16383
+ maxVarInt4 = 1073741823
+ maxVarInt8 = 4611686018427387903
+)
+
+// TCPRequest format:
+// 0x401 (QUIC varint)
+// Address length (QUIC varint)
+// Address (bytes)
+// Padding length (QUIC varint)
+// Padding (bytes)
+
+func ReadTCPRequest(r io.Reader) (string, error) {
+ bReader := quicvarint.NewReader(r)
+ addrLen, err := quicvarint.Read(bReader)
+ if err != nil {
+ return "", err
+ }
+ if addrLen == 0 || addrLen > MaxAddressLength {
+ return "", errors.ProtocolError{Message: "invalid address length"}
+ }
+ addrBuf := make([]byte, addrLen)
+ _, err = io.ReadFull(r, addrBuf)
+ if err != nil {
+ return "", err
+ }
+ paddingLen, err := quicvarint.Read(bReader)
+ if err != nil {
+ return "", err
+ }
+ if paddingLen > MaxPaddingLength {
+ return "", errors.ProtocolError{Message: "invalid padding length"}
+ }
+ if paddingLen > 0 {
+ _, err = io.CopyN(io.Discard, r, int64(paddingLen))
+ if err != nil {
+ return "", err
+ }
+ }
+ return string(addrBuf), nil
+}
+
+func WriteTCPRequest(w io.Writer, addr string) error {
+ padding := tcpRequestPadding.String()
+ paddingLen := len(padding)
+ addrLen := len(addr)
+ sz := int(quicvarint.Len(FrameTypeTCPRequest)) +
+ int(quicvarint.Len(uint64(addrLen))) + addrLen +
+ int(quicvarint.Len(uint64(paddingLen))) + paddingLen
+ buf := make([]byte, sz)
+ i := varintPut(buf, FrameTypeTCPRequest)
+ i += varintPut(buf[i:], uint64(addrLen))
+ i += copy(buf[i:], addr)
+ i += varintPut(buf[i:], uint64(paddingLen))
+ copy(buf[i:], padding)
+ _, err := w.Write(buf)
+ return err
+}
+
+// TCPResponse format:
+// Status (byte, 0=ok, 1=error)
+// Message length (QUIC varint)
+// Message (bytes)
+// Padding length (QUIC varint)
+// Padding (bytes)
+
+func ReadTCPResponse(r io.Reader) (bool, string, error) {
+ var status [1]byte
+ if _, err := io.ReadFull(r, status[:]); err != nil {
+ return false, "", err
+ }
+ bReader := quicvarint.NewReader(r)
+ msgLen, err := quicvarint.Read(bReader)
+ if err != nil {
+ return false, "", err
+ }
+ if msgLen > MaxMessageLength {
+ return false, "", errors.ProtocolError{Message: "invalid message length"}
+ }
+ var msgBuf []byte
+ // No message is fine
+ if msgLen > 0 {
+ msgBuf = make([]byte, msgLen)
+ _, err = io.ReadFull(r, msgBuf)
+ if err != nil {
+ return false, "", err
+ }
+ }
+ paddingLen, err := quicvarint.Read(bReader)
+ if err != nil {
+ return false, "", err
+ }
+ if paddingLen > MaxPaddingLength {
+ return false, "", errors.ProtocolError{Message: "invalid padding length"}
+ }
+ if paddingLen > 0 {
+ _, err = io.CopyN(io.Discard, r, int64(paddingLen))
+ if err != nil {
+ return false, "", err
+ }
+ }
+ return status[0] == 0, string(msgBuf), nil
+}
+
+func WriteTCPResponse(w io.Writer, ok bool, msg string) error {
+ padding := tcpResponsePadding.String()
+ paddingLen := len(padding)
+ msgLen := len(msg)
+ sz := 1 + int(quicvarint.Len(uint64(msgLen))) + msgLen +
+ int(quicvarint.Len(uint64(paddingLen))) + paddingLen
+ buf := make([]byte, sz)
+ if ok {
+ buf[0] = 0
+ } else {
+ buf[0] = 1
+ }
+ i := varintPut(buf[1:], uint64(msgLen))
+ i += copy(buf[1+i:], msg)
+ i += varintPut(buf[1+i:], uint64(paddingLen))
+ copy(buf[1+i:], padding)
+ _, err := w.Write(buf)
+ return err
+}
+
+// UDPMessage format:
+// Session ID (uint32 BE)
+// Packet ID (uint16 BE)
+// Fragment ID (uint8)
+// Fragment count (uint8)
+// Address length (QUIC varint)
+// Address (bytes)
+// Data...
+
+type UDPMessage struct {
+ SessionID uint32 // 4
+ PacketID uint16 // 2
+ FragID uint8 // 1
+ FragCount uint8 // 1
+ Addr string // varint + bytes
+ Data []byte
+}
+
+func (m *UDPMessage) HeaderSize() int {
+ lAddr := len(m.Addr)
+ return 4 + 2 + 1 + 1 + int(quicvarint.Len(uint64(lAddr))) + lAddr
+}
+
+func (m *UDPMessage) Size() int {
+ return m.HeaderSize() + len(m.Data)
+}
+
+func (m *UDPMessage) Serialize(buf []byte) int {
+ // Make sure the buffer is big enough
+ if len(buf) < m.Size() {
+ return -1
+ }
+ binary.BigEndian.PutUint32(buf, m.SessionID)
+ binary.BigEndian.PutUint16(buf[4:], m.PacketID)
+ buf[6] = m.FragID
+ buf[7] = m.FragCount
+ i := varintPut(buf[8:], uint64(len(m.Addr)))
+ i += copy(buf[8+i:], m.Addr)
+ i += copy(buf[8+i:], m.Data)
+ return 8 + i
+}
+
+func ParseUDPMessage(msg []byte) (*UDPMessage, error) {
+ m := &UDPMessage{}
+ buf := bytes.NewBuffer(msg)
+ if err := binary.Read(buf, binary.BigEndian, &m.SessionID); err != nil {
+ return nil, err
+ }
+ if err := binary.Read(buf, binary.BigEndian, &m.PacketID); err != nil {
+ return nil, err
+ }
+ if err := binary.Read(buf, binary.BigEndian, &m.FragID); err != nil {
+ return nil, err
+ }
+ if err := binary.Read(buf, binary.BigEndian, &m.FragCount); err != nil {
+ return nil, err
+ }
+ lAddr, err := quicvarint.Read(buf)
+ if err != nil {
+ return nil, err
+ }
+ if lAddr == 0 || lAddr > MaxMessageLength {
+ return nil, errors.ProtocolError{Message: "invalid address length"}
+ }
+ bs := buf.Bytes()
+ if len(bs) <= int(lAddr) {
+ // We use <= instead of < here as we expect at least one byte of data after the address
+ return nil, errors.ProtocolError{Message: "invalid message length"}
+ }
+ m.Addr = string(bs[:lAddr])
+ m.Data = bs[lAddr:]
+ return m, nil
+}
+
+// varintPut is like quicvarint.Append, but instead of appending to a slice,
+// it writes to a fixed-size buffer. Returns the number of bytes written.
+func varintPut(b []byte, i uint64) int {
+ if i <= maxVarInt1 {
+ b[0] = uint8(i)
+ return 1
+ }
+ if i <= maxVarInt2 {
+ b[0] = uint8(i>>8) | 0x40
+ b[1] = uint8(i)
+ return 2
+ }
+ if i <= maxVarInt4 {
+ b[0] = uint8(i>>24) | 0x80
+ b[1] = uint8(i >> 16)
+ b[2] = uint8(i >> 8)
+ b[3] = uint8(i)
+ return 4
+ }
+ if i <= maxVarInt8 {
+ b[0] = uint8(i>>56) | 0xc0
+ b[1] = uint8(i >> 48)
+ b[2] = uint8(i >> 40)
+ b[3] = uint8(i >> 32)
+ b[4] = uint8(i >> 24)
+ b[5] = uint8(i >> 16)
+ b[6] = uint8(i >> 8)
+ b[7] = uint8(i)
+ return 8
+ }
+ panic(fmt.Sprintf("%#x doesn't fit into 62 bits", i))
+}
diff --git a/third_party/hysteria-core/internal/protocol/proxy_test.go b/third_party/hysteria-core/internal/protocol/proxy_test.go
new file mode 100644
index 0000000..9c16724
--- /dev/null
+++ b/third_party/hysteria-core/internal/protocol/proxy_test.go
@@ -0,0 +1,330 @@
+package protocol
+
+import (
+ "bytes"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestUDPMessage(t *testing.T) {
+ t.Run("buffer too small", func(t *testing.T) {
+ // Make sure Serialize returns -1 when the buffer is too small.
+ tBuf := make([]byte, 20)
+ if (&UDPMessage{
+ SessionID: 66,
+ PacketID: 77,
+ FragID: 2,
+ FragCount: 5,
+ Addr: "random_addr",
+ Data: []byte("random_data"),
+ }).Serialize(tBuf) != -1 {
+ t.Error("Serialize() did not return -1 when the buffer was too small")
+ }
+ })
+
+ type fields struct {
+ SessionID uint32
+ PacketID uint16
+ FragID uint8
+ FragCount uint8
+ Addr string
+ Data []byte
+ }
+ tests := []struct {
+ name string
+ fields fields
+ want []byte
+ }{
+ {
+ name: "test 1",
+ fields: fields{
+ SessionID: 1,
+ PacketID: 1,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "example.com:80",
+ Data: []byte("GET /nothing HTTP/1.1\r\n"),
+ },
+ want: []byte{0x0, 0x0, 0x0, 0x1, 0x0, 0x1, 0x0, 0x1, 0xe, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x3a, 0x38, 0x30, 0x47, 0x45, 0x54, 0x20, 0x2f, 0x6e, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x48, 0x54, 0x54, 0x50, 0x2f, 0x31, 0x2e, 0x31, 0xd, 0xa},
+ },
+ {
+ name: "test 2",
+ fields: fields{
+ SessionID: 1329655244,
+ Addr: "some_random_goofy_ahh_address_which_is_very_long_some_random_goofy_ahh_address_which_is_very_long_some_random_goofy_ahh_address_which_is_very_long_some_random_goofy_ahh_address_which_is_very_long_some_random_goofy_ahh_address_which_is_very_long_some_random_goofy_ahh_address_which_is_very_long_some_random_goofy_ahh_address_which_is_very_long_some_random_goofy_ahh_address_which_is_very_long_some_random_goofy_ahh_address_which_is_very_long_some_random_goofy_ahh_address_which_is_very_long:9000",
+ PacketID: 62233,
+ FragID: 8,
+ FragCount: 19,
+ Data: []byte("God is great, beer is good, and people are crazy."),
+ },
+ want: []byte{0x4f, 0x40, 0xed, 0xcc, 0xf3, 0x19, 0x8, 0x13, 0x41, 0xee, 0x73, 0x6f, 0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x6f, 0x6f, 0x66, 0x79, 0x5f, 0x61, 0x68, 0x68, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x77, 0x68, 0x69, 0x63, 0x68, 0x5f, 0x69, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x73, 0x6f, 0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x6f, 0x6f, 0x66, 0x79, 0x5f, 0x61, 0x68, 0x68, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x77, 0x68, 0x69, 0x63, 0x68, 0x5f, 0x69, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x73, 0x6f, 0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x6f, 0x6f, 0x66, 0x79, 0x5f, 0x61, 0x68, 0x68, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x77, 0x68, 0x69, 0x63, 0x68, 0x5f, 0x69, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x73, 0x6f, 0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x6f, 0x6f, 0x66, 0x79, 0x5f, 0x61, 0x68, 0x68, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x77, 0x68, 0x69, 0x63, 0x68, 0x5f, 0x69, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x73, 0x6f, 0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x6f, 0x6f, 0x66, 0x79, 0x5f, 0x61, 0x68, 0x68, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x77, 0x68, 0x69, 0x63, 0x68, 0x5f, 0x69, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x73, 0x6f, 0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x6f, 0x6f, 0x66, 0x79, 0x5f, 0x61, 0x68, 0x68, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x77, 0x68, 0x69, 0x63, 0x68, 0x5f, 0x69, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x73, 0x6f, 0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x6f, 0x6f, 0x66, 0x79, 0x5f, 0x61, 0x68, 0x68, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x77, 0x68, 0x69, 0x63, 0x68, 0x5f, 0x69, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x73, 0x6f, 0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x6f, 0x6f, 0x66, 0x79, 0x5f, 0x61, 0x68, 0x68, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x77, 0x68, 0x69, 0x63, 0x68, 0x5f, 0x69, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x73, 0x6f, 0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x6f, 0x6f, 0x66, 0x79, 0x5f, 0x61, 0x68, 0x68, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x77, 0x68, 0x69, 0x63, 0x68, 0x5f, 0x69, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x73, 0x6f, 0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x6f, 0x6f, 0x66, 0x79, 0x5f, 0x61, 0x68, 0x68, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x77, 0x68, 0x69, 0x63, 0x68, 0x5f, 0x69, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x3a, 0x39, 0x30, 0x30, 0x30, 0x47, 0x6f, 0x64, 0x20, 0x69, 0x73, 0x20, 0x67, 0x72, 0x65, 0x61, 0x74, 0x2c, 0x20, 0x62, 0x65, 0x65, 0x72, 0x20, 0x69, 0x73, 0x20, 0x67, 0x6f, 0x6f, 0x64, 0x2c, 0x20, 0x61, 0x6e, 0x64, 0x20, 0x70, 0x65, 0x6f, 0x70, 0x6c, 0x65, 0x20, 0x61, 0x72, 0x65, 0x20, 0x63, 0x72, 0x61, 0x7a, 0x79, 0x2e},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := &UDPMessage{
+ SessionID: tt.fields.SessionID,
+ Addr: tt.fields.Addr,
+ PacketID: tt.fields.PacketID,
+ FragID: tt.fields.FragID,
+ FragCount: tt.fields.FragCount,
+ Data: tt.fields.Data,
+ }
+ // Serialize
+ buf := make([]byte, MaxUDPSize)
+ n := m.Serialize(buf)
+ if got := buf[:n]; !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("Serialize() = %v, want %v", got, tt.want)
+ }
+ // Parse back
+ if m2, err := ParseUDPMessage(tt.want); err != nil {
+ t.Errorf("ParseUDPMessage() error = %v", err)
+ } else {
+ if !reflect.DeepEqual(m2, m) {
+ t.Errorf("ParseUDPMessage() = %v, want %v", m2, m)
+ }
+ }
+ })
+ }
+}
+
+func TestMaximumUDPMessageFitsSerializationBuffer(t *testing.T) {
+ message := &UDPMessage{
+ SessionID: 1,
+ FragCount: 1,
+ Addr: string(make([]byte, MaxAddressLength)),
+ Data: make([]byte, MaxUDPSize),
+ }
+ buf := make([]byte, MaxUDPMessageSize)
+ if got := message.Serialize(buf); got != message.Size() {
+ t.Fatalf("Serialize returned %d, want %d", got, message.Size())
+ }
+}
+
+// TestUDPMessageMalformed is to make sure ParseUDPMessage() fails (but not panic) on malformed data.
+func TestUDPMessageMalformed(t *testing.T) {
+ tests := []struct {
+ name string
+ data []byte
+ }{
+ {
+ name: "empty",
+ data: []byte{},
+ },
+ {
+ name: "zeroes 1",
+ data: []byte{0, 0, 0, 0},
+ },
+ {
+ name: "zeroes 2",
+ data: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+ },
+ {
+ name: "incomplete 1",
+ data: []byte{0x66, 0xCC, 0xFF, 0xFF, 0x11, 0x22, 0x33, 0x44, 0x55},
+ },
+ {
+ name: "incomplete 2",
+ data: []byte{0x66, 0xCC, 0xFF, 0xFF, 0x11, 0x22, 0x33, 0x44, 0x90, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if _, err := ParseUDPMessage(tt.data); err == nil {
+ t.Errorf("ParseUDPMessage() should fail")
+ }
+ })
+ }
+}
+
+func TestReadTCPRequest(t *testing.T) {
+ tests := []struct {
+ name string
+ data []byte
+ want string
+ wantErr bool
+ }{
+ {
+ name: "normal no padding",
+ data: []byte("\x0egoogle.com:443\x00"),
+ want: "google.com:443",
+ wantErr: false,
+ },
+ {
+ name: "normal with padding",
+ data: []byte("\x0bholy.cc:443\x02gg"),
+ want: "holy.cc:443",
+ wantErr: false,
+ },
+ {
+ name: "incomplete 1",
+ data: []byte("\x0bhoho"),
+ want: "",
+ wantErr: true,
+ },
+ {
+ name: "incomplete 2",
+ data: []byte("\x0bholy.cc:443\x05x"),
+ want: "",
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := bytes.NewReader(tt.data)
+ got, err := ReadTCPRequest(r)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ReadTCPRequest() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("ReadTCPRequest() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestWriteTCPRequest(t *testing.T) {
+ tests := []struct {
+ name string
+ addr string
+ wantW string // Just a prefix, we don't care about the padding
+ wantErr bool
+ }{
+ {
+ name: "normal 1",
+ addr: "google.com:443",
+ wantW: "\x44\x01\x0egoogle.com:443",
+ wantErr: false,
+ },
+ {
+ name: "normal 2",
+ addr: "client-api.arkoselabs.com:8080",
+ wantW: "\x44\x01\x1eclient-api.arkoselabs.com:8080",
+ wantErr: false,
+ },
+ {
+ name: "empty",
+ addr: "",
+ wantW: "\x44\x01\x00",
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w := &bytes.Buffer{}
+ err := WriteTCPRequest(w, tt.addr)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("WriteTCPRequest() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if gotW := w.String(); !(strings.HasPrefix(gotW, tt.wantW) && len(gotW) > len(tt.wantW)) {
+ t.Errorf("WriteTCPRequest() gotW = %v, want %v", gotW, tt.wantW)
+ }
+ })
+ }
+}
+
+func TestReadTCPResponse(t *testing.T) {
+ tests := []struct {
+ name string
+ data []byte
+ want bool
+ want1 string
+ wantErr bool
+ }{
+ {
+ name: "normal ok no padding",
+ data: []byte("\x00\x0bhello world\x00"),
+ want: true,
+ want1: "hello world",
+ wantErr: false,
+ },
+ {
+ name: "normal error with padding",
+ data: []byte("\x01\x06stop!!\x05xxxxx"),
+ want1: "stop!!",
+ wantErr: false,
+ },
+ {
+ name: "normal ok no message with padding",
+ data: []byte("\x01\x00\x05xxxxx"),
+ want1: "",
+ wantErr: false,
+ },
+ {
+ name: "incomplete 1",
+ data: []byte("\x00\x0bhoho"),
+ want1: "",
+ wantErr: true,
+ },
+ {
+ name: "incomplete 2",
+ data: []byte("\x01\x05jesus\x05x"),
+ want1: "",
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := bytes.NewReader(tt.data)
+ got, got1, err := ReadTCPResponse(r)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ReadTCPResponse() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("ReadTCPResponse() got = %v, want %v", got, tt.want)
+ }
+ if got1 != tt.want1 {
+ t.Errorf("ReadTCPResponse() got1 = %v, want %v", got1, tt.want1)
+ }
+ })
+ }
+}
+
+func TestWriteTCPResponse(t *testing.T) {
+ type args struct {
+ ok bool
+ msg string
+ }
+ tests := []struct {
+ name string
+ args args
+ wantW string // Just a prefix, we don't care about the padding
+ wantErr bool
+ }{
+ {
+ name: "normal ok",
+ args: args{ok: true, msg: "hello world"},
+ wantW: "\x00\x0bhello world",
+ wantErr: false,
+ },
+ {
+ name: "normal error",
+ args: args{ok: false, msg: "stop!!"},
+ wantW: "\x01\x06stop!!",
+ wantErr: false,
+ },
+ {
+ name: "empty",
+ args: args{ok: true, msg: ""},
+ wantW: "\x00\x00",
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w := &bytes.Buffer{}
+ err := WriteTCPResponse(w, tt.args.ok, tt.args.msg)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("WriteTCPResponse() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if gotW := w.String(); !(strings.HasPrefix(gotW, tt.wantW) && len(gotW) > len(tt.wantW)) {
+ t.Errorf("WriteTCPResponse() gotW = %v, want %v", gotW, tt.wantW)
+ }
+ })
+ }
+}
diff --git a/third_party/hysteria-core/internal/utils/atomic.go b/third_party/hysteria-core/internal/utils/atomic.go
new file mode 100644
index 0000000..7739013
--- /dev/null
+++ b/third_party/hysteria-core/internal/utils/atomic.go
@@ -0,0 +1,54 @@
+package utils
+
+import (
+ "sync/atomic"
+ "time"
+)
+
+type AtomicTime struct {
+ v atomic.Value
+}
+
+func NewAtomicTime(t time.Time) *AtomicTime {
+ a := &AtomicTime{}
+ a.Set(t)
+ return a
+}
+
+func (t *AtomicTime) Set(new time.Time) {
+ t.v.Store(new)
+}
+
+func (t *AtomicTime) Get() time.Time {
+ return t.v.Load().(time.Time)
+}
+
+type Atomic[T any] struct {
+ v atomic.Value
+}
+
+func (a *Atomic[T]) Load() T {
+ value := a.v.Load()
+ if value == nil {
+ var zero T
+ return zero
+ }
+ return value.(T)
+}
+
+func (a *Atomic[T]) Store(value T) {
+ a.v.Store(value)
+}
+
+func (a *Atomic[T]) Swap(new T) T {
+ old := a.v.Swap(new)
+ if old == nil {
+ var zero T
+ return zero
+ }
+ return old.(T)
+}
+
+func (a *Atomic[T]) CompareAndSwap(old, new T) bool {
+ return a.v.CompareAndSwap(old, new)
+}
diff --git a/third_party/hysteria-core/internal/utils/qstream.go b/third_party/hysteria-core/internal/utils/qstream.go
new file mode 100644
index 0000000..76519b6
--- /dev/null
+++ b/third_party/hysteria-core/internal/utils/qstream.go
@@ -0,0 +1,62 @@
+package utils
+
+import (
+ "context"
+ "time"
+
+ "github.com/apernet/quic-go"
+)
+
+// QStream is a wrapper of quic.Stream that handles Close() in a way that
+// makes more sense to us. By default, quic.Stream's Close() only closes
+// the write side of the stream, not the read side. And if there is unread
+// data, the stream is not really considered closed until either the data
+// is drained or CancelRead() is called.
+// References:
+// - https://github.com/libp2p/go-libp2p/blob/master/p2p/transport/quic/stream.go
+// - https://github.com/quic-go/quic-go/issues/3558
+// - https://github.com/quic-go/quic-go/issues/1599
+type QStream struct {
+ Stream *quic.Stream
+}
+
+func (s *QStream) StreamID() quic.StreamID {
+ return s.Stream.StreamID()
+}
+
+func (s *QStream) Read(p []byte) (n int, err error) {
+ return s.Stream.Read(p)
+}
+
+func (s *QStream) CancelRead(code quic.StreamErrorCode) {
+ s.Stream.CancelRead(code)
+}
+
+func (s *QStream) SetReadDeadline(t time.Time) error {
+ return s.Stream.SetReadDeadline(t)
+}
+
+func (s *QStream) Write(p []byte) (n int, err error) {
+ return s.Stream.Write(p)
+}
+
+func (s *QStream) Close() error {
+ s.Stream.CancelRead(0)
+ return s.Stream.Close()
+}
+
+func (s *QStream) CancelWrite(code quic.StreamErrorCode) {
+ s.Stream.CancelWrite(code)
+}
+
+func (s *QStream) Context() context.Context {
+ return s.Stream.Context()
+}
+
+func (s *QStream) SetWriteDeadline(t time.Time) error {
+ return s.Stream.SetWriteDeadline(t)
+}
+
+func (s *QStream) SetDeadline(t time.Time) error {
+ return s.Stream.SetDeadline(t)
+}
diff --git a/third_party/hysteria-core/server/.mockery.yaml b/third_party/hysteria-core/server/.mockery.yaml
new file mode 100644
index 0000000..d73136a
--- /dev/null
+++ b/third_party/hysteria-core/server/.mockery.yaml
@@ -0,0 +1,15 @@
+with-expecter: true
+inpackage: true
+dir: .
+packages:
+ github.com/apernet/hysteria/core/v2/server:
+ interfaces:
+ udpIO:
+ config:
+ mockname: mockUDPIO
+ udpEventLogger:
+ config:
+ mockname: mockUDPEventLogger
+ UDPConn:
+ config:
+ mockname: mockUDPConn
diff --git a/third_party/hysteria-core/server/config.go b/third_party/hysteria-core/server/config.go
new file mode 100644
index 0000000..5ef5b5a
--- /dev/null
+++ b/third_party/hysteria-core/server/config.go
@@ -0,0 +1,404 @@
+package server
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "io"
+ "net"
+ "net/http"
+ "sync/atomic"
+ "time"
+
+ "github.com/apernet/hysteria/core/v2/errors"
+ "github.com/apernet/hysteria/core/v2/internal/congestion"
+ "github.com/apernet/hysteria/core/v2/internal/pmtud"
+ "github.com/apernet/hysteria/core/v2/internal/utils"
+ "github.com/apernet/quic-go"
+)
+
+const (
+ defaultStreamReceiveWindow = 8388608 // 8MB
+ defaultConnReceiveWindow = defaultStreamReceiveWindow * 5 / 2 // 20MB
+ defaultMaxIdleTimeout = 30 * time.Second
+ defaultMaxIncomingStreams = 1024
+ defaultMaxIncomingUniStreams = 8
+ defaultMaxHTTPHeaderBytes = 16 << 10
+ defaultUDPIdleTimeout = 60 * time.Second
+ defaultMaxConnections = 512
+ defaultMaxClientConnections = 32
+ defaultMaxTCPHandlers = 1024
+ defaultMaxClientTCPHandlers = 128
+ defaultTCPRequestTimeout = 10 * time.Second
+ defaultAuthenticationTimeout = 10 * time.Second
+ defaultMaxUDPSessions = 1024
+ defaultMaxClientUDPSessions = 64
+)
+
+type Config struct {
+ TLSConfig TLSConfig
+ QUICConfig QUICConfig
+ Conn net.PacketConn
+ StatelessResetKey *quic.StatelessResetKey
+ Cleanup io.Closer
+ RequestHook RequestHook
+ Outbound Outbound
+ CongestionConfig CongestionConfig
+ BandwidthConfig BandwidthConfig
+ IgnoreClientBandwidth bool
+ DisableUDP bool
+ UDPIdleTimeout time.Duration
+ // Resource limits are AutoCAR's security hardening over the v2.12.1
+ // core. They bound work before an outbound socket exists, including
+ // unauthenticated QUIC connections and incomplete UDP fragments.
+ MaxConnections int
+ MaxClientConnections int
+ MaxTCPHandlers int
+ MaxClientTCPHandlers int
+ TCPRequestTimeout time.Duration
+ AuthenticationTimeout time.Duration
+ MaxHTTPHeaderBytes int
+ MaxUDPSessions int
+ MaxClientUDPSessions int
+ Authenticator Authenticator
+ EventLogger EventLogger
+ TrafficLogger TrafficLogger
+ MasqHandler http.Handler
+}
+
+// fill fills the fields that are not set by the user with default values when possible,
+// and returns an error if the user has not set a required field, or if a field is invalid.
+func (c *Config) fill() error {
+ if len(c.TLSConfig.Certificates) == 0 && c.TLSConfig.GetCertificate == nil {
+ return errors.ConfigError{Field: "TLSConfig", Reason: "must set at least one of Certificates or GetCertificate"}
+ }
+ if c.QUICConfig.InitialStreamReceiveWindow == 0 {
+ c.QUICConfig.InitialStreamReceiveWindow = defaultStreamReceiveWindow
+ } else if c.QUICConfig.InitialStreamReceiveWindow < 16384 {
+ return errors.ConfigError{Field: "QUICConfig.InitialStreamReceiveWindow", Reason: "must be at least 16384"}
+ }
+ if c.QUICConfig.MaxStreamReceiveWindow == 0 {
+ c.QUICConfig.MaxStreamReceiveWindow = defaultStreamReceiveWindow
+ } else if c.QUICConfig.MaxStreamReceiveWindow < 16384 {
+ return errors.ConfigError{Field: "QUICConfig.MaxStreamReceiveWindow", Reason: "must be at least 16384"}
+ }
+ if c.QUICConfig.InitialConnectionReceiveWindow == 0 {
+ c.QUICConfig.InitialConnectionReceiveWindow = defaultConnReceiveWindow
+ } else if c.QUICConfig.InitialConnectionReceiveWindow < 16384 {
+ return errors.ConfigError{Field: "QUICConfig.InitialConnectionReceiveWindow", Reason: "must be at least 16384"}
+ }
+ if c.QUICConfig.MaxConnectionReceiveWindow == 0 {
+ c.QUICConfig.MaxConnectionReceiveWindow = defaultConnReceiveWindow
+ } else if c.QUICConfig.MaxConnectionReceiveWindow < 16384 {
+ return errors.ConfigError{Field: "QUICConfig.MaxConnectionReceiveWindow", Reason: "must be at least 16384"}
+ }
+ if c.QUICConfig.MaxIdleTimeout == 0 {
+ c.QUICConfig.MaxIdleTimeout = defaultMaxIdleTimeout
+ } else if c.QUICConfig.MaxIdleTimeout < 4*time.Second || c.QUICConfig.MaxIdleTimeout > 120*time.Second {
+ return errors.ConfigError{Field: "QUICConfig.MaxIdleTimeout", Reason: "must be between 4s and 120s"}
+ }
+ if c.QUICConfig.MaxIncomingStreams == 0 {
+ c.QUICConfig.MaxIncomingStreams = defaultMaxIncomingStreams
+ } else if c.QUICConfig.MaxIncomingStreams < 8 {
+ return errors.ConfigError{Field: "QUICConfig.MaxIncomingStreams", Reason: "must be at least 8"}
+ }
+ if c.QUICConfig.MaxIncomingUniStreams == 0 {
+ c.QUICConfig.MaxIncomingUniStreams = defaultMaxIncomingUniStreams
+ } else if c.QUICConfig.MaxIncomingUniStreams < 3 {
+ return errors.ConfigError{Field: "QUICConfig.MaxIncomingUniStreams", Reason: "must be at least 3"}
+ }
+ c.QUICConfig.DisablePathMTUDiscovery = c.QUICConfig.DisablePathMTUDiscovery || pmtud.DisablePathMTUDiscovery
+ var err error
+ c.CongestionConfig.Type, err = congestion.NormalizeType(c.CongestionConfig.Type)
+ if err != nil {
+ return errors.ConfigError{Field: "CongestionConfig.Type", Reason: err.Error()}
+ }
+ if c.CongestionConfig.Type == congestion.TypeBBR {
+ c.CongestionConfig.BBRProfile, err = congestion.NormalizeBBRProfile(c.CongestionConfig.BBRProfile)
+ if err != nil {
+ return errors.ConfigError{Field: "CongestionConfig.BBRProfile", Reason: err.Error()}
+ }
+ }
+ if c.Conn == nil {
+ return errors.ConfigError{Field: "Conn", Reason: "must be set"}
+ }
+ if c.Outbound == nil {
+ c.Outbound = &defaultOutbound{}
+ }
+ if c.BandwidthConfig.MaxTx != 0 && c.BandwidthConfig.MaxTx < 65536 {
+ return errors.ConfigError{Field: "BandwidthConfig.MaxTx", Reason: "must be at least 65536"}
+ }
+ if c.BandwidthConfig.MaxRx != 0 && c.BandwidthConfig.MaxRx < 65536 {
+ return errors.ConfigError{Field: "BandwidthConfig.MaxRx", Reason: "must be at least 65536"}
+ }
+ if c.UDPIdleTimeout == 0 {
+ c.UDPIdleTimeout = defaultUDPIdleTimeout
+ } else if c.UDPIdleTimeout < 2*time.Second || c.UDPIdleTimeout > 600*time.Second {
+ return errors.ConfigError{Field: "UDPIdleTimeout", Reason: "must be between 2s and 600s"}
+ }
+ if c.MaxConnections == 0 {
+ c.MaxConnections = defaultMaxConnections
+ } else if c.MaxConnections < 1 {
+ return errors.ConfigError{Field: "MaxConnections", Reason: "must be positive"}
+ }
+ if c.MaxClientConnections == 0 {
+ c.MaxClientConnections = min(defaultMaxClientConnections, c.MaxConnections)
+ } else if c.MaxClientConnections < 1 {
+ return errors.ConfigError{Field: "MaxClientConnections", Reason: "must be positive"}
+ }
+ if c.MaxClientConnections > c.MaxConnections {
+ return errors.ConfigError{Field: "MaxClientConnections", Reason: "must not exceed MaxConnections"}
+ }
+ if c.MaxTCPHandlers == 0 {
+ c.MaxTCPHandlers = defaultMaxTCPHandlers
+ } else if c.MaxTCPHandlers < 1 {
+ return errors.ConfigError{Field: "MaxTCPHandlers", Reason: "must be positive"}
+ }
+ if c.MaxClientTCPHandlers == 0 {
+ c.MaxClientTCPHandlers = min(defaultMaxClientTCPHandlers, c.MaxTCPHandlers)
+ } else if c.MaxClientTCPHandlers < 1 {
+ return errors.ConfigError{Field: "MaxClientTCPHandlers", Reason: "must be positive"}
+ }
+ if c.MaxClientTCPHandlers > c.MaxTCPHandlers {
+ return errors.ConfigError{Field: "MaxClientTCPHandlers", Reason: "must not exceed MaxTCPHandlers"}
+ }
+ if c.TCPRequestTimeout == 0 {
+ c.TCPRequestTimeout = defaultTCPRequestTimeout
+ } else if c.TCPRequestTimeout < time.Second || c.TCPRequestTimeout > 60*time.Second {
+ return errors.ConfigError{Field: "TCPRequestTimeout", Reason: "must be between 1s and 60s"}
+ }
+ if c.AuthenticationTimeout == 0 {
+ c.AuthenticationTimeout = defaultAuthenticationTimeout
+ } else if c.AuthenticationTimeout < time.Second || c.AuthenticationTimeout > 60*time.Second {
+ return errors.ConfigError{Field: "AuthenticationTimeout", Reason: "must be between 1s and 60s"}
+ }
+ if c.MaxHTTPHeaderBytes == 0 {
+ c.MaxHTTPHeaderBytes = defaultMaxHTTPHeaderBytes
+ } else if c.MaxHTTPHeaderBytes < 8<<10 || c.MaxHTTPHeaderBytes > 1<<20 {
+ return errors.ConfigError{Field: "MaxHTTPHeaderBytes", Reason: "must be between 8192 and 1048576"}
+ }
+ if c.MaxUDPSessions == 0 {
+ c.MaxUDPSessions = defaultMaxUDPSessions
+ } else if c.MaxUDPSessions < 1 {
+ return errors.ConfigError{Field: "MaxUDPSessions", Reason: "must be positive"}
+ }
+ if c.MaxClientUDPSessions == 0 {
+ c.MaxClientUDPSessions = min(defaultMaxClientUDPSessions, c.MaxUDPSessions)
+ } else if c.MaxClientUDPSessions < 1 {
+ return errors.ConfigError{Field: "MaxClientUDPSessions", Reason: "must be positive"}
+ }
+ if c.MaxClientUDPSessions > c.MaxUDPSessions {
+ return errors.ConfigError{Field: "MaxClientUDPSessions", Reason: "must not exceed MaxUDPSessions"}
+ }
+ if c.Authenticator == nil {
+ return errors.ConfigError{Field: "Authenticator", Reason: "must be set"}
+ }
+ return nil
+}
+
+// TLSConfig contains the TLS configuration fields that we want to expose to the user.
+type TLSConfig struct {
+ Certificates []tls.Certificate
+ GetCertificate func(info *tls.ClientHelloInfo) (*tls.Certificate, error)
+ ClientCAs *x509.CertPool
+ ECHKeys []tls.EncryptedClientHelloKey
+ GetECHKeys func(info *tls.ClientHelloInfo) ([]tls.EncryptedClientHelloKey, error)
+}
+
+// QUICConfig contains the QUIC configuration fields that we want to expose to the user.
+type QUICConfig struct {
+ InitialStreamReceiveWindow uint64
+ MaxStreamReceiveWindow uint64
+ InitialConnectionReceiveWindow uint64
+ MaxConnectionReceiveWindow uint64
+ MaxIdleTimeout time.Duration
+ MaxIncomingStreams int64
+ MaxIncomingUniStreams int64
+ DisablePathMTUDiscovery bool // The server may still override this to true on unsupported platforms.
+ DisableGSO bool
+}
+
+type CongestionConfig struct {
+ Type string
+ BBRProfile string
+}
+
+// RequestHook allows filtering and modifying requests before the server connects to the remote.
+// A request will only be hooked if Check returns true.
+// The returned byte slice, if not empty, will be sent to the remote before proxying - this is
+// mainly for "putting back" the content read from the client for sniffing, etc.
+// Return a non-nil error to abort the connection.
+// Note that due to the current architectural limitations, it can only inspect the first packet
+// of a UDP connection. It also cannot put back any data as the first packet is always sent as-is.
+type RequestHook interface {
+ Check(isUDP bool, reqAddr string) bool
+ TCP(stream HyStream, reqAddr *string) ([]byte, error)
+ UDP(data []byte, reqAddr *string) error
+}
+
+// Outbound provides the implementation of how the server should connect to remote servers.
+// Although UDP includes a reqAddr, the implementation does not necessarily have to use it
+// to make a "connected" UDP connection that does not accept packets from other addresses.
+// In fact, the default implementation simply uses net.ListenUDP for a "full-cone" behavior.
+// CheckUDP is used to check if a UDP packet to reqAddr is permitted (useful for e.g. ACL).
+type Outbound interface {
+ TCP(reqAddr string) (net.Conn, error)
+ UDP(reqAddr string) (UDPConn, error)
+ CheckUDP(reqAddr string) error
+}
+
+// UDPConn is like net.PacketConn, but uses string for addresses.
+type UDPConn interface {
+ ReadFrom(b []byte) (int, string, error)
+ WriteTo(b []byte, addr string) (int, error)
+ Close() error
+}
+
+type defaultOutbound struct{}
+
+var defaultOutboundDialer = net.Dialer{
+ Timeout: 10 * time.Second,
+}
+
+func (o *defaultOutbound) TCP(reqAddr string) (net.Conn, error) {
+ return defaultOutboundDialer.Dial("tcp", reqAddr)
+}
+
+func (o *defaultOutbound) UDP(reqAddr string) (UDPConn, error) {
+ conn, err := net.ListenUDP("udp", nil)
+ if err != nil {
+ return nil, err
+ }
+ return &defaultUDPConn{conn}, nil
+}
+
+func (o *defaultOutbound) CheckUDP(reqAddr string) error {
+ return nil
+}
+
+type defaultUDPConn struct {
+ *net.UDPConn
+}
+
+func (c *defaultUDPConn) ReadFrom(b []byte) (int, string, error) {
+ n, addr, err := c.UDPConn.ReadFrom(b)
+ if addr != nil {
+ return n, addr.String(), err
+ } else {
+ return n, "", err
+ }
+}
+
+func (c *defaultUDPConn) WriteTo(b []byte, addr string) (int, error) {
+ uAddr, err := net.ResolveUDPAddr("udp", addr)
+ if err != nil {
+ return 0, err
+ }
+ return c.UDPConn.WriteTo(b, uAddr)
+}
+
+// BandwidthConfig describes the maximum bandwidth that the server can use, in bytes per second.
+type BandwidthConfig struct {
+ MaxTx uint64
+ MaxRx uint64
+ DisableLossCompensation bool
+}
+
+// Authenticator is an interface that provides authentication logic.
+type Authenticator interface {
+ Authenticate(addr net.Addr, auth string, tx uint64) (ok bool, id string)
+}
+
+// EventLogger is an interface that provides logging logic.
+type EventLogger interface {
+ Connect(addr net.Addr, id string, tx uint64)
+ Disconnect(addr net.Addr, id string, err error)
+ TCPRequest(addr net.Addr, id, reqAddr string)
+ TCPError(addr net.Addr, id, reqAddr string, err error)
+ UDPRequest(addr net.Addr, id string, sessionID uint32, reqAddr string)
+ UDPError(addr net.Addr, id string, sessionID uint32, err error)
+}
+
+type HyStream interface {
+ StreamID() quic.StreamID
+ Read(p []byte) (n int, err error)
+ Write(p []byte) (n int, err error)
+ Close() error
+ SetReadDeadline(t time.Time) error
+ SetWriteDeadline(t time.Time) error
+ SetDeadline(t time.Time) error
+}
+
+// TrafficLogger is an interface that provides traffic logging logic.
+// Tx/Rx in this context refers to the server-remote (proxy target) perspective.
+// Tx is the bytes sent from the server to the remote.
+// Rx is the bytes received by the server from the remote.
+// Apart from logging, the Log function can also return false to signal
+// that the client should be disconnected. This can be used to implement
+// bandwidth limits or post-connection authentication, for example.
+// The implementation of this interface must be thread-safe.
+type TrafficLogger interface {
+ LogTraffic(id string, tx, rx uint64) (ok bool)
+ LogOnlineState(id string, online bool)
+ TraceStream(stream HyStream, stats *StreamStats)
+ UntraceStream(stream HyStream)
+}
+
+type StreamState int
+
+const (
+ // StreamStateInitial indicates the initial state of a stream.
+ // Client has opened the stream, but we have not received the proxy request yet.
+ StreamStateInitial StreamState = iota
+
+ // StreamStateHooking indicates that the hook (usually sniff) is processing.
+ // Client has sent the proxy request, but sniff requires more data to complete.
+ StreamStateHooking
+
+ // StreamStateConnecting indicates that we are connecting to the proxy target.
+ StreamStateConnecting
+
+ // StreamStateEstablished indicates the proxy is established.
+ StreamStateEstablished
+
+ // StreamStateClosed indicates the stream is closed.
+ StreamStateClosed
+)
+
+func (s StreamState) String() string {
+ switch s {
+ case StreamStateInitial:
+ return "init"
+ case StreamStateHooking:
+ return "hook"
+ case StreamStateConnecting:
+ return "connect"
+ case StreamStateEstablished:
+ return "estab"
+ case StreamStateClosed:
+ return "closed"
+ default:
+ return "unknown"
+ }
+}
+
+type StreamStats struct {
+ State utils.Atomic[StreamState]
+
+ AuthID string
+ ConnID uint32
+ InitialTime time.Time
+
+ ReqAddr utils.Atomic[string]
+ HookedReqAddr utils.Atomic[string]
+
+ Tx atomic.Uint64
+ Rx atomic.Uint64
+
+ LastActiveTime utils.Atomic[time.Time]
+}
+
+func (s *StreamStats) setHookedReqAddr(addr string) {
+ if addr != s.ReqAddr.Load() {
+ s.HookedReqAddr.Store(addr)
+ }
+}
diff --git a/third_party/hysteria-core/server/copy.go b/third_party/hysteria-core/server/copy.go
new file mode 100644
index 0000000..ea916d8
--- /dev/null
+++ b/third_party/hysteria-core/server/copy.go
@@ -0,0 +1,80 @@
+package server
+
+import (
+ "errors"
+ "io"
+ "sync"
+ "time"
+)
+
+var errDisconnect = errors.New("traffic logger requested disconnect")
+
+var copyBufPool = sync.Pool{
+ New: func() any {
+ b := make([]byte, 32*1024)
+ return &b
+ },
+}
+
+func copyBufferLog(dst io.Writer, src io.Reader, log func(n uint64) bool) error {
+ bufp := copyBufPool.Get().(*[]byte)
+ buf := *bufp
+ defer copyBufPool.Put(bufp)
+
+ for {
+ nr, er := src.Read(buf)
+ if nr > 0 {
+ if !log(uint64(nr)) {
+ // Log returns false, which means that the client should be disconnected
+ return errDisconnect
+ }
+ _, ew := dst.Write(buf[0:nr])
+ if ew != nil {
+ return ew
+ }
+ }
+ if er != nil {
+ if er == io.EOF {
+ // EOF should not be considered as an error
+ return nil
+ }
+ return er
+ }
+ }
+}
+
+func copyTwoWayEx(id string, serverRw, remoteRw io.ReadWriter, l TrafficLogger, stats *StreamStats) error {
+ errChan := make(chan error, 2)
+ go func() {
+ errChan <- copyBufferLog(serverRw, remoteRw, func(n uint64) bool {
+ stats.LastActiveTime.Store(time.Now())
+ stats.Rx.Add(n)
+ return l.LogTraffic(id, 0, n)
+ })
+ }()
+ go func() {
+ errChan <- copyBufferLog(remoteRw, serverRw, func(n uint64) bool {
+ stats.LastActiveTime.Store(time.Now())
+ stats.Tx.Add(n)
+ return l.LogTraffic(id, n, 0)
+ })
+ }()
+ // Block until one of the two goroutines returns
+ return <-errChan
+}
+
+// copyTwoWay is the "fast-path" version of copyTwoWayEx that does not log traffic or update stream stats.
+// It uses the built-in io.Copy instead of our own copyBufferLog.
+func copyTwoWay(serverRw, remoteRw io.ReadWriter) error {
+ errChan := make(chan error, 2)
+ go func() {
+ _, err := io.Copy(serverRw, remoteRw)
+ errChan <- err
+ }()
+ go func() {
+ _, err := io.Copy(remoteRw, serverRw)
+ errChan <- err
+ }()
+ // Block until one of the two goroutines returns
+ return <-errChan
+}
diff --git a/third_party/hysteria-core/server/copy_benchmark_test.go b/third_party/hysteria-core/server/copy_benchmark_test.go
new file mode 100644
index 0000000..0f17bea
--- /dev/null
+++ b/third_party/hysteria-core/server/copy_benchmark_test.go
@@ -0,0 +1,23 @@
+package server
+
+import (
+ "bytes"
+ "io"
+ "testing"
+)
+
+func BenchmarkCopyBufferLog(b *testing.B) {
+ srcData := make([]byte, 1024*1024) // 1MB
+ for i := range srcData {
+ srcData[i] = byte(i)
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ for i := 0; i < b.N; i++ {
+ src := bytes.NewReader(srcData)
+ dst := io.Discard
+ copyBufferLog(dst, src, func(n uint64) bool { return true })
+ }
+}
diff --git a/third_party/hysteria-core/server/mock_UDPConn.go b/third_party/hysteria-core/server/mock_UDPConn.go
new file mode 100644
index 0000000..5f3d2e9
--- /dev/null
+++ b/third_party/hysteria-core/server/mock_UDPConn.go
@@ -0,0 +1,197 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package server
+
+import mock "github.com/stretchr/testify/mock"
+
+// mockUDPConn is an autogenerated mock type for the UDPConn type
+type mockUDPConn struct {
+ mock.Mock
+}
+
+type mockUDPConn_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *mockUDPConn) EXPECT() *mockUDPConn_Expecter {
+ return &mockUDPConn_Expecter{mock: &_m.Mock}
+}
+
+// Close provides a mock function with no fields
+func (_m *mockUDPConn) Close() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Close")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// mockUDPConn_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
+type mockUDPConn_Close_Call struct {
+ *mock.Call
+}
+
+// Close is a helper method to define mock.On call
+func (_e *mockUDPConn_Expecter) Close() *mockUDPConn_Close_Call {
+ return &mockUDPConn_Close_Call{Call: _e.mock.On("Close")}
+}
+
+func (_c *mockUDPConn_Close_Call) Run(run func()) *mockUDPConn_Close_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *mockUDPConn_Close_Call) Return(_a0 error) *mockUDPConn_Close_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *mockUDPConn_Close_Call) RunAndReturn(run func() error) *mockUDPConn_Close_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ReadFrom provides a mock function with given fields: b
+func (_m *mockUDPConn) ReadFrom(b []byte) (int, string, error) {
+ ret := _m.Called(b)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ReadFrom")
+ }
+
+ var r0 int
+ var r1 string
+ var r2 error
+ if rf, ok := ret.Get(0).(func([]byte) (int, string, error)); ok {
+ return rf(b)
+ }
+ if rf, ok := ret.Get(0).(func([]byte) int); ok {
+ r0 = rf(b)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+
+ if rf, ok := ret.Get(1).(func([]byte) string); ok {
+ r1 = rf(b)
+ } else {
+ r1 = ret.Get(1).(string)
+ }
+
+ if rf, ok := ret.Get(2).(func([]byte) error); ok {
+ r2 = rf(b)
+ } else {
+ r2 = ret.Error(2)
+ }
+
+ return r0, r1, r2
+}
+
+// mockUDPConn_ReadFrom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadFrom'
+type mockUDPConn_ReadFrom_Call struct {
+ *mock.Call
+}
+
+// ReadFrom is a helper method to define mock.On call
+// - b []byte
+func (_e *mockUDPConn_Expecter) ReadFrom(b interface{}) *mockUDPConn_ReadFrom_Call {
+ return &mockUDPConn_ReadFrom_Call{Call: _e.mock.On("ReadFrom", b)}
+}
+
+func (_c *mockUDPConn_ReadFrom_Call) Run(run func(b []byte)) *mockUDPConn_ReadFrom_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte))
+ })
+ return _c
+}
+
+func (_c *mockUDPConn_ReadFrom_Call) Return(_a0 int, _a1 string, _a2 error) *mockUDPConn_ReadFrom_Call {
+ _c.Call.Return(_a0, _a1, _a2)
+ return _c
+}
+
+func (_c *mockUDPConn_ReadFrom_Call) RunAndReturn(run func([]byte) (int, string, error)) *mockUDPConn_ReadFrom_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// WriteTo provides a mock function with given fields: b, addr
+func (_m *mockUDPConn) WriteTo(b []byte, addr string) (int, error) {
+ ret := _m.Called(b, addr)
+
+ if len(ret) == 0 {
+ panic("no return value specified for WriteTo")
+ }
+
+ var r0 int
+ var r1 error
+ if rf, ok := ret.Get(0).(func([]byte, string) (int, error)); ok {
+ return rf(b, addr)
+ }
+ if rf, ok := ret.Get(0).(func([]byte, string) int); ok {
+ r0 = rf(b, addr)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+
+ if rf, ok := ret.Get(1).(func([]byte, string) error); ok {
+ r1 = rf(b, addr)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// mockUDPConn_WriteTo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteTo'
+type mockUDPConn_WriteTo_Call struct {
+ *mock.Call
+}
+
+// WriteTo is a helper method to define mock.On call
+// - b []byte
+// - addr string
+func (_e *mockUDPConn_Expecter) WriteTo(b interface{}, addr interface{}) *mockUDPConn_WriteTo_Call {
+ return &mockUDPConn_WriteTo_Call{Call: _e.mock.On("WriteTo", b, addr)}
+}
+
+func (_c *mockUDPConn_WriteTo_Call) Run(run func(b []byte, addr string)) *mockUDPConn_WriteTo_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte), args[1].(string))
+ })
+ return _c
+}
+
+func (_c *mockUDPConn_WriteTo_Call) Return(_a0 int, _a1 error) *mockUDPConn_WriteTo_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *mockUDPConn_WriteTo_Call) RunAndReturn(run func([]byte, string) (int, error)) *mockUDPConn_WriteTo_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// newMockUDPConn creates a new instance of mockUDPConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockUDPConn(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *mockUDPConn {
+ mock := &mockUDPConn{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/third_party/hysteria-core/server/mock_udpEventLogger.go b/third_party/hysteria-core/server/mock_udpEventLogger.go
new file mode 100644
index 0000000..e1d3db9
--- /dev/null
+++ b/third_party/hysteria-core/server/mock_udpEventLogger.go
@@ -0,0 +1,100 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package server
+
+import mock "github.com/stretchr/testify/mock"
+
+// mockUDPEventLogger is an autogenerated mock type for the udpEventLogger type
+type mockUDPEventLogger struct {
+ mock.Mock
+}
+
+type mockUDPEventLogger_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *mockUDPEventLogger) EXPECT() *mockUDPEventLogger_Expecter {
+ return &mockUDPEventLogger_Expecter{mock: &_m.Mock}
+}
+
+// Close provides a mock function with given fields: sessionID, err
+func (_m *mockUDPEventLogger) Close(sessionID uint32, err error) {
+ _m.Called(sessionID, err)
+}
+
+// mockUDPEventLogger_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
+type mockUDPEventLogger_Close_Call struct {
+ *mock.Call
+}
+
+// Close is a helper method to define mock.On call
+// - sessionID uint32
+// - err error
+func (_e *mockUDPEventLogger_Expecter) Close(sessionID interface{}, err interface{}) *mockUDPEventLogger_Close_Call {
+ return &mockUDPEventLogger_Close_Call{Call: _e.mock.On("Close", sessionID, err)}
+}
+
+func (_c *mockUDPEventLogger_Close_Call) Run(run func(sessionID uint32, err error)) *mockUDPEventLogger_Close_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(uint32), args[1].(error))
+ })
+ return _c
+}
+
+func (_c *mockUDPEventLogger_Close_Call) Return() *mockUDPEventLogger_Close_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *mockUDPEventLogger_Close_Call) RunAndReturn(run func(uint32, error)) *mockUDPEventLogger_Close_Call {
+ _c.Run(run)
+ return _c
+}
+
+// New provides a mock function with given fields: sessionID, reqAddr
+func (_m *mockUDPEventLogger) New(sessionID uint32, reqAddr string) {
+ _m.Called(sessionID, reqAddr)
+}
+
+// mockUDPEventLogger_New_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'New'
+type mockUDPEventLogger_New_Call struct {
+ *mock.Call
+}
+
+// New is a helper method to define mock.On call
+// - sessionID uint32
+// - reqAddr string
+func (_e *mockUDPEventLogger_Expecter) New(sessionID interface{}, reqAddr interface{}) *mockUDPEventLogger_New_Call {
+ return &mockUDPEventLogger_New_Call{Call: _e.mock.On("New", sessionID, reqAddr)}
+}
+
+func (_c *mockUDPEventLogger_New_Call) Run(run func(sessionID uint32, reqAddr string)) *mockUDPEventLogger_New_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(uint32), args[1].(string))
+ })
+ return _c
+}
+
+func (_c *mockUDPEventLogger_New_Call) Return() *mockUDPEventLogger_New_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *mockUDPEventLogger_New_Call) RunAndReturn(run func(uint32, string)) *mockUDPEventLogger_New_Call {
+ _c.Run(run)
+ return _c
+}
+
+// newMockUDPEventLogger creates a new instance of mockUDPEventLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockUDPEventLogger(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *mockUDPEventLogger {
+ mock := &mockUDPEventLogger{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/third_party/hysteria-core/server/mock_udpIO.go b/third_party/hysteria-core/server/mock_udpIO.go
new file mode 100644
index 0000000..bb512c0
--- /dev/null
+++ b/third_party/hysteria-core/server/mock_udpIO.go
@@ -0,0 +1,290 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package server
+
+import (
+ protocol "github.com/apernet/hysteria/core/v2/internal/protocol"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// mockUDPIO is an autogenerated mock type for the udpIO type
+type mockUDPIO struct {
+ mock.Mock
+}
+
+type mockUDPIO_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *mockUDPIO) EXPECT() *mockUDPIO_Expecter {
+ return &mockUDPIO_Expecter{mock: &_m.Mock}
+}
+
+// CheckUDP provides a mock function with given fields: reqAddr
+func (_m *mockUDPIO) CheckUDP(reqAddr string) error {
+ ret := _m.Called(reqAddr)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CheckUDP")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string) error); ok {
+ r0 = rf(reqAddr)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// mockUDPIO_CheckUDP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckUDP'
+type mockUDPIO_CheckUDP_Call struct {
+ *mock.Call
+}
+
+// CheckUDP is a helper method to define mock.On call
+// - reqAddr string
+func (_e *mockUDPIO_Expecter) CheckUDP(reqAddr interface{}) *mockUDPIO_CheckUDP_Call {
+ return &mockUDPIO_CheckUDP_Call{Call: _e.mock.On("CheckUDP", reqAddr)}
+}
+
+func (_c *mockUDPIO_CheckUDP_Call) Run(run func(reqAddr string)) *mockUDPIO_CheckUDP_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *mockUDPIO_CheckUDP_Call) Return(_a0 error) *mockUDPIO_CheckUDP_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *mockUDPIO_CheckUDP_Call) RunAndReturn(run func(string) error) *mockUDPIO_CheckUDP_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Hook provides a mock function with given fields: data, reqAddr
+func (_m *mockUDPIO) Hook(data []byte, reqAddr *string) error {
+ ret := _m.Called(data, reqAddr)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Hook")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func([]byte, *string) error); ok {
+ r0 = rf(data, reqAddr)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// mockUDPIO_Hook_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hook'
+type mockUDPIO_Hook_Call struct {
+ *mock.Call
+}
+
+// Hook is a helper method to define mock.On call
+// - data []byte
+// - reqAddr *string
+func (_e *mockUDPIO_Expecter) Hook(data interface{}, reqAddr interface{}) *mockUDPIO_Hook_Call {
+ return &mockUDPIO_Hook_Call{Call: _e.mock.On("Hook", data, reqAddr)}
+}
+
+func (_c *mockUDPIO_Hook_Call) Run(run func(data []byte, reqAddr *string)) *mockUDPIO_Hook_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte), args[1].(*string))
+ })
+ return _c
+}
+
+func (_c *mockUDPIO_Hook_Call) Return(_a0 error) *mockUDPIO_Hook_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *mockUDPIO_Hook_Call) RunAndReturn(run func([]byte, *string) error) *mockUDPIO_Hook_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ReceiveMessage provides a mock function with no fields
+func (_m *mockUDPIO) ReceiveMessage() (*protocol.UDPMessage, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for ReceiveMessage")
+ }
+
+ var r0 *protocol.UDPMessage
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (*protocol.UDPMessage, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() *protocol.UDPMessage); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*protocol.UDPMessage)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// mockUDPIO_ReceiveMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReceiveMessage'
+type mockUDPIO_ReceiveMessage_Call struct {
+ *mock.Call
+}
+
+// ReceiveMessage is a helper method to define mock.On call
+func (_e *mockUDPIO_Expecter) ReceiveMessage() *mockUDPIO_ReceiveMessage_Call {
+ return &mockUDPIO_ReceiveMessage_Call{Call: _e.mock.On("ReceiveMessage")}
+}
+
+func (_c *mockUDPIO_ReceiveMessage_Call) Run(run func()) *mockUDPIO_ReceiveMessage_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *mockUDPIO_ReceiveMessage_Call) Return(_a0 *protocol.UDPMessage, _a1 error) *mockUDPIO_ReceiveMessage_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *mockUDPIO_ReceiveMessage_Call) RunAndReturn(run func() (*protocol.UDPMessage, error)) *mockUDPIO_ReceiveMessage_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SendMessage provides a mock function with given fields: _a0, _a1
+func (_m *mockUDPIO) SendMessage(_a0 []byte, _a1 *protocol.UDPMessage) error {
+ ret := _m.Called(_a0, _a1)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SendMessage")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func([]byte, *protocol.UDPMessage) error); ok {
+ r0 = rf(_a0, _a1)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// mockUDPIO_SendMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendMessage'
+type mockUDPIO_SendMessage_Call struct {
+ *mock.Call
+}
+
+// SendMessage is a helper method to define mock.On call
+// - _a0 []byte
+// - _a1 *protocol.UDPMessage
+func (_e *mockUDPIO_Expecter) SendMessage(_a0 interface{}, _a1 interface{}) *mockUDPIO_SendMessage_Call {
+ return &mockUDPIO_SendMessage_Call{Call: _e.mock.On("SendMessage", _a0, _a1)}
+}
+
+func (_c *mockUDPIO_SendMessage_Call) Run(run func(_a0 []byte, _a1 *protocol.UDPMessage)) *mockUDPIO_SendMessage_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte), args[1].(*protocol.UDPMessage))
+ })
+ return _c
+}
+
+func (_c *mockUDPIO_SendMessage_Call) Return(_a0 error) *mockUDPIO_SendMessage_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *mockUDPIO_SendMessage_Call) RunAndReturn(run func([]byte, *protocol.UDPMessage) error) *mockUDPIO_SendMessage_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// UDP provides a mock function with given fields: reqAddr
+func (_m *mockUDPIO) UDP(reqAddr string) (UDPConn, error) {
+ ret := _m.Called(reqAddr)
+
+ if len(ret) == 0 {
+ panic("no return value specified for UDP")
+ }
+
+ var r0 UDPConn
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (UDPConn, error)); ok {
+ return rf(reqAddr)
+ }
+ if rf, ok := ret.Get(0).(func(string) UDPConn); ok {
+ r0 = rf(reqAddr)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(UDPConn)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(reqAddr)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// mockUDPIO_UDP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UDP'
+type mockUDPIO_UDP_Call struct {
+ *mock.Call
+}
+
+// UDP is a helper method to define mock.On call
+// - reqAddr string
+func (_e *mockUDPIO_Expecter) UDP(reqAddr interface{}) *mockUDPIO_UDP_Call {
+ return &mockUDPIO_UDP_Call{Call: _e.mock.On("UDP", reqAddr)}
+}
+
+func (_c *mockUDPIO_UDP_Call) Run(run func(reqAddr string)) *mockUDPIO_UDP_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *mockUDPIO_UDP_Call) Return(_a0 UDPConn, _a1 error) *mockUDPIO_UDP_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *mockUDPIO_UDP_Call) RunAndReturn(run func(string) (UDPConn, error)) *mockUDPIO_UDP_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// newMockUDPIO creates a new instance of mockUDPIO. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockUDPIO(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *mockUDPIO {
+ mock := &mockUDPIO{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/third_party/hysteria-core/server/resource_limits_test.go b/third_party/hysteria-core/server/resource_limits_test.go
new file mode 100644
index 0000000..0219725
--- /dev/null
+++ b/third_party/hysteria-core/server/resource_limits_test.go
@@ -0,0 +1,409 @@
+package server
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "net"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/apernet/quic-go"
+
+ "github.com/apernet/hysteria/core/v2/internal/frag"
+ "github.com/apernet/hysteria/core/v2/internal/protocol"
+)
+
+type testAuthenticator struct{}
+
+func (testAuthenticator) Authenticate(net.Addr, string, uint64) (bool, string) {
+ return true, "test"
+}
+
+func TestPerSourceDefaultsRespectSmallGlobalLimits(t *testing.T) {
+ packetConn, err := net.ListenPacket("udp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer packetConn.Close()
+ config := &Config{
+ TLSConfig: TLSConfig{Certificates: []tls.Certificate{{}}},
+ Conn: packetConn,
+ MaxConnections: 2,
+ MaxTCPHandlers: 3,
+ MaxUDPSessions: 4,
+ Authenticator: testAuthenticator{},
+ }
+ if err := config.fill(); err != nil {
+ t.Fatal(err)
+ }
+ if config.MaxClientConnections != 2 || config.MaxClientTCPHandlers != 3 || config.MaxClientUDPSessions != 4 {
+ t.Fatalf("per-source defaults = (%d, %d, %d), want (2, 3, 4)", config.MaxClientConnections, config.MaxClientTCPHandlers, config.MaxClientUDPSessions)
+ }
+}
+
+func TestConnectionAdmissionCoversHandshakeLifecycle(t *testing.T) {
+ slots := make(chan struct{}, 1)
+ clients := newKeyedLimiter(1)
+ firstContext, cancelFirst := context.WithCancel(context.Background())
+ defer cancelFirst()
+ if _, err := admitConnection(firstContext, slots, clients, "192.0.2.10"); err != nil {
+ t.Fatalf("first connection admission: %v", err)
+ }
+ if _, err := admitConnection(context.Background(), slots, clients, "192.0.2.11"); !errors.Is(err, errConnectionCapacity) {
+ t.Fatalf("second connection admission error = %v, want capacity", err)
+ }
+ cancelFirst()
+ deadline := time.Now().Add(time.Second)
+ for len(slots) != 0 && time.Now().Before(deadline) {
+ time.Sleep(time.Millisecond)
+ }
+ if len(slots) != 0 {
+ t.Fatal("connection slot was not released when its context ended")
+ }
+ secondContext, cancelSecond := context.WithCancel(context.Background())
+ defer cancelSecond()
+ if _, err := admitConnection(secondContext, slots, clients, "192.0.2.11"); err != nil {
+ t.Fatalf("released connection slot was not reusable: %v", err)
+ }
+}
+
+func TestConnectionAdmissionLimitIsSharedAcrossSourceConnections(t *testing.T) {
+ global := make(chan struct{}, 3)
+ clients := newKeyedLimiter(1)
+ firstContext, cancelFirst := context.WithCancel(context.Background())
+ defer cancelFirst()
+ if _, err := admitConnection(firstContext, global, clients, "192.0.2.10"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := admitConnection(context.Background(), global, clients, "192.0.2.10"); !errors.Is(err, errConnectionCapacity) {
+ t.Fatalf("same-source connection error = %v, want capacity", err)
+ }
+ otherContext, cancelOther := context.WithCancel(context.Background())
+ defer cancelOther()
+ if _, err := admitConnection(otherContext, global, clients, "192.0.2.11"); err != nil {
+ t.Fatalf("different source was rejected: %v", err)
+ }
+ if len(global) != 2 || clients.count("192.0.2.10") != 1 || clients.count("192.0.2.11") != 1 {
+ t.Fatalf("unexpected admission state: global=%d first=%d other=%d", len(global), clients.count("192.0.2.10"), clients.count("192.0.2.11"))
+ }
+
+ cancelFirst()
+ cancelOther()
+ deadline := time.Now().Add(time.Second)
+ for (len(global) != 0 || clients.count("192.0.2.10") != 0 || clients.count("192.0.2.11") != 0) && time.Now().Before(deadline) {
+ time.Sleep(time.Millisecond)
+ }
+ if len(global) != 0 || clients.count("192.0.2.10") != 0 || clients.count("192.0.2.11") != 0 {
+ t.Fatal("connection admission retained capacity or empty source entries")
+ }
+}
+
+func TestSourceIPKeyGroupsIPv6PrefixAndIgnoresPort(t *testing.T) {
+ first := &net.UDPAddr{IP: net.ParseIP("2001:db8:1234:5678::1"), Port: 443}
+ second := &net.UDPAddr{IP: net.ParseIP("2001:db8:1234:5678:ffff::2"), Port: 8443}
+ other := &net.UDPAddr{IP: net.ParseIP("2001:db8:1234:5679::1"), Port: 443}
+ if sourceIPKey(first) != sourceIPKey(second) {
+ t.Fatalf("same IPv6 /64 produced different keys: %q and %q", sourceIPKey(first), sourceIPKey(second))
+ }
+ if sourceIPKey(first) == sourceIPKey(other) {
+ t.Fatalf("different IPv6 /64 prefixes shared key %q", sourceIPKey(first))
+ }
+ if got := sourceIPKey(&net.UDPAddr{IP: net.ParseIP("192.0.2.10"), Port: 443}); got != "192.0.2.10" {
+ t.Fatalf("IPv4 key = %q, want address without port", got)
+ }
+}
+
+func TestUDPSessionAdmissionPrecedesDefragmentation(t *testing.T) {
+ ioMock := newMockUDPIO(t)
+ events := newMockUDPEventLogger(t)
+ global := make(chan struct{}, 1)
+ first := newUDPSessionManager(ioMock, events, time.Minute, 1, global, "client-a", nil)
+ second := newUDPSessionManager(ioMock, events, time.Minute, 1, global, "client-b", nil)
+
+ incomplete := func(id uint32) *protocol.UDPMessage {
+ return &protocol.UDPMessage{
+ SessionID: id,
+ PacketID: 1,
+ FragID: 0,
+ FragCount: 2,
+ Addr: "example.test:443",
+ Data: []byte("partial"),
+ }
+ }
+
+ first.feed(incomplete(1))
+ second.feed(incomplete(2))
+ if got := first.Count(); got != 1 {
+ t.Fatalf("first manager count = %d, want 1", got)
+ }
+ if got := second.Count(); got != 0 {
+ t.Fatalf("global admission allowed second incomplete session: %d", got)
+ }
+ if got := len(global); got != 1 {
+ t.Fatalf("global slots = %d, want 1", got)
+ }
+
+ events.EXPECT().Close(uint32(1), nil).Once()
+ first.cleanup(false)
+ second.feed(incomplete(2))
+ if got := second.Count(); got != 1 {
+ t.Fatalf("released slot was not reusable: count = %d", got)
+ }
+ events.EXPECT().Close(uint32(2), nil).Once()
+ second.cleanup(false)
+}
+
+func TestUDPSessionRejectsExcessiveFragmentsWithoutState(t *testing.T) {
+ ioMock := newMockUDPIO(t)
+ events := newMockUDPEventLogger(t)
+ global := make(chan struct{}, 1)
+ manager := newUDPSessionManager(ioMock, events, time.Minute, 1, global, "client", nil)
+ manager.feed(&protocol.UDPMessage{
+ SessionID: 7,
+ PacketID: 1,
+ FragID: 0,
+ FragCount: frag.MaxFragments + 1,
+ Addr: "example.test:443",
+ Data: []byte("partial"),
+ })
+ if got := manager.Count(); got != 0 {
+ t.Fatalf("malformed fragment allocated %d sessions", got)
+ }
+ if got := len(global); got != 0 {
+ t.Fatalf("malformed fragment consumed %d global slots", got)
+ }
+}
+
+func TestUDPSessionLimitIsSharedAcrossClientConnections(t *testing.T) {
+ ioMock := newMockUDPIO(t)
+ events := newMockUDPEventLogger(t)
+ global := make(chan struct{}, 3)
+ clients := newKeyedLimiter(1)
+ first := newUDPSessionManager(ioMock, events, time.Minute, 2, global, "192.0.2.10", clients)
+ second := newUDPSessionManager(ioMock, events, time.Minute, 2, global, "192.0.2.10", clients)
+ other := newUDPSessionManager(ioMock, events, time.Minute, 2, global, "192.0.2.11", clients)
+ message := func(id uint32) *protocol.UDPMessage {
+ return &protocol.UDPMessage{
+ SessionID: id,
+ PacketID: 1,
+ FragID: 0,
+ FragCount: 2,
+ Addr: "example.test:443",
+ Data: []byte("partial"),
+ }
+ }
+
+ first.feed(message(1))
+ second.feed(message(2))
+ other.feed(message(3))
+ if first.Count() != 1 || second.Count() != 0 || other.Count() != 1 {
+ t.Fatalf("cross-connection counts = (%d, %d, %d), want (1, 0, 1)", first.Count(), second.Count(), other.Count())
+ }
+ if got := clients.count("192.0.2.10"); got != 1 {
+ t.Fatalf("shared client count = %d, want 1", got)
+ }
+
+ events.EXPECT().Close(uint32(1), nil).Once()
+ first.cleanup(false)
+ second.feed(message(2))
+ if second.Count() != 1 {
+ t.Fatal("released per-client slot was not reusable by another connection")
+ }
+ events.EXPECT().Close(uint32(2), nil).Once()
+ events.EXPECT().Close(uint32(3), nil).Once()
+ second.cleanup(false)
+ other.cleanup(false)
+ if clients.count("192.0.2.10") != 0 || clients.count("192.0.2.11") != 0 {
+ t.Fatal("per-client limiter retained empty source entries")
+ }
+}
+
+func TestPendingTCPHeaderTimesOutAndReleasesSlot(t *testing.T) {
+ stream := newDeadlineStream()
+ slots := make(chan struct{}, 1)
+ handler := &h3sHandler{
+ config: &Config{TCPRequestTimeout: 20 * time.Millisecond},
+ tcpSlots: slots,
+ }
+ release, admitted := handler.admitStream(stream)
+ if !admitted || release == nil {
+ t.Fatal("stream was not admitted")
+ }
+ if got := len(slots); got != 1 {
+ t.Fatalf("TCP handler slots after admission = %d, want 1", got)
+ }
+ done := make(chan struct{})
+ go func() {
+ handler.handleTCPRequest(stream, "test-auth")
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("pending TCP header did not time out")
+ }
+ if !stream.wasClosed() {
+ t.Fatal("timed-out stream was not closed")
+ }
+ release()
+ release()
+ if got := len(slots); got != 0 {
+ t.Fatalf("TCP handler slot was not released exactly once: %d", got)
+ }
+}
+
+func TestTCPHandlerLimitIsSharedAcrossClientConnections(t *testing.T) {
+ global := make(chan struct{}, 3)
+ clients := newKeyedLimiter(1)
+ first := &h3sHandler{
+ config: &Config{TCPRequestTimeout: time.Second},
+ clientKey: "192.0.2.10",
+ tcpSlots: global,
+ tcpClientSlots: clients,
+ }
+ second := &h3sHandler{
+ config: &Config{TCPRequestTimeout: time.Second},
+ clientKey: "192.0.2.10",
+ tcpSlots: global,
+ tcpClientSlots: clients,
+ }
+ other := &h3sHandler{
+ config: &Config{TCPRequestTimeout: time.Second},
+ clientKey: "192.0.2.11",
+ tcpSlots: global,
+ tcpClientSlots: clients,
+ }
+
+ firstRelease, admitted := first.admitStream(newDeadlineStream())
+ if !admitted {
+ t.Fatal("first source stream was rejected")
+ }
+ if release, admitted := second.admitStream(newDeadlineStream()); admitted || release != nil {
+ t.Fatal("second connection bypassed the shared per-source TCP limit")
+ }
+ otherRelease, admitted := other.admitStream(newDeadlineStream())
+ if !admitted {
+ t.Fatal("different source was rejected while global capacity remained")
+ }
+ if len(global) != 2 || clients.count("192.0.2.10") != 1 || clients.count("192.0.2.11") != 1 {
+ t.Fatalf("unexpected limiter state: global=%d first=%d other=%d", len(global), clients.count("192.0.2.10"), clients.count("192.0.2.11"))
+ }
+
+ firstRelease()
+ secondRelease, admitted := second.admitStream(newDeadlineStream())
+ if !admitted {
+ t.Fatal("released per-source TCP slot was not reusable across connections")
+ }
+ secondRelease()
+ otherRelease()
+ if len(global) != 0 || clients.count("192.0.2.10") != 0 || clients.count("192.0.2.11") != 0 {
+ t.Fatal("TCP limiters retained capacity or empty source entries")
+ }
+}
+
+func TestAuthStateWaitsForAuthenticationUpdate(t *testing.T) {
+ handler := &h3sHandler{}
+ handler.authMutex.Lock()
+ result := make(chan struct {
+ id string
+ ok bool
+ }, 1)
+ go func() {
+ id, ok := handler.authState()
+ result <- struct {
+ id string
+ ok bool
+ }{id: id, ok: ok}
+ }()
+
+ select {
+ case <-result:
+ t.Fatal("authState returned during an in-progress authentication update")
+ case <-time.After(20 * time.Millisecond):
+ }
+ handler.authID = "authenticated-user"
+ handler.authenticated = true
+ handler.authMutex.Unlock()
+
+ select {
+ case state := <-result:
+ if !state.ok || state.id != "authenticated-user" {
+ t.Fatalf("authState = (%q, %v), want authenticated user", state.id, state.ok)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("authState remained blocked after authentication completed")
+ }
+}
+
+func TestAuthenticationExpiryIsOneShotAndFailClosed(t *testing.T) {
+ handler := &h3sHandler{}
+ if !handler.expireAuthentication() {
+ t.Fatal("first unauthenticated expiry was ignored")
+ }
+ if handler.expireAuthentication() {
+ t.Fatal("authentication expiry fired more than once")
+ }
+ if id, ok := handler.authState(); ok || id != "" {
+ t.Fatalf("expired auth state = (%q, %v), want unauthenticated", id, ok)
+ }
+
+ authenticated := &h3sHandler{authenticated: true, authID: "client"}
+ if authenticated.expireAuthentication() {
+ t.Fatal("authenticated session was expired")
+ }
+}
+
+type deadlineStream struct {
+ mu sync.Mutex
+ deadline time.Time
+ closed bool
+}
+
+func newDeadlineStream() *deadlineStream { return &deadlineStream{} }
+
+func (s *deadlineStream) StreamID() quic.StreamID { return 0 }
+func (s *deadlineStream) Write(p []byte) (int, error) { return len(p), nil }
+func (s *deadlineStream) SetWriteDeadline(time.Time) error { return nil }
+func (s *deadlineStream) SetDeadline(deadline time.Time) error {
+ return s.SetReadDeadline(deadline)
+}
+func (s *deadlineStream) SetReadDeadline(deadline time.Time) error {
+ s.mu.Lock()
+ s.deadline = deadline
+ s.mu.Unlock()
+ return nil
+}
+func (s *deadlineStream) Read([]byte) (int, error) {
+ s.mu.Lock()
+ deadline := s.deadline
+ s.mu.Unlock()
+ if deadline.IsZero() {
+ return 0, errors.New("missing read deadline")
+ }
+ if delay := time.Until(deadline); delay > 0 {
+ time.Sleep(delay)
+ }
+ return 0, timeoutError{}
+}
+func (s *deadlineStream) Close() error {
+ s.mu.Lock()
+ s.closed = true
+ s.mu.Unlock()
+ return nil
+}
+func (s *deadlineStream) wasClosed() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.closed
+}
+
+type timeoutError struct{}
+
+func (timeoutError) Error() string { return "deadline exceeded" }
+func (timeoutError) Timeout() bool { return true }
+func (timeoutError) Temporary() bool { return true }
+
+var _ HyStream = (*deadlineStream)(nil)
diff --git a/third_party/hysteria-core/server/server.go b/third_party/hysteria-core/server/server.go
new file mode 100644
index 0000000..c9b0abe
--- /dev/null
+++ b/third_party/hysteria-core/server/server.go
@@ -0,0 +1,601 @@
+package server
+
+import (
+ "context"
+ crand "crypto/rand"
+ "crypto/tls"
+ "errors"
+ "math/rand"
+ "net"
+ "net/http"
+ "net/netip"
+ "sync"
+ "time"
+
+ "github.com/apernet/quic-go"
+ "github.com/apernet/quic-go/http3"
+ "github.com/apernet/quic-go/quicvarint"
+
+ coreErrs "github.com/apernet/hysteria/core/v2/errors"
+ "github.com/apernet/hysteria/core/v2/internal/congestion"
+ "github.com/apernet/hysteria/core/v2/internal/protocol"
+ "github.com/apernet/hysteria/core/v2/internal/utils"
+)
+
+const (
+ closeErrCodeOK = 0x100 // HTTP3 ErrCodeNoError
+ closeErrCodeTrafficLimitReached = 0x107 // HTTP3 ErrCodeExcessiveLoad
+)
+
+type Server interface {
+ Serve() error
+ Close() error
+}
+
+func convertToStdTLSConfig(config *Config) *tls.Config {
+ var clientAuth tls.ClientAuthType
+ if config.TLSConfig.ClientCAs != nil {
+ clientAuth = tls.RequireAndVerifyClientCert
+ } else {
+ clientAuth = tls.NoClientCert
+ }
+ return http3.ConfigureTLSConfig(&tls.Config{
+ Certificates: config.TLSConfig.Certificates,
+ GetCertificate: config.TLSConfig.GetCertificate,
+ ClientCAs: config.TLSConfig.ClientCAs,
+ ClientAuth: clientAuth,
+ EncryptedClientHelloKeys: config.TLSConfig.ECHKeys,
+ GetEncryptedClientHelloKeys: config.TLSConfig.GetECHKeys,
+ })
+}
+
+func NewServer(config *Config) (Server, error) {
+ if err := config.fill(); err != nil {
+ return nil, err
+ }
+ tlsConfig := convertToStdTLSConfig(config)
+ quicConfig := &quic.Config{
+ InitialStreamReceiveWindow: config.QUICConfig.InitialStreamReceiveWindow,
+ MaxStreamReceiveWindow: config.QUICConfig.MaxStreamReceiveWindow,
+ InitialConnectionReceiveWindow: config.QUICConfig.InitialConnectionReceiveWindow,
+ MaxConnectionReceiveWindow: config.QUICConfig.MaxConnectionReceiveWindow,
+ MaxIdleTimeout: config.QUICConfig.MaxIdleTimeout,
+ MaxIncomingStreams: config.QUICConfig.MaxIncomingStreams,
+ MaxIncomingUniStreams: config.QUICConfig.MaxIncomingUniStreams,
+ DisablePathMTUDiscovery: config.QUICConfig.DisablePathMTUDiscovery,
+ EnableDatagrams: true,
+ MaxDatagramFrameSize: protocol.MaxDatagramFrameSize,
+ AssumePeerMaxDatagramFrameSize: protocol.MaxDatagramFrameSize,
+ DisablePathManager: true,
+ }
+ srk := config.StatelessResetKey
+ if srk == nil {
+ var k quic.StatelessResetKey
+ if _, err := crand.Read(k[:]); err != nil {
+ return nil, err
+ }
+ srk = &k
+ }
+ tr := &quic.Transport{
+ Conn: config.Conn,
+ DisableGSO: config.QUICConfig.DisableGSO,
+ StatelessResetKey: srk,
+ // Always require QUIC Retry before allocating a connection. This proves
+ // return-path reachability and prevents spoofed Initial packets from
+ // creating handshake state.
+ VerifySourceAddress: func(net.Addr) bool { return true },
+ }
+ connSlots := make(chan struct{}, config.MaxConnections)
+ connClientSlots := newKeyedLimiter(config.MaxClientConnections)
+ tr.ConnContext = func(ctx context.Context, info *quic.ClientInfo) (context.Context, error) {
+ return admitConnection(ctx, connSlots, connClientSlots, sourceIPKey(info.RemoteAddr))
+ }
+ listener, err := tr.Listen(tlsConfig, quicConfig)
+ if err != nil {
+ err = errors.Join(err, tr.Close(), config.Conn.Close())
+ if config.Cleanup != nil {
+ err = errors.Join(err, config.Cleanup.Close())
+ }
+ return nil, err
+ }
+ return &serverImpl{
+ config: config,
+ tr: tr,
+ listener: listener,
+ tcpSlots: make(chan struct{}, config.MaxTCPHandlers),
+ tcpClientSlots: newKeyedLimiter(config.MaxClientTCPHandlers),
+ udpSlots: make(chan struct{}, config.MaxUDPSessions),
+ udpClientSlots: newKeyedLimiter(config.MaxClientUDPSessions),
+ }, nil
+}
+
+type serverImpl struct {
+ config *Config
+ tr *quic.Transport
+ listener *quic.Listener
+ tcpSlots chan struct{}
+ tcpClientSlots *keyedLimiter
+ udpSlots chan struct{}
+ udpClientSlots *keyedLimiter
+}
+
+func (s *serverImpl) Serve() error {
+ for {
+ conn, err := s.listener.Accept(context.Background())
+ if err != nil {
+ return err
+ }
+ go s.handleClient(conn)
+ }
+}
+
+var errConnectionCapacity = errors.New("connection capacity reached")
+
+// admitConnection acquires capacity after Retry has validated the source but
+// before quic-go allocates handshake state. The connection context is canceled
+// on every handshake failure or established-connection close, which releases
+// the slot for the complete lifecycle.
+func admitConnection(ctx context.Context, slots chan struct{}, clientSlots *keyedLimiter, clientKey string) (context.Context, error) {
+ if !clientSlots.tryAcquire(clientKey) {
+ return nil, errConnectionCapacity
+ }
+ select {
+ case slots <- struct{}{}:
+ go func() {
+ <-ctx.Done()
+ <-slots
+ clientSlots.release(clientKey)
+ }()
+ return ctx, nil
+ default:
+ clientSlots.release(clientKey)
+ return nil, errConnectionCapacity
+ }
+}
+
+func (s *serverImpl) Close() error {
+ err := errors.Join(s.listener.Close(), s.tr.Close(), s.config.Conn.Close())
+ if s.config.Cleanup != nil {
+ err = errors.Join(err, s.config.Cleanup.Close())
+ }
+ return err
+}
+
+func (s *serverImpl) handleClient(conn *quic.Conn) {
+ handler := newH3sHandler(s.config, conn, s.tcpSlots, s.tcpClientSlots, s.udpSlots, s.udpClientSlots)
+ authTimer := time.AfterFunc(s.config.AuthenticationTimeout, func() {
+ if handler.expireAuthentication() {
+ _ = conn.CloseWithError(closeErrCodeOK, "authentication timeout")
+ }
+ })
+ h3s := http3.Server{
+ Handler: handler,
+ MaxHeaderBytes: s.config.MaxHTTPHeaderBytes,
+ StreamAdmission: handler.AdmitStream,
+ StreamDispatcher: handler.ProxyStreamHijacker,
+ }
+ err := h3s.ServeQUICConn(conn)
+ authTimer.Stop()
+ // If the client is authenticated, we need to log the disconnect event
+ if authID, authenticated := handler.authState(); authenticated {
+ if tl := s.config.TrafficLogger; tl != nil {
+ tl.LogOnlineState(authID, false)
+ }
+ if el := s.config.EventLogger; el != nil {
+ el.Disconnect(conn.RemoteAddr(), authID, err)
+ }
+ }
+ _ = conn.CloseWithError(closeErrCodeOK, "")
+}
+
+type h3sHandler struct {
+ config *Config
+ conn *quic.Conn
+
+ authenticated bool
+ authExpired bool
+ authMutex sync.RWMutex
+ authID string
+ connID uint32 // a random id for dump streams
+ clientKey string
+ tcpSlots chan struct{}
+ tcpClientSlots *keyedLimiter
+ udpSlots chan struct{}
+ udpClientSlots *keyedLimiter
+}
+
+func newH3sHandler(
+ config *Config,
+ conn *quic.Conn,
+ tcpSlots chan struct{},
+ tcpClientSlots *keyedLimiter,
+ udpSlots chan struct{},
+ udpClientSlots *keyedLimiter,
+) *h3sHandler {
+ return &h3sHandler{
+ config: config,
+ conn: conn,
+ connID: rand.Uint32(),
+ clientKey: sourceIPKey(conn.RemoteAddr()),
+ tcpSlots: tcpSlots,
+ tcpClientSlots: tcpClientSlots,
+ udpSlots: udpSlots,
+ udpClientSlots: udpClientSlots,
+ }
+}
+
+func (h *h3sHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && r.Host == protocol.URLHost && r.URL.Path == protocol.URLPath {
+ h.authMutex.Lock()
+ defer h.authMutex.Unlock()
+ if h.authExpired {
+ h.masqHandler(w, r)
+ return
+ }
+ if h.authenticated {
+ // Already authenticated
+ protocol.AuthResponseToHeader(w.Header(), protocol.AuthResponse{
+ UDPEnabled: !h.config.DisableUDP,
+ Rx: h.config.BandwidthConfig.MaxRx,
+ RxAuto: h.config.IgnoreClientBandwidth,
+ })
+ w.WriteHeader(protocol.StatusAuthOK)
+ return
+ }
+ authReq := protocol.AuthRequestFromHeader(r.Header)
+ actualTx := authReq.Rx
+ ok, id := h.config.Authenticator.Authenticate(h.conn.RemoteAddr(), authReq.Auth, actualTx)
+ if ok {
+ // Set authenticated flag
+ h.authenticated = true
+ h.authID = id
+ if h.config.IgnoreClientBandwidth {
+ // Ignore client bandwidth and use the configured congestion controller.
+ congestion.UseConfigured(h.conn, h.config.CongestionConfig.Type, h.config.CongestionConfig.BBRProfile)
+ actualTx = 0
+ } else {
+ // actualTx = min(serverTx, clientRx)
+ if h.config.BandwidthConfig.MaxTx > 0 && actualTx > h.config.BandwidthConfig.MaxTx {
+ // We have a maxTx limit and the client is asking for more than that,
+ // return and use the limit instead
+ actualTx = h.config.BandwidthConfig.MaxTx
+ }
+ if actualTx > 0 {
+ congestion.UseBrutal(h.conn, actualTx, h.config.BandwidthConfig.DisableLossCompensation)
+ } else {
+ // Client doesn't know its own bandwidth, use the configured congestion controller.
+ congestion.UseConfigured(h.conn, h.config.CongestionConfig.Type, h.config.CongestionConfig.BBRProfile)
+ }
+ }
+ // Auth OK, send response
+ protocol.AuthResponseToHeader(w.Header(), protocol.AuthResponse{
+ UDPEnabled: !h.config.DisableUDP,
+ Rx: h.config.BandwidthConfig.MaxRx,
+ RxAuto: h.config.IgnoreClientBandwidth,
+ })
+ w.WriteHeader(protocol.StatusAuthOK)
+ // Call event logger
+ if tl := h.config.TrafficLogger; tl != nil {
+ tl.LogOnlineState(id, true)
+ }
+ if el := h.config.EventLogger; el != nil {
+ el.Connect(h.conn.RemoteAddr(), id, actualTx)
+ }
+ // Initialize UDP session manager (if UDP is enabled)
+ // We use sync.Once to make sure that only one goroutine is started,
+ // as ServeHTTP may be called by multiple goroutines simultaneously
+ if !h.config.DisableUDP {
+ sm := newUDPSessionManager(
+ &udpIOImpl{h.conn, id, h.config.TrafficLogger, h.config.RequestHook, h.config.Outbound},
+ &udpEventLoggerImpl{h.conn, id, h.config.EventLogger},
+ h.config.UDPIdleTimeout,
+ h.config.MaxClientUDPSessions,
+ h.udpSlots,
+ h.clientKey,
+ h.udpClientSlots,
+ )
+ go sm.Run()
+ }
+ } else {
+ // Auth failed, pretend to be a normal HTTP server
+ h.masqHandler(w, r)
+ }
+ } else {
+ // Not an auth request, pretend to be a normal HTTP server
+ h.masqHandler(w, r)
+ }
+}
+
+func sourceIPKey(address net.Addr) string {
+ if udpAddress, ok := address.(*net.UDPAddr); ok {
+ if ip, valid := netip.AddrFromSlice(udpAddress.IP); valid {
+ return sourcePrefixKey(ip)
+ }
+ }
+ host, _, err := net.SplitHostPort(address.String())
+ if err == nil {
+ if ip, parseErr := netip.ParseAddr(host); parseErr == nil {
+ return sourcePrefixKey(ip)
+ }
+ return host
+ }
+ return address.String()
+}
+
+// sourcePrefixKey prevents a client with an ordinary IPv6 /64 from evading
+// every per-source budget by rotating interface identifiers. IPv4 remains
+// keyed by the individual address. The complete remote address is still used
+// for logging; only resource admission uses this normalized key.
+func sourcePrefixKey(address netip.Addr) string {
+ address = address.Unmap().WithZone("")
+ if address.Is4() {
+ return address.String()
+ }
+ return netip.PrefixFrom(address, 64).Masked().String()
+}
+
+func (h *h3sHandler) ProxyStreamHijacker(ft http3.FrameType, stream *quic.Stream, err error) (bool, error) {
+ authID, authenticated := h.authState()
+ if err != nil || !authenticated {
+ return false, nil
+ }
+
+ switch ft {
+ case protocol.FrameTypeTCPRequest:
+ // StreamDispatcher only peeks the frame type. Consume it so ReadTCPRequest
+ // starts at address length, matching pre-upgrade StreamHijacker behavior.
+ if _, err := quicvarint.Read(quicvarint.NewReader(stream)); err != nil {
+ return false, err
+ }
+ // Wraps the stream with QStream, which handles Close() properly
+ qStream := &utils.QStream{Stream: stream}
+ // Run synchronously in quic-go's per-stream worker. StreamAdmission's
+ // release callback is deferred by that worker, so the global TCP slot
+ // remains held for the complete proxy lifetime, not merely until the
+ // request frame has been dispatched.
+ h.handleTCPRequest(qStream, authID)
+ return true, nil
+ default:
+ return false, nil
+ }
+}
+
+// authState synchronizes HTTP authentication with stream dispatch and
+// disconnect accounting. In particular, a stream arriving concurrently with
+// the authentication response cannot observe authenticated=true with a stale
+// or empty authID.
+func (h *h3sHandler) authState() (string, bool) {
+ h.authMutex.RLock()
+ defer h.authMutex.RUnlock()
+ return h.authID, h.authenticated
+}
+
+// expireAuthentication atomically prevents late authentication. It returns
+// true exactly once when an unauthenticated connection crosses its deadline.
+func (h *h3sHandler) expireAuthentication() bool {
+ h.authMutex.Lock()
+ defer h.authMutex.Unlock()
+ if h.authenticated || h.authExpired {
+ return false
+ }
+ h.authExpired = true
+ return true
+}
+
+// AdmitStream applies listener-wide capacity and a first-byte deadline before
+// the HTTP/3 layer peeks a frame type. The local quic-go fork guarantees that
+// release runs once after dispatch or ordinary HTTP handling completes.
+func (h *h3sHandler) AdmitStream(stream *quic.Stream) (func(), bool) {
+ return h.admitStream(stream)
+}
+
+type readDeadlineSetter interface {
+ SetReadDeadline(time.Time) error
+}
+
+func (h *h3sHandler) admitStream(stream readDeadlineSetter) (func(), bool) {
+ if !h.tcpClientSlots.tryAcquire(h.clientKey) {
+ return nil, false
+ }
+ select {
+ case h.tcpSlots <- struct{}{}:
+ default:
+ h.tcpClientSlots.release(h.clientKey)
+ return nil, false
+ }
+ if err := stream.SetReadDeadline(time.Now().Add(h.config.TCPRequestTimeout)); err != nil {
+ <-h.tcpSlots
+ h.tcpClientSlots.release(h.clientKey)
+ return nil, false
+ }
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ <-h.tcpSlots
+ h.tcpClientSlots.release(h.clientKey)
+ })
+ }, true
+}
+
+func (h *h3sHandler) handleTCPRequest(stream HyStream, authID string) {
+ trafficLogger := h.config.TrafficLogger
+ streamStats := &StreamStats{
+ AuthID: authID,
+ ConnID: h.connID,
+ InitialTime: time.Now(),
+ }
+ streamStats.State.Store(StreamStateInitial)
+ streamStats.LastActiveTime.Store(time.Now())
+ defer func() {
+ streamStats.State.Store(StreamStateClosed)
+ }()
+ if trafficLogger != nil {
+ trafficLogger.TraceStream(stream, streamStats)
+ defer trafficLogger.UntraceStream(stream)
+ }
+
+ // Read request
+ _ = stream.SetReadDeadline(time.Now().Add(h.config.TCPRequestTimeout))
+ reqAddr, err := protocol.ReadTCPRequest(stream)
+ if err != nil {
+ _ = stream.Close()
+ return
+ }
+ _ = stream.SetReadDeadline(time.Time{})
+ streamStats.ReqAddr.Store(reqAddr)
+ // Call the hook if set
+ var putback []byte
+ var hooked bool
+ if h.config.RequestHook != nil {
+ hooked = h.config.RequestHook.Check(false, reqAddr)
+ // When the hook is enabled, the server should always accept a connection
+ // so that the client will send whatever request the hook wants to see.
+ // This is essentially a server-side fast-open.
+ if hooked {
+ streamStats.State.Store(StreamStateHooking)
+ _ = protocol.WriteTCPResponse(stream, true, "RequestHook enabled")
+ putback, err = h.config.RequestHook.TCP(stream, &reqAddr)
+ if err != nil {
+ _ = stream.Close()
+ return
+ }
+ streamStats.setHookedReqAddr(reqAddr)
+ }
+ }
+ // Log the event
+ if h.config.EventLogger != nil {
+ h.config.EventLogger.TCPRequest(h.conn.RemoteAddr(), authID, reqAddr)
+ }
+ // Dial target
+ streamStats.State.Store(StreamStateConnecting)
+ tConn, err := h.config.Outbound.TCP(reqAddr)
+ if err != nil {
+ if !hooked {
+ _ = protocol.WriteTCPResponse(stream, false, err.Error())
+ }
+ _ = stream.Close()
+ // Log the error
+ if h.config.EventLogger != nil {
+ h.config.EventLogger.TCPError(h.conn.RemoteAddr(), authID, reqAddr, err)
+ }
+ return
+ }
+ if !hooked {
+ _ = protocol.WriteTCPResponse(stream, true, "Connected")
+ }
+ streamStats.State.Store(StreamStateEstablished)
+ // Put back the data if the hook requested
+ if len(putback) > 0 {
+ n, _ := tConn.Write(putback)
+ streamStats.Tx.Add(uint64(n))
+ }
+ // Start proxying
+ if trafficLogger != nil {
+ err = copyTwoWayEx(authID, stream, tConn, trafficLogger, streamStats)
+ } else {
+ // Use the fast path if no traffic logger is set
+ err = copyTwoWay(stream, tConn)
+ }
+ if h.config.EventLogger != nil {
+ h.config.EventLogger.TCPError(h.conn.RemoteAddr(), authID, reqAddr, err)
+ }
+ // Cleanup
+ _ = tConn.Close()
+ _ = stream.Close()
+ // Disconnect the client if TrafficLogger requested
+ if err == errDisconnect {
+ _ = h.conn.CloseWithError(closeErrCodeTrafficLimitReached, "")
+ }
+}
+
+func (h *h3sHandler) masqHandler(w http.ResponseWriter, r *http.Request) {
+ if h.config.MasqHandler != nil {
+ h.config.MasqHandler.ServeHTTP(w, r)
+ } else {
+ // Return 404 for everything
+ http.NotFound(w, r)
+ }
+}
+
+// udpIOImpl is the IO implementation for udpSessionManager with TrafficLogger support
+type udpIOImpl struct {
+ Conn *quic.Conn
+ AuthID string
+ TrafficLogger TrafficLogger
+ RequestHook RequestHook
+ Outbound Outbound
+}
+
+func (io *udpIOImpl) ReceiveMessage() (*protocol.UDPMessage, error) {
+ for {
+ msg, err := io.Conn.ReceiveDatagram(context.Background())
+ if err != nil {
+ // Connection error, this will stop the session manager
+ return nil, err
+ }
+ udpMsg, err := protocol.ParseUDPMessage(msg)
+ if err != nil {
+ // Invalid message, this is fine - just wait for the next
+ continue
+ }
+ if io.TrafficLogger != nil {
+ ok := io.TrafficLogger.LogTraffic(io.AuthID, uint64(len(udpMsg.Data)), 0)
+ if !ok {
+ // TrafficLogger requested to disconnect the client
+ _ = io.Conn.CloseWithError(closeErrCodeTrafficLimitReached, "")
+ return nil, errDisconnect
+ }
+ }
+ return udpMsg, nil
+ }
+}
+
+func (io *udpIOImpl) SendMessage(buf []byte, msg *protocol.UDPMessage) error {
+ if io.TrafficLogger != nil {
+ ok := io.TrafficLogger.LogTraffic(io.AuthID, 0, uint64(len(msg.Data)))
+ if !ok {
+ // TrafficLogger requested to disconnect the client
+ _ = io.Conn.CloseWithError(closeErrCodeTrafficLimitReached, "")
+ return errDisconnect
+ }
+ }
+ msgN := msg.Serialize(buf)
+ if msgN < 0 {
+ return coreErrs.ProtocolError{Message: "UDP message exceeds serialization limit"}
+ }
+ return io.Conn.SendDatagram(buf[:msgN])
+}
+
+func (io *udpIOImpl) Hook(data []byte, reqAddr *string) error {
+ if io.RequestHook != nil && io.RequestHook.Check(true, *reqAddr) {
+ return io.RequestHook.UDP(data, reqAddr)
+ } else {
+ return nil
+ }
+}
+
+func (io *udpIOImpl) UDP(reqAddr string) (UDPConn, error) {
+ return io.Outbound.UDP(reqAddr)
+}
+
+func (io *udpIOImpl) CheckUDP(reqAddr string) error {
+ return io.Outbound.CheckUDP(reqAddr)
+}
+
+type udpEventLoggerImpl struct {
+ Conn *quic.Conn
+ AuthID string
+ EventLogger EventLogger
+}
+
+func (l *udpEventLoggerImpl) New(sessionID uint32, reqAddr string) {
+ if l.EventLogger != nil {
+ l.EventLogger.UDPRequest(l.Conn.RemoteAddr(), l.AuthID, sessionID, reqAddr)
+ }
+}
+
+func (l *udpEventLoggerImpl) Close(sessionID uint32, err error) {
+ if l.EventLogger != nil {
+ l.EventLogger.UDPError(l.Conn.RemoteAddr(), l.AuthID, sessionID, err)
+ }
+}
diff --git a/third_party/hysteria-core/server/udp.go b/third_party/hysteria-core/server/udp.go
new file mode 100644
index 0000000..d13a81d
--- /dev/null
+++ b/third_party/hysteria-core/server/udp.go
@@ -0,0 +1,470 @@
+package server
+
+import (
+ "errors"
+ "math/rand"
+ "sync"
+ "time"
+
+ "github.com/apernet/quic-go"
+
+ "github.com/apernet/hysteria/core/v2/internal/frag"
+ "github.com/apernet/hysteria/core/v2/internal/protocol"
+ "github.com/apernet/hysteria/core/v2/internal/utils"
+)
+
+const (
+ idleCleanupInterval = 1 * time.Second
+ maxSessionACLCache = 256
+)
+
+type udpIO interface {
+ ReceiveMessage() (*protocol.UDPMessage, error)
+ SendMessage([]byte, *protocol.UDPMessage) error
+ Hook(data []byte, reqAddr *string) error
+ UDP(reqAddr string) (UDPConn, error)
+ CheckUDP(reqAddr string) error
+}
+
+type udpEventLogger interface {
+ New(sessionID uint32, reqAddr string)
+ Close(sessionID uint32, err error)
+}
+
+type udpSessionEntry struct {
+ ID uint32
+ OverrideAddr string // Ignore the address in the UDP message, always use this if not empty
+ OriginalAddr string // The original address in the UDP message
+ D *frag.Defragger
+ Last *utils.AtomicTime
+ IO udpIO
+
+ DialFunc func(addr string, firstMsgData []byte) (conn UDPConn, actualAddr string, err error)
+ ExitFunc func(err error)
+
+ conn UDPConn
+ connLock sync.Mutex
+ closed bool
+
+ aclCache map[string]error
+}
+
+func newUDPSessionEntry(
+ id uint32, io udpIO,
+ dialFunc func(string, []byte) (UDPConn, string, error),
+ exitFunc func(error),
+) (e *udpSessionEntry) {
+ e = &udpSessionEntry{
+ ID: id,
+ D: &frag.Defragger{},
+ Last: utils.NewAtomicTime(time.Now()),
+ IO: io,
+
+ DialFunc: dialFunc,
+ ExitFunc: exitFunc,
+ }
+
+ return e
+}
+
+// CloseWithErr closes the session and calls ExitFunc with the given error.
+// A nil error indicates the session is cleaned up due to timeout.
+func (e *udpSessionEntry) CloseWithErr(err error) {
+ // We need this lock to ensure not to create conn after session exit
+ e.connLock.Lock()
+
+ if e.closed {
+ // Already closed
+ e.connLock.Unlock()
+ return
+ }
+
+ e.closed = true
+ if e.conn != nil {
+ _ = e.conn.Close()
+ }
+ e.connLock.Unlock()
+
+ e.ExitFunc(err)
+}
+
+// Feed feeds a UDP message to the session.
+// If the message itself is a complete message, or it completes a fragmented message,
+// the message is written to the session's UDP connection, and the number of bytes
+// written is returned.
+// Otherwise, 0 and nil are returned.
+func (e *udpSessionEntry) Feed(msg *protocol.UDPMessage) (int, error) {
+ e.Last.Set(time.Now())
+ dfMsg := e.D.Feed(msg)
+ if dfMsg == nil {
+ return 0, nil
+ }
+
+ if e.conn == nil {
+ err := e.initConn(dfMsg)
+ if err != nil {
+ return 0, err
+ }
+ if e.OverrideAddr == "" {
+ e.aclCache = map[string]error{dfMsg.Addr: nil}
+ }
+ }
+
+ addr := dfMsg.Addr
+ if e.OverrideAddr != "" {
+ addr = e.OverrideAddr
+ } else if err := e.checkAddr(addr); err != nil {
+ return 0, err
+ }
+
+ return e.conn.WriteTo(dfMsg.Data, addr)
+}
+
+// checkAddr checks outbound policy for the given address.
+// The decision is cached in e.aclCache for future use.
+func (e *udpSessionEntry) checkAddr(addr string) error {
+ if decision, ok := e.aclCache[addr]; ok {
+ return decision
+ }
+ decision := e.IO.CheckUDP(addr)
+ if len(e.aclCache) >= maxSessionACLCache {
+ for k := range e.aclCache {
+ delete(e.aclCache, k)
+ break
+ }
+ }
+ if e.aclCache == nil {
+ e.aclCache = make(map[string]error, 4)
+ }
+ e.aclCache[addr] = decision
+ return decision
+}
+
+// initConn initializes the UDP connection of the session.
+// If no error is returned, the e.conn is set to the new connection.
+func (e *udpSessionEntry) initConn(firstMsg *protocol.UDPMessage) error {
+ // We need this lock to ensure not to create conn after session exit
+ e.connLock.Lock()
+
+ if e.closed {
+ e.connLock.Unlock()
+ return errors.New("session is closed")
+ }
+
+ conn, actualAddr, err := e.DialFunc(firstMsg.Addr, firstMsg.Data)
+ if err != nil {
+ // Fail fast if DialFunc failed
+ // (usually indicates the connection has been rejected by the ACL)
+ e.connLock.Unlock()
+ // CloseWithErr acquires the connLock again
+ e.CloseWithErr(err)
+ return err
+ }
+
+ e.conn = conn
+
+ if firstMsg.Addr != actualAddr {
+ // Hook changed the address, enable address override
+ e.OverrideAddr = actualAddr
+ e.OriginalAddr = firstMsg.Addr
+ }
+ go e.receiveLoop()
+
+ e.connLock.Unlock()
+ return nil
+}
+
+// receiveLoop receives incoming UDP packets, packs them into UDP messages,
+// and sends using the IO.
+// Exit when either the underlying UDP connection returns error (e.g. closed),
+// or the IO returns error when sending.
+func (e *udpSessionEntry) receiveLoop() {
+ udpBuf := make([]byte, protocol.MaxUDPSize)
+ msgBuf := make([]byte, protocol.MaxUDPMessageSize)
+ for {
+ udpN, rAddr, err := e.conn.ReadFrom(udpBuf)
+ if err != nil {
+ e.CloseWithErr(err)
+ return
+ }
+ e.Last.Set(time.Now())
+
+ if e.OriginalAddr != "" {
+ // Use the original address in the opposite direction,
+ // otherwise the QUIC clients or NAT on the client side
+ // may not treat it as the same UDP session.
+ rAddr = e.OriginalAddr
+ }
+
+ msg := &protocol.UDPMessage{
+ SessionID: e.ID,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: rAddr,
+ Data: udpBuf[:udpN],
+ }
+ err = sendMessageAutoFrag(e.IO, msgBuf, msg)
+ if err != nil {
+ e.CloseWithErr(err)
+ return
+ }
+ }
+}
+
+// sendMessageAutoFrag tries to send a UDP message as a whole first,
+// but if it fails due to quic.ErrMessageTooLarge, it tries again by
+// fragmenting the message.
+func sendMessageAutoFrag(io udpIO, buf []byte, msg *protocol.UDPMessage) error {
+ err := io.SendMessage(buf, msg)
+ var errTooLarge *quic.DatagramTooLargeError
+ if errors.As(err, &errTooLarge) {
+ // Message too large, try fragmentation
+ msg.PacketID = uint16(rand.Intn(0xFFFF)) + 1
+ fMsgs := frag.FragUDPMessage(msg, int(errTooLarge.MaxDatagramPayloadSize))
+ if len(fMsgs) == 0 {
+ return frag.ErrFragmentationLimit
+ }
+ for _, fMsg := range fMsgs {
+ err := io.SendMessage(buf, &fMsg)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ } else {
+ return err
+ }
+}
+
+// udpSessionManager manages the lifecycle of UDP sessions.
+// Each UDP session is identified by a SessionID, and corresponds to a UDP connection.
+// A UDP session is created when a UDP message with a new SessionID is received.
+// Similar to standard NAT, a UDP session is destroyed when no UDP message is received
+// for a certain period of time (specified by idleTimeout).
+type udpSessionManager struct {
+ io udpIO
+ eventLogger udpEventLogger
+ idleTimeout time.Duration
+ maxSessions int
+ globalSlots chan struct{}
+ clientKey string
+ clientSlots *keyedLimiter
+
+ mutex sync.RWMutex
+ m map[uint32]*udpSessionEntry
+}
+
+func newUDPSessionManager(
+ io udpIO,
+ eventLogger udpEventLogger,
+ idleTimeout time.Duration,
+ maxSessions int,
+ globalSlots chan struct{},
+ clientKey string,
+ clientSlots *keyedLimiter,
+) *udpSessionManager {
+ return &udpSessionManager{
+ io: io,
+ eventLogger: eventLogger,
+ idleTimeout: idleTimeout,
+ maxSessions: maxSessions,
+ globalSlots: globalSlots,
+ clientKey: clientKey,
+ clientSlots: clientSlots,
+ m: make(map[uint32]*udpSessionEntry),
+ }
+}
+
+// Run runs the session manager main loop.
+// Exit and returns error when the underlying io returns error (e.g. closed).
+func (m *udpSessionManager) Run() error {
+ stopCh := make(chan struct{})
+ go m.idleCleanupLoop(stopCh)
+ defer close(stopCh)
+ defer m.cleanup(false)
+
+ for {
+ msg, err := m.io.ReceiveMessage()
+ if err != nil {
+ return err
+ }
+ m.feed(msg)
+ }
+}
+
+func (m *udpSessionManager) idleCleanupLoop(stopCh <-chan struct{}) {
+ ticker := time.NewTicker(idleCleanupInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ m.cleanup(true)
+ case <-stopCh:
+ return
+ }
+ }
+}
+
+func (m *udpSessionManager) cleanup(idleOnly bool) {
+ // We use RLock here as we are only scanning the map, not deleting from it.
+ m.mutex.RLock()
+ timeoutEntry := make([]*udpSessionEntry, 0, len(m.m))
+ now := time.Now()
+ for _, entry := range m.m {
+ if !idleOnly || now.Sub(entry.Last.Get()) > m.idleTimeout {
+ timeoutEntry = append(timeoutEntry, entry)
+ }
+ }
+ m.mutex.RUnlock()
+
+ for _, entry := range timeoutEntry {
+ // This eventually calls entry.ExitFunc,
+ // where the m.mutex will be locked again to remove the entry from the map.
+ entry.CloseWithErr(nil)
+ }
+}
+
+func (m *udpSessionManager) feed(msg *protocol.UDPMessage) {
+ // Reject malformed or excessive fragmentation before allocating session
+ // state. Legitimate 4096-byte Hysteria datagrams need only a handful of
+ // fragments at the 1200-byte QUIC datagram size.
+ if msg.FragCount == 0 || msg.FragID >= msg.FragCount || msg.FragCount > frag.MaxFragments {
+ return
+ }
+ m.mutex.RLock()
+ entry := m.m[msg.SessionID]
+ m.mutex.RUnlock()
+
+ // Create a new session if not exists
+ if entry == nil {
+ m.mutex.Lock()
+ entry = m.m[msg.SessionID]
+ if entry == nil {
+ if len(m.m) >= m.maxSessions || !tryAcquire(m.globalSlots) {
+ m.mutex.Unlock()
+ return
+ }
+ if !m.clientSlots.tryAcquire(m.clientKey) {
+ release(m.globalSlots)
+ m.mutex.Unlock()
+ return
+ }
+ }
+ m.mutex.Unlock()
+ }
+
+ if entry == nil {
+ dialFunc := func(addr string, firstMsgData []byte) (conn UDPConn, actualAddr string, err error) {
+ // Call the hook
+ err = m.io.Hook(firstMsgData, &addr)
+ if err != nil {
+ return conn, actualAddr, err
+ }
+ actualAddr = addr
+ // Log the event
+ m.eventLogger.New(msg.SessionID, addr)
+ // Dial target
+ conn, err = m.io.UDP(addr)
+ return conn, actualAddr, err
+ }
+ exitFunc := func(err error) {
+ // Log the event
+ m.eventLogger.Close(entry.ID, err)
+
+ // Remove the session from the map
+ m.mutex.Lock()
+ delete(m.m, entry.ID)
+ m.mutex.Unlock()
+ release(m.globalSlots)
+ m.clientSlots.release(m.clientKey)
+ }
+
+ entry = newUDPSessionEntry(msg.SessionID, m.io, dialFunc, exitFunc)
+
+ // Insert the admitted session into the map. feed is called by one Run
+ // goroutine, while cleanup only removes entries through ExitFunc.
+ m.mutex.Lock()
+ m.m[msg.SessionID] = entry
+ m.mutex.Unlock()
+ }
+
+ // Feed the message to the session
+ // Feed (send) errors are ignored for now,
+ // as some are temporary (e.g. invalid address)
+ _, _ = entry.Feed(msg)
+}
+
+func tryAcquire(slots chan struct{}) bool {
+ if slots == nil {
+ return true
+ }
+ select {
+ case slots <- struct{}{}:
+ return true
+ default:
+ return false
+ }
+}
+
+func release(slots chan struct{}) {
+ if slots != nil {
+ <-slots
+ }
+}
+
+func (m *udpSessionManager) Count() int {
+ m.mutex.RLock()
+ defer m.mutex.RUnlock()
+ return len(m.m)
+}
+
+// keyedLimiter enforces a shared budget across all QUIC connections with the
+// same source key. Counts exist only while at least one resource is live, so
+// rotating source addresses cannot grow this map beyond the corresponding
+// process-wide resource cap.
+type keyedLimiter struct {
+ mu sync.Mutex
+ max int
+ counts map[string]int
+}
+
+func newKeyedLimiter(maximum int) *keyedLimiter {
+ return &keyedLimiter{max: maximum, counts: make(map[string]int)}
+}
+
+func (l *keyedLimiter) tryAcquire(key string) bool {
+ if l == nil {
+ return true
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.counts[key] >= l.max {
+ return false
+ }
+ l.counts[key]++
+ return true
+}
+
+func (l *keyedLimiter) release(key string) {
+ if l == nil {
+ return
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ count := l.counts[key]
+ if count <= 1 {
+ delete(l.counts, key)
+ return
+ }
+ l.counts[key] = count - 1
+}
+
+func (l *keyedLimiter) count(key string) int {
+ if l == nil {
+ return 0
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.counts[key]
+}
diff --git a/third_party/hysteria-core/server/udp_test.go b/third_party/hysteria-core/server/udp_test.go
new file mode 100644
index 0000000..3edf3bd
--- /dev/null
+++ b/third_party/hysteria-core/server/udp_test.go
@@ -0,0 +1,191 @@
+package server
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "go.uber.org/goleak"
+
+ "github.com/apernet/hysteria/core/v2/internal/protocol"
+)
+
+func TestUDPSessionManager(t *testing.T) {
+ io := newMockUDPIO(t)
+ eventLogger := newMockUDPEventLogger(t)
+ sm := newUDPSessionManager(io, eventLogger, 2*time.Second, 64, make(chan struct{}, 128), "client", nil)
+
+ msgCh := make(chan *protocol.UDPMessage, 4)
+ io.EXPECT().ReceiveMessage().RunAndReturn(func() (*protocol.UDPMessage, error) {
+ m := <-msgCh
+ if m == nil {
+ return nil, errors.New("closed")
+ }
+ return m, nil
+ })
+
+ go sm.Run()
+
+ udpReadFunc := func(addr string, ch chan []byte, b []byte) (int, string, error) {
+ bs := <-ch
+ if bs == nil {
+ return 0, "", errors.New("closed")
+ }
+ n := copy(b, bs)
+ return n, addr, nil
+ }
+
+ // Test normal session creation & timeout
+ msg1 := &protocol.UDPMessage{
+ SessionID: 1234,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "address1.com:9000",
+ Data: []byte("hello"),
+ }
+ eventLogger.EXPECT().New(msg1.SessionID, msg1.Addr).Return().Once()
+ udpConn1 := newMockUDPConn(t)
+ udpConn1Ch := make(chan []byte, 1)
+ io.EXPECT().Hook(msg1.Data, &msg1.Addr).Return(nil).Once()
+ io.EXPECT().UDP(msg1.Addr).Return(udpConn1, nil).Once()
+ udpConn1.EXPECT().WriteTo(msg1.Data, msg1.Addr).Return(5, nil).Once()
+ udpConn1.EXPECT().ReadFrom(mock.Anything).RunAndReturn(func(b []byte) (int, string, error) {
+ return udpReadFunc(msg1.Addr, udpConn1Ch, b)
+ })
+ io.EXPECT().SendMessage(mock.Anything, &protocol.UDPMessage{
+ SessionID: msg1.SessionID,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: msg1.Addr,
+ Data: []byte("hi back"),
+ }).Return(nil).Once()
+ msgCh <- msg1
+ udpConn1Ch <- []byte("hi back")
+
+ msg2data := []byte("how are you doing?")
+ msg2_1 := &protocol.UDPMessage{
+ SessionID: 5678,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 2,
+ Addr: "address2.net:12450",
+ Data: msg2data[:6],
+ }
+ msg2_2 := &protocol.UDPMessage{
+ SessionID: 5678,
+ PacketID: 0,
+ FragID: 1,
+ FragCount: 2,
+ Addr: "address2.net:12450",
+ Data: msg2data[6:],
+ }
+
+ eventLogger.EXPECT().New(msg2_1.SessionID, msg2_1.Addr).Return().Once()
+ udpConn2 := newMockUDPConn(t)
+ udpConn2Ch := make(chan []byte, 1)
+ // On fragmentation, make sure hook gets the whole message
+ io.EXPECT().Hook(msg2data, &msg2_1.Addr).Return(nil).Once()
+ io.EXPECT().UDP(msg2_1.Addr).Return(udpConn2, nil).Once()
+ udpConn2.EXPECT().WriteTo(msg2data, msg2_1.Addr).Return(11, nil).Once()
+ udpConn2.EXPECT().ReadFrom(mock.Anything).RunAndReturn(func(b []byte) (int, string, error) {
+ return udpReadFunc(msg2_1.Addr, udpConn2Ch, b)
+ })
+ io.EXPECT().SendMessage(mock.Anything, &protocol.UDPMessage{
+ SessionID: msg2_1.SessionID,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: msg2_1.Addr,
+ Data: []byte("im fine"),
+ }).Return(nil).Once()
+ msgCh <- msg2_1
+ msgCh <- msg2_2
+ udpConn2Ch <- []byte("im fine")
+
+ msg3 := &protocol.UDPMessage{
+ SessionID: 1234,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "address1.com:9000",
+ Data: []byte("who are you?"),
+ }
+ udpConn1.EXPECT().WriteTo(msg3.Data, msg3.Addr).Return(12, nil).Once()
+ io.EXPECT().SendMessage(mock.Anything, &protocol.UDPMessage{
+ SessionID: msg3.SessionID,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: msg3.Addr,
+ Data: []byte("im your father"),
+ }).Return(nil).Once()
+ msgCh <- msg3
+ udpConn1Ch <- []byte("im your father")
+
+ // Make sure timeout works (connections closed & close events emitted)
+ udpConn1.EXPECT().Close().RunAndReturn(func() error {
+ close(udpConn1Ch)
+ return nil
+ }).Once()
+ udpConn2.EXPECT().Close().RunAndReturn(func() error {
+ close(udpConn2Ch)
+ return nil
+ }).Once()
+ eventLogger.EXPECT().Close(msg1.SessionID, nil).Once()
+ eventLogger.EXPECT().Close(msg2_1.SessionID, nil).Once()
+
+ time.Sleep(3 * time.Second) // Wait for timeout
+ mock.AssertExpectationsForObjects(t, io, eventLogger, udpConn1, udpConn2)
+
+ // Test UDP connection close error propagation
+ errUDPClosed := errors.New("UDP connection closed")
+ msg4 := &protocol.UDPMessage{
+ SessionID: 666,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "oh-no.com:27015",
+ Data: []byte("dont say bye"),
+ }
+ eventLogger.EXPECT().New(msg4.SessionID, msg4.Addr).Return().Once()
+ udpConn4 := newMockUDPConn(t)
+ io.EXPECT().Hook(msg4.Data, &msg4.Addr).Return(nil).Once()
+ io.EXPECT().UDP(msg4.Addr).Return(udpConn4, nil).Once()
+ udpConn4.EXPECT().WriteTo(msg4.Data, msg4.Addr).Return(12, nil).Once()
+ udpConn4.EXPECT().ReadFrom(mock.Anything).Return(0, "", errUDPClosed).Once()
+ udpConn4.EXPECT().Close().Return(nil).Once()
+ eventLogger.EXPECT().Close(msg4.SessionID, errUDPClosed).Once()
+ msgCh <- msg4
+
+ time.Sleep(1 * time.Second)
+ mock.AssertExpectationsForObjects(t, io, eventLogger, udpConn4)
+
+ // Test UDP connection creation error propagation
+ errUDPIO := errors.New("UDP IO error")
+ msg5 := &protocol.UDPMessage{
+ SessionID: 777,
+ PacketID: 0,
+ FragID: 0,
+ FragCount: 1,
+ Addr: "callmemaybe.com:15353",
+ Data: []byte("babe i miss you"),
+ }
+ eventLogger.EXPECT().New(msg5.SessionID, msg5.Addr).Return().Once()
+ io.EXPECT().Hook(msg5.Data, &msg5.Addr).Return(nil).Once()
+ io.EXPECT().UDP(msg5.Addr).Return(nil, errUDPIO).Once()
+ eventLogger.EXPECT().Close(msg5.SessionID, errUDPIO).Once()
+ msgCh <- msg5
+
+ time.Sleep(1 * time.Second)
+ mock.AssertExpectationsForObjects(t, io, eventLogger)
+
+ // Leak checks
+ close(msgCh) // This will return error from ReceiveMessage(), should stop the session manager
+ time.Sleep(1 * time.Second) // Wait one more second just to be sure
+ assert.Zero(t, sm.Count(), "session count should be 0")
+ goleak.VerifyNone(t)
+}
diff --git a/third_party/quic-go/.clusterfuzzlite/Dockerfile b/third_party/quic-go/.clusterfuzzlite/Dockerfile
new file mode 100644
index 0000000..9c00ced
--- /dev/null
+++ b/third_party/quic-go/.clusterfuzzlite/Dockerfile
@@ -0,0 +1,5 @@
+FROM gcr.io/oss-fuzz-base/base-builder-go:v1
+
+COPY . $SRC/quic-go
+WORKDIR $SRC/quic-go
+COPY .clusterfuzzlite/build.sh $SRC/
diff --git a/third_party/quic-go/.clusterfuzzlite/build.sh b/third_party/quic-go/.clusterfuzzlite/build.sh
new file mode 100644
index 0000000..d11d10b
--- /dev/null
+++ b/third_party/quic-go/.clusterfuzzlite/build.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+
+set -euo pipefail
+
+go version
+go env
+
+build_native_go_fuzzer() {
+ local pkg=$1
+ local fuzz=$2
+ local name=$3
+ local corpus_dir="${WORK:-/tmp}/quic-go-seed-corpus/$name"
+ local corpus_zip="$OUT/${name}_seed_corpus.zip"
+
+ # FUZZ_CORPUS_DIR makes go-ossfuzz-seeds write each f.Add seed as a raw
+ # libFuzzer corpus file. OSS-Fuzz picks up _seed_corpus.zip from
+ # $OUT and unpacks it next to the fuzzer binary.
+ rm -rf "$corpus_dir"
+ mkdir -p "$corpus_dir"
+ FUZZ_CORPUS_DIR="$corpus_dir" go test "$pkg" -run "^${fuzz}$" -count=1 -v
+
+ rm -f "$corpus_zip"
+ corpus_files=$(find "$corpus_dir" -type f | wc -l)
+ echo "$name: generated $corpus_files corpus files"
+ if [[ "$corpus_files" -gt 0 ]]; then
+ (cd "$corpus_dir" && zip -q -r "$corpus_zip" .)
+ fi
+
+ compile_native_go_fuzzer_v2 "$pkg" "$fuzz" "$name"
+}
+
+build_native_go_fuzzer github.com/quic-go/quic-go/internal/wire FuzzFrames frame_fuzzer_v2
+build_native_go_fuzzer github.com/quic-go/quic-go/internal/wire FuzzTransportParameters transportparameter_fuzzer_v2
+build_native_go_fuzzer github.com/quic-go/quic-go/http3 FuzzFrameParser http3_frame_fuzzer
+build_native_go_fuzzer github.com/quic-go/quic-go/internal/wire FuzzHeaderParser header_fuzzer_v2
+build_native_go_fuzzer github.com/quic-go/quic-go/internal/handshake FuzzHandshake handshake_fuzzer_v2
+build_native_go_fuzzer github.com/quic-go/quic-go FuzzFrameSorter frame_sorter_fuzzer
+build_native_go_fuzzer github.com/quic-go/quic-go/http3 FuzzHeaderParsing http3_header_parsing_fuzzer
diff --git a/third_party/quic-go/.clusterfuzzlite/project.yaml b/third_party/quic-go/.clusterfuzzlite/project.yaml
new file mode 100644
index 0000000..4f2ee4d
--- /dev/null
+++ b/third_party/quic-go/.clusterfuzzlite/project.yaml
@@ -0,0 +1 @@
+language: go
diff --git a/third_party/quic-go/.githooks/README.md b/third_party/quic-go/.githooks/README.md
new file mode 100644
index 0000000..e38700c
--- /dev/null
+++ b/third_party/quic-go/.githooks/README.md
@@ -0,0 +1,8 @@
+# Git Hooks
+
+This directory contains useful Git hooks for working with quic-go.
+
+Install them by running
+```bash
+git config core.hooksPath .githooks
+```
diff --git a/third_party/quic-go/.githooks/pre-commit b/third_party/quic-go/.githooks/pre-commit
new file mode 100644
index 0000000..0e3c572
--- /dev/null
+++ b/third_party/quic-go/.githooks/pre-commit
@@ -0,0 +1,34 @@
+#!/bin/bash
+
+# Check that test files don't contain focussed test cases.
+errored=false
+for f in $(git diff --diff-filter=d --cached --name-only); do
+ if [[ $f != *_test.go ]]; then continue; fi
+ output=$(git show :"$f" | grep -n -e "FIt(" -e "FContext(" -e "FDescribe(")
+ if [ $? -eq 0 ]; then
+ echo "$f contains a focussed test:"
+ echo "$output"
+ echo ""
+ errored=true
+ fi
+done
+
+pushd ./integrationtests/gomodvendor > /dev/null
+go mod tidy
+if [[ -n $(git diff --diff-filter=d --name-only -- "go.mod" "go.sum") ]]; then
+ echo "go.mod / go.sum in integrationtests/gomodvendor not tidied"
+ errored=true
+fi
+popd > /dev/null
+
+# Check that all Go files are properly gofumpt-ed.
+output=$(gofumpt -d $(git diff --diff-filter=d --cached --name-only -- '*.go'))
+if [ -n "$output" ]; then
+ echo "Found files that are not properly gofumpt-ed."
+ echo "$output"
+ errored=true
+fi
+
+if [ "$errored" = true ]; then
+ exit 1
+fi
diff --git a/third_party/quic-go/.github/FUNDING.yml b/third_party/quic-go/.github/FUNDING.yml
new file mode 100644
index 0000000..7de30a1
--- /dev/null
+++ b/third_party/quic-go/.github/FUNDING.yml
@@ -0,0 +1,13 @@
+# These are supported funding model platforms
+
+github: [marten-seemann] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
+patreon: # Replace with a single Patreon username
+open_collective: # Replace with a single Open Collective username
+ko_fi: # Replace with a single Ko-fi username
+tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+liberapay: # Replace with a single Liberapay username
+issuehunt: # Replace with a single IssueHunt username
+otechie: # Replace with a single Otechie username
+lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
+custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
diff --git a/third_party/quic-go/.github/dependabot.yml b/third_party/quic-go/.github/dependabot.yml
new file mode 100644
index 0000000..5ace460
--- /dev/null
+++ b/third_party/quic-go/.github/dependabot.yml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/third_party/quic-go/.github/workflows/build-interop-docker.yml b/third_party/quic-go/.github/workflows/build-interop-docker.yml
new file mode 100644
index 0000000..2bdcf0e
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/build-interop-docker.yml
@@ -0,0 +1,51 @@
+name: Build interop Docker image
+
+permissions: read-all
+
+on:
+ push:
+ branches:
+ - master
+ tags:
+ - 'v*'
+ pull_request:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'push' && github.ref != 'refs/heads/master' }}
+
+jobs:
+ interop:
+ runs-on: ${{ fromJSON(vars['DOCKER_RUNNER_UBUNTU'] || '"ubuntu-latest"') }}
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v7
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
+ with:
+ platforms: linux/amd64,linux/arm64
+ - name: Login to Docker Hub
+ if: github.event_name == 'push'
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+ - name: set tag name
+ id: tag
+ # Tagged releases won't be picked up by the interop runner automatically,
+ # but they can be useful when debugging regressions.
+ run: |
+ if [[ $GITHUB_REF == refs/tags/* ]]; then
+ echo "tag=${GITHUB_REF#refs/tags/}" | tee -a $GITHUB_OUTPUT;
+ else
+ echo 'tag=latest' | tee -a $GITHUB_OUTPUT;
+ fi
+ - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
+ with:
+ context: "."
+ file: "interop/Dockerfile"
+ platforms: linux/amd64,linux/arm64
+ push: ${{ github.event_name == 'push' }}
+ tags: martenseemann/quic-go-interop:${{ steps.tag.outputs.tag }}
diff --git a/third_party/quic-go/.github/workflows/clusterfuzz-coverage.yml b/third_party/quic-go/.github/workflows/clusterfuzz-coverage.yml
new file mode 100644
index 0000000..5b6d342
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/clusterfuzz-coverage.yml
@@ -0,0 +1,106 @@
+name: ClusterFuzz coverage
+on:
+ schedule:
+ - cron: '12 3,11,19 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ coverage:
+ runs-on: ubuntu-latest
+ env:
+ DOCKER_DEFAULT_PLATFORM: linux/amd64
+ CLUSTERFUZZ_CORPUS_BUCKET: quic-go-corpus.clusterfuzz-external.appspot.com
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/checkout@v7
+ with:
+ repository: google/oss-fuzz
+ path: oss-fuzz
+ - uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
+ - name: Configure Google Cloud credentials
+ run: |
+ cat > "$RUNNER_TEMP/gcp-ossfuzz-user-credentials.json" <<'EOF'
+ ${{ secrets.GCP_OSSFUZZ_USER_CREDENTIALS }}
+ EOF
+ echo "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE=$RUNNER_TEMP/gcp-ossfuzz-user-credentials.json" >> "$GITHUB_ENV"
+ - name: Download ClusterFuzz corpus
+ run: |
+ set -euo pipefail
+
+ targets=(
+ frame_fuzzer_v2
+ transportparameter_fuzzer_v2
+ http3_frame_fuzzer
+ header_fuzzer_v2
+ handshake_fuzzer_v2
+ frame_sorter_fuzzer
+ http3_header_parsing_fuzzer
+ )
+
+ mkdir -p oss-fuzz/build/corpus/quic-go
+ for target in "${targets[@]}"; do
+ source_dir="gs://${CLUSTERFUZZ_CORPUS_BUCKET}/libFuzzer/quic-go_${target}"
+ corpus_dir="oss-fuzz/build/corpus/quic-go/${target}"
+
+ echo "$target: downloading live corpus"
+ rm -rf "$corpus_dir"
+ mkdir -p "$corpus_dir"
+
+ gcloud storage cp --recursive "$source_dir/*" "$corpus_dir" || echo "$target: no live corpus found"
+ done
+
+ corpus_files=$(find oss-fuzz/build/corpus/quic-go -type f | wc -l | tr -d ' ')
+ if [ "$corpus_files" = 0 ]; then
+ echo "no ClusterFuzz corpus files downloaded"
+ exit 1
+ fi
+ - name: Summarize ClusterFuzz corpus
+ run: |
+ set -euo pipefail
+
+ corpus_root=oss-fuzz/build/corpus/quic-go
+ {
+ echo "## ClusterFuzz corpus"
+ echo
+ echo "| Fuzzer | Corpus files downloaded |"
+ echo "| --- | ---: |"
+
+ for corpus_dir in "$corpus_root"/*; do
+ [ -d "$corpus_dir" ] || continue
+
+ target=$(basename "$corpus_dir")
+ corpus_files=$(find "$corpus_dir" -type f | wc -l | tr -d ' ')
+ if [ "$corpus_files" = 0 ]; then
+ corpus_files="---"
+ fi
+ echo "| \`$target\` | $corpus_files |"
+ done
+ } >> "$GITHUB_STEP_SUMMARY"
+ - name: Build coverage fuzzers
+ working-directory: oss-fuzz
+ run: |
+ python3 infra/helper.py build_image --no-pull quic-go
+ python3 infra/helper.py build_fuzzers --sanitizer coverage --mount_path /src/quic-go quic-go "$GITHUB_WORKSPACE"
+ - name: Generate coverage
+ working-directory: oss-fuzz
+ run: |
+ rm -f build/out/quic-go/qpack_decode_fuzzer
+ python3 infra/helper.py coverage --no-corpus-download --no-serve quic-go
+ - name: Prepare Codecov coverage
+ working-directory: oss-fuzz
+ run: |
+ sed "s#^/out/src/quic-go/#${GITHUB_WORKSPACE}/#" \
+ build/out/quic-go/fuzz.cov > "$GITHUB_WORKSPACE/clusterfuzz.coverprofile"
+ test -s "$GITHUB_WORKSPACE/clusterfuzz.coverprofile"
+ - name: Upload coverage to Codecov
+ if: ${{ !cancelled() }}
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+ with:
+ disable_search: true
+ files: clusterfuzz.coverprofile
+ flags: clusterfuzz
+ name: ClusterFuzz fuzzing
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/third_party/quic-go/.github/workflows/clusterfuzz-lite-batch.yml b/third_party/quic-go/.github/workflows/clusterfuzz-lite-batch.yml
new file mode 100644
index 0000000..ea6b30b
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/clusterfuzz-lite-batch.yml
@@ -0,0 +1,87 @@
+name: ClusterFuzzLite batch fuzzing
+on:
+ schedule:
+ - cron: '0 0/6 * * *'
+
+permissions:
+ contents: read
+ security-events: write
+
+jobs:
+ batch-fuzzing:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ sanitizer:
+ - address
+ steps:
+ - name: Build Fuzzers (${{ matrix.sanitizer }})
+ id: build
+ uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+ with:
+ language: go
+ sanitizer: ${{ matrix.sanitizer }}
+ - name: Run Fuzzers (${{ matrix.sanitizer }})
+ id: run
+ uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ fuzz-seconds: 3600
+ mode: 'batch'
+ sanitizer: ${{ matrix.sanitizer }}
+ output-sarif: true
+ storage-repo: https://${{ secrets.CLUSTERFUZZ_LITE_STORAGE }}@github.com/quic-go/clusterfuzzlite-storage.git
+ storage-repo-branch: master
+ storage-repo-branch-coverage: gh-pages
+
+ coverage:
+ needs: batch-fuzzing
+ runs-on: ubuntu-latest
+ env:
+ DOCKER_DEFAULT_PLATFORM: linux/amd64
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/checkout@v7
+ with:
+ repository: google/oss-fuzz
+ path: oss-fuzz
+ - uses: actions/checkout@v7
+ with:
+ repository: quic-go/clusterfuzzlite-storage
+ ref: master
+ path: clusterfuzzlite-storage
+ token: ${{ secrets.CLUSTERFUZZ_LITE_STORAGE }}
+ - name: List fuzz targets
+ run: find clusterfuzzlite-storage/corpus -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort
+ - name: Prepare corpus
+ run: |
+ mkdir -p oss-fuzz/build/corpus
+ mv clusterfuzzlite-storage/corpus oss-fuzz/build/corpus/quic-go
+ - name: Use ClusterFuzzLite build script
+ run: |
+ cp .clusterfuzzlite/build.sh oss-fuzz/projects/quic-go/build.sh
+ sed -i '4i cd "$SRC/quic-go"' oss-fuzz/projects/quic-go/build.sh
+ - name: Build coverage fuzzers
+ working-directory: oss-fuzz
+ run: |
+ python3 infra/helper.py build_image --no-pull quic-go
+ python3 infra/helper.py build_fuzzers --sanitizer coverage --mount_path /src/quic-go quic-go "$GITHUB_WORKSPACE"
+ - name: Generate coverage
+ working-directory: oss-fuzz
+ run: python3 infra/helper.py coverage --no-corpus-download --no-serve quic-go
+ - name: Prepare Codecov coverage
+ working-directory: oss-fuzz
+ run: |
+ sed "s#^/out/src/quic-go/#${GITHUB_WORKSPACE}/#" \
+ build/out/quic-go/fuzz.cov > "$GITHUB_WORKSPACE/clusterfuzz-lite-batch.coverprofile"
+ test -s "$GITHUB_WORKSPACE/clusterfuzz-lite-batch.coverprofile"
+ - name: Upload coverage to Codecov
+ if: ${{ !cancelled() }}
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+ with:
+ disable_search: true
+ files: clusterfuzz-lite-batch.coverprofile
+ flags: clusterfuzz-lite-batch
+ name: ClusterFuzzLite batch fuzzing
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/third_party/quic-go/.github/workflows/clusterfuzz-lite-pr.yml b/third_party/quic-go/.github/workflows/clusterfuzz-lite-pr.yml
new file mode 100644
index 0000000..afdb417
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/clusterfuzz-lite-pr.yml
@@ -0,0 +1,51 @@
+name: ClusterFuzzLite PR fuzzing
+on:
+ pull_request:
+ paths:
+ - '**'
+
+permissions:
+ contents: read
+ security-events: write
+
+jobs:
+ PR:
+ if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' }}
+ env:
+ # Forked PRs don't receive repository secrets, so they run without corpus storage.
+ CLUSTERFUZZ_LITE_STORAGE_REPO: >-
+ ${{
+ github.event.pull_request.head.repo.id == github.event.pull_request.base.repo.id &&
+ format('https://{0}@github.com/quic-go/clusterfuzzlite-storage.git', secrets.CLUSTERFUZZ_LITE_STORAGE) || ''
+ }}
+ runs-on: ${{ fromJSON(vars['CLUSTERFUZZ_LITE_RUNNER_UBUNTU'] || '"ubuntu-latest"') }}
+ concurrency:
+ group: ${{ github.workflow }}-${{ matrix.sanitizer }}-${{ github.ref }}
+ cancel-in-progress: true
+ strategy:
+ fail-fast: false
+ matrix:
+ sanitizer:
+ - address
+ steps:
+ - name: Build Fuzzers (${{ matrix.sanitizer }})
+ uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+ with:
+ language: go
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ sanitizer: ${{ matrix.sanitizer }}
+ storage-repo: ${{ env.CLUSTERFUZZ_LITE_STORAGE_REPO }}
+ storage-repo-branch: master
+ storage-repo-branch-coverage: gh-pages
+ - name: Run Fuzzers (${{ matrix.sanitizer }})
+ uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ fuzz-seconds: 480
+ mode: 'code-change'
+ sanitizer: ${{ matrix.sanitizer }}
+ output-sarif: true
+ parallel-fuzzing: true
+ storage-repo: ${{ env.CLUSTERFUZZ_LITE_STORAGE_REPO }}
+ storage-repo-branch: master
+ storage-repo-branch-coverage: gh-pages
diff --git a/third_party/quic-go/.github/workflows/clusterfuzz-lite-prune.yml b/third_party/quic-go/.github/workflows/clusterfuzz-lite-prune.yml
new file mode 100644
index 0000000..cf420fd
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/clusterfuzz-lite-prune.yml
@@ -0,0 +1,29 @@
+name: ClusterFuzzLite pruning
+on:
+ schedule:
+ - cron: '0 2 * * *'
+
+permissions:
+ contents: read
+ security-events: write
+
+jobs:
+ Pruning:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Build Fuzzers
+ id: build
+ uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+ with:
+ language: go
+ - name: Run Fuzzers
+ id: run
+ uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ fuzz-seconds: 600
+ mode: 'prune'
+ output-sarif: true
+ storage-repo: https://${{ secrets.CLUSTERFUZZ_LITE_STORAGE }}@github.com/quic-go/clusterfuzzlite-storage.git
+ storage-repo-branch: master
+ storage-repo-branch-coverage: gh-pages
diff --git a/third_party/quic-go/.github/workflows/codspeed.yml b/third_party/quic-go/.github/workflows/codspeed.yml
new file mode 100644
index 0000000..f30db5e
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/codspeed.yml
@@ -0,0 +1,30 @@
+name: Benchmarks
+
+permissions:
+ contents: read
+ id-token: write
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ schedule:
+ - cron: "0 0 * * *"
+ workflow_dispatch:
+
+jobs:
+ benchmarks:
+ name: Benchmarks
+ runs-on: codspeed-macro
+ timeout-minutes: 180
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version: "1.26.x"
+ check-latest: true
+ - name: Run the benchmarks
+ uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
+ with:
+ mode: walltime
+ run: go test -run=^$ -bench=. ./integrationtests/self
diff --git a/third_party/quic-go/.github/workflows/cross-compile.sh b/third_party/quic-go/.github/workflows/cross-compile.sh
new file mode 100644
index 0000000..cd52622
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/cross-compile.sh
@@ -0,0 +1,33 @@
+#!/bin/bash
+
+set -e
+
+dist="$1"
+goos=$(echo "$dist" | cut -d "/" -f1)
+goarch=$(echo "$dist" | cut -d "/" -f2)
+
+# cross-compiling for android is a pain...
+if [[ "$goos" == "android" ]]; then exit; fi
+# iOS builds require Cgo, see https://github.com/golang/go/issues/43343
+# Cgo would then need a C cross compilation setup. Not worth the hassle.
+if [[ "$goos" == "ios" ]]; then exit; fi
+
+# Write all log output to a temporary file instead of to stdout.
+# That allows running this script in parallel, while preserving the correct order of the output.
+log_file=$(mktemp)
+
+error_handler() {
+ cat "$log_file" >&2
+ rm "$log_file"
+ exit 1
+}
+
+trap 'error_handler' ERR
+
+echo "$dist" >> "$log_file"
+out="main-$goos-$goarch"
+GOOS=$goos GOARCH=$goarch go build -o $out example/main.go >> "$log_file" 2>&1
+rm $out
+
+cat "$log_file"
+rm "$log_file"
diff --git a/third_party/quic-go/.github/workflows/cross-compile.yml b/third_party/quic-go/.github/workflows/cross-compile.yml
new file mode 100644
index 0000000..0861b9c
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/cross-compile.yml
@@ -0,0 +1,50 @@
+on: [push, pull_request]
+
+permissions: read-all
+
+jobs:
+ crosscompile:
+ permissions:
+ actions: write
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ go: [ "1.25.x", "1.26.x", "1.27.0-rc.1" ]
+ runs-on: ${{ fromJSON(vars['CROSS_COMPILE_RUNNER_UBUNTU'] || '"ubuntu-latest"') }}
+ name: "Cross Compilation (Go ${{matrix.go}})"
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version: ${{ matrix.go }}
+ check-latest: true
+ - name: Get Date
+ id: get-date
+ run: echo "date=$(/bin/date -u "+%Y%m%d")" >> $GITHUB_OUTPUT
+ - name: Load Go build cache
+ id: load-go-cache
+ uses: actions/cache/restore@v6
+ with:
+ path: ~/.cache/go-build
+ key: go-${{ matrix.go }}-crosscompile-${{ steps.get-date.outputs.date }}
+ restore-keys: go-${{ matrix.go }}-crosscompile-
+ - name: Install build utils
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y gcc-multilib
+ - name: Install dependencies
+ run: go build example/main.go
+ - name: Run cross compilation
+ # run in parallel on as many cores as are available on the machine
+ run: go tool dist list | xargs -I % -P "$(nproc)" .github/workflows/cross-compile.sh %
+ - name: Save Go build cache
+ # only store cache when on master
+ if: github.event_name == 'push' && github.ref_name == 'master'
+ uses: actions/cache/save@v6
+ with:
+ path: ~/.cache/go-build
+ # Caches are immutable, so we only update it once per day (at most).
+ # See https://github.com/actions/cache/blob/main/tips-and-workarounds.md#update-a-cache
+ key: go-${{ matrix.go }}-crosscompile-${{ steps.get-date.outputs.date }}
diff --git a/third_party/quic-go/.github/workflows/go-generate.sh b/third_party/quic-go/.github/workflows/go-generate.sh
new file mode 100644
index 0000000..fab97c7
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/go-generate.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+
+set -e
+
+# delete all go-generated files (that adhere to the comment convention)
+git ls-files -z | grep --include \*.go -lrIZ "^// Code generated .* DO NOT EDIT\.$" | tr '\0' '\n' | xargs rm -f
+
+# First regenerate sys_conn_buffers_write.go.
+# If it doesn't exist, the following mockgen calls will fail.
+go generate -run "sys_conn_buffers_write.go"
+# now generate everything
+go generate ./...
+
+# Check if any files were changed
+git diff --exit-code || (
+ echo "Generated files are not up to date. Please run 'go generate ./...' and commit the changes."
+ exit 1
+)
diff --git a/third_party/quic-go/.github/workflows/govulncheck.yml b/third_party/quic-go/.github/workflows/govulncheck.yml
new file mode 100644
index 0000000..8b69155
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/govulncheck.yml
@@ -0,0 +1,19 @@
+on:
+ schedule:
+ - cron: "17 4 * * *"
+ workflow_dispatch:
+
+permissions: read-all
+
+jobs:
+ govulncheck:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version: "1.26.x"
+ check-latest: true
+ - name: Run govulncheck
+ run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
diff --git a/third_party/quic-go/.github/workflows/integration.yml b/third_party/quic-go/.github/workflows/integration.yml
new file mode 100644
index 0000000..8a6f7cd
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/integration.yml
@@ -0,0 +1,89 @@
+on: [push, pull_request]
+
+permissions: read-all
+
+jobs:
+ integration:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ "ubuntu" ]
+ go: [ "1.25.x", "1.26.x", "1.27.0-rc.1" ]
+ race: [ false ]
+ include:
+ - os: "ubuntu"
+ go: "1.25.x"
+ race: true
+ - os: "windows"
+ go: "1.25.x"
+ race: false
+ - os: "macos"
+ go: "1.25.x"
+ race: false
+ runs-on: ${{ fromJSON(vars[format('INTEGRATION_RUNNER_{0}', matrix.os)] || format('"{0}-latest"', matrix.os)) }}
+ timeout-minutes: 30
+ defaults:
+ run:
+ shell: bash # by default Windows uses PowerShell, which uses a different syntax for setting environment variables
+ env:
+ DEBUG: false # set this to true to export qlogs and save them as artifacts
+ TIMESCALE_FACTOR: 3
+ name: "Integration (${{ matrix.os }}, Go ${{ matrix.go }}${{ matrix.race && ', race' || '' }})"
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version: ${{ matrix.go }}
+ check-latest: true
+ - name: Install go-junit-report
+ run: go install github.com/jstemmer/go-junit-report/v2@v2.1.0
+ - name: Set qlogger
+ if: env.DEBUG == 'true'
+ run: echo "QLOGFLAG= -qlog" >> $GITHUB_ENV
+ - name: Enable race detector
+ if: ${{ matrix.race }}
+ run: echo "RACEFLAG= -race" >> $GITHUB_ENV
+ - run: go version
+ - name: Run tools tests
+ run: go test ${{ env.RACEFLAG }} -v -timeout 30s -shuffle=on ./integrationtests/tools/... 2>&1 | go-junit-report -set-exit-code -iocopy -out report_tools.xml
+ - name: Run version negotiation tests
+ run: go test ${{ env.RACEFLAG }} -v -timeout 30s -shuffle=on ./integrationtests/versionnegotiation ${{ env.QLOGFLAG }} 2>&1 | go-junit-report -set-exit-code -iocopy -out report_versionnegotiation.xml
+ - name: Run FIPS 140 tests
+ if: ${{ matrix.go != '1.25.x' && (success() || failure()) }}
+ working-directory: integrationtests/fips
+ run: go test -v -timeout 1m -shuffle=on . 2>&1 | go-junit-report -set-exit-code -iocopy -out ../../report_fips.xml
+ - name: Run self tests, using QUIC v1
+ if: success() || failure() # run this step even if the previous one failed
+ run: go test ${{ env.RACEFLAG }} -v -timeout 5m -shuffle=on ./integrationtests/self -version=1 ${{ env.QLOGFLAG }} 2>&1 | go-junit-report -set-exit-code -iocopy -out report_self.xml
+ - name: Run self tests, using QUIC v2
+ if: ${{ !matrix.race && (success() || failure()) }} # run this step even if the previous one failed
+ run: go test ${{ env.RACEFLAG }} -v -timeout 5m -shuffle=on ./integrationtests/self -version=2 ${{ env.QLOGFLAG }} 2>&1 | go-junit-report -set-exit-code -iocopy -out report_self_v2.xml
+ - name: Run self tests, with GSO disabled
+ if: ${{ matrix.os == 'ubuntu' && (success() || failure()) }} # run this step even if the previous one failed
+ env:
+ QUIC_GO_DISABLE_GSO: true
+ run: go test ${{ env.RACEFLAG }} -v -timeout 5m -shuffle=on ./integrationtests/self -version=1 ${{ env.QLOGFLAG }} 2>&1 | go-junit-report -set-exit-code -iocopy -out report_self_nogso.xml
+ - name: Run self tests, with ECN disabled
+ if: ${{ !matrix.race && matrix.os == 'ubuntu' && (success() || failure()) }} # run this step even if the previous one failed
+ env:
+ QUIC_GO_DISABLE_ECN: true
+ run: go test ${{ env.RACEFLAG }} -v -timeout 5m -shuffle=on ./integrationtests/self -version=1 ${{ env.QLOGFLAG }} 2>&1 | go-junit-report -set-exit-code -iocopy -out report_self_noecn.xml
+ - name: Run benchmarks
+ if: ${{ !matrix.race }}
+ run: go test -v -run=^$ -timeout 5m -shuffle=on -bench=. ./integrationtests/self
+ - name: save qlogs
+ if: ${{ always() && env.DEBUG == 'true' }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: qlogs-${{ matrix.os }}-go${{ matrix.go }}-race${{ matrix.race }}
+ path: integrationtests/self/*.qlog
+ retention-days: 7
+ - name: Upload report to Codecov
+ if: ${{ !cancelled() && !matrix.race }}
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+ with:
+ report_type: test_results
+ name: Unit tests
+ files: report_tools.xml,report_versionnegotiation.xml,report_self.xml,report_self_v2.xml,report_self_nogso.xml,report_self_noecn.xml,report_fips.xml
+ env_vars: OS,GO
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/third_party/quic-go/.github/workflows/lint.yml b/third_party/quic-go/.github/workflows/lint.yml
new file mode 100644
index 0000000..a0c00b4
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/lint.yml
@@ -0,0 +1,101 @@
+on: [push, pull_request]
+
+permissions: read-all
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version: "1.26.x"
+ check-latest: true
+ - name: Check for //go:build ignore in .go files
+ run: |
+ IGNORED_FILES=$(grep -rl '//go:build ignore' . --include='*.go') || true
+ if [ -n "$IGNORED_FILES" ]; then
+ echo "::error::Found ignored Go files: $IGNORED_FILES"
+ exit 1
+ fi
+ - name: Check that go.mod is tidied
+ if: success() || failure() # run this step even if the previous one failed
+ run: go mod tidy -diff
+ - name: Check that FIPS go.mod is tidied
+ if: success() || failure() # run this step even if the previous one failed
+ working-directory: integrationtests/fips
+ run: go mod tidy -diff
+ - name: Run go fix
+ if: success() || failure() # run this step even if the previous one failed
+ run: go fix -diff ./...
+ - name: Run code generators
+ if: success() || failure() # run this step even if the previous one failed
+ run: .github/workflows/go-generate.sh
+ - name: Check that go mod vendor works
+ if: success() || failure() # run this step even if the previous one failed
+ run: |
+ cd integrationtests/gomodvendor
+ go mod vendor
+ - name: Run gcassert
+ if: success() || failure() # run this step even if the previous one failed
+ run: go tool gcassert ./...
+ # Only run govulncheck on pull requests, not on pushes (including merge commits to master).
+ # govulncheck queries the vulnerability database at runtime, so a newly disclosed vulnerability
+ # could otherwise cause the post-merge run to fail even though nothing in the repository changed,
+ # breaking master through no fault of the merged PR.
+ - name: Run govulncheck
+ if: ${{ (success() || failure()) && github.event_name == 'pull_request' }}
+ run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
+ golangci-lint:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ go: [ "1.25.x", "1.26.x" ]
+ env:
+ GOLANGCI_LINT_VERSION: v2.11.4
+ name: golangci-lint (Go ${{ matrix.go }})
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version: ${{ matrix.go }}
+ check-latest: true
+ - name: golangci-lint (Linux)
+ uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
+ with:
+ args: --timeout=3m
+ version: ${{ env.GOLANGCI_LINT_VERSION }}
+ - name: golangci-lint (Windows)
+ if: success() || failure() # run this step even if the previous one failed
+ uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
+ env:
+ GOOS: "windows"
+ with:
+ args: --timeout=3m
+ version: ${{ env.GOLANGCI_LINT_VERSION }}
+ - name: golangci-lint (OSX)
+ if: success() || failure() # run this step even if the previous one failed
+ uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
+ env:
+ GOOS: "darwin"
+ with:
+ args: --timeout=3m
+ version: ${{ env.GOLANGCI_LINT_VERSION }}
+ - name: golangci-lint (FreeBSD)
+ if: success() || failure() # run this step even if the previous one failed
+ uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
+ env:
+ GOOS: "freebsd"
+ with:
+ args: --timeout=3m
+ version: ${{ env.GOLANGCI_LINT_VERSION }}
+ - name: golangci-lint (others)
+ if: success() || failure() # run this step even if the previous one failed
+ uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
+ env:
+ GOOS: "solaris" # some OS that we don't have any build tags for
+ with:
+ args: --timeout=3m
+ version: ${{ env.GOLANGCI_LINT_VERSION }}
diff --git a/third_party/quic-go/.github/workflows/unit.yml b/third_party/quic-go/.github/workflows/unit.yml
new file mode 100644
index 0000000..747240f
--- /dev/null
+++ b/third_party/quic-go/.github/workflows/unit.yml
@@ -0,0 +1,75 @@
+on: [push, pull_request]
+
+permissions: read-all
+
+jobs:
+ unit:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ "ubuntu", "windows", "macos" ]
+ go: [ "1.25.x", "1.26.x", "1.27.0-rc.1" ]
+ runs-on: ${{ fromJSON(vars[format('UNIT_RUNNER_{0}', matrix.os)] || format('"{0}-latest"', matrix.os)) }}
+ name: Unit tests (${{ matrix.os}}, Go ${{ matrix.go }})
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version: ${{ matrix.go }}
+ check-latest: true
+ - run: go version
+ - name: Install go-junit-report
+ run: go install github.com/jstemmer/go-junit-report/v2@v2.1.0
+ - name: Remove integrationtests
+ shell: bash
+ run: git rm -r --cached integrationtests && rm -rf integrationtests
+ - name: Run tests
+ env:
+ TIMESCALE_FACTOR: 10
+ run: go test -v -shuffle on -cover -coverprofile coverage.txt ./... 2>&1 | go-junit-report -set-exit-code -iocopy -out report.xml
+ - name: Run tests as root
+ if: ${{ matrix.os == 'ubuntu' }}
+ env:
+ TIMESCALE_FACTOR: 10
+ FILE: sys_conn_helper_linux_test.go
+ run: |
+ test -f $FILE # make sure the file actually exists
+ TEST_NAMES=$(grep '^func Test' "$FILE" | sed 's/^func \([A-Za-z0-9_]*\)(.*/\1/' | tr '\n' '|')
+ go test -c -cover -tags root -o quic-go.test .
+ sudo ./quic-go.test -test.v -test.run "${TEST_NAMES%|}" -test.coverprofile coverage-root.txt 2>&1 | go-junit-report -set-exit-code -iocopy -package-name github.com/quic-go/quic-go -out report_root.xml
+ rm quic-go.test
+ - name: Run tests with race detector
+ if: ${{ matrix.os == 'ubuntu' }} # speed things up. Windows and OSX VMs are slow
+ env:
+ TIMESCALE_FACTOR: 20
+ run: go test -v -shuffle on ./...
+ - name: Run handshake tests in FIPS140 mode
+ if: ${{ matrix.go != '1.25.x' }}
+ env:
+ GODEBUG: fips140=only
+ run: go test -v ./internal/handshake -run 'TestToken|TestRetry|TestInitial|TestDecode|TestEncrypt'
+ - name: Run benchmark tests
+ run: go test -v -run=^$ -benchtime 0.5s -bench=. ./...
+ - name: Upload coverage to Codecov
+ if: ${{ !cancelled() }}
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+ env:
+ OS: ${{ matrix.os }}
+ GO: ${{ matrix.go }}
+ with:
+ files: coverage.txt,coverage-root.txt
+ env_vars: OS,GO
+ token: ${{ secrets.CODECOV_TOKEN }}
+ - name: Upload test report to Codecov
+ if: ${{ !cancelled() }}
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+ env:
+ OS: ${{ matrix.os }}
+ GO: ${{ matrix.go }}
+ with:
+ report_type: test_results
+ name: Unit tests
+ files: report.xml,report_root.xml
+ env_vars: OS,GO
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/third_party/quic-go/.gitignore b/third_party/quic-go/.gitignore
new file mode 100644
index 0000000..60571ed
--- /dev/null
+++ b/third_party/quic-go/.gitignore
@@ -0,0 +1,20 @@
+debug
+debug.test
+main
+mockgen_tmp.go
+*.qtr
+*.qlog
+*.sqlog
+*.txt
+race.[0-9]*
+
+fuzzing/*/*.zip
+fuzzing/*/coverprofile
+fuzzing/*/crashers
+fuzzing/*/sonarprofile
+fuzzing/*/suppressions
+fuzzing/*/corpus/
+
+**/testdata/fuzz/
+
+gomock_reflect_*/
diff --git a/third_party/quic-go/.golangci.yml b/third_party/quic-go/.golangci.yml
new file mode 100644
index 0000000..82bf4fc
--- /dev/null
+++ b/third_party/quic-go/.golangci.yml
@@ -0,0 +1,99 @@
+version: "2"
+linters:
+ default: none
+ enable:
+ - asciicheck
+ - copyloopvar
+ - depguard
+ - exhaustive
+ - govet
+ - ineffassign
+ - misspell
+ - nolintlint
+ - prealloc
+ - staticcheck
+ - unconvert
+ - unparam
+ - unused
+ - usetesting
+ settings:
+ depguard:
+ rules:
+ random:
+ deny:
+ - pkg: "math/rand$"
+ desc: use math/rand/v2
+ - pkg: "golang.org/x/exp/rand"
+ desc: use math/rand/v2
+ quicvarint:
+ list-mode: strict
+ files:
+ - '**/github.com/quic-go/quic-go/quicvarint/*'
+ - '!$test'
+ allow:
+ - $gostd
+ rsa:
+ list-mode: original
+ deny:
+ - pkg: crypto/rsa
+ desc: "use crypto/ed25519 instead"
+ ginkgo:
+ list-mode: original
+ deny:
+ - pkg: github.com/onsi/ginkgo
+ desc: "use standard Go tests"
+ - pkg: github.com/onsi/ginkgo/v2
+ desc: "use standard Go tests"
+ - pkg: github.com/onsi/gomega
+ desc: "use standard Go tests"
+ http3-internal:
+ list-mode: lax
+ files:
+ - '**/http3/**'
+ deny:
+ - pkg: 'github.com/quic-go/quic-go/internal'
+ desc: 'no dependency on quic-go/internal'
+ misspell:
+ ignore-rules:
+ - ect
+ # see https://github.com/ldez/usetesting/issues/10
+ usetesting:
+ context-background: false
+ context-todo: false
+ exclusions:
+ generated: lax
+ presets:
+ - comments
+ - common-false-positives
+ - legacy
+ - std-error-handling
+ rules:
+ - linters:
+ - depguard
+ path: internal/qtls
+ - linters:
+ - exhaustive
+ - prealloc
+ - unparam
+ path: _test\.go
+ - linters:
+ - staticcheck
+ path: _test\.go
+ text: 'SA1029:' # inappropriate key in call to context.WithValue
+ paths:
+ - internal/handshake/cipher_suite.go
+ - third_party$
+ - builtin$
+ - examples$
+formatters:
+ enable:
+ - gofmt
+ - gofumpt
+ - goimports
+ exclusions:
+ generated: lax
+ paths:
+ - internal/handshake/cipher_suite.go
+ - third_party$
+ - builtin$
+ - examples$
diff --git a/third_party/quic-go/AUTOCAR_PATCHES.md b/third_party/quic-go/AUTOCAR_PATCHES.md
new file mode 100644
index 0000000..0fe526a
--- /dev/null
+++ b/third_party/quic-go/AUTOCAR_PATCHES.md
@@ -0,0 +1,28 @@
+# AutoCAR security hardening
+
+This directory is based on AutoCAR's pinned
+`github.com/apernet/quic-go` pseudo-version
+`v0.61.1-0.20260806010916-184d081eef3e` and remains licensed under the MIT
+license in `LICENSE`.
+
+AutoCAR adds one narrow HTTP/3 server hook: `StreamAdmission` runs immediately
+after a bidirectional stream is accepted, before a handler goroutine is started
+or the first frame type is read. It can reject a stream or return a callback
+that releases process-wide capacity when handling ends.
+
+The Hysteria server adapter uses this hook to apply its global handler budget
+and first-byte deadline before `StreamDispatcher` peeks the frame type. Without
+that ordering, a peer could open many streams and send an incomplete frame type
+without entering Hysteria's normal TCP request handler. Its dispatcher handles
+the TCP relay synchronously in quic-go's existing per-stream worker so the
+release callback cannot run while the destination header or relay is still
+active.
+
+The pre-handshake server path also cancels `ConnContext`, closes a just-created
+qlog trace and releases the Initial packet if connection-ID generation fails.
+This preserves admission-slot accounting even during a system randomness
+failure before a connection object exists.
+
+The testdata helper generates an ephemeral ECDSA P-256 CA and leaf in a private
+temporary directory at runtime. Fixed test private-key files are deliberately
+excluded from the fork.
diff --git a/third_party/quic-go/FIPS140.md b/third_party/quic-go/FIPS140.md
new file mode 100644
index 0000000..7d7d92e
--- /dev/null
+++ b/third_party/quic-go/FIPS140.md
@@ -0,0 +1,37 @@
+# FIPS 140-3
+
+quic-go relies on the Go standard library for cryptography, including the Go Cryptographic Module described in [The FIPS 140-3 Go Cryptographic Module](https://go.dev/blog/fips140). quic-go does not seek separate FIPS 140-3 validation as a cryptographic module. This document explains how quic-go uses Go standard library cryptography for QUIC operations relevant to FIPS 140-3.
+
+Starting with quic-go v0.60, the behavior described here applies when built with Go 1.26 or newer. With older Go versions, quic-go still builds and runs as usual, without any attempt to meet FIPS 140 requirements.
+
+## QUIC operations relevant to FIPS 140-3
+
+quic-go delegates the TLS 1.3 handshake, certificate handling, cipher suite selection, session tickets, and the TLS key schedule to `crypto/tls`. When Go's FIPS 140-3 mode is active, `crypto/tls` restricts the algorithms it negotiates.
+
+### Packet protection AEADs
+
+The main quic-go-specific FIPS-relevant operations are the AEADs protecting Handshake, 0-RTT, and 1-RTT packets.
+
+AES-GCM packet protection AEADs are constructed through the Go standard library's TLS 1.3 AES-GCM implementation. Today this uses `go:linkname` to call the unexported `crypto/tls.aeadAESGCMTLS13`, because the standard library does not yet expose a QUIC-specific constructor; see [golang/go#79219](https://github.com/golang/go/issues/79219).
+
+ChaCha20-Poly1305 is not used in Go's FIPS 140-3 mode. `crypto/tls` avoids that cipher suite during negotiation, and quic-go additionally guards its internal ChaCha20-Poly1305 path when FIPS 140-3 mode is enabled.
+
+### Header protection
+
+For Handshake, 0-RTT, and 1-RTT packets protected with AES cipher suites, header protection keys are derived with `crypto/hkdf` and the AES block operation uses `crypto/aes`. ChaCha20 header protection is tied to the ChaCha20-Poly1305 cipher suite and is not reachable in FIPS 140-3 mode.
+
+### Address validation tokens
+
+quic-go encrypts the address validation tokens it sends in Retry packets and NEW_TOKEN frames. These are not TLS session tickets (those are handled by `crypto/tls`); they carry server-defined state such as the client address, timestamp, RTT information, and Retry connection IDs.
+
+Token-protection keys are derived with `crypto/hkdf`, AES is used via `crypto/aes`, and the token AEAD is constructed with `cipher.NewGCMWithRandomNonce`, keeping token encryption on standard library primitives.
+
+## QUIC operations not relevant to FIPS 140-3
+
+### Initial packet protection
+
+Initial packet protection (including Initial header protection) is not treated as FIPS 140-relevant confidentiality protection: the Initial secrets are derived from constants in RFC 9001 and the packet's destination connection ID, so any observer can derive the same keys. quic-go therefore disables strict FIPS 140 enforcement around Initial packet construction in Go 1.26 FIPS 140-3 mode. See the IETF QUIC mailing list discussion at .
+
+### Retry packet integrity tag
+
+RFC 9001 defines the Retry packet integrity tag using fixed keys and nonces. It guards against accidental corruption and casual injection but does not encrypt packet contents. quic-go treats it as outside the FIPS 140 scope and disables strict FIPS 140 enforcement for that AEAD construction in Go 1.26 FIPS 140-3 mode.
diff --git a/third_party/quic-go/FUZZING.md b/third_party/quic-go/FUZZING.md
new file mode 100644
index 0000000..e7500aa
--- /dev/null
+++ b/third_party/quic-go/FUZZING.md
@@ -0,0 +1,59 @@
+# Fuzzing
+
+[](https://introspector.oss-fuzz.com/project-profile?project=quic-go)
+[](https://app.codecov.io/gh/quic-go/quic-go?flags%5B0%5D=clusterfuzz)
+[](https://app.codecov.io/gh/quic-go/quic-go?flags%5B0%5D=clusterfuzz-lite-batch)
+
+Run the commands below from a local [`google/oss-fuzz`](https://github.com/google/oss-fuzz) checkout.
+Fuzz target names match the binary names listed in `oss-fuzz.sh` (for example, `frame_fuzzer_v2`).
+
+Update the base images:
+```sh
+python3 infra/helper.py pull_images
+```
+
+## Running fuzzers locally
+
+The following steps run a single fuzz target and then open its line-by-line coverage in `go tool cover`.
+
+```sh
+export DOCKER_DEFAULT_PLATFORM=linux/amd64
+export FUZZ_TARGET=
+export CORPUS_DIR=corpus/$FUZZ_TARGET
+
+mkdir -p "$CORPUS_DIR"
+
+python3 infra/helper.py build_image --no-pull quic-go
+python3 infra/helper.py build_fuzzers --sanitizer address quic-go
+python3 infra/helper.py run_fuzzer --corpus-dir="$CORPUS_DIR" quic-go "$FUZZ_TARGET"
+```
+
+Leave `run_fuzzer` running for a while to build up a corpus. It unpacks the seed corpus zip into the corpus directory and appends new entries as it discovers them.
+
+```sh
+python3 infra/helper.py build_fuzzers --sanitizer coverage quic-go
+python3 infra/helper.py coverage --no-serve --fuzz-target "$FUZZ_TARGET" --corpus-dir="$CORPUS_DIR" quic-go
+sed "s#^/out/#$(pwd)/build/out/quic-go/#" build/out/quic-go/fuzz.cov > "/tmp/quic-go-$FUZZ_TARGET.coverprofile"
+go tool cover -html="/tmp/quic-go-$FUZZ_TARGET.coverprofile"
+```
+
+The `sed` command rewrites the container paths in `fuzz.cov` so that `go tool cover` can locate the source files in the local checkout.
+
+To produce a coverage report against a modified local source tree, mount the local checkout when building the coverage fuzzers, the same way you would for reproducers:
+
+```sh
+python3 infra/helper.py build_fuzzers --sanitizer coverage --mount_path /root/go/src/github.com/apernet/quic-go quic-go
+```
+
+## Reproducing an OSS-Fuzz testcase
+
+Download the reproducer file from the OSS-Fuzz report. To test a local fix, rebuild the fuzzers with the modified quic-go checkout mounted at the path expected by `oss-fuzz.sh`:
+
+```sh
+export DOCKER_DEFAULT_PLATFORM=linux/amd64
+export FUZZ_TARGET=
+
+python3 infra/helper.py build_image --no-pull quic-go
+python3 infra/helper.py build_fuzzers --sanitizer address --mount_path /root/go/src/github.com/apernet/quic-go quic-go
+python3 infra/helper.py reproduce quic-go "$FUZZ_TARGET"
+```
diff --git a/third_party/quic-go/LICENSE b/third_party/quic-go/LICENSE
new file mode 100644
index 0000000..51378be
--- /dev/null
+++ b/third_party/quic-go/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2016 the quic-go authors & Google, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/third_party/quic-go/README.md b/third_party/quic-go/README.md
new file mode 100644
index 0000000..22056d9
--- /dev/null
+++ b/third_party/quic-go/README.md
@@ -0,0 +1,65 @@
+
+
+
+
+# A QUIC implementation in pure Go
+
+
+[](https://quic-go.net/docs/)
+[](https://pkg.go.dev/github.com/apernet/quic-go)
+[](https://codecov.io/gh/quic-go/quic-go/)
+[](https://issues.oss-fuzz.com/issues?q=quic-go)
+
+quic-go is an implementation of the QUIC protocol ([RFC 9000](https://datatracker.ietf.org/doc/html/rfc9000), [RFC 9001](https://datatracker.ietf.org/doc/html/rfc9001), [RFC 9002](https://datatracker.ietf.org/doc/html/rfc9002)) in Go. It has support for HTTP/3 ([RFC 9114](https://datatracker.ietf.org/doc/html/rfc9114)), including QPACK ([RFC 9204](https://datatracker.ietf.org/doc/html/rfc9204)) and HTTP Datagrams ([RFC 9297](https://datatracker.ietf.org/doc/html/rfc9297)).
+
+In addition to these base RFCs, it also implements the following RFCs:
+
+* Unreliable Datagram Extension ([RFC 9221](https://datatracker.ietf.org/doc/html/rfc9221))
+* Datagram Packetization Layer Path MTU Discovery (DPLPMTUD, [RFC 8899](https://datatracker.ietf.org/doc/html/rfc8899))
+* QUIC Version 2 ([RFC 9369](https://datatracker.ietf.org/doc/html/rfc9369))
+* QUIC Event Logging using qlog ([draft-ietf-quic-qlog-main-schema](https://datatracker.ietf.org/doc/draft-ietf-quic-qlog-main-schema/) and [draft-ietf-quic-qlog-quic-events](https://datatracker.ietf.org/doc/draft-ietf-quic-qlog-quic-events/))
+* QUIC Stream Resets with Partial Delivery ([draft-ietf-quic-reliable-stream-reset-07](https://datatracker.ietf.org/doc/html/draft-ietf-quic-reliable-stream-reset-07) and [draft-ietf-quic-reliable-stream-reset-09](https://datatracker.ietf.org/doc/html/draft-ietf-quic-reliable-stream-reset-09))
+
+Support for WebTransport over HTTP/3 ([draft-ietf-webtrans-http3](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)) is implemented in [webtransport-go](https://github.com/quic-go/webtransport-go).
+
+Detailed documentation can be found on [quic-go.net](https://quic-go.net/docs/).
+
+## FIPS 140-3
+
+Starting with v0.60, quic-go supports use in FIPS 140-3 environments when built with Go 1.26 or newer, using Go standard library cryptography for the QUIC code paths relevant in FIPS mode; see [FIPS140.md](FIPS140.md) for details.
+
+## Projects using quic-go
+
+| Project | Description | Stars |
+| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| [AdGuardHome](https://github.com/AdguardTeam/AdGuardHome) | Free and open source, powerful network-wide ads & trackers blocking DNS server. |  |
+| [algernon](https://github.com/xyproto/algernon) | Small self-contained pure-Go web server with Lua, Markdown, HTTP/2, QUIC, Redis and PostgreSQL support |  |
+| [caddy](https://github.com/caddyserver/caddy/) | Fast, multi-platform web server with automatic HTTPS |  |
+| [cloudflared](https://github.com/cloudflare/cloudflared) | A tunneling daemon that proxies traffic from the Cloudflare network to your origins |  |
+| [frp](https://github.com/fatedier/frp) | A fast reverse proxy to help you expose a local server behind a NAT or firewall to the internet |  |
+| [go-libp2p](https://github.com/libp2p/go-libp2p) | libp2p implementation in Go, powering [Kubo](https://github.com/ipfs/kubo) (IPFS) and [Lotus](https://github.com/filecoin-project/lotus) (Filecoin), among others |  |
+| [gost](https://github.com/go-gost/gost) | A simple security tunnel written in Go |  |
+| [Hysteria](https://github.com/apernet/hysteria) | A powerful, lightning fast and censorship resistant proxy |  |
+| [Mercure](https://github.com/dunglas/mercure) | An open, easy, fast, reliable and battery-efficient solution for real-time communications |  |
+| [nodepass](https://github.com/NodePassProject/nodepass) | A secure, efficient TCP/UDP tunneling solution that delivers fast, reliable access across network restrictions using pre-established TCP/QUIC/WebSocket or HTTP/2 connections. |  |
+| [OONI Probe](https://github.com/ooni/probe-cli) | Next generation OONI Probe. Library and CLI tool. |  |
+| [reverst](https://github.com/flipt-io/reverst) | Reverse Tunnels in Go over HTTP/3 and QUIC |  |
+| [RoadRunner](https://github.com/roadrunner-server/roadrunner) | High-performance PHP application server, process manager written in Go and powered with plugins |  |
+| [syncthing](https://github.com/syncthing/syncthing/) | Open Source Continuous File Synchronization |  |
+| [traefik](https://github.com/traefik/traefik) | The Cloud Native Application Proxy |  |
+| [v2ray-core](https://github.com/v2fly/v2ray-core) | A platform for building proxies to bypass network restrictions |  |
+| [YoMo](https://github.com/yomorun/yomo) | Streaming Serverless Framework for Geo-distributed System |  |
+
+If you'd like to see your project added to this list, please send us a PR.
+
+## Release Policy
+
+quic-go always aims to support the latest two Go releases.
+
+## Contributing
+
+We are always happy to welcome new contributors! We have a number of self-contained issues that are suitable for first-time contributors, they are tagged with [help wanted](https://github.com/apernet/quic-go/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22). If you have any questions, please feel free to reach out by opening an issue or leaving a comment.
+
+## License
+
+The code is licensed under the MIT license. The logo and brand assets are excluded from the MIT license. See [assets/LICENSE.md](https://github.com/apernet/quic-go/tree/master/assets/LICENSE.md) for the full usage policy and details.
diff --git a/third_party/quic-go/SECURITY.md b/third_party/quic-go/SECURITY.md
new file mode 100644
index 0000000..d8f0570
--- /dev/null
+++ b/third_party/quic-go/SECURITY.md
@@ -0,0 +1,14 @@
+# Security Policy
+
+quic-go is an implementation of the QUIC protocol and related standards. No software is perfect, and we take reports of potential security issues very seriously.
+
+## Reporting a Vulnerability
+
+If you discover a vulnerability that could affect production deployments (e.g., a remotely exploitable issue), please report it [**privately**](https://github.com/apernet/quic-go/security/advisories/new).
+Please **DO NOT file a public issue** for exploitable vulnerabilities.
+
+If the issue is theoretical, non-exploitable, or related to an experimental feature, you may discuss it openly by filing a regular issue.
+
+## Reporting a non-security bug
+
+For bugs, feature requests, or other non-security concerns, please open a GitHub [issue](https://github.com/apernet/quic-go/issues/new).
diff --git a/third_party/quic-go/assets/LICENSE.md b/third_party/quic-go/assets/LICENSE.md
new file mode 100644
index 0000000..672a20f
--- /dev/null
+++ b/third_party/quic-go/assets/LICENSE.md
@@ -0,0 +1,27 @@
+# quic-go Logo and Trademark Usage Policy
+
+## Exception to Main License
+
+The files in this directory (collectively, "Brand Assets") are **excluded** from the quic-go project's main MIT License. These assets are protected by copyright and trademark laws.
+
+## Permitted Use
+
+You are granted a limited, non-exclusive license to use these Brand Assets solely for the following purposes:
+
+- **Editorial and Press:** You may use the Brand Assets in blog posts, news articles, video reviews, and public presentations that discuss, review, or reference the quic-go project.
+
+- **Reference:** You may use the Brand Assets to indicate your project's compatibility with or dependence on quic-go (e.g., "Powered by quic-go").
+
+## Restricted Use
+
+You may NOT:
+
+- **Modification:** Modify the Brand Assets in any way (including changing colors, aspect ratio, or obscuring the image). Resizing the image while maintaining the original aspect ratio is permitted.
+
+- **No Branding:** Use the Brand Assets as the logo, icon, or mascot for your own project, product, or service.
+
+- **No Endorsement:** Use the Brand Assets in a way that suggests your project is officially sponsored by, endorsed by, or affiliated with the quic-go maintainers.
+
+## Termination
+
+The quic-go project reserves the right to revoke this authorization at any time if usage is found to be confusing, misleading, or detrimental to the project's reputation.
diff --git a/third_party/quic-go/assets/logo.svg b/third_party/quic-go/assets/logo.svg
new file mode 100644
index 0000000..ecf2bc0
--- /dev/null
+++ b/third_party/quic-go/assets/logo.svg
@@ -0,0 +1,330 @@
+
+
+
+
diff --git a/third_party/quic-go/assets/quic-go-logo.png b/third_party/quic-go/assets/quic-go-logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..6be4ed43184346bddc76d6ff258eddb05488bb50
GIT binary patch
literal 105123
zcmeFY_g7Qf^FEH{%2iYZ1f*-EBOtwlC4ka95}Nc9nzTsAMlVuBN9irpP(mmwz4sPM
zqy`89siE@;-q)4;{sX?hefL`7pMNf)7Pf$dhcvFTuUs`9EnOid&K5)v2!zMR
z0qkOC;$*?&=xmj|E=fy7bf4%Y@Tu0@l(lI{LhQ)F+2)a?fP{XoeeR7H8}%aL>SZRE
z=rUTz!^>FWn~lgbA6P#sdrh(zQFa+nkFWCIF?M@@tDJ=b{?O1I}N_jbg_K-<`B;SbP7zzyMZBv(i1F
zz>%$?F*j-Js6;%}Pt!S!gPWKKjpBeWbF8OzVmt40(~vW0#)4(Do&gnrSC-CFUmMHm@`6wSuZ#YnaMTnDnX$Uj8Y)Rft^2M5%nwdcM}tRyUxnO(qfWS
znacr_vyCzV@T;9ljljNo1Du}#g@ZNpfjgdLU2}vHO?90ZWA
z54zgpO+=JJ6sz%u2YR9bTQp^HE(mH4*#EmR$W9BeeFE|U>F89+rQb)Jvu77MKumdI
z;okwCr3tpB>?>U1;LpIOk)ZZV1Le;-CTv311eGRcDJJxEbad{lI9~P%1Ix?H4Wn*f{qvJV
z?|v`Ko7Pz;*Xnz#SIDFu59;|^rt&Y&W|WunK4i$^;ei%bJUA5qh6RImfMgv(PFZKq
zL$&Av)E&w|oUtr9AYgMT%Jx=h3opP_@kId{8J`_r&Ipxb2#1Egw&ks=^nB&TZLJ9)
z@L1Y0Z11QD0Prmld73OP2bXyY_Bl47DOxPOSN;U(tJW#rn3uQwI_u$uIs5;8zHIcm
z20TYvEURv0FKw4}jIy<|snjzn1@2Cq%6E}l@HujD7L1b*`Xql}biSpdph^W}3vKsz
zN`u963Y*$}s`{8!E()EDNk~~yv>kd%Ps3nJKEyviXTY)fMg>yG-4S&+c>%6(YF9Sf
zjW=$fRn4x>$SBAu_@Qa2W1y$62h!J9HzIsiw`1=f7H}yjIeH}S>1Y=ug=l2v6b#Po
z#!enHs@9cMv8o^W+=f-^1GSj9#!O3eiu|S$BzI
zGVYR~pzNo-?Ana!DYXI>V?RT>FE!0;h~pJhI@dniD!x!S_Gn5#>+5Sq`H40@!asSl
zIm2Lq&>uYrC0$e6x=Wol)tqlHJ+Ni)Ec)d1dEjkrq1h8lb%hqu)L@EOjTqT34?uw#
z2_vTRmD%kU#FRXPy@YOd+LV5_`v!Djed-}MA51xEgofn!%
zg1O6)xsOX)H4`v}oScsf6-nZ9;o}@swX|Ntxs}>1$LD}wQ^n8E3445jUf^?J1ZyLe0)_)DTT^I
z-RLr#vGq?bTYJ0*y@vYgqq@MbYmQ&cks3?cvq^&(SMfla00S0~F)N_s>kPUSM2Z@k
zWKh_U%F9tciwfp2Jj#+lx!YO2DtcOikUg>OR8-`QVB}<%Nioz(sn0EN!dUvf*_#nn
zRrd`*W9-=geB4fUQ9<4XE#8fAxbxRh6#BB&h2-DP&bP*sSj9J@a)|-Q_W@5!j7I
z41uZ!VoOVbN6ooe*_B%H1wZQQl$jdJ=i3nJDGPWH384iBZVmgOhRnRIaxYWDqO0I
zd;0W{HA~CI_Pt3Vb1|}Due{!V0779UQfcTd5)OBD%iH_wkFfsf8RwpXeaRhZXnI&3
z99{K&bUZpGneF62*~>Y1J++BS30&_tF?FlU*t%71&*2
ztl>yJQfeCIF{Ninp)oj^T)_k=+**#BnkH$Ss_@$ssb%(pP!Da8qKGD+2q_HHH^z^3
zb#^X&%D7JXN2+hZ;g-@wL>a?=#bbu708X{~lb>awz6ZY)=VkN#cbx`vY>NFb8(mf2
zf`yTz;)t}7Z3P9c-VxB4psOFWYediH%syjS2rxK|)pk?z{t*$;q|$+v^6;D-UO%Z7
zdBX&Jmm#I+Rpoc!zV|MUM8lS=^DroAAPnaos=>={;gOk_mnW!2bK&JuUj5dS0>W?)
z0a%5;v)v2Z$|}ubuUH+7(b<(e+FB2`vFA@NCC)I=-W+(JYV22IhJP={w|EeEjXkP{
z)j|8KY){pG4~C?xt2$7S`Hy=1=NZqPF7WH!K(igcoGpef(*Is=l6;x|}YXDD~j=fp+vDscqC0%3Oadr2^
zlz|7Lx%X`|zp6aLyKQX4uJdrKe!n0i^Gddjas~+KQz9#6sG-3xGW;LC(5L^_%MFBK
zL?qB$;S1`QF}l94??5;Fwo8`Wq!2zvyDQDBv@xieSU(epHrCQOZT35xTyb^RRAAK3
z;EiuJ3M~k$+xyVAcF%JW$1r8+>2Blfl=8SBB$CSRU||=BY&;W0RApp14!Nt-#l`1_
zc6CY*gN~jh-}$4CZ<`-~TT9rqgK7mxDLCytv7RZXCTrn6v>dKlI#1S@1u!Z{Z^D{gtuL_9Ir%8HEmiCVO?-}$-jytC)zP$(
z5}DK(q=j3fxU>Lnf@tQG@9^Y!8W3aF5dBE{W};Ll=son-kfhsJp6buVn9S@TxN9EIgp+4j@ruC
zlL@eqkF#U%VfGdHXt7*_(S3#bFR>Pu>gwt?p;s@U$Q|w9iyb1G+H3xN#hXlQsdn7`
ziX6B2$)+9!;0_mad4o4(u@jnp?*SmZ0WeJU_RWj7Z&t{OYS@hsyE|Y>sSOW@u^A>-dtnZr7h=`t;TUc7Maz?Gcvt^J^w+xU_Rea;N>^#^((WggQWTfXR7%wLc
z3|&x}mPi-b{_sZA%11Y0)a9osS-wp>VG3E+mF+n9V=l1?#*s0ri*>QS$9g^s7LAnN
zsGLEr%Ok8(ViH^xeLu}u_dZ28R-t0*J=Jx311uJ^1NS&^0xc5_6}@W$f?ucm`BEvS
zZ1NvSbYWpWM}sC}mau9?|8i+eKEyqHmv;Q27Ut5dMikWL?ErdxI2I6;Ea}Fe
z