peinser/quic-sni-router

GitHub: peinser/quic-sni-router

一个轻量级 QUIC 数据平面路由器,通过解析 QUIC 初始包中的 TLS ClientHello SNI 将 UDP 数据报转发到对应后端,自身不终结 TLS。

Stars: 0 | Forks: 0

# quic-sni-router [![CI](https://static.pigsec.cn/wp-content/uploads/repos/2026/06/57c1604f1d124615.svg)](https://github.com/Peinser/quic-sni-router/actions/workflows/ci.yaml) [![Image](https://static.pigsec.cn/wp-content/uploads/repos/2026/06/26308e7305124621.svg)](https://github.com/Peinser/quic-sni-router/actions/workflows/image.yaml) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![Status: pre-1.0](https://img.shields.io/badge/status-pre--1.0-orange.svg)](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, 子域名突变, 客户端加密, 系统底层, 网关路由, 网络通信, 请求拦截