skip to content
阳帆の小窝
Table of Contents

在工作中我们时常需要通过 SSH 访问 Github,而在一些极端网络环境下,稳定可靠的访问链路变得可遇不可求。本文将讲解一种基于 HAProxy 的高性能前置代理方案。

之所以是 HAProxy 而不是 Nginx/OpenResty 或者 ebpf+XDP,是因为 HAProxy 原生自带的 TCP 负载均衡的性能可以超过使用 stream 模块的 OpenResty 的性能,而 ebpf+XDP 较为难写且可定制性较差,所以我们下次再讲。

在开始教学之前,让我们介绍一下 HAProxy:

什么是 HAProxy

HAProxy 是一款免费、快速且可靠的反向代理产品,提供高可用的负载平衡以及针对基于 TCP 和 HTTP 的应用程序的代理。它特别适合高流量网站,并为世界上访问量最大的网站提供支持。多年来,它已成为事实上的标准开源负载平衡器,现在随大多数主流 Linux 发行版一起提供,并且通常默认部署在云平台中。

以下是官网记录的每个主要版本的更改历史记录:

  • 版本 3.3:后端 QUIC、持久统计数据、更加无缝的 ACME、内核 TLS+ 拼接、TLS ECH、改进的性能和可观察性
  • 版本 3.2:ACME 和 SSL 管理、改进的 CPU 可扩展性、QUIC 性能、改进的故障排除
  • 版本 3.1:故障排除、改进的配置可靠性、改进的 QUIC 和 H2 性能、新的 SPOE 引擎、更精细的错误报告
  • 版本 3.0:crt 存储、持久统计信息、系统日志负载平衡、JSON 和 CBOR 日志编码、虚拟映射和 acl、缓存零复制、H2/H3 协议级保护

本文将安装 3.3-stable 版本。

准备环境

请在接下来的操作中准备以下内容:

  1. 安装有 Debian 12/13 的生产服务器
  2. 该服务器需要有稳定的海外连接,特指连接到 github.com:22
  3. 该服务器通常需要离您物理距离接近,以便提供更低延迟的中继服务
  4. 您需要有一定的 Linux 基础理解

下面开始安装 HAProxy,这也是准备环境的一部分:

请自行查看 haproxy.debian.net 获取合适的运行命令

curl https://haproxy.debian.net/haproxy-archive-keyring.gpg \
--create-dirs --output /etc/apt/keyrings/haproxy-archive-keyring.gpg
echo deb "[signed-by=/etc/apt/keyrings/haproxy-archive-keyring.gpg]" \
https://haproxy.debian.net trixie-backports-3.3 main \
> /etc/apt/sources.list.d/haproxy.list
apt-get update
# 我们安装 3.3-stable 版本
apt-get install haproxy=3.3.\*

配置 HAProxy

笨狼有这么几个诉求:

  1. 公网 :443 进来的流量,如果是 SSH(走 443 的 SSH),丢给 GitHub 的 SSH 服务。其他正常 HTTPS 流量,通过 HAProxy 终止并提供使用教程的响应。
  2. 公网 :22 进来的流量,如果是 SSH 则丢给 GitHub,但要限流。拒掉非 SSH 的流量。
  3. TLS 终止要完全在 HAProxy 上处理,可以使用 Unix Socket(但表现不佳)或者监听本机 IP。
  4. 对于非 SSH、非 TLS 的瞎探测流量,直接拒绝。

画出来就清晰了:

:443 → f_ingress-prod
├─ SSH- → b_ssh_github-prod → github.com:22
└─ TLS → b_https-local → f_https_redirect (127.0.0.2:443) → 302 跳转
:22 → f_ssh-prod (限流) → b_ssh_github-prod → github.com:22

先打地基

global
log /dev/log local0
chroot /var/lib/haproxy
stats socket /run/haproxy/admin.sock mode 660 level admin
stats timeout 30s
user haproxy
group haproxy
daemon
maxconn 256

maxconn 256 不是笨狼这台机器能扛的极限,压测可以轻松到万级并发连接。stats socket 留了个管理口,后期调路由不用重启。

defaults
log global
mode tcp
option dontlognull
option http-server-close
option redispatch
timeout connect 5s
timeout client 600s
timeout server 600s
timeout queue 10s

全是 TCP 模式,因为我代理的是 SSH 和 TLS,没必要解包到 HTTP。timeout queue 10s 这个值是从后端服务器的 maxconn 溢出场景倒推出来的,后面会看到。

状态页面

listen f_stats
stats enable
bind *:65430
mode http
log global
maxconn 3
stats refresh 30s
stats uri /
stats realm password-fxxk-you
stats auth user:password
stats hide-version

笨狼这里瞎写了一个冷门端口,路径加上认证。

限流表

backend ratelimit_ssh
# 最多记录 8k 条条目,过期时间为 n 秒,并存储过去 n 秒内的连接速率
# 将数据保留时间设置为超过 rates() 统计时间范围并无额外收益,因此将 expire 设为相同或 +1 秒是最优选择
stick-table type ipv4 size 8k expire 300s store conn_rate(300s),conn_cur

这个表专门给 SSH 前端用。expire 300sconn_rate(300s) 的时间窗口一致,过期时间稍微比窗口长一点点就可以了。太长了浪费内存,短了会导致老数据提前消失,限流精度下降。conn_cur 记录当前并发数,配合后面的 ge 5 用。

443 前端 f_ingress-prod

frontend f_ingress-prod
bind :443
mode tcp
tcp-request inspect-delay 5s
tcp-request content capture req.ssl_sni len 255
# 日志记录
log-format "%[src]:%[src_port] -> %[capture.req.hdr(0)] @ %[fe_name] -> %[be_name]"
# 检查是否是 TLS 流量
tcp-request content accept if { req.ssl_hello_type 1 }
# 检查是否是 SSH 流量
tcp-request content accept if { req.payload(0,4) -m str SSH- }
# 都不是,拒掉
tcp-request content reject
# 挑选后端
use_backend b_ssh_github-prod if { req.payload(0,4) -m str SSH- }
default_backend b_https-local
  1. inspect-delay 5s 很重要。如果不延迟,HAProxy 会在拿到前几个字节就尝试匹配规则,但 SSL ClientHello 和 SSH banner 都不是瞬间能读完的。5 秒足够客户端把 hello 发完,发不完滚出去
  2. req.ssl_hello_type 1 匹配 SSL/TLS 的 ClientHello 消息类型,只是判断它是 TLS 流量就放行,后续会走 b_https-local 做真正的 TLS termination
  3. capture req.ssl_sni len 255 只是拿来写日志的,记录一下客户端请求的域名,方便排查
  4. SSH 检测靠 req.payload(0,4) 看前 4 个字节是不是 SSH-
  5. 如果是 SSH 就去 b_ssh_github-prod,否则默认走 b_https-local

这里有个隐含逻辑:非 SSH、非 TLS 的流量(比如赤裸裸的 HTTP GET)会在 tcp-request content reject 被拒绝。

22 端口 f_ssh-prod

22 端口的 frontend 比 443 简单,但因为限流逻辑加进去,顺序不能乱。

frontend f_ssh-prod
bind *:22
mode tcp
tcp-request inspect-delay 5s
tcp-request connection track-sc0 src table ratelimit_ssh
# 限流
acl blocked src_conn_rate(ratelimit_ssh) ge 50
acl blocked src_conn_cur(ratelimit_ssh) ge 5
tcp-request connection reject if blocked
# 检查是否是 SSH 流量
tcp-request content accept if { req.payload(0,4) -m str SSH- }
tcp-request content reject
default_backend b_ssh_github-prod

tcp-request content accept if { req.payload(0,4) -m str SSH- } 只接受真正的 SSH banner,其他任何数据直接 reject。

为什么不在 tcp-request connection 层面就放行?因为 connection 层看不到 payload 内容,只能基于 IP、端口这些。SSH banner 检测必须在 content 层,所以必须得有 inspect-delay

简单但有细节的后端们

SSH 后端:

backend b_ssh_github-prod
mode tcp
server s_ssh_github github.com:22 check inter 600s rise 2 fall 3 maxconn 50

inter 600s 每 10 分钟才检查一次健康状态,因为 github.com 这种外部服务就算挂了你也做不了什么,没必要频繁探测。maxconn 50 匹配了前端限流的单 IP 并发上限 5 乘以大概 10 个活跃用户,防止本地连接数失控。

本地 HTTPS 后端:

backend b_https-local
mode tcp
server socket 127.0.0.2:443 send-proxy-v2

注意send-proxy-v2,因为后面的 f_https_redirect 需要接收 PROXY 协议来拿到真实源IP。

本地重定向前端

frontend f_https_redirect
bind 127.0.0.2:443 ssl crt /etc/haproxy/self-sign.pem alpn h2,http/1.1 accept-proxy
mode http
http-request redirect location https://example.com code 302

bind 127.0.0.2 不是 127.0.0.1,是为了跟系统其他 loopback 服务分开,避免端口冲突。self-sign.pem 是自签证书,因为这个重定向只对内部代理有效,外部用户看到的是这个自签名证书——反正他们只会被 302 跳走,无所谓。

最后做个 302 跳转,把杂七杂八的 HTTPS 流量都赶到一个特定链接。

总结

使用 haproxy -c -f /etc/haproxy/haproxy.cfg 检查配置,确认无误后可重启/重载 HAProxy 实例:systemctl restart haproxy

关于大家最关心的问题:22 端口的扫描会从每小时几千条降到几乎为零 —— 因为不符合 SSH banner 的直接被 reject,连限流表都没进。限流表里真正记录的都是先发 SSH- banner 再暴力破解的,那些才会触发 src_conn_rate 限制。

不过更好的方法肯定是直接上 IP 白名单,这个就得你自己做了。

人机验证:请刷新页面以加载评论区