peinser/quic-sni-router
GitHub: peinser/quic-sni-router
一个轻量级 QUIC 数据平面路由器,通过解析 QUIC 初始包中的 TLS ClientHello SNI 将 UDP 数据报转发到对应后端,自身不终结 TLS。
Stars: 0 | Forks: 0
# quic-sni-router
[](https://github.com/Peinser/quic-sni-router/actions/workflows/ci.yaml)
[](https://github.com/Peinser/quic-sni-router/actions/workflows/image.yaml)
[](LICENSE)
[](ROADMAP.md)
一个用于 Pod 终结的 mTLS 和 HTTP/3 服务的小型 QUIC SNI 路由器。
目标:
```
client UDP 443 -> quic-sni-router -> FlightDeck pod UDP 8443
```
`quic-sni-router` 接收 UDP QUIC 数据包,仅对 QUIC v1 (RFC 9000) 和 QUIC v2 (RFC 9369) 初始数据包(Initial packets)进行足以读取 TLS ClientHello SNI 的解密,选择已配置的后端,然后原封不动地转发原始数据报。后端继续负责终结 TLS/mTLS;路由器不会加载证书或私钥。
当前状态是一个 MVP 数据平面,具备通过 OpenSSL libcrypto 进行 QUIC v1 + v2 初始数据包去保护、CRYPTO 帧提取、TLS ClientHello SNI 提取、精确 SNI 路由查找、UDP 转发、会话固定、CI、模糊测试工具(fuzz harnesses)、Docker 端到端测试以及 devcontainer 设置。
数据平面在进入数据包循环之前会预先解析已配置的后端主机,并维护多元组以及观察到的 CID 会话别名。每个客户端元组都会获得一个通往其后端的专属上游 UDP socket,因此后端会为每个客户端看到一个路由器源端口,并且返回流量通过接收 socket 进行精确解复用:任意数量的并发 QUIC 连接到一个后端都能正常工作,包括具有零长度或握手后轮换的连接 ID 的客户端(Chrome 使用零长度的客户端 CID)。NAT 重绑定可以在尽力而为的基础上通过学习到的长头部或短头部 CID 进行恢复。Linux 构建使用 `epoll` 加上 `recvmmsg`/`sendmmsg` 进行批量 I/O;其他平台使用可移植的 `poll` + `recvfrom`/`sendto` 循环。
## 面向 WAN 的注意事项
在将路由器暴露于公共互联网之前,请阅读以下内容:
- **后端隔离。** 后端应位于不可路由的/集群内部网络中。后端返回流量通过每流上游 socket(临时端口)被接受;如果攻击者既能伪造后端源地址,又能命中活动流的临时端口,就可以向该流的客户端注入数据包。QUIC 自身的 AEAD 使这仅是一种干扰而非完全接管,但更安全的做法是让后端无法从 WAN 访问。
- **防放大。** 路由器会丢弃来自未知来源且短于 1200 字节的初始数据报(RFC 9000 §14.1),因此它不会成为 UDP 放大器。添加上游 BCP 38 / uRPF 以完全阻止伪造源。
- **CPU DoS。** 进程内没有针对每个来源的速率限制。在暴露给不受信任的网络之前,请结合 eBPF/nftables/云 LB 速率限制。
- **会话表。** 为 `expected_connections_per_second × idleTimeout_seconds × ~4` 配置 `maxSessions`:每个新的 QUIC 连接大约创建 4 个表条目(转发元组、DCID 别名、server-SCID 别名、DCID+SCID 对)。在 1000 conn/s 和默认 60s `idleTimeout` 的情况下,大约是 24 万。达到上限时,将驱逐 `last_seen` 最早的条目;如果配置不足,您会看到活动连接在传输过程中中断。
- **文件描述符。** 每个并发客户端流持有一个 UDP socket;流表大小为 `maxSessions / 4`。进程在启动时将 `RLIMIT_NOFILE` 提升至其硬限制,如果仍无法覆盖流表,则会发出警告;超出限制时,最旧的流将按 LRU 方式被回收。请将容器的 `ulimit -n`(或 `LimitNOFILE`)设置在预期的并发连接数之上。
- **单线程。** 每个路由器进程固定在一个核心上。运行多个进程;会自动设置 `SO_REUSEPORT`,以便内核跨进程哈希流。
- **DNS 是一次性的。** 后端在启动时以及每次热重载时被解析(见下文)。要在不编辑配置的情况下获取后端 IP 更改,请重启进程。
- **热重载。** 包含 `config.yaml` 的目录通过 `inotify` 进行监控。编辑文件或通过 Kubernetes 交换 ConfigMap 符号链接会触发重新解析 + DNS 重新解析 + 原子交换,且不会丢失数据包。新配置中消失的后端会话将被驱逐(硬切换);指向依然存在的后端的会话将继续保持。对 `listen.udp` 和 `sessions.maxSessions` 的更改将被记录并忽略,直到重启。
- **感知 ECH 的行为。** 对于 Encrypted ClientHello,路由器看到的是 OUTER ClientHello 的覆盖主机名(例如 `cloudflare-ech.com`)——而不是加密的真实内部主机名。我们根据外部 SNI 中的内容进行路由,因此使用 ECH 的客户端只有在覆盖主机名是已配置的路由时才能正常工作;否则,它们的数据包将像任何其他未路由的 SNI 一样被丢弃。如果不终结 TLS(我们不终结),路由器永远无法看到内部主机名。
- **QUIC 版本。** 接受 v1 (RFC 9000) 和 v2 (RFC 9369)。任何其他版本都会在解析器处返回 `UNSUPPORTED` 并丢弃数据包(客户端将通过版本协商进行回退)。
有关完整的威胁模型,请参阅 [docs/threat-model.md](docs/threat-model.md)。
## 构建
```
make build
make test
make test-e2e
make sanitize
make fuzz-smoke
make benchmark
```
`make test-e2e` 使用 Docker Compose 启动两个模拟的 HTTP/3 后端,并验证 SNI 是否通过路由器路由到两者。它需要 Docker 和网络访问权限来构建 Python/aioquic 测试镜像。
## 运行
```
quic-sni-router config.yaml
```
从 `docker/Dockerfile` 构建生产镜像:
```
make docker-build
```
将路由配置挂载到默认路径以运行它:
```
docker run --rm -p 443:443/udp -v ./router.yaml:/config/router.yaml:ro \
harbor.peinser.com/uas/quic-sni-router:dev
```
运行时镜像暴露 `443/udp`,以非 root 用户 `qsr` 运行,并默认使用 `/config/router.yaml`。它不包含 TLS 私钥,也不终结后端 TLS 或 mTLS。
## Kubernetes
### Helm(推荐)
```
helm install qsr oci://harbor.peinser.com/library/charts/quic-sni-router \
--version \
--namespace tower-system --create-namespace \
-f values.yaml
```
该 chart 位于 [charts/quic-sni-router/](charts/quic-sni-router/),并在每次推送到 `main` 时发布到 `oci://harbor.peinser.com/library/charts/quic-sni-router`(参见 [.github/workflows/helm.yaml](.github/workflows/helm.yaml))。默认值:2 副本 `Deployment`,`LoadBalancer` Service(`externalTrafficPolicy: Cluster`;如果您想要真实的客户端源 IP,请切换为 `Local`),`PodDisruptionBudget`,加固的 pod + 容器安全上下文,`automountServiceAccountToken: false`,没有 CPU 限制(避免 UDP 数据平面上的 CFS 节流),以及 `ConfigMap` 的 `inotify` 驱动热重载(`helm upgrade` 时无需 pod 重启)。
有关完整的值参考、schema 和 WAN 部署清单,请参阅 [charts/quic-sni-router/README.md](charts/quic-sni-router/README.md)。
### 原始清单
如果您不使用 Helm,请将路由器配置作为 ConfigMap 挂载到 `/config/router.yaml`,并通过 `LoadBalancer`、`NodePort`、主机网络 DaemonSet 或等效的集群边缘模式暴露 UDP/443。
```
apiVersion: v1
kind: ConfigMap
metadata:
name: quic-sni-router
data:
router.yaml: |
listen:
udp: ":443"
routes:
rvr-a.flightdeck.tower.peinser.com:
host: flightdeck-rvr-a.tower-system.svc.cluster.local
port: 8443
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: quic-sni-router
spec:
selector:
matchLabels:
app: quic-sni-router
template:
metadata:
labels:
app: quic-sni-router
spec:
containers:
- name: router
image: harbor.peinser.com/library/quic-sni-router:sha-
ports:
- containerPort: 443
protocol: UDP
volumeMounts:
- name: config
mountPath: /config/router.yaml
subPath: router.yaml
readOnly: true
volumes:
- name: config
configMap:
name: quic-sni-router
```
后端服务应在其配置的 UDP 端口上监听 QUIC,并保留自己的证书、客户端 CA 策略和 mTLS 强制执行。路由器在基于 SNI 的后端选择之后,原封不动地转发原始 QUIC 数据报。
如果路由指向普通的 Kubernetes `Service`,Kubernetes 可能会在 Service 端点之间负载均衡 UDP 流量。QUIC 要求连接的所有数据报都到达拥有该 QUIC 连接状态的同一个后端 pod。请在路由到 Service 时使用稳定的每 pod 端点、带有显式 pod DNS 名称的无头(headless)Service,或配置 Service 亲和性:
```
apiVersion: v1
kind: Service
metadata:
name: flightdeck-rvr-a
namespace: tower-system
spec:
type: ClusterIP
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800
selector:
app: flightdeck-rvr-a
ports:
- name: quic
protocol: UDP
port: 8443
targetPort: 8443
```
`sessionAffinity: ClientIP` 可以将来自一个路由器 pod 的 UDP 数据包固定到一个后端 pod,但它不感知 QUIC,如果许多客户端会话通过同一个路由器 pod IP 到达,可能会破坏负载均衡。为了实现可预测的每连接分发,请优先考虑直接路由到 pod 端点,或者在路由器后面放置一个感知 QUIC 的负载均衡器。
## 性能构建
生产容器使用 `-DCMAKE_BUILD_TYPE=Release` 配置 CMake,并使用 `clang` 构建。对于 Clang/GCC,这已经启用了工具链的 Release 默认设置,通常是 `-O3 -DNDEBUG`。Linux 构建使用 `epoll` 加上 `recvmmsg`/`sendmmsg`;早期的 `io_uring` 路径已被删除,因为它每包 `submit; wait_cqe` 的模式严格慢于批量 `recvmmsg`/`sendmmsg`。
有用的构建开关:
- `-DQSR_ENABLE_SANITIZERS=ON`:ASAN/UBSAN 测试构建。
- `-DQSR_BUILD_FUZZERS=ON`:libFuzzer 测试工具。
- `-DQSR_BUILD_BENCHMARKS=ON`:合成数据平面基准测试。
- `-DQSR_CPU_TARGET=native`:为本地 CPU 调优 Release 构建。
- `-DQSR_CPU_TARGET=znver3` 或 `znver4`:为已知的 Ryzen 代次调优 Release 构建。
- `-DQSR_ENABLE_LTO=ON`:在编译器/链接器支持时启用 Release 跨过程优化。
- `-DQSR_ENABLE_PACKET_DEBUG=ON`:编译数据包决策日志记录支持。已发布的镜像为此使用单独的 `-debug` 标签,并且在运行时记录数据包之前仍然需要 `QSR_DEBUG_PACKETS=1`。
对于仅使用 Ryzen 的主机,请针对原生调优的构建对可移植的 Release 构建进行基准测试:
```
make benchmark
make benchmark-native
```
对于同一个 Ryzen 集群上的特定主机容器镜像,请使用以下命令构建:
```
make docker-build QSR_CPU_TARGET=native QSR_ENABLE_LTO=ON
```
仅当镜像将在与构建主机兼容的 CPU 上运行时才使用 `native`。对于已发布的可移植镜像,请将 `QSR_CPU_TARGET` 留空。镜像工作流发布成对的多架构清单(`linux/amd64` 和 `linux/arm64`):默认标签已将数据包调试日志记录编译排除在外,而匹配的 `-debug` 标签将其编译进去,以便用于生产诊断。
## 配置
```
listen:
udp: ":443"
sessions:
idleTimeout: 60s # 1..86400, applied with CLOCK_MONOTONIC
maxSessions: 100000 # session-table entries; one QUIC connection uses multiple aliases
routes:
rvr-a.flightdeck.tower.peinser.com:
host: flightdeck-rvr-a.tower-system.svc.cluster.local
port: 8443 # 1..65535
```
解析器使用 [libyaml](https://github.com/yaml/libyaml) (YAML 1.1),因此接受完整的输入面——块和流样式、单双引号标量、多行标量、任意位置的注释、锚点和别名。但是,schema 是严格限制的:
- 顶级键必须是 `listen`、`sessions`、`routes` 之一。未知的键(拼写错误)将被拒绝,而不是被静默忽略。
- `listen.udp`、`sessions.idleTimeout`(可选的 `s` 后缀,范围 `1..86400`)、`sessions.maxSessions`(范围 `1..1000000`)为标量。
- 每个路由仅包含 `host:` 和 `port:`(范围 `1..65535`);其他每路由键将被拒绝。
- SNI 键被规范化为小写 ASCII DNS 名称,并进行了标签验证(无前导连字符,无空标签,最多 255 个字符)。
- 空文件 = 使用默认值。
更多示例请参见 `docs/examples.md`,包括 devcontainer 后端服务以及路由/会话查找设计说明。
有关由 SNI 路由的两个 HTTP/3 mTLS 后端的 Docker Compose 演示,请参阅 `examples/mtls-backends/`。
## 测试
- `make test`:C 单元测试。
- `make sanitize`:ASAN/UBSAN 下的 C 单元测试。
- `make fuzz-smoke`:在有 libFuzzer 可用的地方进行简短的 libFuzzer 冒烟测试。
- `make test-e2e`:使用 aioquic 模拟后端的 Docker HTTP/3 SNI 路由测试(涵盖 v1 和 v2)。
- `make test-e2e-reload`:Docker 热重载测试——从一个路由开始,通过 `docker cp` 传入新配置,断言 inotify 驱动了重载并且新路由正常工作。
- `make test-loadtest`:使用大量全新 QUIC 握手的 Docker 负载下正确性测试。设置QSR_LOADTEST_DIRECT=1` 以绕过路由器进行基线比较,或设置 `QSR_LOADTEST_PERSISTENT=1` 以在每个工作线程中重用一个 HTTP/3 会话。
- `make benchmark`:用于路由查找、会话查找和 CRYPTO 帧提取的合成 CPU 基准测试。
## 参考
- `dlundquist/sniproxy`:用于分离监听器、解析器、路由表和转发职责。
- `AGWA/snid`:用于最小化的 SNI 多路复用理念和后端安全约束。
- `HyBuildNet/quic-relay`:用于具有 SNI 路由、处理程序链可扩展性、负载均衡、热重载和可选 QUIC TLS 终结的 Go QUIC 反向代理,以进行 Hytale 协议检查。
- 有关 TCP SNI 代理中哪些内容可移植、哪些不可移植的信息,请参见 `docs/inspirations.md`。
## 许可证
版权所有 2026 Peinser BV.
根据 Apache License 2.0 获得许可。有关完整文本,请参见 [LICENSE](LICENSE);有关第三方归属,请参见 [NOTICE](NOTICE)。
标签:Bash脚本, HTTP/3, QUIC协议, SNI解析, UML, 子域名突变, 客户端加密, 系统底层, 网关路由, 网络通信, 请求拦截