<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Caddy · XB 现场笔记</title><link>https://hk.xbcatm.com/zh/tags/caddy/</link><description>基于 HTTP、DNS、TLS、Caddy、SSH、发布与恢复的可复现运维现场笔记。</description><generator>Hugo</generator><language>zh-CN</language><atom:link href="https://hk.xbcatm.com/zh/tags/caddy/index.xml" rel="self" type="application/rss+xml"/><item><title>Caddy 自动管理 HTTPS 证书的签发与续期</title><link>https://hk.xbcatm.com/zh/posts/caddy-auto-https-handles-renewals/</link><pubDate>Thu, 21 Jan 2027 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/zh/posts/caddy-auto-https-handles-renewals/</guid><description>正确配置名称、验证路径、存储与信任后，Caddy 可自动管理证书；续期仍需要运维监控。</description><content:encoded>&lt;p&gt;Caddy 可以自动获取并续签受管理的证书，不需要运维人员手工实现 ACME 协议。自动化省去了重复操作，却没有取消签发前提，也不能代替对失败的监控。今天证书有效，不证明下次续期仍能完成，更不证明整条链路中的每一跳都使用同一张证书。应先明确服务名称、验证路径、持久存储和 TLS 终止位置，再判断自动化覆盖到哪里。&lt;/p&gt;
&lt;h2 id="先让自动-https-知道服务名称"&gt;先让自动 HTTPS 知道服务名称&lt;/h2&gt;
&lt;p&gt;当配置让 Caddy 知道符合条件的主机名或地址时，自动 HTTPS 才会按规则启用，同时还要考虑文档列出的例外与显式覆盖。Caddyfile 中的站点地址包含主机名，是常见触发方式。file_server 指令负责提供文件，本身并不等于为某个尚未指定的域名申请公开证书，不能把文件服务功能与证书管理的启用条件混在一起。&lt;/p&gt;
&lt;p&gt;命令行区别很明确：不带参数的 &lt;code&gt;caddy file-server&lt;/code&gt; 默认监听八十端口；提供 &lt;code&gt;--domain&lt;/code&gt; 后，才会尝试为指定主机名启用 HTTPS，并将默认监听端口改为四百四十三。无论命令怎样拼写，都不能单独证明 DNS、端口转发或证书授权已经正确。显式 HTTP 配置、手动加载的证书等条件，也可能改变自动行为，应该检查实际生效配置。&lt;/p&gt;
&lt;p&gt;本地开发还有不同的信任边界。对于内部名称，Caddy 可以使用自己的本地证书机构。客户端需要通过适当流程信任该机构；某台机器能够正常访问，不代表这张证书已经获得公网客户端的普遍信任。更不能为了修复客户端信任问题，把私有 CA 的签名密钥发给所有客户端。信任根分发与私钥保管是两项不同工作。&lt;/p&gt;
&lt;h2 id="让挑战方式与真实路径匹配"&gt;让挑战方式与真实路径匹配&lt;/h2&gt;
&lt;p&gt;Caddy 默认同时启用 HTTP-01 与 TLS-ALPN-01。前者通过公网八十端口验证，后者通过四百四十三端口上的特殊 TLS 握手验证；Caddy 可以在可用方式之间选择。某次 HTTP 挑战失败，并不说明 DNS 验证就是唯一替代方案。应先从日志确认实际失败的是哪种挑战，再检查该方式的入口、转发和防火墙规则。&lt;/p&gt;
&lt;p&gt;代理或 CDN 可能干扰这些路径，但它们的存在不会自动使签发变得不可能。即使普通 HTTPS 在 CDN 终止，HTTP 挑战请求也可能被正确转发。DNS-01 则通过 TXT 记录证明控制权，不要求申请证书的服务器接收入站验证流量，但需要受支持的服务商集成和范围适当的 DNS 凭据。选择方式应依据真实路由和权限，而不是凭拓扑名称猜测。&lt;/p&gt;
&lt;h2 id="坏掉的样子"&gt;坏掉的样子&lt;/h2&gt;
&lt;p&gt;陈旧的 A 或 AAAA 记录、端口阻断、错误转发或 CDN 规则变化，都可能让首次签发成功之后的验证失败。应同时检查两个地址族，并读取日志中的具体挑战错误。反复清空存储再重试，不是有效诊断：这样会丢掉本可复用的证书状态，还可能增加不必要的签发尝试，消耗正常业务需要的配额。&lt;/p&gt;
&lt;p&gt;实例替换时应保留 Caddy 的数据存储，并保护其中的敏感密钥材料。多个实例需要符合 Caddy 协调语义的存储安排；几台机器上恰好有同名目录，并不表示它们共享了状态。应验证这套配置如何恢复，同时避免把私钥打印到日志、复制进报告或提交到版本库。自动管理证书并不会免除持久化与访问控制责任。&lt;/p&gt;
&lt;p&gt;现有证书仍然可用时，续期自动化就可能已经失败。应监控续期错误，以及客户端实际收到的证书到期时间，为处置留下足够提前量。源站续签成功，不能证明 CDN 对外证书已经更新；CDN 对访客启用 TLS，也不会取消对另一条 HTTPS 源站连接的安全和身份检查。这两个终止位置需要分别验收。&lt;/p&gt;
&lt;p&gt;签发实验应使用预发签发者，避免把不受公开信任的测试证书带到生产。DNS 凭据轮换后，也需要受控的验证检查。告警提前量应按响应能力与证书寿命选择，不能把一个固定三十天的算术示例当成 Caddy 的续期调度规范，更不能因脚本输出正常就断言真实证书一定会自动更新。&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;
&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/posts-caddy-auto-https-handles-renewals.detail.zh.svg" alt="Caddy 前提、持久存储及实际证书监控，与三十天算术告警模型及其历史证据边界分开。" width="560" height="1985"&gt;
&lt;figcaption&gt;Caddy 证书生命周期：机制与证据边界。&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id="快速验证"&gt;快速验证&lt;/h2&gt;
&lt;p&gt;保存为 &lt;code&gt;expiry_alert.py&lt;/code&gt; 并运行 &lt;code&gt;python3 expiry_alert.py&lt;/code&gt;，须启用断言，不使用 -O 或 PYTHONOPTIMIZE；只需要 Python 标准库。程序使用固定 UTC 观察时刻，三十天阈值是本例运维告警的自主选择，不是 Caddy 内部策略。它不会联系域名或证书机构，不启动监听器，不创建密钥，也不修改系统信任设置，适合单独验证日期边界。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# An operator-chosen alert threshold, not Caddy&amp;#39;s renewal scheduler.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;ALERT_BEFORE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;expiry_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;not_after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;not_after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utcoffset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;timezone-aware timestamps required&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;not_after&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;ALERT_BEFORE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;warning&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ok&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;fresh&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ok&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;due&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;warning&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;boundary&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;warning&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;not_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expiry_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;not_after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: remaining=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;d alert=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;expiry_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;expiry_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;naive timestamp rejected&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;missing timezone accepted&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;PASS: expiry alert boundaries; no certificate issuance tested&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;四个打印案例依次得到正常、警告、警告和过期。恰好剩余三十天也进入警告范围，到期瞬间则单独归为过期。负例传入的是缺少时区的到期时间；函数也检查观察时刻，但本例没有单独测试第二个参数缺少时区的分支。断言验证的是这套明确的告警规则，不依赖执行当天，也不假设所有证书都拥有相同总寿命。&lt;/p&gt;
&lt;p&gt;保留的精确记录覆盖这份未变的算术夹具；本轮源码复核没有重跑。该实验没有运行 Caddy、获取证书或观察真实续期。部署验收仍需要检查客户端实际收到的证书、已经配置的挑战路径，并在预期持久存储条件下观察续期成功。应把这些运行证据与这份可重复的小型日期算术检查分开记录，才能知道哪一部分已经证明，哪一部分仍需要实际环境验证。&lt;/p&gt;
&lt;h2 id="延伸阅读"&gt;延伸阅读&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://hk.xbcatm.com/zh/posts/tls-certificate-expiry-is-a-date-not-a-guess/"&gt;TLS 证书过期是一个能读到的日期&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hk.xbcatm.com/zh/posts/check-https-beyond-a-lock/"&gt;检查 HTTPS，而不只是那把锁&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="来源"&gt;来源&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://docs.python.org/3/reference/simple_stmts.html#the-assert-statement"&gt;Python assert statement&lt;/a&gt;，优化模式省略断言及其内部调用，已核验 2026-10-18。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://caddyserver.com/docs/automatic-https"&gt;Caddy 自动 HTTPS&lt;/a&gt;，启用条件、本地信任、挑战选择及存储，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://caddyserver.com/docs/command-line#caddy-file-server"&gt;Caddy file-server 命令&lt;/a&gt;，默认监听端口与 domain 参数，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://letsencrypt.org/docs/challenge-types/"&gt;Let&amp;rsquo;s Encrypt 挑战类型&lt;/a&gt;，验证端口、DNS 自动化及受限凭据，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://letsencrypt.org/docs/rate-limits/"&gt;Let&amp;rsquo;s Encrypt 速率限制&lt;/a&gt;，实验使用预发环境与签发失败，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>发布一个静态站点并证明边缘一致</title><link>https://hk.xbcatm.com/zh/tutorials/ship-a-static-release/</link><pubDate>Tue, 11 Aug 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/zh/tutorials/ship-a-static-release/</guid><description>构建并暂存静态产物，切换发布指针，验证公共响应样本，并明确回滚与证据边界。</description><content:encoded>&lt;p&gt;一次发布，只有在所需公共边缘检查与选定产物一致时才算完成，而不是上传退出码为零就完成。本教程串起&lt;strong&gt;构建&lt;/strong&gt;、&lt;strong&gt;校验&lt;/strong&gt;、&lt;strong&gt;发布&lt;/strong&gt;与&lt;strong&gt;验证&lt;/strong&gt;，同时区分本地检查和部署变更。发布会改变服务器；结尾的探针才是只读网络观察。&lt;/p&gt;
&lt;p&gt;命令假定使用 Bash、GNU find/sort/sha256sum、rsync、Python 3 与项目固定版本的 Hugo。构建步骤从仓库根运行。接触线上主机前，先在一次性目录演练&lt;a href="https://hk.xbcatm.com/zh/decisions/adr-complete-directory-release/"&gt;目录切换示例&lt;/a&gt;。明确实际服务根、部署负责人、上一版目标与回滚过程。下面的 &lt;code&gt;/srv&lt;/code&gt; 布局要求部署已配置完成，不是仓库会自动安装的东西。&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/tutorial-release.detail.zh.svg" alt="构建并验证一份准确产物，最后一次构建后生成清单，核验全新的空暂存目录。选择前失败停止，旧版不变；选择时替换发布指针。之后对公共响应与产物文件进行采样核对，失败时新版仍被选中，直到显式回滚到所记录上一版并重新探测。原子命名空间替换不能保证跨请求一致或断电持久性。磁盘检查、交付字节、TLS 身份、到期、DNS 缓存变化与独立备份各有证据要求。" width="720" height="4720"&gt;&lt;figcaption&gt;指针切换改变失败边界：之前停止可保留旧版，之后需要显式回滚并重验。磁盘检查与边缘样本证明不同事实。&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="1-从已记录输入构建一份产物"&gt;1. 从已记录输入构建一份产物&lt;/h2&gt;
&lt;p&gt;从干净源码版本开始，运行项目构建并记录已安装生成器版本：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git status --short
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git rev-parse HEAD
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hugo version
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;make build
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;短状态为空可支持干净工作树的判断，但命令本身不会强制执行。在称其为某个提交的发布前，应检查所有变更。&lt;code&gt;make build&lt;/code&gt; 将短提交号和当前 UTC 构建时间写入 &lt;code&gt;data/buildinfo.toml&lt;/code&gt;，再生成 &lt;code&gt;public/&lt;/code&gt;。页面中的标记说明记录了哪些输入，不能证明有未提交修改的工作树只含那个提交。&lt;/p&gt;
&lt;p&gt;固定 Hugo 与 vendored 主题可减少漂移，但构建时间字段使不同时间的普通构建不一定逐字节相同。应构建选定发布一次、验证后传输这份准确产物，而不是在服务器另行重建。&lt;/p&gt;
&lt;h2 id="2-检查完整离线契约"&gt;2. 检查完整离线契约&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;make check&lt;/code&gt; 检查当前产物的生成视觉资源、离线包新鲜度、内部链接、无脚本输出、来源链接及下载校验和。完整仓库关卡则使用：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;make verify
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;make check-upstream
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;完整关卡还运行回归测试、双语文章检查和审阅清单新鲜度校验，再重新构建并检查输出。因此要在最后一次构建后才生成发布清单。构建成功及审阅记录未过期，不能证明每篇文章技术正确，也不证明公共服务器使用了预期头部。页数与耗时会变化，应保留实际输出，不与文章里复制来的计数作比较。&lt;/p&gt;
&lt;h2 id="3-用清单标识产物字节"&gt;3. 用清单标识产物字节&lt;/h2&gt;
&lt;p&gt;计算哈希前，确认 &lt;code&gt;public/&lt;/code&gt; 只包含目录与常规文件，并停止所有写入，直到暂存完成。下面的命令不会创建快照，也不会检查每种特殊文件类型。清单放在 &lt;code&gt;public/&lt;/code&gt; 外，避免把自身纳入哈希。NUL 分隔文件名可保留空格及其他 shell 敏感字符：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;manifest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/release.sha256&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; public
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; find . -type f -print0 &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;LC_ALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C sort -z &lt;span class="p"&gt;|&lt;/span&gt; xargs -0 sha256sum
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;)&lt;/span&gt; &amp;gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; public &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; sha256sum -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sha256sum &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;GNU 校验行的摘要在文件名前。&lt;code&gt;sha256sum -c&lt;/code&gt; 成功说明已列出的文件匹配，不证明没有额外文件。全新的空发布目录可避免目标端残留旧文件，但不能发现计算哈希后在源端新增的额外文件。要证明常规文件清单完整一致，应按相同路径、排序与哈希规则重算暂存清单，在选择指针前比较完整列表。另行检查符号链接：&lt;code&gt;find -type f&lt;/code&gt; 不包含它们，而意外链接可能暴露预期根外的文件。将清单及其自身摘要留在发布记录中；校验和证明相对于清单的一致性，不能证明不可信清单的真实性。&lt;/p&gt;
&lt;h2 id="4-暂存并选择完整发布"&gt;4. 暂存并选择完整发布&lt;/h2&gt;
&lt;p&gt;假定 Caddy 已服务 &lt;code&gt;/srv/www/example-site/current&lt;/code&gt;，它是指向 &lt;code&gt;releases/&lt;/code&gt; 内部的符号链接，所有权与读取权限正确。&lt;code&gt;releases&lt;/code&gt; 父目录须事先存在。发布者必须串行执行，并选择唯一发布 ID。把此块作为独立 Bash 脚本，在部署机器上包含已验证 &lt;code&gt;public/&lt;/code&gt; 与 &lt;code&gt;release.sha256&lt;/code&gt; 的目录中运行。运行前按部署策略检查产物的所有权与读取权限。此块自行设置清单路径和遇错停止策略；前置条件、复制或校验和失败时，必须在选择指针前停止。它拒绝暂存产物内的符号链接；这是会修改部署的步骤。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/srv/www/example-site
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;manifest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/release.sha256&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; -d public
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;release_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20261015T120000Z
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;release&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="s2"&gt;/releases/&lt;/span&gt;&lt;span class="nv"&gt;$release_id&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; -L &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="s2"&gt;/current&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;previous&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;readlink &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="s2"&gt;/current&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mkdir &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="c1"&gt;# Fail if this release already exists.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rsync -a public/ &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="s2"&gt;/&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; sha256sum -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;links&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -type l -print -quit&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$links&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Refusing symlinks in the staged artifact&amp;#34;&lt;/span&gt; &amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Ownership and readable permissions must match the deployment policy.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 - &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$release_id&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;PYTHON&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;from pathlib import Path
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;import os, sys, uuid
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;base = Path(sys.argv[1])
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;release = base / &amp;#34;releases&amp;#34; / sys.argv[2]
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;current = base / &amp;#34;current&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;if not current.is_symlink() or not release.is_dir():
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; raise SystemExit(&amp;#34;Expected current symlink and a complete release directory&amp;#34;)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;pending = base / (&amp;#34;.current-&amp;#34; + uuid.uuid4().hex)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;pending.symlink_to(os.path.relpath(release, base), target_is_directory=True)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;os.replace(pending, current)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;print(&amp;#34;selected:&amp;#34;, os.readlink(current))
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;PYTHON&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Previous target: %s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$previous&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;临时链接与 &lt;code&gt;current&lt;/code&gt; 共用父目录，因此替换通过同文件系统的一次 rename 完成。普通 &lt;code&gt;mv new existing-directory&lt;/code&gt; 不会交换两个非空目录。不要用此片段转换已有的真实目录服务根，应单独规划迁移。在预发布环境验证真实服务器的根解析及文件系统行为。&lt;/p&gt;
&lt;p&gt;旧目标仍在磁盘上，不等于切换后旧资源 URL 仍可访问。应在新服务命名空间或稳定资源库中保留旧版指纹资源字节。浏览器多个请求可能跨越两个发布，缓存也可能持有旧 HTML。原子命名空间替换不是跨请求快照隔离，也不是断电持久性保证。&lt;/p&gt;
&lt;h2 id="5-验证实际交付字节与策略"&gt;5. 验证实际交付字节与策略&lt;/h2&gt;
&lt;p&gt;切换后，用固定表示选择比较公共响应样本与相应产物文件：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://www.xbcatm.com/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;curl -sS --max-time &lt;span class="m"&gt;20&lt;/span&gt; -H &lt;span class="s1"&gt;&amp;#39;Accept-Encoding: identity&amp;#39;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -D served.headers -o served.html -w &lt;span class="s1"&gt;&amp;#39;%{http_code}&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cmp public/index.html served.html
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sh public/downloads/check-http-headers.sh &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;从暂存时使用的同一产物目录运行此块。头部辅助脚本使用该可信产物内的副本；此步骤无需源码工作树。&lt;code&gt;cmp&lt;/code&gt; 成功证明这个首页样本逐字节匹配。遇到意外重定向、返回 200 的代理错误页、CDN 转换或编码响应时，需要调查。检查 &lt;code&gt;served.headers&lt;/code&gt; 中的 &lt;code&gt;Content-Encoding&lt;/code&gt;、缓存年龄、CSP 与内容类型。头部脚本打印响应及最终状态，不会强制所有预期条件。&lt;/p&gt;
&lt;p&gt;对中文内容、嵌套文章、CSS、SVG、下载项、重定向及错误路由重复适用检查。记录 URL 到文件的映射，包括目录索引。示例的逐项校验只证明所列文件的一致性，不证明暂存清单完整，也不证明所有公共缓存节点的交付结果。完整边缘论断需要更广泛抓取和表示感知检查；少数探针始终只是样本。&lt;/p&gt;
&lt;p&gt;切换后的检查失败时，新版仍被选中，直到你采取行动。用新临时链接指向已记录的 &lt;code&gt;previous&lt;/code&gt; 值，再次原子替换 &lt;code&gt;current&lt;/code&gt;，随后重做探针。不要只移走新树，诊断期间也不要删除证据或上一版。&lt;/p&gt;
&lt;h2 id="6-分别检查-tls-身份和到期窗口"&gt;6. 分别检查 TLS 身份和到期窗口&lt;/h2&gt;
&lt;p&gt;从仓库根运行到期辅助脚本：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sh static/downloads/check-tls-expiry.sh www.xbcatm.com &lt;span class="m"&gt;30&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;它提取证书、打印日期、检查剩余到期间隔，却不是完整 TLS 身份测试：当前实现没有让主机名或信任验证失败成为致命错误。应按照&lt;a href="https://hk.xbcatm.com/zh/tutorials/audit-a-public-edge/"&gt;公共边缘信任教程&lt;/a&gt;执行显式验证握手与 SAN 检查。不能从“至少还有 30 天有效期”推断信任链或吊销检查成功。&lt;/p&gt;
&lt;p&gt;临近过期是另一项发布风险。保留实际 &lt;code&gt;notAfter&lt;/code&gt;、信任结果与客户端环境，不要把历史签发者或日期复制到发布日志。&lt;/p&gt;
&lt;h2 id="7-dns-变化时纳入缓存窗口"&gt;7. DNS 变化时纳入缓存窗口&lt;/h2&gt;
&lt;p&gt;纯内容发布不一定改变 DNS。若主机或边缘变化，应在缓存过期期间继续维护旧目标服务。变更时才降低 TTL，不会缩短已经按旧 TTL 缓存的条目。解析器 serve-stale、负缓存和客户端缓存会让窗口更复杂；TTL 不是保证所有客户端都完成迁移的统一截止点。CAA 变化影响签发策略，不负责把流量路由到新内容。&lt;/p&gt;
&lt;h2 id="8-保留演练过的恢复路径"&gt;8. 保留演练过的恢复路径&lt;/h2&gt;
&lt;p&gt;上一版目录有利于快速回滚，但不是独立备份。磁盘故障、误清理或部署账户被攻破，都可能影响所有本地发布。应保留源码、构建输入、配置及适当的独立产物备份，并在隔离环境演练恢复。一次成功演练证明的是那个时刻的那条恢复路径，不会永久保证之后的变更。&lt;/p&gt;
&lt;h2 id="9-精确记录结果"&gt;9. 精确记录结果&lt;/h2&gt;
&lt;p&gt;保留源码版本与干净工作树状态、构建元数据和工具版本、清单摘要、选中与上一版目标、探针时间、HTTP/TLS 观察及任何回滚。说明覆盖的是样本、全部映射 URL，还是多个观测点。关卡失败时，应记录失败与恢复过程，而不是“它工作过”。&lt;/p&gt;
&lt;p&gt;切换前失败可以让旧版保持不变；切换后恢复则是需要单独验证的显式操作。区分这两点，才能让发布路径可审计，而不假装每次失败都会自动恢复旧树。&lt;/p&gt;
&lt;h2 id="来源"&gt;来源&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://download.samba.org/pub/rsync/rsync.1"&gt;rsync 手册&lt;/a&gt;，归档复制与源路径尾斜线语义，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc8767.html"&gt;RFC 8767：Serving Stale Data&lt;/a&gt;，TTL 过期后的 DNS serve-stale 行为，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://manpages.debian.org/trixie/coreutils/sha256sum.1.en.html"&gt;GNU sha256sum 手册，Debian 软件包&lt;/a&gt;，校验和输出及验证选项，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://docs.python.org/3/library/os.html#os.replace"&gt;Python &lt;code&gt;os.replace&lt;/code&gt;&lt;/a&gt;，原子替换与文件系统限制，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/file_server"&gt;Caddy &lt;code&gt;file_server&lt;/code&gt;&lt;/a&gt;，根与符号链接处理，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl 手册&lt;/a&gt;，状态捕获、响应头及内容解码，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://docs.openssl.org/3.0/man1/openssl-s_client/"&gt;OpenSSL &lt;code&gt;s_client&lt;/code&gt;&lt;/a&gt;，显式证书验证，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://reproducible-builds.org/docs/definition/"&gt;Reproducible Builds：定义&lt;/a&gt;，产物身份与可复现性的区别，已核验 2026-10-15。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>安全响应头应描述实际页面</title><link>https://hk.xbcatm.com/zh/notes/security-headers-should-describe-the-actual-page/</link><pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/zh/notes/security-headers-should-describe-the-actual-page/</guid><description>当紧凑的策略与静态页面真正加载的资源匹配时，它更安全。</description><content:encoded>&lt;p&gt;安全响应头不是安全代码的替代品，但它们让浏览器强制实施有用的边界。策略应描述实际被服务的页面，而不是从另一个项目复制来的泛型模板。&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/security-layers.detail.zh.svg" alt="HTML、本地样式表与 favicon 决定 CSP 资源许可。Content-Security-Policy 强制执行；Report-Only 揭示违规且报告需另行配置。CSP 应通过响应头发送：frame-ancestors 在 meta 中被忽略且不回退到 default-src，form-action 需要自己的指令，style-src self 本身不允许内联样式。HSTS 从有效 HTTPS 响应学习；www.example.test 的 includeSubDomains 覆盖该主机与后代，不覆盖 example.test 或 api.example.test。应检查正常页、缺失页与资源响应，并在真实浏览器验证执行。本图不声称已经测试。" width="720" height="3460"&gt;&lt;figcaption&gt;把响应头视为部署契约：匹配实际资源，区分强制执行与仅报告策略，再验证准确主机范围和浏览器行为。&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="从资源图开始"&gt;从资源图开始&lt;/h2&gt;
&lt;p&gt;如果站点只提供 HTML、本地样式表、一个 favicon、没有任何可执行 JavaScript，那么一条窄策略是可理解的：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Content-Security-Policy: default-src &amp;#39;self&amp;#39;; base-uri &amp;#39;self&amp;#39;; form-action &amp;#39;none&amp;#39;; frame-ancestors &amp;#39;none&amp;#39;; object-src &amp;#39;none&amp;#39;; script-src &amp;#39;none&amp;#39;; style-src &amp;#39;self&amp;#39;; img-src &amp;#39;self&amp;#39; data:
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;script-src 'none'&lt;/code&gt; 只有在站点不需要可执行脚本时才是正确的选择。加入搜索、评论或统计标签会改变资源图，并应触发一次有意识的策略复核。&lt;/p&gt;
&lt;h2 id="增加传输与嵌套保护"&gt;增加传输与嵌套保护&lt;/h2&gt;
&lt;p&gt;对仅 HTTPS 的站点，下列响应头是常见起点：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Strict-Transport-Security: max-age=31536000; includeSubDomains
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;X-Content-Type-Options: nosniff
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;X-Frame-Options: DENY
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Referrer-Policy: strict-origin-when-cross-origin
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;只有当每个子域名都准备好提供 HTTPS 时才启用带 includeSubDomains 的 HSTS。响应头是与未来部署的契约，而不只是扫描器里的一个分数。&lt;/p&gt;
&lt;h2 id="检查行为而不只是存在"&gt;检查行为，而不只是存在&lt;/h2&gt;
&lt;p&gt;请求一个正常页面、一个缺失页面与一个静态资源。错误响应应携带与成功响应同样重要的安全响应头。还要确认压缩与缓存不会改变内容类型，也不会暴露内部路径。&lt;/p&gt;
&lt;p&gt;本站的&lt;a href="https://hk.xbcatm.com/zh/resources/"&gt;Caddy 示例&lt;/a&gt;只是一个起点，不是普适策略。请根据你自己的部署调整主机名、证书处理与应用路由。&lt;/p&gt;
&lt;p&gt;通过响应头发送 CSP。meta 策略中的 &lt;code&gt;frame-ancestors&lt;/code&gt; 会被忽略，而且它不会继承 &lt;code&gt;default-src&lt;/code&gt;；&lt;code&gt;form-action&lt;/code&gt; 也需要自己的指令。&lt;code&gt;style-src 'self'&lt;/code&gt; 本身不允许内联样式，因此应测试生成的主题，包括深色模式。只有页面确实需要 data 图片时，才在 &lt;code&gt;img-src&lt;/code&gt; 中保留 &lt;code&gt;data:&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; 可在上线过程中揭示违规，但不会强制执行策略；报告端点还需单独配置。除了查看响应头，也要在真实浏览器中验证执行效果。HSTS 从有效 HTTPS 响应中学习；带有 &lt;code&gt;includeSubDomains&lt;/code&gt; 时，它还覆盖设置策略的主机的后代：&lt;code&gt;www.example.test&lt;/code&gt; 的策略不覆盖父域 &lt;code&gt;example.test&lt;/code&gt; 或同级 &lt;code&gt;api.example.test&lt;/code&gt;。确认这个准确范围后，再逐步延长有效期。&lt;/p&gt;
&lt;h2 id="来源"&gt;来源&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP"&gt;MDN: Content Security Policy guide&lt;/a&gt;，CSP 指令名称与语法，已核验 2026-07-23。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security"&gt;MDN: &lt;code&gt;Strict-Transport-Security&lt;/code&gt;&lt;/a&gt;，HSTS 指令与部署注意事项，已核验 2026-07-23。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/header"&gt;Caddy &lt;code&gt;header&lt;/code&gt; 指令&lt;/a&gt;，响应头操作指令，已核验 2026-07-23。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.w3.org/TR/CSP3/"&gt;W3C Content Security Policy Level 3&lt;/a&gt;，响应头交付、指令回退、嵌套与仅报告策略；当前为工作草案，已核验 2026-10-15。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc6797.html"&gt;RFC 6797 — HTTP Strict Transport Security&lt;/a&gt;，第 8.1–8.3 节的有效 HTTPS 交付与主机及后代匹配，已核验 2026-10-15。&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>压缩会改变所选中的 HTTP 表示</title><link>https://hk.xbcatm.com/zh/notes/compression-changes-the-selected-http-representation/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/zh/notes/compression-changes-the-selected-http-representation/</guid><description>把内容编码、验证器与 Vary 视为同一个可观察的响应契约。</description><content:encoded>&lt;p&gt;压缩节省传输字节，但也制造了资源的另一种表示。客户端用 &lt;code&gt;Accept-Encoding&lt;/code&gt; 声明可接受的内容编码；服务器用 &lt;code&gt;Content-Encoding&lt;/code&gt; 描述实际应用的编码。&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/compression.detail.zh.svg" alt="协商分支选择 identity 字节 A 或编码后字节 B，因此强标签不同。Vary 控制匹配，弱标签有语义等价的条件。另一路径说明响应头保留编码后 Content-Length，curl 解码正文后才与制品校验和比较。" width="720" height="2320"&gt;&lt;figcaption&gt;压缩选择的是表示字节，协商、缓存匹配与验证器必须一致。编码后字节不同就需要不同强标签。下载正文解码后再与制品比较；编码后 Content-Length 不必等于解码文件大小。&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="让协商可见"&gt;让协商可见&lt;/h2&gt;
&lt;p&gt;当响应可能随 &lt;code&gt;Accept-Encoding&lt;/code&gt; 变化时，把该请求字段写进 &lt;code&gt;Vary&lt;/code&gt;。这告诉共享缓存：压缩响应与 identity 响应不是可互换的缓存条目。&lt;/p&gt;
&lt;p&gt;不要只检查一个浏览器请求，而是比较两条路径：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D - -o /dev/null &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s1"&gt;&amp;#39;Accept-Encoding: identity&amp;#39;&lt;/span&gt; https://www.example.test/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS --compressed -D - -o /dev/null &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; https://www.example.test/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;检查 &lt;code&gt;Content-Encoding&lt;/code&gt;、&lt;code&gt;Vary&lt;/code&gt;、&lt;code&gt;Content-Length&lt;/code&gt;（如存在）与验证器。当内容编码改变表示字节时，强实体标签必须不同。语义等价的变体可以共享弱标签，但它不能用于强比较。条件请求必须针对同一个协商出的表示检查。&lt;/p&gt;
&lt;h2 id="压缩合适的内容"&gt;压缩合适的内容&lt;/h2&gt;
&lt;p&gt;HTML、CSS、JSON、XML、SVG 等文本格式通常适合压缩；已经压缩过的格式未必适合。Caddy 的 &lt;code&gt;encode&lt;/code&gt; 指令会协商支持的编码，并且通常对没有合适内容类型或体积过小的响应不做压缩。&lt;/p&gt;
&lt;p&gt;不要为了满足某个评分而加压缩。测量一个有代表性的响应，确认边缘返回预期的头，并核验可下载文件在传输解码后仍保留正确的内容类型与校验和。&lt;/p&gt;
&lt;p&gt;使用 &lt;code&gt;--compressed&lt;/code&gt; 时，curl 自动解码正文，但保存的响应头不会随之改写。若存在 &lt;code&gt;Content-Length&lt;/code&gt;，它描述的是编码后内容，未必等于解码后下载文件的大小。应在内容解码后才把校验和与制品比较；HTTP 传输分帧属于另一层。此外，该选项不保证服务器一定压缩，应查看真实响应，尤其是小文件与已经压缩过的文件。&lt;/p&gt;
&lt;h2 id="来源"&gt;来源&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;，内容编码与 &lt;code&gt;Vary&lt;/code&gt;，已核验 2026-10-15。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/encode"&gt;Caddy &lt;code&gt;encode&lt;/code&gt; 指令&lt;/a&gt;，内容编码选择与协商，已核验 2026-10-15。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl manual&lt;/a&gt;，&lt;code&gt;--compressed&lt;/code&gt;，已核验 2026-10-15。&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>证书过期是一个日期，而不是仪表盘上的颜色</title><link>https://hk.xbcatm.com/zh/notes/certificate-expiry-is-a-date-not-a-dashboard-color/</link><pubDate>Wed, 10 Jun 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/zh/notes/certificate-expiry-is-a-date-not-a-dashboard-color/</guid><description>检查真实主机名服务的证书，并把剩余天数变成可行动的阈值。</description><content:encoded>&lt;p&gt;HTTPS 进程可能是健康的，而公共证书却临近过期、为错误的名字签发，或在另一条边缘上被不同地提供。一条有用的检查，是向公共端点索取它会给真实客户端的那张证书。&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/tls-expiry.detail.zh.svg" alt="公共主机名通过 SNI 选择证书。观察到的 subject、issuer、指纹、notBefore 与 notAfter 都是证据，不是验证结果。剩余时间与按服务恢复目标选择、且小于正常证书寿命的阈值比较。另行检查主机名、证书链与信任库、notBefore 和吊销状态，并保留诊断信息。管道可能打印证书却隐藏验证错误，因此最后一个命令的退出码不能证明 TLS 健康。" width="720" height="3050"&gt;&lt;figcaption&gt;先检视真实客户端看到的证书，再独立验证。到期告警应使用服务自己的响应阈值，配合握手检查、续期失败告警，以及相关边缘和地址族抽查。&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="包含主机名"&gt;包含主机名&lt;/h2&gt;
&lt;p&gt;TLS 虚拟主机依赖服务器名称。把主机名作为 SNI 传入，并检视对端证书：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;www.example.test
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;openssl s_client -connect &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;:443&amp;#34;&lt;/span&gt; -servername &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &amp;lt;/dev/null 2&amp;gt;/dev/null &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; openssl x509 -noout -subject -issuer -dates -fingerprint -sha256
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这条命令是只读的。它不会续签任何东西，也不能证明每个中间证书都被每个客户端接受。但它确实给出为该主机名呈现的证书的 not-before、not-after、subject、issuer 与指纹。&lt;/p&gt;
&lt;h2 id="在阈值上告警"&gt;在阈值上告警&lt;/h2&gt;
&lt;p&gt;选择能留出续期失败、DNS 问题与人工响应时间的阈值。确切数字属于服务的恢复目标；并非普通的&amp;quot;30 天&amp;quot;规则就能替代这个决定。配套的&lt;a href="https://hk.xbcatm.com/zh/resources/"&gt;TLS 过期检查&lt;/a&gt;会打印过期时间，并在剩余区间低于你传入的阈值时以非零退出。&lt;/p&gt;
&lt;p&gt;尽可能从主机外部运行检查。本地进程看到的监听器、证书文件或代理路径，可能与公共网络上的访问者不同。&lt;/p&gt;
&lt;h2 id="把观察与验证分开"&gt;把观察与验证分开&lt;/h2&gt;
&lt;p&gt;SNI 用于选择虚拟主机，并不验证证书是否匹配该名称。即使 &lt;code&gt;s_client&lt;/code&gt; 报告验证问题，这条管道也可能打印证书，而且它隐藏了标准错误。另做主机名与证书链验证，保留诊断信息并使用已知信任库。不要把这条检视管道最后一个命令的退出码当成 TLS 健康结论。&lt;/p&gt;
&lt;p&gt;配套脚本以 OpenSSL &lt;code&gt;-checkend&lt;/code&gt; 检查 &lt;code&gt;notAfter&lt;/code&gt;，并不能证明主机名匹配、信任关系、吊销状态或 &lt;code&gt;notBefore&lt;/code&gt; 已经到来。把到期告警与握手检查、续期失败告警配合使用。阈值应小于证书正常寿命，短期证书尤其如此；还应抽查相关边缘与地址族。&lt;/p&gt;
&lt;h2 id="来源"&gt;来源&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/automatic-https"&gt;Caddy Automatic HTTPS&lt;/a&gt;，自动 TLS 证书管理，已核验 2026-10-15。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.openssl.org/3.0/man1/openssl-s_client/"&gt;OpenSSL &lt;code&gt;s_client&lt;/code&gt; 文档&lt;/a&gt;，SNI 与证书检查选项，已核验 2026-10-15。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.openssl.org/3.0/man1/openssl-x509/"&gt;OpenSSL &lt;code&gt;x509&lt;/code&gt; 文档&lt;/a&gt;，证书解析、日期与指纹，已核验 2026-10-15。&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>静态服务器应有一个明确的根目录</title><link>https://hk.xbcatm.com/zh/notes/a-static-server-should-have-one-obvious-root/</link><pubDate>Thu, 04 Jun 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/zh/notes/a-static-server-should-have-one-obvious-root/</guid><description>让 Caddy 文件服务、生成制品与回滚路径保持一致。</description><content:encoded>&lt;p&gt;静态部署最有用的属性是：运维人员不用读应用代码，就能回答&amp;quot;这条响应是哪个目录提供的？&amp;quot;。Caddy 的 &lt;code&gt;root&lt;/code&gt; 与 &lt;code&gt;file_server&lt;/code&gt; 指令直接提供了这个边界。&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/caddy-root.detail.zh.svg" alt="完整目录先核验，再设为 Caddy 根目录；新目录通过冒烟测试前保留旧的已知良好目录。虚线发布树包含 HTML、文本与资源。指向树外的符号链接可能暴露服务用户可读取的目标。私有文件留在树外；边缘检查独立核验状态码、内容类型、重定向、证书名称与缺失路由行为。" width="720" height="3010"&gt;&lt;figcaption&gt;明确提供响应的目录。检查符号链接及读取和目录穿越权限，再验证边缘的真实响应。&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="让根目录明确"&gt;让根目录明确&lt;/h2&gt;
&lt;p&gt;一个最小的站点块可以贴近制品布局：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;www.example.test {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; root * /srv/example-site
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; encode zstd gzip
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; header {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -Server
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; X-Content-Type-Options nosniff
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; X-Frame-Options DENY
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Referrer-Policy strict-origin-when-cross-origin
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; file_server
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;主机名与路径都是占位值；&lt;code&gt;www.example.test&lt;/code&gt; 不是可签发公开可信证书的生产名称。生产环境应使用自己控制且可解析的主机名，测试环境的本地 TLS 信任则另行安排。发布命令应发布一个完整目录、核验它，然后才把这个目录设为配置里的根。在新目录通过冒烟测试之前，保留旧的已知良好目录。&lt;/p&gt;
&lt;h2 id="检查边缘而不只是文件树"&gt;检查边缘，而不只是文件树&lt;/h2&gt;
&lt;p&gt;请求一个 HTML 页面、一个可下载的文本文件和一个缺失路径。把内容类型与状态码与本地制品比较。即使 404 页看起来很精致，若返回 &lt;code&gt;200&lt;/code&gt; 仍是部署缺陷。&lt;/p&gt;
&lt;p&gt;当 Caddy 管理 HTTPS 时，证书签发与 HTTP 到 HTTPS 的重定向都属于边缘行为。请从裸的 HTTP 监听器测试重定向，并从公共主机名读取证书名称。不要只凭进程健康就推断成功。&lt;/p&gt;
&lt;h2 id="根目录是发布边界不是沙箱"&gt;根目录是发布边界，不是沙箱&lt;/h2&gt;
&lt;p&gt;把版本控制元数据、备份、凭据与私有配置留在发布树之外。Caddy 会跟随根目录中的符号链接，因此链接可能暴露根目录外的文件。既要检查制品内容，也要检查服务用户的读取与目录穿越权限。此外，仅把 &lt;code&gt;404.html&lt;/code&gt; 放进目录并不会配置自定义错误处理；应核验缺失路由的状态码与预期错误页行为。&lt;/p&gt;
&lt;h2 id="来源"&gt;来源&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/file_server"&gt;Caddy &lt;code&gt;file_server&lt;/code&gt; 指令&lt;/a&gt;，静态文件服务与根目录选择，已核验 2026-10-15。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/header"&gt;Caddy &lt;code&gt;header&lt;/code&gt; 指令&lt;/a&gt;，响应头配置，已核验 2026-10-15。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/automatic-https"&gt;Caddy Automatic HTTPS&lt;/a&gt;，证书签发与 HTTPS 重定向，已核验 2026-10-15。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options"&gt;MDN: &lt;code&gt;X-Frame-Options&lt;/code&gt;&lt;/a&gt;，防止被嵌套的选项，已核验 2026-10-15。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc7034.html"&gt;RFC 7034 — X-Frame-Options&lt;/a&gt;，DENY 嵌入策略，已核验 2026-10-15。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hk.xbcatm.com/zh/resources/"&gt;静态网站发布检查清单&lt;/a&gt;，本地配套资源。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc6761.html"&gt;RFC 6761 — Special-Use Domain Names&lt;/a&gt;，保留的 &lt;code&gt;.test&lt;/code&gt; 名称，已核验 2026-10-15。&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item></channel></rss>