2024 年,我在一台新装的 CentOS 7 上部署一个静态站点,用 nginx 把一个目录挂出去。配置文件我写得很顺手,root 指向那个目录,server_name、端口都没问题。文件我也放好了,chmod 755 给足了权限,所有者也对。可浏览器一访问,迎面一个冷冰冰的 403 Forbidden。我太熟悉 403 了——不就是权限问题嘛。于是我把 nginx 配置、目录权限、文件所有者、nginx 运行用户,前前后后查了不下五遍,每一项都对得明明白白,挑不出一丝毛病。可 403 就是雷打不动。把我逼到墙角的,是这样一个事实:按我所知道的全部 Linux 权限知识,这个文件 nginx 就是该能读到——可它偏偏读不到,像是有一道我看不见的墙,横在文件和 nginx 之间。后来才知道,那道墙真实存在,它叫 SELinux。这件事逼着我把 Linux 的 SELinux、安全上下文、审计日志这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7 全新安装,nginx 部署静态站点
事故现象:
- nginx 配置正确,root 指向 /data/www
- 文件 chmod 755,所有者也对,nginx 用户能读
- 浏览器访问,稳定 403 Forbidden
- nginx error.log:Permission denied,可传统权限明明都对
现场排查:
# 1. 传统权限 —— 反复确认,全对
$ ls -l /data/www/index.html
-rwxr-xr-x. 1 nginx nginx 2048 ... index.html # ★ 权限、属主都对
# 2. ★ nginx 进程以 nginx 用户跑,理论上读得到
$ ps -ef | grep nginx
nginx 8801 ... nginx: worker process
# 3. ★ 注意 ls -l 那个权限位末尾的"点" —— 它在暗示 SELinux
$ ls -l /data/www/index.html
-rwxr-xr-x【.】 1 nginx ... # ★ 这个 . = 有 SELinux 上下文
# 4. ★ 看 SELinux 是不是开着
$ getenforce
Enforcing # ★ 开着,且在强制模式
# 5. ★ 看 SELinux 的安全上下文 —— ls -Z
$ ls -Z /data/www/index.html
... unconfined_u:object_r:default_t:s0 index.html
# ^^^^^^^^^^ ★ default_t —— 不对!
$ ls -Z /usr/share/nginx/html/index.html
... system_u:object_r:httpd_sys_content_t:s0 # ★ 正常该是这个 type
根因(后来想清楚的):
1. ★ Linux 的权限有【两套】:传统的 rwx(自主访问控制),
和 SELinux 的安全上下文(强制访问控制)。
2. 我反复查的 rwx,只是【第一道门】,它全开着。
3. ★ SELinux 是【第二道门】:它额外规定"哪个类型的
进程,能访问哪个类型的文件"。
4. nginx 进程的类型是 httpd_t,SELinux 的策略规定
httpd_t 只能读 httpd_sys_content_t 类型的文件。
5. ★ 我的文件在 /data/www,是手动建的目录,文件的
type 是 default_t —— 不在 httpd_t 被允许读的清单里。
6. 于是:传统权限放行,SELinux 拦截 -> 403。
两道门,过了第一道,卡在第二道。
ls -l 末尾有"点"、getenforce 是 Enforcing,就要想到 SELinux。
修复 1:SELinux 是什么——传统权限之上的第二道门
# === ★ 先建立模型:Linux 上,一次文件访问要过【两道门】===
# === 第一道门:传统权限(DAC,自主访问控制)===
# 就是我们最熟的 rwx + 属主/属组。
# 它的逻辑:文件的【拥有者】决定谁能读写。
$ ls -l index.html
-rwxr-xr-x. 1 nginx nginx ...
# ★ 这道门,我反复查的就是它,它全是放行的。
# === ★ 第二道门:SELinux(MAC,强制访问控制)===
# 它【不看】属主,而是给【每个进程】和【每个文件】
# 都贴一个"标签"(安全上下文),然后由【系统策略】
# 统一规定:带 A 标签的进程,能不能碰带 B 标签的文件。
# ★ 关键区别:这道门【文件拥有者说了不算】,
# 策略说了算 —— 这就是"强制(Mandatory)"的含义。
# === 为什么要有第二道门 ===
# 传统权限有个软肋:一个进程被攻破后,它能访问的东西,
# 就是它运行用户能访问的【全部】。
# ★ SELinux 把进程"关进笼子":就算 nginx 被攻破,
# 它也只能碰 SELinux 允许 httpd_t 碰的那一小撮文件,
# 碰不了 /etc/shadow、碰不了别人的数据。
# === ★ 一次访问的完整判定 ===
# 进程访问文件 ->【先过传统 rwx】-> 通过
# ->【再过 SELinux 策略】-> 通过 -> 真正放行
# ★ 两道门【都】点头才行。任何一道拒绝,访问就失败。
# 我这次:第一道点头,第二道摇头 —— 结果就是 403。
# === ls -l 那个不起眼的"点",就是 SELinux 的指示灯 ===
$ ls -l index.html
-rwxr-xr-x. 1 ... # ★ 末尾的 . = 这个文件【有】SELinux 上下文
# 看到这个点,就该意识到:这台机器上,第二道门是开着的。
修复 2:getenforce 与三种模式——先确认是不是 SELinux
# === ★ 排查时,第一步先搞清楚 SELinux 现在是什么状态 ===
# === 看当前模式 ===
$ getenforce
Enforcing
# SELinux 有三种模式:
# Enforcing ★ 强制:策略不允许的,真的【拦截】
# Permissive 宽容:不允许的【只记日志、不拦截】(排查神器)
# Disabled 彻底关闭
# === 看更详细的状态 ===
$ sestatus
SELinux status: enabled
Current mode: enforcing
Mode from config file: enforcing
Loaded policy name: targeted
# === ★ 排查核心技巧:临时切到 Permissive,看问题消不消失 ===
$ setenforce 0 # 0 = 临时切到 Permissive
$ setenforce 1 # 1 = 切回 Enforcing
# ★ 切到 0 之后,如果 403 立刻好了 —— 实锤就是 SELinux 拦的。
# 切到 0 还是 403 -> 跟 SELinux 无关,别冤枉它。
# ★ setenforce 是【临时】的,重启就回到配置文件的值。
# === 这是个【精准的判断手段】,不是解决方案 ===
# 很多人查到这一步,图省事就把 SELinux 关了完事 ——
# ★ 强烈不建议。SELinux 是一道真实的安全防线,
# 关掉它等于拆掉第二道门。正确做法是【修上下文】(下文)。
# === 永久改模式(真要改才动它)===
$ vi /etc/selinux/config
SELINUX=enforcing # enforcing / permissive / disabled
# ★ 改 Disabled 需要重启;且从 Disabled 重新启用比较麻烦。
# 我的建议:用 setenforce 0 临时确认问题,然后老老实实
# 修上下文,最后 setenforce 1 切回来 —— 别永久关。
修复 3:安全上下文——进程和文件身上的"标签"
# === ★ SELinux 的核心,就是给一切贴"安全上下文"标签 ===
# === 看文件的上下文:ls -Z ===
$ ls -Z /data/www/index.html
unconfined_u:object_r:default_t:s0 index.html
# (1)用户 (2)角色 (3)类型 (4)级别
# ★ 这四段里,日常排查【最关键的是第(3)段:类型(type)】。
# === 看进程的上下文:ps -Z ===
$ ps -eZ | grep nginx
system_u:system_r:httpd_t:s0 8801 nginx: worker process
# ^^^^^^^ ★ nginx 进程的类型 = httpd_t
# === ★ SELinux 的判定,本质就一句话 ===
# "类型为 httpd_t 的进程,能访问类型为 X 的文件吗?"
# 系统策略里写死了:httpd_t 能读 httpd_sys_content_t,
# 能写 httpd_sys_rw_content_t …… 但【不包括 default_t】。
# === ★ 对比正常的和出问题的,差异一目了然 ===
$ ls -Z /usr/share/nginx/html/index.html # nginx 默认目录
... httpd_sys_content_t ... # ★ 类型对
$ ls -Z /data/www/index.html # 我手建的目录
... default_t ... # ★ 类型错!
# ★ 同样的 rwx,一个能被 nginx 读,一个不能 ——
# 差别【只】在这个 SELinux 类型上。
# === 几个 web 场景常见的文件类型 ===
# httpd_sys_content_t 网站静态文件(只读)
# httpd_sys_rw_content_t 网站需要写的目录(上传等)
# httpd_log_t 日志目录
# ★ 文件【放在哪个目录】,默认会继承那个目录的类型规则 ——
# 这就是为什么手建的 /data/www 里文件都是 default_t。
# === 查某进程/某文件该用什么上下文 ===
$ id -Z # 看当前用户的 SELinux 上下文
$ matchpathcon /data/www # ★ 看某路径【应该】是什么上下文
修复 4:看 SELinux 拒绝日志——别再瞎猜
# === ★ SELinux 拦了什么,不用猜 —— 它每一次拦截都记日志 ===
# === 拒绝记录在审计日志里 ===
$ tail -f /var/log/audit/audit.log | grep AVC
type=AVC ... denied { read } for pid=8801 comm="nginx" \
name="index.html" ... scontext=...:httpd_t:... \
tcontext=...:default_t:...
# ★ 一行 AVC denied 把一切说清了:
# denied { read } —— 被拒的动作是 read
# comm="nginx" —— 谁被拒了
# scontext httpd_t —— 发起方(源)的类型
# tcontext default_t —— 被访问对象(目标)的类型
# 翻译:httpd_t 想 read 一个 default_t 的文件,策略不允许。
# === ★ 更好用:ausearch 专门查 SELinux 拒绝 ===
$ ausearch -m AVC -ts recent # 最近的 AVC 拒绝记录
$ ausearch -m AVC -ts today # 今天的
$ ausearch -m AVC -c nginx # 只看 nginx 相关的
# === ★ 最贴心的:sealert 直接给"人话解释 + 修复建议" ===
$ sealert -a /var/log/audit/audit.log
# 它会逐条分析 AVC,并【直接给出该执行的修复命令】——
# 比如告诉你 "run restorecon ..." 或 "setsebool ..."。
# ★ 排查 SELinux,养成习惯:先 sealert,照它的建议来。
# (sealert 来自 setroubleshoot-server 包,可能要先装)
# === 一个坑:Permissive 模式下也照样记 AVC ===
# ★ 这其实是好事:可以先 setenforce 0(不拦截),
# 把功能跑一遍,让 audit.log 把【所有】会被拦的点
# 都记下来,再一次性修完 —— 避免改一个发现还有下一个。
修复 5:修复上下文与布尔值——正确的解法
# === ★ 找到原因后,正确的修法有两类:改上下文、调布尔值 ===
# === 解法 A:restorecon —— 把文件恢复成"该有的"上下文 ===
$ restorecon -Rv /data/www
# restorecon = 按系统策略,把路径恢复成它【应该】的上下文。
# ★ 但注意:/data/www 是我【自定义】的目录,系统默认策略里
# 没有它的规则 —— 直接 restorecon 它还是会变回 default_t。
# === ★ 解法 B(本场景正解):semanage 注册自定义路径规则 ===
$ semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?"
# ★ 这一句的意思:告诉 SELinux —— "/data/www 及其下所有文件,
# 今后【应该】是 httpd_sys_content_t 类型"。
$ restorecon -Rv /data/www
# ★ 再 restorecon,这次它就按新规则,把整个目录刷成对的类型。
$ ls -Z /data/www/index.html
... httpd_sys_content_t ... # ★ 类型对了,403 消失!
# semanage + restorecon 是【持久】的:重打标签、系统重启都不丢。
# === chcon:临时改一个文件的上下文(不推荐长期用)===
$ chcon -t httpd_sys_content_t /data/www/index.html
# ★ chcon 改动【不写入策略】,下次 restorecon 或 relabel
# 就被打回原形。只适合临时验证,正解还是 semanage。
# === ★ 解法 C:有些功能不是上下文问题,是"布尔开关"关着 ===
# SELinux 有一堆"布尔值(boolean)",是策略里的开关。
$ getsebool -a | grep httpd # 看 httpd 相关的所有开关
httpd_can_network_connect --> off # ★ 比如这个:nginx 能否对外发起连接
# 典型场景:nginx 做反向代理 proxy_pass,会被这个开关拦。
$ setsebool -P httpd_can_network_connect on
# ★ -P = 持久化(写进策略,重启不丢)。不加 -P 只是临时。
# === 怎么判断该用哪个解法 ===
# AVC 里是"文件类型不对(tcontext)" -> semanage fcontext + restorecon
# 功能是"建网络连接/发邮件/写某目录" -> 多半是某个 boolean -> setsebool
# ★ 拿不准就跑 sealert,它会直接告诉你该用哪条命令。
修复 6:SELinux 排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 权限对却访问失败,先想到 SELinux 这第二道门 ===
$ ls -l 文件 # 末尾有 . = 有 SELinux 上下文
$ getenforce # Enforcing = 第二道门开着
# === 2. ★ 用 setenforce 0 快速确认是不是 SELinux ===
$ setenforce 0 # 切宽容,问题没了 -> 实锤是 SELinux
$ setenforce 1 # ★ 确认完切回来,别图省事永久关
# === 3. ★ 别猜,看 AVC 日志 ===
$ ausearch -m AVC -ts recent # 看拒绝记录
$ sealert -a /var/log/audit/audit.log # 看人话解释 + 修复建议
# === 4. 对比上下文找差异:ls -Z 比正常的和异常的 ===
$ ls -Z 异常文件; ls -Z 正常文件 # 差异几乎全在 type 段
# === 5. ★ 自定义目录用 semanage + restorecon,持久 ===
$ semanage fcontext -a -t 类型 "/路径(/.*)?"
$ restorecon -Rv /路径
# chcon 只适合临时验证,会被打回原形。
# === 6. 功能类问题(网络连接等)查 boolean ===
$ getsebool -a | grep 关键词
$ setsebool -P 开关名 on # -P 持久化
# === 7. 排查 SELinux 问题的命令链 ===
$ getenforce # ① 第二道门开着吗
$ setenforce 0 # ② 临时关,问题消失=实锤
$ ausearch -m AVC -ts recent # ③ 看它拦了什么
$ ls -Z 文件 # ④ 看文件当前上下文
$ sealert -a /var/log/audit/audit.log # ⑤ 看修复建议
$ semanage fcontext / setsebool # ⑥ 持久化修复
# 按这个顺序,SELinux 问题基本能定位。
命令速查
需求 命令
=============================================================
看 SELinux 当前模式 getenforce / sestatus
临时切宽容/强制 setenforce 0 / setenforce 1
看文件的安全上下文 ls -Z 文件
看进程的安全上下文 ps -eZ | grep 进程
看路径应该是什么上下文 matchpathcon 路径
查 SELinux 拒绝记录 ausearch -m AVC -ts recent
看人话解释和修复建议 sealert -a /var/log/audit/audit.log
注册自定义路径上下文规则 semanage fcontext -a -t 类型 "/路径(/.*)?"
按规则恢复上下文 restorecon -Rv /路径
看/改 SELinux 布尔开关 getsebool -a | grep X / setsebool -P 开关 on
口诀:权限全对却访问失败 -> setenforce 0 试,好了就是 SELinux
文件类型不对用 semanage+restorecon;功能被拦查 setsebool
避坑清单
- Linux 文件访问要过两道门:传统 rwx 权限和 SELinux 策略,都放行才通
- ls -l 权限位末尾有点表示文件带 SELinux 上下文,getenforce 看门开没开
- 权限全对却 403/Permission denied,用 setenforce 0 临时切宽容快速确认
- setenforce 0 是临时排查手段,确认后要切回 1,别图省事永久关 SELinux
- SELinux 判定看的是进程类型和文件类型,ls -Z 第三段 type 最关键
- 手动建的目录里文件默认是 default_t,不在 httpd_t 允许读的清单里
- SELinux 每次拦截都记 AVC 日志,ausearch 查记录、sealert 给修复建议
- 自定义目录用 semanage fcontext 注册规则再 restorecon,改动才持久
- chcon 改的上下文不写入策略,下次 restorecon 或重打标签会被打回原形
- 功能类问题如反代发起网络连接,多半是某个 boolean 关着,用 setsebool -P 打开
总结
这次"nginx 配置全对、文件权限全对,却死活 403"的事故,纠正了我一个根深蒂固的认知盲区——在我脑子里,Linux 的"权限"一直是一个完整的、不留缝隙的整体。我所理解的权限,就是 ls -l 打印出来的那一行:那九个 rwx 位,加上文件的属主和属组。一个进程能不能读一个文件,在我看来,就是这一行信息说了算的、唯一的、终局的判定。正是这个"权限只有一套"的假设,把我牢牢困在了原地:我对着 rwx、对着属主查了一遍又一遍,每一次的结论都是"按规则,nginx 完全应该读得到这个文件"。可现实偏偏是它读不到。当一个系统的行为,和你脑中那套自认为完备的规则发生了正面冲突,问题往往不在规则的细节,而在于——你脑中那套规则,它本身就是不完整的。复盘到根上,我才真正理解,Linux 上一次文件访问要闯的,从来不是一道门,而是两道。第一道门,就是我无比熟悉的传统权限,它有个学名叫 DAC,自主访问控制——"自主"的意思是,这道门的开合,由文件的拥有者自己决定,他想给谁权限就给谁。这道门,我反复确认过,它对 nginx 是敞开的。可在它之后,还立着第二道我从来不知道其存在的门,它叫 SELinux,它的学名是 MAC,强制访问控制。这道门的逻辑和第一道截然不同:它根本不看文件属于谁,它给系统里的每一个进程、每一个文件,都贴上一张叫"安全上下文"的标签,然后由一套全局的、文件拥有者无权干涉的策略来裁决——带着这种标签的进程,到底能不能碰带着那种标签的文件。"强制"二字的分量正在于此:它不容文件主人讨价还价。我的 nginx 进程,带着 httpd_t 这张标签;而我亲手在 /data/www 里建起来的文件,因为那是个系统策略并不认识的自定义目录,继承到的是一张 default_t 的标签。系统策略里清清楚楚地写着:httpd_t 的进程,可以去读 httpd_sys_content_t 的文件——但这张允许清单里,从来没有 default_t 的名字。于是事情就成了这样:nginx 拿着第一道门的通行证,顺利地推开了它,却结结实实地撞在了第二道门上;那道门不关心它的属主是不是 nginx、权限是不是 755,它只问一句"你这个 httpd_t,凭什么读我这个 default_t",然后给出了否定的答案。那个让我百思不解的 403,就是第二道门的回声。这次从一个"配置全对却 403"的死局里走出来,我最大的收获,是在脑子里那张"Linux 权限"的地图上,郑重地添上了过去完全空白的另一半。ls -l 那一行 rwx,它真实、它重要,但它只是两道门里的第一道;它说"可以",不等于访问就一定能成。每当 ls -l 末尾静静躺着的那个小小的点,它就是在提醒我:这台机器上,还有第二道门开着。排查问题最危险的处境,从来不是规则太复杂,而是你以为自己已经掌握了全部规则——而真正的答案,恰恰藏在你那张自以为完整的地图之外,那片你从未标注过的疆域里。
—— 别看了 · 2026