自从 NAS 上线以来1,家庭服务器上保存的数据越来越多,服务器安全变得越来越重要。跟普通服务器一样,我的设备对公网开放访问。不免有好事之徒光临,或尝试暴力登录 SSH,或尝试扫描系统漏洞。虽然基本都以失败告终,但还是需要找一个比较完备的解决方案。正所谓「不怕贼偷,就怕贼惦记」。我得想办法给这些人发出明确的信号,该服务器有基本的安全措施,还是去别处搞事吧。研究再三,发现也就 Fail2Ban2 可堪此重任。本文向大家分享我的实践经验。
整体框架
Fail2Ban 是一套日志处理框架,其基本思路是通过正则表达式来匹配日志中的恶意 IP 地址,如果该 IP 地址符合给定的条件,就将其屏蔽。这个屏蔽动作一般是通过系统防火墙来实现,比如在 Linux 下使用 iptables 或其后继 nftables。这里的「屏蔽」只是一种动作类型,你可以让 Fail2Ban 针对指定 IP 地址执行任意动作,比如可以加到 Nginx 黑名单、也可以发告警邮件等等。本质上就是执行一段 UNIX 命令或者脚本,非常灵活。所以 Fail2Ban 可能改名为 Log2Action 会更贴切一些😄
⚠️Important安全无小事!建议使用 Linux 发行版官方仓库来安装 Fail2Ban。发行版官方会及时更新漏洞修复问题。
Debian/Ubuntu 下可以执行
apt-get install fail2ban安装。其他发行版请自行调研。
配置文件
安装之后可以到 /etc/fail2ban 查看配置。Fail2Ban 的配置有点多,上来让人头大。不要紧,我们细细道来。
tree /etc/fail2ban/ -L1
/etc/fail2ban/
├── action.d
├── fail2ban.conf
├── fail2ban.d
├── filter.d
├── jail.conf
├── jail.d
├── paths-arch.conf
├── paths-common.conf
├── paths-debian.conf
└── paths-opensuse.conf
主配置为 fail2ban.conf,里面是 fail2ban 服务进程相关的选项,正常使用无需改动。 paths-XXX.conf 是一些跟日志路径相关变量,也不用管他。最主要的是 jail/filter/action 相关的配置。
filter.d 目录保存各种正则匹配规则,用来查询有问题的日志及其对应的 IP 地址。 action.d 目录保存各类执行动作配置,比如加到防火墙、发送邮件等等。
jail.conf 及其对应的 jail.d 中则保存各种业务规则,它最主要的功能是把日志、规则和动作关联起来。下面我们给一个具体的例子来说明这三种配置文件该如何使用。
Nginx 4XX 错误
前面已经说过,我在 NAS 设备上部署了面向公网的 Web 服务。但好事者一直尝试扫描系统漏洞。这些恶意行为通常会触发 HTTP 4XX 错误。比如探测缓冲区漏洞会触发 400,访问需要登录的内容会触发 401,扫描常用系统的后面页面会触发 404,访问超过频次限制会触发 425。特别的,因为我的网站都默认使用 HTTPS 协议,如果使用明文 HTTP 协议访问、或者直接使用 IP 地址访问、或者使用随便猜一个域名来访问,这些都会触发 444 状态3。
正常访问一般不会触发 4XX 错误。如果有 4XX 状态码,大概率是有人在搞事。我的目标🎯是把这些搞事的 IP 地址封禁一段时间,让对方知道我们是有备而来。
现在说 filter.d 中的匹配规则。这个目录里已经有很多文件了,用来匹配常用系统的异常日志。但大多比较复杂,不适合初学者。这次我们从零开始构建名为 nginx-4xx.conf 的规则文件。
一条匹配规则需要完成两个任务:其一是提取日志发生的时间点,该时间用来统计和计数;其二是提取客户端的 IP 地址,用来计数和封禁。
这里比较有意思的是 Fail2Ban 内置非常完备的时间匹配规则,理论上我们不需要自己写。但 Fail2Ban 自带的规则需要兼容各种系统,虽然完备,执行效率较低,我建议在必要的时候还是自己写比较好。为什么说是必要呢?因为 Fail2Ban 是很老的项目,当年 UNIX 系统多使用纯文本保存日志文件,不同应用的日期格式各不相同,需要单独适配。但是现代 Linux 基本都改用 systemd 了,日志使用 journald 统一处理,这就相当于把日志的时间戳格式也统一了。基于此,我们也就没必要自己写时间匹配规则了。
Nginx 条件日志
在讨论日志匹配规则之前,我们先要修改 Nginx 的配置,将 4XX 对应的访问日志发送给 systemd-journald。好在 Nginx 和 systemd 同时支持 syslog 协议。这里的关键点是只让 systemd-journald 记录 4XX 相关日志!具体配置如下:
http {
# ...
map $status $syslog {
~^4 1;
default 0;
}
access_log syslog:server=unix:/dev/log,nohostname combined if=$syslog;
# ...
}
首先通过 map 定义了中间变量$syslog,它的值取决于 HTTP 响应码$status,如果 Nginx 返回 4XX 状态(通过正则~^4匹配),就将$syslog置为1,否则置为0。
然后我们就可以在access_log指令中通过if=$syslog来实现「条件日志」功能。也就说只有在$syslog值为零暨返回状态码为4XX时才将对应的访问日志发送给 syslog。systemd 提供的 syslog 套接字路径为/dev/log。这里额外添加了nohostname参数,让 Nginx 只发送日志内容,不需要发送形如nas nginx[154119]这类信息,因为 systemd 会自动处理。
重载 Nginx 配置后就可以通过journalct -xf -u nginx查看4XX日志了:
Dec 07 15:05:01 nas nginx[154119]: 2.2.2.2 - - [07/Dec/2025:15:05:01 +0800] "GET / HTTP/1.1" 400 0 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.85 Safari/537.36 Edg/90.0.818.46"
日志过滤规则
Fail2Ban 会通过 journald 的接口读到上面的日志,然后执行正则匹配。我们新建规则文件 filter.d/nginx-4xx.conf:
[Definition]
failregex = ^\S+ \S+ <ADDR> .+ "[^"]+" 4\d\d
journalmatch = _SYSTEMD_UNIT=nginx.service + _COMM=nginx
报错日志通过failregex指定正则,日志时间戳通过datepattern指定规则。前面已经说过,不需要管时间。
Fail2Ban 会先匹配时间戳,匹配之后会把这部分内容剔除,再执行 failregex 匹配规则。时间匹配会优先提取行首的内容,而 journald 的日志都在开头位置,所以无论你怎么改写 datepattern,都无法匹配 Nginx 日志中的[07/Dec/2025:15:05:01 +0800],这是使用 systemd 跟普通文件最大的不同。
上面的日志在执行failregex就会变成(注意,最前面的时间已经没有了):
nas nginx[154119]: 2.2.2.2 - - [07/Dec/2025:15:05:01 +0800] "GET / HTTP/1.1" 400 0 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.85 Safari/537.36 Edg/90.0.818.46"
这时我们用^\S+ \S+ <ADDR> .+ "[^"]+" 4\d\d就能匹配到所有4XX日志了。其中的<ADDR> 是 Fail2Ban 提供的特殊占位符,用来匹配和提取 IP 地址,匹配到的值可以在动作脚本中通过<ip>来引用。网上多数资源都使用<HOST>,其实并不科学。<HOST>是用来提取域名和 IP 地址。但防火墙本身不能根据域名封禁,Fail2Ban 会再通过 DNS 查询对应的 IP 地址。所以不如直接使用<ADDR>,明确而高效。
下面的journalmatch是 Fail2Ban 在查询 systemd-journald 日志的时候用的过滤参数, _SYSTEMD_UNIT表示服务名,_COMM表示程序名,这里只会查询 nginx 记录的日志,提高处理效率。
保存之后可以使用 fail2ban-regex 来测试正则匹配规则:
$ fail2ban-regex systemd-journal filter.d/nginx-4xx.conf
Running tests
=============
Use filter file : nginx-4xx, basedir: /etc/fail2ban
Use systemd journal
Use encoding : UTF-8
Use journal match : _SYSTEMD_UNIT=nginx.service + _COMM=nginx
Results
=======
Failregex: 227 total
|- #) [# of hits] regular expression
| 1) [227] ^\S+ \S+ <ADDR> .+ "[^"]+" 4\d\d
`-
Ignoreregex: 0 total
Lines: 244 lines, 0 ignored, 227 matched, 17 missed
[processed in 0.01 sec]
|- Missed line(s):
| 2025-12-06T21:04:51.118472+08:00 nas nginx[4016903]: ...
fail2ban-regex 会显示匹配结果,多少条符合条件、多少条没有命中等等。
以上是 systemd-journald 相关的匹配规则。虽然我比较推荐使用 journald,但也有不得不继续使用普通日志文件的场景。所以这里也对此做简单说明。
过滤普通日志文件
还是以 Nginx 的访问日志为例:
1.1.1.1 - - [05/Dec/2025:00:53:16 +0800] "GET /robots.txt HTTP/1.1" 400 248 "-" "Mozilla/5.0 (compatible; CensysInspect/1.1; +https://about.censys.io/)"
你需要把 failregex 改为:
failregex = ^<ADDR> \S+ \S+ \[\] "[^"]+" 4\d\d
注意正则中多了\[\],是为了匹配[]。为什么会这样呢,那就是因为 Fail2Ban 会先匹配时间戳,也就是日志中05/Dec/2025:00:53:16 +0800,匹配之后就会剔除,日志变成了
1.1.1.1 - - [] "GET /robots.txt HTTP/1.1" 400 248 "-" "Mozilla/5.0 (compatible; CensysInspect/1.1; +https://about.censys.io/)"
所以需要额外添加\[\]🤦♂️
前面也说过,Fail2Ban 自动匹配时间需要处理多种情况,性能较差,建议自定义 datepattern 模式。比如我们可以指定为:
datepattern = \[%%d/%%b/%%Y:%%H:%%M:%%S %%z\]\s+
注意,该模式会匹配时间两侧的方括号和右边的空格。所以匹配之后日志变成了:
1.1.1.1 - - "GET /robots.txt HTTP/1.1" 400 248 "-" "Mozilla/5.0 (compatible; CensysInspect/1.1; +https://about.censys.io/)"
因此对应的 failregex 也应该变成^<ADDR> \S+ \S+ "[^"]+" 4\d\d。
Fail2Ban 内置的正则匹配规则非常复杂,我的建议是不要学,也不要用。大家可以根据自己的实际情况通过业务系统配置将精确的错误日志发送给 systemd,然后用最简单的正则来匹配。
jail 规则
有了 filter 我们就可以写 jail 了。新建规则文件 jail.conf/nginx-4xx.conf
[nginx-4xx]
enabled = true
backend = systemd
filter = nginx-4xx
maxretry = 1
banaction = dummy开始的[nginx-4xx]表示 jail 名称。我不确定是否一定要于文件名保持一致。我猜不需要, Fail2Ban 会以文件内容中的配置为准。
enabled = true表示启用该动作规则,backend = systemd 表示从 systemd-journald 读取对应的日志文件,filter = nginx-4xx 表示根据 filter.d/nginx-4xx.conf 中的规则提取报错信息。
maxretry = 1 表示动作触发阈值,这里为了调试方便指定为一,表示在一段时间内对应的错误日志只要出现一次就得采取行动。这里的一段时间由 jail.conf 中的 findtime 指定,默认为十分钟,大家可以可以按需调整。
banaction = dummy 表示发现异常日志需要执行的动作。这里的 dummy 表示什么也不做, Fail2Ban 只会在 /var/log/fail2ban.log 记录相关日志,用于调试。
Fail2Ban 默认只会封禁十分钟,这个时间可以通过 bantime 来修改。
保存 jail 配置之后可以使用 fail2ban-client 来加载:
$ fail2ban-client reload一切顺利的话就可以查看 jail 状态了:
$ fail2ban-client status nginx-4xx
Status for the jail: nginx-4xx
|- Filter
| |- Currently failed: 0
| |- Total failed: 11
| `- Journal matches: _SYSTEMD_UNIT=nginx.service + _COMM=nginx
`- Actions
|- Currently banned: 0
|- Total banned: 0
`- Banned IP list:最后我们讲解动作 action 💦
action 规则
Fail2Ban 默认提供 nftables 对应的 action 配置,我们只需要把 banaction 改为 nftables-allports 就能自动屏蔽有问题的 IP 地址。
但是默认的 nftables 有两个问题。其二,虽然可以屏蔽所有端口,但还是要分协议,你只能屏蔽 tcp 或者 udp,不能同时屏蔽两种协议。其二,虽然支持屏蔽 IPv6 地址,但只能屏蔽单个地址。我们知道,IPv6 地址分配通常使用 /64 掩码,一台主机理论上有 2^64 个不同的 v6 地址,只屏蔽一个地址根本不起作用。
基于以上两点,我需要定制自己的 nftables action。
在讲解 action 之前我还得先简要说说 nftables 的用法和概念。
nftables 基础
nftables 有三个概念:
- table 可以有多个表,每张表包含多个 chain
- chain 每张表可以包含不同类型的链,filter hook 分 input/forward/output,每个链包含多条规则
- rule 具体的协议规则
创建表命令:
nft add table inet fooinet 表示该表同时处理 ipv4 和 ipv6 两种协议。
给 foo 表创建 input 链命令:
nft add chain inet foo bar-chain { type filter hook input priority -1 \; policy accept \; }假设我们希望对外开放 22 端口,可以给 foo 表中的 bar-chain 链添加如下规则:
nft add rule inet foo bar-chain tcp dport 22 accept允许传入 TCP 协议中目标端口为 22 的报文。
⛔Warning添加规则时一定要再三检查,尤其是动作类型为 drop/reject 这样的规则。一不小心就可能把自己锁在门外🤦♂️
假如我们想封禁 IPv6 地址 2001:db8::1 的所有数据,则可以:
nft add rule inet foo bar-chain ip6 saddr 2001:db8::1/64 drop注意最后的 /64 表示网络掩码,nftables 会自动将地址转换成 2001:db8::/64,这样就会封禁整个 /64 网段。如果只想封单个地址,可以将 /64 改为 /128 或者直接省略掩码。
如果是 v4 地址 1.1.1.1 则可以:
nft add rule inet foo bar-chain ip saddr 1.1.1.1/32 drop同样这里也有掩码 /32,因为已经是最大掩码了,可以直接省略。IPv4 地址不存在 IPv6 的问题,不需要加掩码。但为了统一处理,这里指定为 /32。
如果用 iptables,你需要为每一个地址加一条规则。但 nftables 比较方便,它支持 set 概念,可以把一组地址加到一个 set 中,然后针对这个 set 设置规则。
nft add set inet foo addr-bad4 { type ipv4_addr \; flags interval \; }
nft add set inet foo addr-bad6 { type ipv6_addr \; flags interval \; }上面分别创建了 addr-bad4 和 addr-bad6 两个集合并指定其类型。nftables 不支持在一个 set 中同时保存 IPv4 地址和 IPv6 地址,需要分开处理。这让我有点不爽。
另外还需要注意这里的flags interval参数,不指定的话只能加入单个 IP 地址,无法添加网段!
有了 set 我们可以向里面加入地址网段:
nft add element inet foo addr-bad4 { 1.1.1.1/32 }
nft add element inet foo addr-bad6 { 2001:db8::1/64 }最后通过 @addr-badX 引用对应的 set 列表来设置规则:
nft add rule inet foo bar-chain ip saddr @addr-bad4 drop
nft add rule inet foo bar-chain ip6 saddr @addr-bad6 drop以上就是 nftables 所需要的内容。现在终于可以定制 action 了。
action 规则
action 文件的结构如下:
[Definition]
# action 首次执行前会先调用
actionstart =
# action reload 时调用
actionflush =
# action 停止时调用
actionstop =
# action 每次执行前调用
actioncheck =
# 执行 action 动作
actionban =
# 撤销 action 动作
actionunban =
[Init]
# 默认初始化变量
[Init?family=inet6]
# 针对 ipv6 额外初始化接下来我们就需要把前面说的 nftables 指令按照 Fail2Ban 的要求组织到 conf 文件。先定义需要的变量:
[Init]
# 表名
table = f2b-table2
# 链名
chain = f2b-chain
# jail 名字,默认为 default
# 实际会根据 jail 配置自动调整
# 假如 jail 名为 nginx-4xx
# 这里的 name 值就会变成 nginx-4xx
name = default
addr_set = addr-set-<name>
# IP 地址列表类型
addr_type = ipv4_addr
# IP 地址类型
addr_family = ip
# IP 网段掩码
addr_mask = 32
[Init?family=inet6]
# 针对 IPv6 作适当调整
addr_family = ip6
addr_type = ipv6_addr
addr_set = addr6-set-<name>
addr_mask = 64以上变量都可以通过<var-name>的形式在 action 中引用。比如最简单的三个动作:
[Definition]
actioncheck = nft list chain inet <table> <chain> | grep -q '@<addr_set>[ \t]'
actionban = nft add element inet <table> <addr_set> \{ <ip>/<addr_mask> \}
actionunban = nft delete element inet <table> <addr_set> \{ <ip>/<addr_mask> \}等号之后是 shell 指令。大括号在 shell 中有特殊含义,所以需要转义。Fail2Ban 在执行时会自动根据[Init]中的变量来替换<table>/<chain>/<addr_set>/<addr_mask>。其中<ip>比较特别,它就是在 filter 一节中<ADDR>匹配的 IP 地址。
剩下的动作虽然执行频率较低,结构却比较复杂。
[Definition]
_nft_list = nft -a list chain inet <table> <chain>
_nft_get_handle_id = grep -oP '@<addr_set>\s+.*\s+\Khandle\s+(\d+)$'
_nft_add_set = nft add set inet <table> <addr_set> \{ type <addr_type>\; flags interval\; \}
nft add rule inet <table> <chain> <addr_family> saddr @<addr_set> drop
_nft_del_set = { %(_nft_list)s | %(_nft_get_handle_id)s; } | while read -r hdl; do
nft delete rule inet <table> <chain> $hdl; done
nft delete set inet <table> <addr_set>
_nft_shutdown_table = { nft list table inet <table> | grep -qP '^\s+set\s+'; } || {
nft delete table inet <table>
}
actionstart = nft add table inet <table>
nft add chain inet <table> <chain> \{ type filter hook input priority -1 \; \}
%(_nft_add_set)s
actionflush = { nft flush set inet <table> <addr_set> 2> /dev/null; } || {
%(_nft_del_set)s
%(_nft_add_set)s
}
actionstop = %(_nft_del_set)s
<_nft_shutdown_table>这里用到的%(XXX)s是字符串替换,有点类似 C 语言的宏替换。我这里将对应的占位符替换成实际的内容给大家参考。
当前 action 首次执行之前需要创建对应的 table/chain 并添加默认规则。对应 actionstart 的脚本如下,请自行对应跟前面的 nftables 命令:
nft add table inet <table>
nft add chain inet <table> <chain> \{ type filter hook input priority -1 \; \}
nft add set inet <table> <addr_set> \{ type <addr_type>\; flags interval\; \}
nft add rule inet <table> <chain> <addr_family> saddr @<addr_set> drop
如果用户执行 fail2ban-client reload 命令,就会触发 actionflush 指令:
{ nft flush set inet <table> <addr_set> 2> /dev/null; } || {
# 找出所有每一条规则并循环删除
{ nft -a list chain inet <table> <chain> | grep -oP '@<addr_set>\s+.*\s+\Khandle\s+(\d+)$' } | while read -r hdl; do
nft delete rule inet <table> <chain> $hdl; done
# 删除地址集合
nft delete set inet <table> <addr_set>
# 创建空地址集合
nft add set inet <table> <addr_set> \{ type <addr_type>\; flags interval\; \}
# 添加默认规则(根据地址集合封禁地址)
nft add rule inet <table> <chain> <addr_family> saddr @<addr_set> drop
}如果用户停止某 fail 就会触发 actionstop 此时需要潜所有防火墙规则:
# 找出所有每一条规则并循环删除
{ nft -a list chain inet <table> <chain> | grep -oP '@<addr_set>\s+.*\s+\Khandle\s+(\d+)$' } | while read -r hdl; do
nft delete rule inet <table> <chain> $hdl; done
# 删除地址集合
nft delete set inet <table> <addr_set>
{ nft list table inet <table> | grep -qP '^\s+set\s+'; } || {
# 删除表名
nft delete table inet <table>
}把以上所有 action 内容保存到 action.d/nftables2.conf,然后将 jail.d/nginx-4xx.conf 中的 banaction 改为 nftables2 就大功告成了。不要忘记通过 fail2ban reload nginx-4xx 重载配置。
如果有 IP 触发了规则,Fail2Ban 就会修改 nftables 防火墙屏蔽它:
$ nft list ruleset
table inet f2b-table2 {
set addr-set-nginx-4xx {
type ipv4_addr
flags interval
elements = { 1.1.1.1 }
}
set addr6-set-nginx-4xx {
type ipv4_addr
flags interval
elements = { 2001:db8::/64 }
}
chain f2b-chain {
type filter hook input priority filter - 1; policy accept;
ip saddr @addr-set-sshd drop
ip6 saddr @addr6-set-sshd drop
ip saddr @addr-set-nginx-4xx drop
}
}技术总结
最后的最后,做一下技术总结。
Fail2Ban 使用正则监听并统计特定错误日志中的 IP 地址,如果满足一定条件就执行 jail 中定义好的动作。通常这个动作就是使用防火墙封禁。通过 systemd-journald 配合精准的业务日志控制,可以显著减少需要 Fail2Ban 处理的日志量级,从而降低资源消耗并提高整个系统的灵敏度。本文还通过自定义 action 实现一次封禁所有协议和 /64 IPv6 地址段。该方案可以较为有效地应对 cc 攻击。但是如果攻击方通过代理池频繁变化攻击 IP 地址,该方案难以奏效。
参见这篇 ./nas.html↩︎
实现细节请参考这篇文章 ./hide-web.html↩︎