FreeBSD PF 防火墙端口管理与自动黑名单脚本

整理一套 FreeBSD PF 防火墙实用配置:默认拒绝入站、按 TCP/UDP 开放端口、自动拉黑高频连接来源,并定期释放黑名单。

FreeBSD 自带的 PF 防火墙已经足够强大。对个人服务器来说,一个比较稳的思路是:默认拒绝所有入站连接,只开放自己明确需要的端口,并让 PF 对高频连接来源自动加入黑名单。

这篇文章整理一套可落地的 PF 管理方式。核心目标有四个:

  • 默认拒绝入站,允许出站。
  • 自动保留当前正在监听的 SSH 端口,避免远程配置时把自己锁在门外。
  • 按 TCP/UDP 分别开放端口,适合 SSH、Web、Hysteria2、sing-box 等不同服务。
  • 对 TCP 高频连接启用 overload <blacklist> 自动拉黑,并定期释放过期黑名单。

先启用 PF

用 root 用户执行:

sysrc pf_enable="YES"
sysrc pflog_enable="YES"

如果是首次启用,先不要急着 service pf start。PF 服务启动时会读取 /etc/pf.conf,如果这个文件不存在或不可读,就会看到:

/etc/rc.d/pf: WARNING: /etc/pf.conf is not readable.

所以首次启动前,先写入一个最小可用配置。下面这份配置只放行当前 SSH 端口和出站连接,避免远程操作时把自己锁在外面。

如果你的 SSH 不是 22 端口,把最后一行里的 22 换成你实际的 SSH 端口:

cat > /etc/pf.conf <<'EOF'
set skip on lo0
set block-policy drop
scrub in all fragment reassemble

block in all
pass out all keep state

pass in quick inet proto icmp all icmp-type echoreq keep state
pass in quick inet proto tcp from any to any port 22 keep state
EOF

先检查语法:

pfctl -nf /etc/pf.conf

没有报错后,再加载并启动:

kldload pf
service pf start

已经启动过的系统,后续可以用:

service pf status

检查 PF 状态。

如果你已经执行了 service pf start 并看到 /etc/pf.conf is not readable,不用慌。按上面的方式创建 /etc/pf.conf 后,再执行:

service pf start

如果服务已经处于运行状态,则执行:

pfctl -f /etc/pf.conf

最小 PF 配置思路

最小化规则大致是这样:

set skip on lo0
set block-policy drop
scrub in all fragment reassemble

block in all
pass out all keep state

pass in quick inet proto tcp from any to any port 22 keep state

这表示:

  • 跳过本机回环接口 lo0
  • 默认静默丢弃入站包。
  • 允许所有出站连接并保留状态。
  • 只开放 TCP 22 端口。

不过手动长期维护 /etc/pf.conf 容易漏端口,也容易误删 SSH 规则。下面这个脚本就是为了解决这件事。

推荐脚本

脚本已经整理到 GitHub 仓库:hanigege/scripts/pf_manager.sh

在 FreeBSD 服务器上可以直接下载到 /root/pf-manager.sh

fetch -o /root/pf-manager.sh https://raw.githubusercontent.com/hanigege/scripts/main/pf_manager.sh
chmod 700 /root/pf-manager.sh
/root/pf-manager.sh

如果你更习惯用 curl,也可以这样执行:

curl -fsSL https://raw.githubusercontent.com/hanigege/scripts/main/pf_manager.sh -o /root/pf-manager.sh
chmod 700 /root/pf-manager.sh
/root/pf-manager.sh

脚本设计说明

这个脚本的重点是稳妥维护 PF 规则,而不是把所有功能都堆进去。它主要做了几件事:

  • 默认不提供“开放所有端口”选项,避免误操作后把服务器直接暴露到公网。
  • 每次更新规则前,都会先执行 pfctl -nf 做语法检查,通过后才覆盖 /etc/pf.conf
  • TCP 和 UDP 分开处理:TCP 可以用连接数和连接频率触发自动拉黑,UDP 只限制单个来源的状态数量。
  • 端口列表会自动规范化、排序、去重,避免重复写入同一个端口。
  • 黑名单会同步保存到 /etc/pf_blacklist.txt,重启后仍然可以加载。
  • 每日自动释放黑名单任务可以安装,也可以移除;任务已安装时,菜单会用红字显示 [已安装]

TCP 与 UDP 的差异

PF 的自动拉黑常见写法是:

pass in quick inet proto tcp from any to any port 22 flags S/SA keep state (max-src-conn 80, max-src-conn-rate 30/10, overload <blacklist> flush global)

这里适合 TCP,因为 TCP 有连接和握手状态。比如 SSH 端口遇到短时间大量连接,PF 能统计来源并触发 overload <blacklist>

UDP 没有 TCP 那种连接握手,很多代理或游戏服务又天然会有高频 UDP 包。如果直接照搬 TCP 连接率限制,容易误判或者规则行为不符合预期。所以脚本里对 UDP 使用更保守的规则:

pass in quick inet proto udp from any to any port 443 keep state (max-src-states 80)

这不会像 TCP 那样自动拉黑,但能限制单个来源制造过多状态。

开放端口示例

运行脚本后选择 3,输入端口和协议即可。

例如 SSH 是 TCP:

tcp 20789

Hysteria2 常见是 UDP 443:

udp 443

如果某个服务同时需要 TCP 和 UDP,再选择 TCP 和 UDP

查看当前 PF 生效规则:

pfctl -sr

查看状态表:

pfctl -ss

查看黑名单:

pfctl -t blacklist -T show

自动黑名单释放

永久封禁并不一定是好事。很多扫描来源是动态 IP,今天是攻击流量,过几天可能被运营商分给正常用户。黑名单长期膨胀也不利于审计。

比较平衡的做法是:每天释放 24 小时内没有继续活动的黑名单地址。

脚本菜单里选择 10 会安装下面这条 crontab:

0 3 * * * /sbin/pfctl -t blacklist -T expire 86400 >/dev/null 2>&1 && /sbin/pfctl -t blacklist -T show > /etc/pf_blacklist.txt

安装成功后,脚本会把写入的任务打印出来。再次回到菜单时,第 10 项后面会显示红色的 [已安装]

10. 安装每日自动释放黑名单任务 [已安装]
11. 移除每日自动释放黑名单任务

你也可以随时运行下面的命令确认:

crontab -l | grep 'pfctl -t blacklist'

如果能看到上面的 0 3 * * * ... 这一行,就表示已经安装成功。

如果以后不想自动释放黑名单了,在脚本菜单里选择 11,确认后会移除这条 crontab。移除后再执行上面的 grep 命令,正常情况下不会再看到这条任务。

含义是:

  • 每天凌晨 3 点执行。
  • expire 86400 会释放 86400 秒内没有活动的表项。
  • 清理后把内核表重新写回 /etc/pf_blacklist.txt

也可以手动执行一次:

pfctl -t blacklist -T expire 86400
pfctl -t blacklist -T show > /etc/pf_blacklist.txt

测试自动拉黑

可以从另一台机器,对服务器上“已有服务正在监听,并且 PF 规则允许外部访问”的 TCP 端口做并发连接测试。这里的监听者是 SSH、Web 服务或其他业务程序,不是 PF 防火墙本身;PF 只负责放行、丢弃和统计连接状态,不会替业务程序监听端口。

测试机器上需要有 nc 命令。很多精简系统默认没有安装,可以按系统选择安装:

# Debian / Ubuntu
apt install -y netcat-openbsd

# RHEL / Rocky / AlmaLinux / Fedora
dnf install -y nmap-ncat

# Alpine Linux
apk add --no-cache netcat-openbsd

正式并发测试前,先做一次单连接确认:

nc -z -v -w 3 你的服务器IP 20789

如果这里显示 succeeded 或类似“连接成功”的提示,才说明这个端口确实能从测试机器连到服务器。然后再做并发测试:

for i in $(seq 1 100); do
  nc -z -v -w 1 你的服务器IP 20789 &
done
wait

然后在服务器上查看:

pfctl -t blacklist -T show

需要注意:如果目标 TCP 端口虽然被 PF 规则放行,但服务器上没有程序监听,客户端通常会看到 Connection refused。这时连接会被系统快速重置,PF 状态不会堆积,可能不会触发自动拉黑。这不代表 PF 没生效,而是端口上没有真实服务承接连接。

如果目标端口没有被 PF 放行,通常会表现为超时,因为 PF 在最外层直接丢弃了包,连接根本到不了服务器上的业务程序。

如果并发测试里大量看到 timed out,但黑名单仍然是空的,通常说明这些连接没有命中带 overload <blacklist> 的放行规则。先在服务器上确认这个端口确实处于监听状态,并且 PF 规则里有对应的 TCP 放行规则:

sockstat -4 -l -P tcp
pfctl -sr

远程管理用的 SSH 端口不建议反复拿来做压力测试。更稳妥的方式是临时开放一个测试 TCP 端口,确认有测试服务监听后,再从另一台机器并发连接这个测试端口。

防止远程锁死自己

远程配置防火墙时,建议按这个顺序做:

  1. 保留一个已经登录的 SSH 终端,不要关闭。
  2. 运行脚本,先确认它自动识别到了当前 SSH 端口。
  3. 新开一个 SSH 窗口测试能否登录。
  4. 再关闭旧窗口。

如果规则写错,旧窗口还在,就可以马上修复:

pfctl -d

或者重新运行脚本生成规则。

总结

这套 PF 配置适合个人 FreeBSD 服务器长期使用:默认拒绝入站,明确开放端口,TCP 高频连接自动拉黑,黑名单定时释放。

最推荐的日常操作是:

/root/pf-manager.sh

然后只通过菜单维护开放端口和黑名单,尽量不要手动来回改 /etc/pf.conf