nginx 的 return 写在 server 层,会绕过块内所有 location

下午给一批子域名加统一拦截规则。

配置写完,nginx -t 通过,重载,随手测了一个随机域名,返回 000,符合预期。收工。

一小时后,我顺手测了另一个路径,也返回 000。

这个 000 就不对了。


起因:一批泛解析的子域名

服务器上有一批子域名,十几个,每个都在跑各自的服务。

域名是泛解析的,也就是 *.budingwz.cn 全部都解析到同一台服务器。这意味着随便什么域名 —— abc.budingwz.cn、test123.budingwz.cn —— 都能访问到主站内容。

对搜索引擎来说,「主域名之外还挂着一堆内容相同的站点」是站群判定的典型特征。之前主站被判定为垃圾站,这批子站也脱不了干系。

所以得治。

思路很简单:加一个兜底 server 块,把所有没匹配到具体配置的域名直接掐断。

server {
    listen 80 default_server;
    listen 443 ssl http2 default_server;
    server_name _;

    ssl_certificate     /path/fullchain.pem;
    ssl_certificate_key /path/privkey.pem;

    return 444;
}

几点说明:

  • server_name _; 是通配写法,表示「任何域名都匹配我」
  • listen ... default_server 把这个块标记为默认服务器,只有它才能接住未匹配的域名
  • default_server 是关键。 没有这个标记,nginx 会按配置文件的字典序挑第一个 server 块当默认 —— 那就变成「随机挑一个子站来服务未知域名」,比不处理还糟

至于 return 444:这是 nginx 特有的状态码,不回任何响应,直接关闭连接。

为什么用 444,不用 403 或 404

这一步我犹豫过,说下取舍逻辑。

方案 我认为的问题
403 Forbidden 给搜索引擎一个明确的「存在但拒绝访问」信号。长期大量 403 可能被判定为抓取异常
404 Not Found 告诉搜索引擎「这个域名存在,只是没内容」—— 反而可能被当成待收录页面排队
444 直接断连 不发任何响应头,连接直接关闭。请求在 HTTP 层面就结束了,不留下页面级信号

需要说明的是:这三条不是从某个官方文档里抄来的定论,是基于「泛解析站点的未知域名本来就不该被搜索引擎看到」这个判断做的取舍。 如果你有明确依据认为 403 更合适,用 403 也没问题 —— 关键是别让它变成 200。

444 还有个附带好处:它几乎不消耗服务器资源。 nginx 官方文档对它的定义是:

The non-standard code 444 closes a connection without sending a response header.

翻译过来就是「关闭连接,不发送响应头」。普通的 403/404 需要走完整个 HTTP 响应流程,组织响应头、生成响应体;444 直接关 socket,省下这些开销。

对泛解析站点来说,每天可能有大量扫描器、爬虫在轮询各种不存在的子域名,这笔省下来的资源很可观。

写完 nginx -t,通过。nginx -s reload,生效。测了一个随机域名,000,符合预期。

看起来完美。


一小时后发现问题

顺手测了一下证书续签路径。

宝塔的 Let's Encrypt 证书是自动续签的。续签时,验证机构会往 /.well-known/acme-challenge/ 放一个验证文件,然后 Let's Encrypt 的服务器来取这个文件,确认域名归你所有。

这条路径必须永远可达,否则新域名申请不了证书,老域名续签会失败。

问题在于:兜底块接管了所有域名,而 /.well-known/ 这条路径也被 return 444 一起掐了。所以得加放行:

server {
    listen 443 ssl http2 default_server;
    server_name _;

    ssl_certificate     /path/fullchain.pem;
    ssl_certificate_key /path/privkey.pem;

    location ^~ /.well-known/acme-challenge/ {
        root /www/wwwroot/$host;
        default_type "text/plain";
    }

    return 444;          # ← 问题在这一行
}

^~ 是 location 匹配修饰符里优先级最高的(前缀匹配且一旦命中就停止后续正则匹配),按理说这条放行规则应该稳稳生效。

测试:

curl -sk -o /dev/null -w '%{http_code}' \
  -H 'Host: brandnew.budingwz.cn' \
  https://127.0.0.1/.well-known/acme-challenge/test

返回 000。

放行规则完全没生效。

排查

第一反应是 location 优先级搞错了,于是把 ^~ 换成 =(精确匹配,优先级更高),重载,再测 —— 还是 000。

第二反应是 root 拼错了。检查 $host 变量,没问题。

第三反应才回到最朴素的方向:这个请求到底有没有走到 location 匹配?

于是我把 return 444 临时注释掉,只留 location:

curl -> 404      ← 路径通了!

把 return 444 加回去:

curl -> 000      ← 又断了

结论明确:return 444 的执行时机,早于 location 匹配。

不是优先级问题,不是变量问题 —— 是顺序问题。location 根本没机会出场。


nginx 的请求处理顺序

这是问题的根源,值得单独说清楚。

nginx 请求处理阶段顺序示意图
nginx 按阶段处理请求:return 属于 rewrite 模块,在第 2 阶段就执行,早于第 3 阶段的 location 匹配 —— 所以写在 server 层会绕过块内所有 location

nginx 官方文档里对 rewrite 模块的执行顺序写得很明确:

The break, if, return, rewrite, and set directives are processed in the following order:
- the directives of this module specified on the server level are executed sequentially;
- repeatedly:
- a location is searched based on a request URI;
- the directives of this module specified inside the found location are executed sequentially;

翻译过来就是:

  1. 先顺序执行写在 server 层的 rewrite 模块指令
  2. 然后才根据 URI 去搜索 location
  3. 再执行命中的 location 里的 rewrite 指令

而 return 正是 ngx_http_rewrite_module 的指令之一。

对应到 nginx 内部的请求处理阶段,大致是这样的:

阶段 做什么 谁在这阶段执行
1. post-read 读请求头 —
2. server-rewrite 执行 server 块的 rewrite 指令 return、rewrite、set
3. find-config 匹配 location location 匹配逻辑
4. rewrite 执行 location 内的 rewrite 指令 location 里的 rewrite
5. content 执行内容处理器 proxy_pass、fastcgi_pass、root+try_files

关键在阶段 2 和阶段 3 的先后。

return 在阶段 2 执行,而 location 匹配是阶段 3 的事。

所以只要 return 写在 server 顶层,它下面的所有 location 都形同虚设 —— 请求在第 2 阶段就已经被 return 结束了,永远走不到第 3 阶段。

这也解释了为什么 nginx -t 检测不出来:它只检查语法,不检查语义。 这段配置语法完全合法,文件能被正常解析,只是逻辑上是错的。

补充一句:这个"阶段"的说法来自 nginx 源码里 ngx_http_core_module 定义的 phase 枚举,官方文档没有专门展开。上面引用的那段执行顺序说明,是文档里能直接查到、且与实测结果一致的部分。


修法

把 return 444 从 server 顶层挪进 location / 里。

return 写在 server 层与写进 location 的正确错误对比图
同一段配置语法都合法、nginx -t 都能通过。差别只在 return 的位置:server 层绕过放行规则(实测 000),location 层两条规则各自独立(实测 404)

完整配置:

server {
    listen 80 default_server;
    listen 443 ssl http2 default_server;

    server_name _;

    # 用主站证书即可 —— 444 不返回内容,证书域名不匹配也无所谓
    ssl_certificate     /www/server/panel/vhost/cert/www.budingwz.cn/fullchain.pem;
    ssl_certificate_key /www/server/panel/vhost/cert/www.budingwz.cn/privkey.pem;
    ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
    ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5;
    ssl_prefer_server_ciphers on;

    # ---- 证书续签校验目录必须放行 ----
    # 用 $host 动态拼目录:$host 就是域名本身,与宝塔的目录命名一致。
    # 这样 http://新域名/.well-known/acme-challenge/xxx 能正确命中,
    # 新域名后续申请证书不会被兜底块挡住。
    location ^~ /.well-known/acme-challenge/ {
        root /www/wwwroot/$host;
        default_type "text/plain";
        try_files $uri @acme_main;
    }

    # 退化路径:万一按域名找不到,再试主站目录
    location @acme_main {
        root /www/wwwroot/www_budingwz_cn;
        default_type "text/plain";
        try_files $uri =404;
    }

    # 其余一切请求:断开,不返回任何内容。
    # 【关键】444 必须写在 location 里,不能写在 server 顶层。
    location / {
        return 444;
    }
}

改动就一处:return 444 现在挂在 location / 里。

这样一来,请求会先经过阶段 3 的 location 匹配:

  • 命中 ^~ /.well-known/acme-challenge/ → 走放行逻辑,正常返回文件
  • 没命中 → 落到 location / → 执行 return 444

两条规则互相独立,互不干扰。

再测:

curl -sk -o /dev/null -w '%{http_code}' \
  -H 'Host: brandnew.budingwz.cn' \
  https://127.0.0.1/.well-known/acme-challenge/test

返回 404。

404 在这里是好结果。 它说明请求走到了阶段 5 的内容处理器,只是目录下确实没这个文件。路径是通的,证书能签。

对比一下三种结果的语义:

返回 含义 结论
000 连接被断,location 没执行 ✗ 证书路径被拦,续签会失败
404 走完了流程,文件不存在 ✓ 路径可达,符合预期
403 被某条规则拒绝 ⚠ 要具体看规则,但至少说明请求走到了 nginx

注意 404 和 403 都算「路径可达」—— 关键是不能是 000。

主站和子站的 /.well-known/ 由于各自的 server 块配置不同,返回码可能不一样(本站实测主站返回 403,走兜底块的新域名返回 404),但只要是正常 HTTP 响应,就说明没被兜底块掐断。

怎么确认改对了

改完配置只测一条路径是不够的。我写了个脚本,把相关路径一次性测完:

#!/bin/bash
# 验证兜底块:随机域名该断、正式站点该通、证书路径该可达
SITE="www.budingwz.cn"

echo "--- ① 随机子域名应被掐断(期望 000)---"
for d in zzztest random123 abc hello-world; do
    code=$(curl -sk -o /dev/null -w '%{http_code}' \
        -H "Host: $d.budingwz.cn" https://127.0.0.1/ --max-time 10)
    printf "  %-22s → %s\n" "$d.budingwz.cn" "$code"
done

echo "--- ② 正式子站应正常(期望 200)---"
for d in jinqun1 pdf xiufu; do
    code=$(curl -sk -o /dev/null -w '%{http_code}' \
        -H "Host: $d.budingwz.cn" https://127.0.0.1/ --max-time 10)
    printf "  %-22s → %s\n" "$d.budingwz.cn" "$code"
done

echo "--- ③ 主站不能受影响(期望 200)---"
code=$(curl -sk -o /dev/null -w '%{http_code}' \
    -H "Host: $SITE" https://127.0.0.1/ --max-time 10)
printf "  %-22s → %s\n" "$SITE" "$code"

echo "--- ④ 证书验证路径必须可达(不能是 000)---"
for d in $SITE jinqun1.budingwz.cn brandnew.budingwz.cn; do
    code=$(curl -sk -o /dev/null -w '%{http_code}' \
        -H "Host: $d" https://127.0.0.1/.well-known/acme-challenge/ --max-time 10)
    printf "  %-22s → %s\n" "$d" "$code"
done

四个检查项缺一不可:

  1. 随机域名断开 —— 确认兜底块生效
  2. 正式子站正常 —— 确认没误伤
  3. 主站正常 —— 确认没影响收录
  4. 证书路径不是 000 —— 就是这一条,漏了的话问题两个月后才炸

12 项全部符合预期,才算改完。


为什么这个错误特别危险

因为它不会立刻暴露。

配置写完,nginx -t 通过,站点访问正常,随机域名确实被掐断了,验证「通过」。看起来一切正常。

问题在于 /.well-known/ 这条路径平时没人访问。它只在证书续签时才被用到 —— 而 Let's Encrypt 证书是 90 天有效期,宝塔一般提前 30 天续签。

也就是说,这个错误会在 60 天后以「证书突然过期」的面貌出现。

到时候你查续签日志,看到的是 403 或 timeout,然后开始怀疑 DNS、怀疑防火墙、怀疑 Let's Encrypt 抽风 —— 而配置里那条导致问题的 return,早就不记得是什么时候加的了。

这次是运气好,写完一小时就顺手测了这条路径。如果没测,两个月后就要把这套排查流程重走一遍。


顺带一个教训:别被响应头骗了

排查这个问题时,我还犯了另一个错。

在改 sitemap 之前,我看 /sitemap.xml 的响应头是这样的:

Content-Type: text/xml
Content-Length: 1041
Last-Modified: ...
ETag: "6aba5fec-411"
Accept-Ranges: bytes

ETag、Accept-Ranges、Content-Length —— 全是 nginx 静态文件的标准特征。加上文件确实躺在站点根目录,我直接断定:这是个手动维护的静态文件,难怪每发文章都得重新生成。

于是花时间写了个插件去「接管」它。

部署完发现响应头一点没变,大小还是 1041。

这才去找原因 —— 根目录躺着一个 sitemap.php,是我之前写的(忘了),nginx 伪静态里有这么一条:

location = /sitemap.xml {
    try_files $uri /sitemap.php?$args;
}

它每次被请求时检查缓存,超过 1 小时就查库重新生成,然后写回静态文件。我看到的那个"静态文件",只是它的缓存产物。

所以我把文件 mv 走后,sitemap.php 立刻又生成了一个 —— 我部署的插件根本没被触发。

改 URL 前先查清它由谁负责的三步排查流程
查 nginx 伪静态 → 找生成脚本 → 实测确认响应头归属,避免误判「静态文件」而重复造轮子

教训:响应头的静态特征不等于文件是手动维护的。缓存产物会带一模一样的头。

正确做法是改之前先查一遍伪静态规则:

grep -rn 'sitemap' /www/server/panel/vhost/rewrite/

一行命令,两分钟的事。比误判后推翻重做划算得多。

同一个 URL 可能被三层同时接管 —— nginx rewrite、独立脚本、应用路由。漏看任何一层,都会做出重复甚至冲突的改动。


小结

四件事,按重要性排:

1. return 写在 nginx server 层会绕过该块内所有 location。

因为它在 rewrite 阶段(阶段 2)执行,早于 location 匹配(阶段 3)。需要 location 生效时,return 必须放进 location /。

2. nginx -t 只查语法,不查语义。

语法正确的配置可以逻辑上是错的,而且能通过所有静态检查。唯一的验证方式是实测。

3. 测配置不能只测你改的那一条,要测所有受影响的路径。

这次如果只测了「随机域名被掐断没有」,结论是「正常」。只有把「证书路径可达不可达」也测了,才能发现问题。

改完配置,把所有相关路径列出来,写个脚本一次跑完。比重载后随手点两下靠谱得多。

4. 改一个 URL 之前,先查清它由谁负责。

响应头的特征只是线索,不是结论。grep 一遍配置文件,两分钟的事。

发表评论