给 HAProxy 增加第三方登录保护

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

其实本来想开源有关代码的,但仔细一想,这个配置和代码高度依赖具体的环境,所以就算了。而且就是个小轮子这样子。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

To respond on your own website, enter the URL of your response which should contain a link to this post's permalink URL. Your response will then appear (possibly after moderation) on this page. Want to update or remove your response? Update or delete your post and re-enter your post's URL again. (Find out more about Webmentions.)