HAProxy 作为一个非常强大且高效的负载均衡软件,真的是想做啥都可以了。最近就比较头疼某个很多人使用的 SSH 端口整天被人扫,想做一个动态 IP 白名单。顺着这个思路下去,就做了一个 HAProxy 的第三方登录保护的方案出来。
SSH 端口的保护
由于 SSH 协议的鉴权通常没法做到 reject,我也不大想去研究协议的细节,于是就准备直接从 4 层 TCP 的位置去保护。HAProxy 端想做一个动态白名单,本来是想用 acl file list 实现的,然后发现只有收费版 HAProxy 才有一个定时重载文件列表的模块 lb-update。看了看 HAProxy 的管理接口似乎可以实现动态维护 acl 列表,于是准备写一个小的 Web 应用去维护这个列表。
仔细看了看这个接口,似乎还有一个 table 的特性更符合需求,还可以动态记录访问次数什么的,于是就搞起来了。
global
stats socket ipv4@127.0.0.1:1234 level admin
stats timeout 5s
frontend sshd-in
mode tcp
bind *:22
stick-table type ipv6 size 100 expire 15m store conn_cnt,conn_cur,gpc1 # logging
tcp-request connection track-sc0 src
acl list_allowed src -f /usr/local/etc/haproxy/acl/allowed.txt
acl table_allowed src_get_gpc0(sshd-allowed-table) eq 1
use_backend myssh if list_allowed or table_allowed
tcp-request connection sc-inc-gpc1 if !list_allowed !table_allowed
default_backend local-banned-ssh
backend sshd-allowed-table
stick-table type ipv6 size 100 expire 15d store gpc0,conn_cnt # allowance
# The maximum duration is slightly above 24 days.
HAProxy 建立 table 一般用的是 stick-table,一个 stick-table 跟一个 proxy (frontend/backend) 绑定所以这个 table 的名字就跟 proxy 的名字相同。因为不支持一个 proxy 两个 table,所以需要建一个 dummy 的 backend 来建表。
这里我们使用了两条 ACL,一条是静态的文件列表用来定义本地 IP 等白名单,另一条则是动态的 table (sshd-allowed-table),用 src 这个 IP 地址查表(查不到默认返回 0),只有在表里 gpc0(第 0 个普通计数器)的值等于 1 才会放行。
接下来的问题就是如何维护这个动态列表了。我用 Python 的 Starlette 写了个小应用来做网页鉴权(因为需要集成一个网页登录的 IAM 系统),鉴权完成之后就去操作 HAProxy 更新。如果你的鉴权比较简单的话,我猜直接用 HAProxy 的规则来鉴权更新也不是不行。
import asyncio
import starlette.request
import logging
logger = logging.getLogger("haproxy_table")
request: starlette.request.Request
username: str
ip: str = request.client.host
if '.' in ip:
ip = '::ffff:'+ip
try:
_, writer = await asyncio.open_connection("127.0.0.1", 1234)
except ConnectionRefusedError:
logger.error("Connection failure")
writer.write(
"set table {table} key {key} data.gpc0 {value}\n".format(
table="sshd-allowed-table",
key=ip,
value=1,
).encode()
)
await writer.drain()
writer.close()
await writer.wait_closed()
logger.info("{ip} set {value} by {username}".format(
ip=ip, value=1, username=username,
))
最后你或许注意到了 local-banned-ssh 这个 backend,这个 backend 主要就是用来警告用户去注册自己的 IP 的。这时候可以随便开一个新 sshd 进程的 Docker 就排上了用场,docker run -d -v "$PWD/sshd-entrypoint/:/etc/entrypoint.d/" panubo/sshd
之后,我在 sshd-entrypoint 里放了一个 bash:
#!/usr/bin/env bash
printf '%s\n' \
"==============================================================" \
" Please register your IP every 15 days, or you CANNOT get in! " \
" https://register.example.org/ " \
"==============================================================" \
> /etc/ssh/sshd-banner
printf '%s\n' \
'set /files/etc/ssh/sshd_config/Banner /etc/ssh/sshd-banner' \
'set /files/etc/ssh/sshd_config/PubkeyAuthentication no' \
'set /files/etc/ssh/sshd_config/ChallengeResponseAuthentication no' \
'set /files/etc/ssh/sshd_config/PasswordAuthentication no' \
'set /files/etc/ssh/sshd_config/HostbasedAuthentication yes' \
'set /files/etc/ssh/sshd_config/IgnoreUserKnownHosts yes' \
| augtool -s 1> /dev/null
HAProxy 自己切 backend 就很舒服。这个 dummy 的 sshd 服务的 host key 可以跟实际服务的不一样,这样其实也可以有效提示用户这个连接需要关照,不过即便用户连上了也永远会被拒绝登录就是了(HostbasedAuthentication
里一条记录都没有)。
HTTP 协议基于 Cookie 的保护
对于 HTTP 协议的话,就不需要用 IP 地址这么死板的做法了。有一种 stateless 的实现是用 JWT cookie,不过我个人其实不是很喜欢 JWT 的大小,也不想搞 Lua,而且有些应用的规模也还没达到不 stateless 就支撑不住的情况。于是我还是借用了 table 这个的做法,Cookie 查表,查不到的跳转到另一个域名,这个域名的 Web 应用会设置跨域共享的 Cookie,顺便把 Cookie 写到 HAProxy 的表里,最后再跳转回应用域名。
写 HAProxy 的配置的时候也遇到一个小坑,因为用了 accept-proxy,根据文档,被转发前的来路 IP 地址需要至少在 session 层拿,tcp-request connection
层永远会是代理的地址。
frontend https-in
bind 127.0.0.1:1443 accept-proxy ssl crt /usr/local/etc/haproxy/ssl/cert.pem
http-request add-header X-Forwarded-Proto https
mode http
stick-table type ipv6 size 100 expire 15m store http_req_cnt,gpc1 # logging
tcp-request session track-sc0 src
acl list_allowed src -f /usr/local/etc/haproxy/acl/https-in-allowed.txt
acl cookie_allowed req.cook(nproxy_ticket),table_gpc0(http-allowed-table) gt 0
# explicit allow
acl host_register hdr(host) -i register.example.org
http-request sc-inc-gpc1 if !list_allowed !cookie_allowed !host_register
http-request redirect code 307 location https://register.example.org/authorize?npredir=https://%[hdr(host)]%[capture.req.uri] if !list_allowed !cookie_allowed !host_register
http-request track-sc1 req.cook(nproxy_ticket) table http-allowed-table if cookie_allowed
backend http-allowed-table
stick-table type string len 36 size 100 expire 3d store gpc0,http_req_cnt # allowance
其实本来想开源有关代码的,但仔细一想,这个配置和代码高度依赖具体的环境,所以就算了。而且就是个小轮子这样子。