上周三下午,手机弹出一条告警:站点 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?

排查:三层日志都没说真话
这种情况有个麻烦的地方:报错信息指向的是结果,不是原因。 日志说 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 支持正则,那就用正则排除 .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 是这样操作的:
- 打开配置文件,改几行
nginx -s reload- 网站打不开了
问题出在第 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 放行
这里补一个原理,理解了就不容易再犯。
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 规则"。
一层一层缩小,别在结果层瞎猜。

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