端口加固与暴露面收口:从nftables规则到退路设计

一条规则把凌晨三点的备份变成了整站 502

去年冬天,我给一台跑了四年的博客服务器加了条 iptables 规则,想把对外暴露的端口收一收。改完用 curl 测了首页,200;用手机流量打开,也正常。第二天早上七点,手机上一堆告警:站点 502,MySQL 连不上,SSH 端口超时。

最后定位出来的原因很朴素:我把 3306 端口的入站规则收窄到了「只允许 127.0.0.1」,但 PHP 连数据库走的是内网网卡地址 172.17.0.5;同时我顺手把 22 端口改成了「只允许办公 IP」,而我那天在外地用手机热点登进来过一次,源地址变了。更糟的是备份脚本的 rsync 走的是 873 端口,也被一起关掉,于是三点的备份失败,没人知道,一直失败到被发现。

这件事之后我明白:端口安全不是「关掉不需要的」这么简单,而是三件事——把真正在监听的东西盘清楚、把访问来源收紧到可验证的范围内、并且给自己留一条不至于把自己锁在门外的后路。这篇文章就写这三件事的具体做法,配置以 Debian 12 + nftables 为主,iptables 只做对照说明。

先盘清暴露面:你要防的是「正在监听」,不是「端口号」

多数人加固端口的方式是凭记忆背端口号:22 是 SSH,80 是 HTTP,3306 是 MySQL,然后一条条写规则。这个思路会漏掉两类东西:一类是自己装的、跑在非常规端口上的服务;另一类是容器映射出来的端口,它们根本不在 ss 的常规输出里被重视。

正确的第一步是列清单,而不是动手改规则:

ss -lntup | sort -k5
# 只关心对外可达的:监听地址是 0.0.0.0 或 :: 的
ss -lntu | awk 'NR==1 || $5 ~ /^(0\.0\.0\.0|\*|\[::\]):/'

这一步要特别看三样东西。

第一,监听地址是 127.0.0.1:3306 还是 0.0.0.0:3306。前者只有本机能连,后者全世界的 IP 都能尝试连。很多「MySQL 被人暴力破解」的事故,根子在 my.cnf 里 bind-address = 0.0.0.0,而不是密码太弱。

第二,容器带来的额外监听。docker ps 里写 -p 8080:80 的,实际是在宿主机监听 8080,而且 Docker 默认直接改 NAT 表,把流量转进容器,它会绕过你在 INPUT 链上写的很多规则。判断方法:

docker ps --format '{{.Names}} {{.Ports}}'

如果某一行显示 0.0.0.0:8080->80/tcp,那这个 8080 就是对外暴露的,哪怕你以为自己只在本机测试。要收窄的话有两个做法:一是改 -p 127.0.0.1:8080:80,二是用 DOCKER-USER 链拦截(下面会讲)。

第三,unix socket 不算端口,别把它和 TCP 混起来看。PHP-FPM 走 /run/php/php8.2-fpm.sock 的,压根不需要开 9000 端口,很多人习惯性把 9000 开着,其实是历史遗留。

nftables 的规则应该怎么写才不锁死自己

Debian 10 之后 nftables 是默认后端,虽然后来有一段时间 iptables-nft 兼容层还在,但直接写 nft 规则更清楚。一个能用的最小骨架:

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        iif lo accept
        ct state established,related accept
        ct state invalid drop

        ip protocol icmp accept
        ip6 nexthdr icmpv6 accept

        tcp dport { 22, 80, 443 } accept
        udp dport 443 accept
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
    }

    chain output {
        type filter hook output priority 0; policy accept;
    }
}

几条要注意的语义细节,每一条我都踩过或者见别人踩过:

第一,ct state established,related accept 必须放在前面。nftables 是按顺序匹配的,如果这条写在端口规则之后,那么你已经建立的连接在后续数据包到达时不会命中它,会出现「能连上但传一半就卡住」的诡异现象。这和「connection tracking 有没有开」是两件事,即使 conntrack 正常工作,规则顺序错了照样断。

第二,policy drop 意味着所有没被 accept 的都丢。所以 iif lo accept 必须写,否则本机进程之间通过 127.0.0.1 的通信也会被丢——这是新手最常见的自锁方式,症状是本机能 ping 通自己,但 PHP 连不上本机 MySQL。

第三,ICMP 不要全丢。有人为了「防止被扫描」把 ICMP 关掉,结果 MTU 探测失效(IPv6 没有分片,完全依赖 ICMPv6 的 Packet Too Big),大包传输直接黑洞,表现为「小文件能下,图片加载到一半卡住」。允许 ICMPv6 是 IPv6 环境下的硬性要求。

第四,规则写到文件里,用 nft -f 加载,不要一条条手工敲。手敲的规则重启即丢,而且没法版本化。/etc/nftables.conf 里写好,systemctl enable nftables 才会开机加载。

「差点把自己锁在外面」的正确自保姿势

我那次事故最要命的一点不是规则写错,而是改规则的过程中没有一个独立的、正在进行的会话作为退路。改端口类的规则有一条铁律:

先开两个 SSH 会话。在第一个会话里改规则,改完不要在第一个会话里立刻退出——切到第二个已经登录的会话,用一个新的连接去测试能不能登进来:

ssh -p 22 -o ConnectTimeout=5 root@你的服务器IP 'echo ok'

只有这条新连接成功了,才能认为规则是安全的。如果失败了,从第一个还活着的会话里回滚。

为什么必须在第二个会话里测?因为「已建立的连接」会被 ct state established 放行,你当前敲命令的这个会话是「已建立」,它永远能继续用,测不出新连接被拒。

另外两条保险:

一是云厂商的控制台 / VNC 一定要提前确认可用。很多云主机的「救援模式」「VNC 登录」是最后一根稻草,但有人是在锁死之后才发现自己忘了控制台密码。

二是用 nft 原子应用 + 定时回滚。做法是改规则前挂一个五分钟的定时任务,把旧配置恢复回去;确认没问题后再取消:

cp /etc/nftables.conf /etc/nftables.conf.bak
# 五分钟后如果没取消,自动恢复旧配置
echo 'sleep 300; cp /etc/nftables.conf.bak /etc/nftables.conf; nft -f /etc/nftables.conf' | at now
# 测通了以后
atq          # 找到任务号
atrm 任务号

这个「定时回滚」的手法不光用于防火墙,改 SSH 配置、改网卡配置、改 systemd 服务都值得用。它的成本只有两行命令,换来的是「最坏情况五分钟自愈」。

SSH 端口收窄:按来源而不是按端口号

很多人加固 SSH 的做法是改端口,从 22 改成 52222,以为这样能躲开扫描。实测数据并不支持这个预期:现在的扫描器是全端口扫描,几小时内就能找到。改端口唯一的作用是减少日志噪音,不构成安全措施。

真正起作用的是来源限定。三种方式,安全性从低到高:

方式一,防火墙层按 IP 段放行。固定办公 IP 的可以这么做,缺点是换网络就进不去。我建议把它和「允许密钥登录」叠加使用,而不是单独依赖。

tcp dport 22 ip saddr { 203.0.113.0/24, 198.51.100.7 } accept

方式二,authorized_keys 里用 from= 限定。这个是按密钥限来源,比按端口限更细,因为它可以做到「这把密钥只能从这个 IP 用」:

from="203.0.113.7,198.51.100.0/24" ssh-ed25519 AAAAC3... backup@ops

注意 from= 里写的是客户端来源 IP,如果服务器前面有 NAT 或者跳板机,要写跳板机的地址。

方式三,关掉密码登录和 root 直接登录。这一条是收益最大、代价最小的:

# /etc/ssh/sshd_config.d/10-hardening.conf
PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
MaxAuthTries 3
LoginGraceTime 20

改完必须 sshd -t 校验语法,再 systemctl reload ssh。语法错误时 reload 会失败但旧进程还在跑,所以看起来「还连着」,一重启就彻底进不去——这是另一个经典死法:配置文件语法错,一切正常,直到重启。所以改动的验收标准应该是「sshd -T 能输出合并后的完整配置」,而不只是「我还连着」。

只允许本机访问的服务,别只靠 bind-address

MySQL、Redis、Memcached 这类「应该只听本机」的服务,很多人只改 bind-address。这够用,但有两个边角:一是配置文件可能被包管理器的升级覆盖,二是某些容器化部署直接监听 0.0.0.0。所以应该在防火墙上也封一层:

# 只允许回环和指定内网段访问 3306
tcp dport 3306 ip saddr { 127.0.0.1, 172.17.0.0/16 } accept
tcp dport 3306 drop

这里的 172.17.0.0/16 就是我那次事故的根因:容器网段。如果你的 PHP 跑在容器里、MySQL 跑在宿主机上,那必须放行容器网段,否则容器连不上。反过来说,如果你想彻底不让外部访问,就把这个网段删掉,改成只留 127.0.0.1,同时把 PHP 也放到同一个网络命名空间里。

Redis 还有一个额外的坑:protected-mode yes 在没有密码、没有 bind 的情况下会拒绝非本机连接,但一旦你为了「方便」设了密码又 bind 0.0.0.0,protected-mode 就不保护你了。正确做法是 bind 127.0.0.1 + 有密码 + 防火墙三重。

Docker 的 DOCKER-USER 链:容器端口不能靠 INPUT 链挡

前面提到 Docker 会自己改 NAT 表。具体来说,从外部进来的、目标是容器端口的流量,在 netfilter 里的路径是 PREROUTING(DNAT)→ FORWARD,不经过 INPUT(iptables 后端下)。所以在 INPUT 里写 tcp dport 8080 drop 对容器端口是无效的,这一点非常反直觉。

Docker 专门留了一个 DOCKER-USER 链给用户加规则,它在 Docker 自己的规则之前被求值。iptables 后端下写法是:

iptables -I DOCKER-USER -i eth0 -p tcp --dport 8080 ! -s 203.0.113.0/24 -j DROP

nftables 后端下,Docker 建的是 ip filter DOCKER-USER 这张表,要用 nft add rule ip filter DOCKER-USER ... 追加。注意这两套后端下链的位置和写法不同,别照抄博客。

一个更省事的替代做法是根本不用 -p 暴露端口:把服务放到同一个 docker 网络里,Nginx 容器通过服务名访问(proxy_pass http://app:8080),完全不需要把端口映到宿主机。这是我现在的默认做法,从源头上就没有暴露面。

验证:不要相信「我能访问」,要相信「别人访问不到」

规则上线后,验证必须从外部发起,从服务器内部测什么都测不出来。三个办法:

第一,用第三方的端口探测服务。从外网扫自己的公网 IP,看看开放了哪些端口,和你的预期对齐。这类服务很多,但注意别把自己的 IP 提交到不确定的地方。

第二,从另一台机器扫。如果你有第二台 VPS,用 nc -vz 或者 nmap 扫一遍:

for p in 22 80 443 3306 6379 8080 9000; do
  nc -z -w 2 你的IP $p && echo "$p OPEN" || echo "$p closed"
done

第三,看日志验证有没有被挡。nftables 默认丢弃是静默的,你不知道挡了多少。临时打开日志很有用:

nft add rule inet filter input log prefix "NFT-DROP: " limit rate 5/minute counter drop

然后用 journalctl -k -f | grep NFT-DROP 看实时的丢弃记录。limit rate 是必须的,否则被扫描时日志能瞬间把磁盘写满——这条我吃过亏,一次全端口扫描把 /var 写掉了 3G。

最后一点:把开放端口的清单写进文档。我在 /root/README 里记着「哪些端口对外、为什么、谁在用」,因为半年后你会忘记当时为什么开了 873,然后要么误删导致备份断,要么不敢删一直留着。

复盘:那次事故真正教我的三件事

回头看,那次 502 事故的直接原因是三条规则写错,但根本原因在于流程:

其一,改完没有验证「该通的还通着」。我只验证了首页,没有验证 MySQL、没有验证备份链路、没有验证 SSH 新连接。正确的验证清单应该覆盖所有关键链路,而不只是「首页能打开」。

其二,备份失败没有告警。备份脚本三点跑、四点结束、失败的输出写进日志、没人看。端口加固只是把一直存在的脆弱暴露出来了。如果你的备份失败一个月都不会有人发现,那备份等于没有。

其三,变更没有记录。改了哪三条规则、为什么改、什么时间改的,全在脑子里。事后复盘时靠 journalctl 的时间戳才拼回来。现在我所有防火墙改动都先写到 /root/change.log,一行时间一行原因,成本极低。

端口加固这个事,做到 80 分只需要半小时,做到 95 分需要的是流程和文档,而不是更复杂的规则。对个人站长来说,把「所有关键链路都在监控里」「所有变更都留痕」「改危险配置前有退路」这三件事做扎实,比堆一百条防火墙规则有价值得多。

Last modification:October 25th, 2026 at 08:25 pm

Leave a Comment