简单用爪实现 HAProxy 代理 Github SSH
/ 10 min read
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 版本。
准备环境
请在接下来的操作中准备以下内容:
- 安装有 Debian 12/13 的生产服务器
- 该服务器需要有稳定的海外连接,特指连接到
github.com:22 - 该服务器通常需要离您物理距离接近,以便提供更低延迟的中继服务
- 您需要有一定的 Linux 基础理解
下面开始安装 HAProxy,这也是准备环境的一部分:
请自行查看 haproxy.debian.net 获取合适的运行命令
curl https://haproxy.debian.net/haproxy-archive-keyring.gpg \ --create-dirs --output /etc/apt/keyrings/haproxy-archive-keyring.gpgecho 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.listapt-get update# 我们安装 3.3-stable 版本apt-get install haproxy=3.3.\*配置 HAProxy
笨狼有这么几个诉求:
- 公网
:443进来的流量,如果是 SSH(走 443 的 SSH),丢给 GitHub 的 SSH 服务。其他正常 HTTPS 流量,通过 HAProxy 终止并提供使用教程的响应。 - 公网
:22进来的流量,如果是 SSH 则丢给 GitHub,但要限流。拒掉非 SSH 的流量。 - TLS 终止要完全在 HAProxy 上处理,可以使用 Unix Socket(但表现不佳)或者监听本机 IP。
- 对于非 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 256maxconn 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 300s 跟 conn_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-localinspect-delay 5s很重要。如果不延迟,HAProxy 会在拿到前几个字节就尝试匹配规则,但 SSL ClientHello 和 SSH banner 都不是瞬间能读完的。5 秒足够客户端把 hello 发完,发不完滚出去req.ssl_hello_type 1匹配 SSL/TLS 的 ClientHello 消息类型,只是判断它是 TLS 流量就放行,后续会走b_https-local做真正的 TLS terminationcapture req.ssl_sni len 255只是拿来写日志的,记录一下客户端请求的域名,方便排查- SSH 检测靠
req.payload(0,4)看前 4 个字节是不是SSH- - 如果是 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-prodtcp-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 50inter 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 302bind 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 白名单,这个就得你自己做了。
人机验证:请刷新页面以加载评论区