网站突然提示证书不受信任?三个月前埋的雷,今天炸了

上周三下午,手机弹出一条告警:站点 HTTPS 访问异常,浏览器提示证书过期。

打开一看,Let's Encrypt 的证书确实过期了,已经过期两天。可我记得续签是自动的——宝塔面板里明明勾着自动续签,之前也从没出过问题。

登录服务器查续签日志,错误信息很明确:

Challenge failed for domain www.example.com
Detail: Invalid response from http://www.example.com/.well-known/acme-challenge/xxxx
        [IP]: 403

403。 校验用的 token 文件能被写入,但验证服务器取不到——请求被拦了。

问题是我什么时候拦过 .well-known?

SSL 证书续签失败故障时间线示意图
错误配置的延迟暴露:加规则当天一切正常,约三个月后以「证书过期」的面貌出现

排查:三层日志都没说真话

这种情况有个麻烦的地方:报错信息指向的是结果,不是原因。 日志说 403,但没告诉你谁返回的 403。

于是按层次往下查。

第一层:确认文件真的写进去了

ls -la /www/wwwroot/example.com/.well-known/acme-challenge/

文件在,权限 644,属主 www。说明写入环节没问题。

第二层:确认谁返回了 403

在服务器本机上直接请求那个路径:

curl -sI -H 'Host: www.example.com' http://127.0.0.1/.well-known/acme-challenge/xxxx
# HTTP/1.1 403 Forbidden
# Server: nginx

nginx 返回的 403。 不是 PHP,不是文件权限,是 nginx 层拦的。

第三层:找出是哪条规则拦的

这一步是关键。nginx 的 location 匹配规则是正则先命中先执行,同一个请求可能被多条规则命中,只有第一条生效。所以要看规则的实际排列顺序:

grep -n "location" /www/server/panel/vhost/nginx/example.com.conf
grep -n "location" /www/server/panel/vhost/rewrite/example.com.conf

第二条命令的输出让我停住了。

location ~ /\. {
    deny all;
}

这条规则拦掉了所有以点开头的路径——.git、.env、.htaccess,以及 .well-known。

而它写在宝塔自动生成的那条规则之前:

location ~ /\.well-known {
    allow all;
}

顺序反了。我的规则先命中,返回 403,宝塔那条 allow all 根本没机会执行。

这条规则是什么时候加的

翻改动记录,找到了。三个月前做安全加固时加的,当时的目的很合理:

# 禁止访问隐藏文件
location ~ /\. {
    deny all;
}

.git 目录暴露会泄漏源码,.env 会泄漏数据库密码,.htaccess 会泄漏重写规则。拦掉它们是对的。

但我拦的时候没想清楚一件事:.well-known 也是以点开头的。

而且这个错误有个很恶劣的特点——它不会立刻报错。

加规则那天,站点访问一切正常,页面打得开,图片能加载,测了 .git/config 确实返回 403,验证通过,收工。

证书续签是三个月后的事。等它出问题时,你早就忘了三个月前改过什么,甚至不记得自己碰过 nginx 配置。

nginx location 匹配顺序错误与正确写法对比图
写法 A 把 deny 规则写在 allow 之前,先命中导致拦截;写法 B 用负向先行断言,不依赖顺序

修法:加一个负向先行断言

nginx 的 location 支持正则,那就用正则排除 .well-known:

location ~ /\.(?!well-known) {
    deny all;
}

(?!...) 是负向先行断言,含义是"这个位置后面不能跟着 well-known"。所以:

  • /.git/config → 匹配 \.,后面是 git,不是 well-known → 拒绝
  • /.well-known/acme-challenge/xxx → 匹配 \.,后面正好是 well-known → 不匹配这条规则,继续往下走,命中宝塔的 allow all

注意这里的顺序:因为我把 (?!well-known) 写进了自己的规则里,就不依赖"我的规则必须在宝塔规则之后"这个前提了。两条规则谁先谁后都能正常工作。 这比调整顺序更稳——宝塔面板有时会重写配置文件,顺序可能被打乱。

改完立即验证,两步都要做:

# 1. 确认 acme 校验路径不再被拦(返回 404 也算正常,说明 nginx 放行了)
curl -sI -H 'Host: www.example.com' http://127.0.0.1/.well-known/acme-challenge/test | head -1

# 2. 确认敏感文件仍然被拦(必须还是 403)
curl -sI -H 'Host: www.example.com' http://127.0.0.1/.git/config | head -1

第一条返回 404(nginx 放行,但文件不存在),第二条返回 403。两个都对,才算修好。

改 nginx 的正确姿势:校验不过就回滚

顺带说个更要紧的事。很多人改 nginx 是这样操作的:

  1. 打开配置文件,改几行
  2. nginx -s reload
  3. 网站打不开了

问题出在第 2 步。

nginx -s reload 在配置有语法错误时,会保留旧配置继续服务。听起来很安全。但如果你的配置语法正确、逻辑错误(比如把 .well-known 拦了),reload 会成功。错误配置直接生效。网站静默出错,没有任何提示。

所以正确的流程必须是"先校验,再加载":

# 1. 备份原文件(带时间戳,便于回退到具体某次)
CONF="/www/server/panel/vhost/rewrite/example.com.conf"
cp -a "$CONF" "${CONF}.bak-$(date +%Y%m%d%H%M%S)"

# 2. 写入新规则
cat > "$CONF" <<'EOF'
location ~ /\.(?!well-known) { deny all; }
EOF

# 3. 语法校验 —— 不通过就回滚,绝不带病重载
if nginx -t; then
    echo "语法通过"
else
    echo "语法失败,回滚"
    cp -a "${CONF}.bak-"* "$CONF"      # 恢复到最近的备份
    exit 1
fi

# 4. 平滑重载(不中断现有连接)
nginx -s reload

第 3 步是核心。nginx -t 不通过就不该 reload,这是纪律,不是建议。

改 nginx 配置的四步安全流程示意图
备份 → 写入 → nginx -t 校验(不过即回滚)→ 平滑重载

为什么域名验证需要 nginx 放行

这里补一个原理,理解了就不容易再犯。

Let's Encrypt 验证域名所有权有两种方式:

方式 原理 需要什么
HTTP-01 往 /.well-known/acme-challenge/ 写一个 token 文件,验证服务器从公网访问它 nginx 必须能正常响应这个路径
DNS-01 往域名 DNS 加一条 TXT 记录 域名解析商 API 权限

HTTP-01 是默认方式,也是最省事的——不用配置 DNS API。但它的前提是:验证服务器能通过公网读到你写的那个文件。

所以任何拦截 .well-known、拦截 acme-challenge、或者做了全站强制跳转但没排除这个路径的配置,都会让续签失败。

顺便提两类同源问题:

重定向陷阱:如果你配了 http → https 的强制跳转,要注意验证服务器走的是 HTTP。跳转到 HTTPS 通常没问题(Let's Encrypt 会跟随),但如果跳转链里有环或跳到别的域名,就会失败。

CDN / 反向代理:如果站点前面挂了 CDN,.well-known 的请求可能被 CDN 缓存或者拦截。这种情况需要在 CDN 里给这个路径配置"不缓存、直接回源"。

事后:加一道巡检

坑修好了,但我不想再被同类型的问题咬第二次。所以加了一个简单巡检:

#!/bin/bash
# 检查所有站点的证书剩余有效期,少于 15 天就告警
for conf in /www/server/panel/vhost/nginx/*.conf; do
    domain=$(grep -m1 server_name "$conf" | awk '{print $2}' | tr -d ';')
    [ -z "$domain" ] && continue
    end=$(echo | openssl s_client -servername "$domain" -connect "$domain":443 2>/dev/null \
          | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
    [ -z "$end" ] && continue
    days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
    if [ "$days" -lt 15 ]; then
        echo "[告警] $domain 证书剩余 ${days} 天"
    fi
done

配合 cron 每周跑一次,有告警就说明续签出了问题。

为什么值得加这道巡检:证书过期这件事,用户比你先知道。等有人反馈"你这网站不安全",流量和信任都已经损失了。而证书问题在过期前两周就有征兆(续签失败),只要有人看就能提前发现。

这个坑的通用教训

回头看,这类问题的模式很清楚:

你为了安全加的规则,可能会破坏另一个安全机制。

拦截隐藏文件是为了安全,阻断 SSL 续签也是安全问题。两个安全目标打架了,而我当时只验证了前者,没想过后者会被影响。

所以加安全规则时,值得多问一句:

  • 这条规则会拦掉哪些以点开头的路径?(.well-known /.well-known/acme-challenge)
  • 有没有依赖这些路径的自动化服务?(SSL 续签、搜索引擎验证、站点地图验证)
  • 规则生效后,多久才会暴露问题?(如果是几个月后,那必须写个巡检)

第三条最重要。这比"立刻报错的错误"难处理得多。

它躲过了你当时的验证。然后在某天以完全不同的面貌出现——你看到的是"证书过期"。你联想不到"三个月前改的那条 deny 规则"。

一层一层缩小,别在结果层瞎猜。

从现象逐层倒推到根因的排查路径示意图
现象层 → HTTP 层 → 服务层 → 规则层 → 机制层,别在结果层瞎猜

再补一条维护习惯:每次改完 nginx,把改了什么、为什么改,写进当天的记录里。 一行就够——「2026-06-12 加 location ~ /\. 禁用隐藏文件」。三个月后证书出问题时,翻记录比翻配置文件快得多。我用的是最土的办法:在 /root/ops-log.md 里按日期追加。土,但管用。


本站为个人技术记录,内容均为原创实操总结,转载请注明出处。

发表评论