前阵子线上MySQL告警,安全扫描发现3306端口对全网段开放。这个库本来只给办公网和几台应用服务器用,结果防火墙规则里白纸黑字写着--add-port=3306/tcp,等于把数据库裸奔在公网上。改配置的时候我又发现,团队里不少人对firewalld的白名单规则理解还停留在“开放端口”这一步,一旦要限制来源IP,就开始各种临时拼命令,甚至有人手动去改iptables,导致firewalld重启之后规则被清空。这篇就把我在CentOS 7上用firewalld做IP与端口白名单的完整配置经验整理出来,从基本概念到底层逻辑、从单条命令到生产环境排错,一次讲透。
1. firewalld的核心机制:zone、service与端口之间到底怎么联动
1.1 为什么你总听到“默认zone”,它决定了规则写在哪
很多人刚接触firewalld时,最困惑的就是zone(区域)这个概念。简单说,zone就是一套规则的集合,而每张网卡、每个网络接口都会被划分到某个zone里。CentOS 7安装完后,默认的zone是public,这也是绝大多数人操作firewalld时实际生效的那套规则。
查看当前默认zone和网卡归属情况的命令很简单:
firewall-cmd --get-default-zone firewall-cmd --get-active-zones firewall-cmd --list-all注意--list-all不带zone参数时,列出的就是默认zone的规则。如果你的服务器有多个网卡,比如eth0是公网、eth1是内网,生产环境通常会把它们划分到不同zone,比如public和internal,然后分别施加规则。只看默认zone很容易漏掉另一半规则,这是排查“端口明明放了却连不上”时的第一个盲区。
# 把eth1划入internal zone firewall-cmd --zone=internal --change-interface=eth1 # 永久生效 firewall-cmd --permanent --zone=internal --change-interface=eth11.2 service、port、rich rule三种规则的本质区别
firewalld里有三种常用的规则载体:service、port、rich rule。它们的定位完全不同:
- service:一组端口的逻辑抽象,比如ssh服务对应22端口,http服务对应80端口。使用service的好处是语义清晰,不用记端口号,还方便统一管理。
- port:直接指定端口号或端口范围,比如8080/tcp、1000-2000/tcp。它的粒度比service细,但只能限定“端口+协议”,不能限定来源IP。
- rich rule:一种更底层的规则语言,可以同时指定来源地址、目标端口、协议、动作(accept/reject/drop),还能附加日志记录。IP与端口白名单的核心场景,基本都要靠rich rule来实现。
用一个生活化的类比:service是“餐厅套餐”,port是“单点菜”,rich rule则是“指定谁可以进包厢,谁只能在门外等”。前两者解决“开不开放”的问题,rich rule解决“对谁开放、对谁拒绝”的问题。
2. 开放端口的几种写法,以及--permanent这个参数的真实代价
2.1 最常用的三条开放端口命令,选哪条要看场景
给你一个端口,比如8080,如果要放行TCP流量,你会看到网上有这几种写法:
firewall-cmd --add-port=8080/tcp firewall-cmd --permanent --add-port=8080/tcp firewall-cmd --zone=public --add-port=8080/tcp --permanent三条命令效果有差异。第一条只临时放行,firewalld重启或者reload之后规则就没了;第二条写入了永久配置,但不会立刻生效到当前运行环境;第三条是指定zone的永久放行,最明确但最啰嗦。
问题就出在“永久”两个字上。很多人以为加了--permanent规则立刻就生效,实际上它只是把规则写进了/etc/firewalld/zones/public.xml,当前运行中的防火墙规则并没有变化。只有执行firewall-cmd --reload,永久配置才会真正加载进来。
我遇到过不止一次这样的情况:同事提交工单说“端口已经放开了”,结果服务根本连不上。查下来发现他执行了--permanent --add-port,但忘了reload。更隐蔽的问题是,如果此时服务器重启,firewalld会加载永久配置,端口反而“莫名”开放了——这个时间差在排查问题时很迷惑人。
最稳妥的做法是两条命令配合使用:先用不带--permanent的命令立即放行验证,确认服务正常后,再执行带--permanent的写盘命令,最后reload一次。这样既不影响当前业务,也能保证重启后规则还在。
firewall-cmd --add-port=8080/tcp firewall-cmd --permanent --add-port=8080/tcp firewall-cmd --reload2.2 端口范围与协议选择,别把UDP漏了
需要一次性放行连续端口时,用短横线连接起始端口和结束端口:
firewall-cmd --add-port=1000-2000/tcp这里有两个容易忽略的点。第一,如果服务同时用到TCP和UDP,比如DNS的53端口、某些音视频通信端口,需要分别放行两种协议:
firewall-cmd --add-port=53/tcp firewall-cmd --add-port=53/udp第二,端口范围的规则在生产环境要特别谨慎,范围太大等于给攻击者留了一整片探索空间。我见过有人在服务器上开放1-65535/tcp来排查问题,结果忘了删,安全扫描一抓一个准。临时排障可以,务必事后收敛。
2.3 查看和移除规则的完整命令
开放端口是第一步,学会回收端口才能真正管好白名单。常用命令如下:
# 查看当前zone放行的所有端口 firewall-cmd --list-ports # 查看当前zone的全部规则(含service、port、rich rule) firewall-cmd --list-all # 查看所有rich rule firewall-cmd --list-rich-rules # 移除端口放行(临时) firewall-cmd --remove-port=8080/tcp # 移除端口放行(永久,需要reload生效) firewall-cmd --permanent --remove-port=8080/tcp firewall-cmd --reload如果是通过service放行的端口,要用--list-services查看,--list-ports是看不到的。这个细节经常让人误判“明明没开放,为什么端口通着”。
3. IP白名单的实现:从一条rich rule到完整配置模板
3.1 最经典的场景:3306端口只允许192.168.1.0/24访问
回到文章开头的MySQL案例。需求看起来很简单:数据库服务器上3306端口,只允许内网192.168.1.0/24网段的机器访问,其他地址一律拒绝。
很多人第一反应是:先放行3306端口,再用rich rule拒绝其他IP。但这里有个关键认知要先纠正——firewalld的默认策略就是拒绝未放行的端口。也就是说,你什么都不做的时候,外部根本连不上3306。要达到“只允许指定网段访问”,只需要添加一条允许规则就够了,不需要再额外添加拒绝规则。
真正完整的配置只需两步。第一步,添加rich rule放行指定网段对3306的访问:
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port protocol="tcp" port="3306" accept' firewall-cmd --reload第二步,验证规则是否生效:
firewall-cmd --list-rich-rules看到类似下面的输出就说明配置成功了:
rule family="ipv4" source address="192.168.1.0/24" port port="3306" protocol="tcp" accept此时从192.168.1.0/24网段的机器连接3306是通的,从其他IP连接则是被拒绝的状态。不需要额外写drop规则,默认策略已经帮我们兜底了。
3.2 rich rule语法逐段拆解,看懂才能灵活改
上面那条rich rule看起来像天书,拆开就很好理解。以rule family="ipv4" source address="192.168.1.0/24" port protocol="tcp" port="3306" accept为例:
| 关键字 | 含义 | 示例值 |
|---|---|---|
| rule | 声明这是一条自定义规则 | 固定写法 |
| family | 地址族 | ipv4,也可以写ipv6 |
| source address | 来源IP或网段 | 192.168.1.0/24 |
| port protocol | 协议类型 | tcp、udp、icmp等 |
| port | 目标端口 | 3306 |
| accept | 匹配后的动作 | accept/reject/drop |
动作的区别很重要:accept是放行,reject是拒绝并返回错误信息(连接方会立刻收到“连接被拒”),drop是直接把包丢弃(连接方会一直卡在超时等待上)。从安全角度,drop更隐蔽,不给攻击者反馈信息;从排障角度,reject更容易定位问题。生产环境一般推荐对非法来源用drop,但如果你需要快速判断“防火墙是否拦了我”,reject的报错更直观。
如果需要允许单个IP,把网段换成具体地址就行:
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="203.0.113.10" port protocol="tcp" port="22" accept'如果需要允许多个分散的IP,可以逐条添加rich rule。规则之间是或的关系,匹配任意一条就会放行。但如果IP数量很多,逐条写会让配置变得臃肿,后续维护也不方便,这种情况更推荐用ipset,后面会专门讲。
3.3 反向需求:端口对全网开放,唯独屏蔽某个IP
还有些场景是“端口默认对所有人开放,但某个IP搞破坏,要单独屏蔽”。比如网站80端口面向公网正常服务,但某个来源一直在刷接口,需要单独拉黑。
这时候先放行端口,再针对该IP添加一条drop规则:
# 第一步:放行80端口 firewall-cmd --permanent --add-port=80/tcp # 第二步:屏蔽恶意来源IP的80端口访问 firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="198.51.100.66" port protocol="tcp" port="80" drop' firewall-cmd --reload这里的关键是理解规则的匹配顺序。firewalld的rich rule并不是按照添加顺序逐条匹配、命中即停的,它内部会统一评估。实际经验是:当同时存在accept和drop规则时,更精确的匹配规则优先生效。比如上面的配置中,来自198.51.100.66的请求会命中drop规则被丢弃,其他IP的请求命中accept规则被放行。这也是为什么“先放行再屏蔽”能够正常工作。
如果你担心规则顺序问题,可以加一条日志规则来验证:
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="198.51.100.66" port protocol="tcp" port="80" log prefix="BLOCKED_BAD_IP" level="info" drop'加log之后,匹配到的流量会记录到系统日志中,既方便确认规则是否生效,也为后续分析攻击来源留了证据。
3.4 组合场景:同一台服务器上的差异化白名单策略
实际生产环境中,很少只有一个端口需要白名单。这里分享一个我常用的配置模板,假设场景是:Nginx服务器,80端口对公网完全开放,443端口对公网开放,但8888端口的管理后台只允许办公网203.0.113.0/24访问,而22端口SSH只允许跳板机203.0.113.5访问。
# 放行80和443,面向公网 firewall-cmd --permanent --add-port=80/tcp firewall-cmd --permanent --add-port=443/tcp # 管理后台8888端口只对办公网段开放 firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="203.0.113.0/24" port protocol="tcp" port="8888" accept' # SSH只允许跳板机IP firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="203.0.113.5" port protocol="tcp" port="22" accept' firewall-cmd --reload这套模板的优点是结构清晰,每个端口的开放范围一眼就能看懂。后续新增来源IP,只需再添加一条对应的rich rule;移除权限,删除对应规则即可。比把所有规则堆在一起再用--list-all去猜要省心得多。
4. 白名单配置不生效的排查链路:从firewalld自身到SELinux再到连接层
4.1 第一层:先确认firewalld规则真的如你所想
配置完白名单后连不上,第一反应应该是回看规则本身。首选的排查命令是:
firewall-cmd --list-all firewall-cmd --list-rich-rules重点看三件事:规则是否出现在输出里、所在的zone是否是当前网卡实际使用的zone、来源IP/端口是否写对了。我遇到过不少次“眼睛盲区”——规则写得没错,但写进了internal zone,而网卡实际挂在public zone,自然不生效。
还有个隐蔽问题是多网卡服务器的“默认zone陷阱”。firewall-cmd --add-port=8080/tcp这条命令本身只操作默认zone,如果业务流量走的是非默认zone的网卡,规则就加错了地方。所以生产环境里我习惯每条命令都显式指定zone,比如--zone=public --add-port=8080/tcp,宁可多打几个字,也不给排查留隐患。
4.2 第二层:确认服务真的在监听你放的端口
防火墙规则没问题时,下一个嫌疑是服务本身没在监听。用下面的命令检查端口状态:
ss -lntp | grep 3306重点看Local Address那列。如果显示的是127.0.0.1:3306,说明服务只监听了本机回环地址,外部流量根本到不了这个端口,防火墙放不放行都一样连不上。这种情况要改服务配置,让MySQL监听0.0.0.0:3306或指定内网网卡IP。
另外,也要确认服务没有监听在其他端口上。比如你按默认端口放行了3306,但MySQL实际配置改了端口,自然连不通。这类问题靠ss -lntp一眼就能看穿。
4.3 第三层:SELinux在背后有没有捣乱
CentOS 7默认开启SELinux,它会独立于防火墙对进程的网络访问做限制。表现症状是:防火墙规则看着全对、端口也在监听,但外部连接就是不通,或者服务启动时报告“Permission denied”。
先查看SELinux状态:
getenforce如果输出是Enforcing,那就要考虑是否为端口添加SELinux放行策略。以自定义SSH端口2222为例,SELinux默认只放行22端口,改端口后需要执行:
# 查看当前SSH相关的SELinux端口标签 semanage port -l | grep ssh # 为2222端口添加ssh_port_t标签 semanage port -a -t ssh_port_t -p tcp 2222注意semanage命令需要安装policycoreutils-python包:
yum install -y policycoreutils-python快速验证是不是SELinux的问题,可以临时将其设为permissive模式:
setenforce 0如果此时服务立刻能通了,那基本可以确定是SELinux拦截。确认之后建议不要长期关闭SELinux,而是按上面的方式为具体端口添加策略,这样既解决问题又保留安全防护。
4.4 第四层:验证工具的选择与误区
规则、监听、SELinux都查完了还没解决,就要回头审视你的验证方式。本机验证和远程验证是完全两回事。
在服务器本机执行telnet 127.0.0.1 3306,能通只能说明服务在本机正常,不能说明防火墙白名单配置成功。因为有些流量在本地回环上根本不会经过防火墙的完整链路。正确的验证方式是从外部一台白名单内的机器执行:
telnet <服务器IP> 3306或者用nc:
nc -vz <服务器IP> 3306批量扫描端口时用nmap更直观:
nmap -p 3306 <服务器IP>这里还要注意一个环境差异:如果服务器跑在云上,除了操作系统里的firewalld,云平台的安全组/防火墙规则也必须同步放行对应端口。遇到过太多“firewalld规则全对,但安全组没放行”的情况。云厂商控制台的安全组规则和系统内部防火墙是两道独立关卡,必须同时通过才行。
5. 几个被问过无数次的细节:自定义服务、持久化与日常验证
5.1 把常用端口封装成自定义service,多端口管理更优雅
上面所有示例都是直接操作端口号,这在规则少的时候没问题。但如果你的业务端口很多,比如一个应用要同时放行8080、8081、8082三个端口,每次加规则都要写三条命令,配置文件也显得凌乱。
更优雅的做法是自定义一个service。在/etc/firewalld/services/目录下创建一个XML文件,比如myapp.xml:
<?xml version="1.0" encoding="utf-8"?> <service> <short>MyApp</short> <description>My application service ports</description> <port protocol="tcp" port="8080"/> <port protocol="tcp" port="8081"/> <port protocol="tcp" port="8082"/> </service>保存后重载firewalld,然后直接用service名放行:
firewall-cmd --reload firewall-cmd --permanent --add-service=myapp firewall-cmd --reload这样做的最大好处是语义化。以后再看到--list-services输出里有myapp,团队任何人都能明白这个服务对应哪些端口,而不是面对一串数字去猜测用途。
5.2 规则持久化的真正含义:runtime与permanent的区别
firewalld有runtime和permanent两套配置状态。runtime是当前内存中生效的规则,permanent是落盘到/etc/firewalld/zones/下的XML文件。
两者的关系可以这样理解:runtime像程序的当前进程状态,permanent像配置文件。改配置后要重启进程才生效(对应reload),而进程运行中的临时修改如果不写回配置,重启后一切归零。
一个实用的技巧是--runtime-to-permanent参数。当你用不带--permanent的命令做了大量临时调整,验证全部通过后,一条命令就能把当前所有runtime规则固化为永久配置:
firewall-cmd --runtime-to-permanent这比手工逐条添加--permanent要省事得多,还能避免“临时规则和永久规则不一致”的混乱状态。
还要提醒的是:不要手动去修改/etc/firewalld/zones/public.xml,除非你非常清楚XML格式。手动编辑容易导致格式错误,轻则规则加载失败,重则firewalld服务直接起不来。建议一律通过firewall-cmd命令来修改。
5.3 底层关系:firewalld讲的是nftables的语言
CentOS 7的firewalld底层其实是iptables,CentOS 7之后的版本开始转向nftables。但无论底层怎么变,用户都不应该直接去改iptables规则。原因很简单:firewalld维护着自己的规则状态,你手动加的iptables规则在firewalld reload时可能被清掉,反过来firewalld生成的规则也可能和手动规则冲突。
可以用iptables -L -n查看当前生效的内核规则,但只建议看,不建议动。排查问题时的正确姿势永远是回到firewalld命令。
5.4 生产环境中的白名单规范化建议
最后分享几个我在生产环境踩过坑后沉淀下来的习惯:
第一,维护一份端口与IP白名单对应表。哪怕只是个Markdown表格,记录每个端口为什么开放、对谁开放、负责人是谁、申请日期是什么。线上环境人员流动频繁,半年后没人说得清这条规则是谁加的,有文档至少能追溯。
第二,变更前备份配置。每次修改规则前,先复制一份当前配置文件:
cp /etc/firewalld/zones/public.xml /etc/firewalld/zones/public.xml.bak.$(date +%Y%m%d%H%M%S)万一改错了,回滚只需要覆盖文件并reload,比在新规则上继续修补要快得多。
第三,变更遵循“先加后删”原则。替换白名单时,先添加新规则并确认服务正常,再删除旧规则,避免中间出现权限空窗期。比如要把某个IP从允许列表换成另一个IP,先添加新IP的allow规则,确认新IP能正常访问后,再删掉旧IP的规则。这个顺序能防止误删导致的管理入口丢失。
第四,定期审计。每隔一段时间用firewall-cmd --list-all和firewall-cmd --list-rich-rules导出规则,和文档里的白名单表对照,清理掉那些早已无人使用的放行规则。安全是持续运营出来的,不是配置一次就完事的。
今天讲的这些命令和思路,覆盖了CentOS 7上firewalld最核心的使用场景:从理解zone机制到正确放行端口,再到用rich rule实现精细的IP白名单,最后是完整的排错路径。这套方法论在CentOS 8、Rocky Linux、AlmaLinux这些同样使用firewalld的系统上也能直接复用,区别只在于底层规则引擎换成了nftables,但firewall-cmd的命令语法还是一致的。
最后说一个我自己的小习惯:每次提交完防火墙变更,我都会顺手把public.xml复制一份带时间戳的备份。这个动作救过我两次——一次是误删rich rule导致管理端口全断,另一次是reload之后发现规则顺序不对。firewalld的XML配置看着简单,但线上环境里规则一多,回滚能力比什么都重要。