HTTPS 上线记:免费证书、自动续签与三处联动
这个系列共四篇:迁移实录 · 写作与发布流程 · 换服务器手册 · HTTPS 上线记
站点搬到静态之后,一直跑在裸 HTTP 上。不是忘了,是 HTTPS 这件事有几处相互牵扯:证书要签发、要能自动续、vhost 要加 443、强制跳转要放行例外、评论反代和公众号回调都得跟着改。
最后落成一条命令:sudo blog tls issue。下面记的是它背后做了什么,以及为什么要这么做。
一句话版本
sudo blog tls status # 剩几天、443 开没开、续签 cron 在不在
sudo blog tls issue # 首次签发(没装 acme.sh 会自己从 gitee 镜像装)
sudo blog tls renew # 强制重签,调试用
签发完证书落在宝塔约定的路径,restore.sh 换机时认得它:
/www/server/panel/vhost/cert/blog.zhourunsheng.com/fullchain.pem 证书链
/www/server/panel/vhost/cert/blog.zhourunsheng.com/privkey.pem 私钥(600)
为什么用 acme.sh,而不是面板上点一下
宝塔面板有「申请 Let’s Encrypt」按钮,点下去更省事。我先试了那条路,报错:
配置文件被修改,不支持文件验证
原因是本站的 vhost 不是面板生成的,而是 tools/build_prod_deploy.py 用一个模板渲染出来的——里面带着 349 条历史 301 跳转、两条 map、/isso/ 反代、/mp-weixin/ 的 PHP 段。面板的 acme_v2.can_use_base_file_check() 只在自己的配置文件里用正则找 location ... /.well-known/ {,而我最初把这段写成了 include,它匹配不到,就给了上面那句话。
所以我改用 acme.sh。它的好处是不关心 vhost 怎么来的,只要 /.well-known/acme-challenge/ 能从公网读到文件就行。
后话:后来我把那个 location 直接写进了 vhost 主文件(而不是 include),面板那条路其实也通了。但续签已经归 acme.sh,就别再在面板里点「申请/续签」了——两套客户端会互相覆盖证书文件,谁后跑谁生效,出问题很难查。
HTTP-01 验证与它的前提
签证书要证明「你确实控制这个域名」。Let’s Encrypt 的 HTTP-01 方式最省事:它给你一个 token,你把它放到 http://域名/.well-known/acme-challenge/<token>,它来读,读到就算通过。
这意味着两个前提:
- 80 端口要从公网可达——很多人只在 443 上放证书,却忘了 HTTP-01 走的是 80;
/.well-known/这条路径不能被任何规则拦掉(这一点在下面「强制跳转」一节会变成大坑)。
acme.sh 的签发命令长这样,-w 指向站点的物理根目录:
/root/.acme.sh/acme.sh --issue -d blog.zhourunsheng.com \
-w /www/wwwroot/blog-static/public \
--keylength 2048
密钥长度用 2048。不是不能上 4096,但握手更慢、老设备兼容性更差,对个人站没必要。
签发完还要 --install-cert 把文件搬到站点实际读取的位置,并注册重载钩子:
/root/.acme.sh/acme.sh --install-cert -d blog.zhourunsheng.com \
--key-file /www/server/panel/vhost/cert/blog.zhourunsheng.com/privkey.pem \
--fullchain-file /www/server/panel/vhost/cert/blog.zhourunsheng.com/fullchain.pem \
--reloadcmd "/www/server/nginx/sbin/nginx -s reload"
--reloadcmd 不能省。证书文件换了但 nginx 不重载,浏览器拿到的还是旧证书——表现为「明明续签成功了,访问还是报证书过期」。这个坑很常见,且容易误判成「续签没跑」。
自动续签:一个 cron,一条命令
Let’s Encrypt 的证书只有 90 天。这不是苛刻,是推动自动化的设计——把「别忘了续」变成程序的事。
acme.sh 装的时候会问要不要挂 cron。我关掉了自动挂(--install --no-cron),改成由脚本显式写入,因为要指定绝对路径和日志位置:
17 3 * * * /root/.acme.sh/acme.sh --cron --home /root/.acme.sh >> /var/log/acme.cron.log 2>&1
每天凌晨 3:17 检查一次。没到期就什么都不做,快到期才续——所以日志大部分时间是空的,这很正常,不代表没跑。
用 --cron 而不是逐个域名 --renew,好处是以后加域名不用改 cron:acme.sh 会遍历它管的所有证书。
验证续签是否真的在工作,看两处:
sudo blog tls status
# ✓ 证书有效至 Feb 11 02:09:13 2027 GMT(剩 89 天)
# ✓ vhost 已监听 443
# ✓ 续签 cron 已挂
# ✓ acme.sh 3.1.5
sudo cat /var/log/acme.cron.log # 续签日志
blog tls status 里我加了一条阈值判断:剩余不足 14 天就打警告。因为 90 天的证书、每天检查的 cron,正常情况永远不该出现「只剩 10 天」——出现了就说明续签坏了,得去看日志。这个提醒比等到过期才发现有用得多。
关于 acme.sh 的安装源
官网 get.acme.sh 在墙内基本下不来。脚本里用的是 gitee 镜像:
mirror="https://gitee.com/neilpang/acme.sh/raw/master/acme.sh"
curl -fsSL -m 60 "$mirror" -o "$d/acme.sh"
sh ./acme.sh --install --no-cron
acme.sh 本身是个 shell 脚本,不依赖任何运行时,装到哪里都行,这也是它比 certbot 轻的地方。
强制跳转:难的不是跳转,是例外
证书装好了,接下来让 HTTP 自动跳 HTTPS。直觉写法是在 server 块里加一行:
if ($request_uri !~ ^/\.well-known/) {
return 301 https://$host$request_uri;
}
但这条规则会踩三个坑,每一个都会让站点以某种方式坏掉。所以最终实现用了三张 map 配合一个 if。
例外一:/.well-known/ —— 跳过它续签就会死
这是最隐蔽的一条。你给「http 跳 https」加了规则,/.well-known/acme-challenge/ 也走了 http,于是被 301 到 https。
然后 acme.sh 来读 token:它请求 http://域名/.well-known/acme-challenge/xxx,收到的是 301。多数情况下它不会跟到 https,因为校验的目标就是 http 这一层。于是验证失败,续签失败,90 天后证书过期,全站 HTTPS 打不开。
更糟的是它的时间差:签发时你还没配跳转,一切正常;等到 90 天后自动续签时才炸。这个坑有 90 天的延迟,等发现时已经很被动了。
所以 /.well-known/ 必须永远在例外表里:
map $request_uri $redirect_to_https
{
default 1;
~^/\.well-known/ 0; # ACME 校验:跳了续签必失败
~^/mp-weixin/ 0; # 见下条
}
例外二:/mp-weixin/ —— 301 会破坏 POST
公众号回调是 POST 请求。而 301 对 POST 的处理,各家实现并不一致:按 RFC 应该保持方法,但现实中不少客户端会把它降级成 GET,请求体直接丢掉。
表现是:域名验证能过(那是 GET),真正收到消息时静默出错——用户发消息没反应,日志里看到的却是一次 GET。
其实更根本的解法是把回调地址直接改成 https,就不用例外了。但迁移期间 80 和 443 并存,两条路都得通,所以先加例外。
例外三:Host 不是本站域名 —— 会拿到证书错误
服务器 443 上只装了这一张证书(blog.zhourunsheng.com)。如果有人用 IP 直连、或者用别的域名解析过来访问,跳转到 https 之后,浏览器拿到的证书与访问地址不匹配,直接报证书错误。
所以跳转要同时判断 Host:
map $host $is_blog_host
{
default 0;
blog.zhourunsheng.com 1;
www.blog.zhourunsheng.com 1;
}
三个条件都满足才跳,用字符串拼接一次判断,避免在 server 块里嵌套 if:
map "$scheme$redirect_to_https$is_blog_host" $blog_force_https
{
default 0;
"http11" 1; # http + 未命中例外 + 本站域名
}
server {
if ($blog_force_https) {
return 301 https://$host$request_uri;
}
}
为什么用 map 而不是直接写 if
两个原因。一是 map 在 server-rewrite 阶段求值,与 location 的匹配顺序无关,三条例外都能稳定生效;二是 nginx 的 if 有著名的「if is evil」问题——写在 location 里的 if 会各自开一个隐式 location,和 try_files、proxy_pass 混用时行为难以预料。放在 server 层做一次纯变量判断,是最不容易出错的形式。
历史链接会多一跳
本站老链接本来就 301 到新格式。加上强制跳转后,访问一个老链接是两次跳转:
http 原址 → https 原址 → https 新址
为什么不直接把 349 条 location 抄成 http/https 两份?因为那样就有两份规则要维护,迟早漂移。多一跳的代价,比两份规则不一致要小。站点的自检脚本也按「最多两跳」写的。
没开 HSTS,是故意的
HSTS 让浏览器记住「这个站只能走 https」,能防降级攻击。但它有个副作用:一旦下发,在 max-age 到期前浏览器拒绝用 http 访问——如果证书出任何问题,你的站就彻底打不开了,连排查的入口都没有。
考虑到本站在用 Let’s Encrypt 的新层级证书链(YR2 ← ISRG Root YR),很老的 Android 7.1 以下、以及久未更新的 Win7 可能不信任这个新根。所以我选择先不下发 HSTS,让跳转跑一段时间,确认没有老设备报错之后再考虑,且会从很短的 max-age 起步。
三处联动:HTTPS 不只是加一行 listen
证书和跳转就位后,还有三个地方要跟着改,否则会出现「页面能开但某块功能悄悄坏了」。
nginx vhost:443 与 80 并存
vhost 由模板生成,新增的部分是:
server {
listen 80 default_server;
listen 443 ssl default_server;
http2 on;
server_name blog.zhourunsheng.com www.blog.zhourunsheng.com;
ssl_certificate /www/server/panel/vhost/cert/blog.zhourunsheng.com/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/blog.zhourunsheng.com/privkey.pem;
# 按既定决策:不下发 Strict-Transport-Security
}
同一个 server 块同时监听 80 和 443,是刻意的:跳转规则只写一份,server_name 只写一份。
Isso 评论:反代要传对协议头
评论系统 Isso 跑在 127.0.0.1:8080,由 nginx 反代到 /isso/。切 https 后,反代必须把原始协议告诉后端:
location ^~ /isso/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header X-Script-Name /isso;
proxy_cookie_path / /isso/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme; # 关键:告诉 Isso 前端是 https
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
X-Forwarded-Proto 不加的后果:Isso 以为自己在一个 http 站点上,生成的表单地址、重定向地址都会是 http。浏览器在 https 页面上加载 http 资源或提交到 http 地址,会被拦(混合内容),表现为评论区加载不出来或提交无反应。
另一个坑是 proxy_cookie_path。Isso 默认把 cookie 写在 /(因为对它来说自己就在根上),但对外实际路径是 /isso/。不重写的话,cookie 的作用域覆盖整个站点,属于不必要的扩散;重写之后它老老实实待在 /isso/ 下。
公众号回调:必须走 https
这个改动最直接——微信要求回调地址是 https。改完之后:
- 后台「服务器配置」里的 URL 改成
https://blog.zhourunsheng.com/mp-weixin/main.php,Token 不变; - 保存时微信会立即发一次验证请求(GET 带
echostr),我们的入口要能验签并把echostr原样回显; - 那之后所有消息推送都走 https。
这个环节有一个额外的注意点:/mp-weixin/ 是 PHP,走 FastCGI;而 HTTPS 只影响传输层,PHP 侧不需要任何改动。也就是说回调代码一行没改,只是地址从 http 换成了 https。
验证它是否真的通了,看日志里微信的请求:
grep 'mp-weixin' /www/wwwlogs/blog.zhourunsheng.com.log | tail -5
微信服务器的特征很好认:UA 是固定的 Mozilla/4.0,来源是腾讯云 IP。看到 200 且响应体有内容,就说明验签、解密、回复整条链路是通的。
换机器时这张证书怎么办
这是 HTTPS 里最容易被忽略的一环。证书和私钥在 /www/server/panel/vhost/cert/ 下,不在源码目录里,如果只拷源码,新机器上没有证书,而 vhost 里有 listen 443——nginx 会因为读不到证书文件直接拒绝启动,站点表现为完全打不开。
所以快照里带了一份:
ops/cert/fullchain.pem
ops/cert/privkey.pem # 600,与 isso.cfg 同级别敏感
restore.sh 会把它铺回原路径。但这里有个联动必须处理:如果没有可用证书,光摘掉 443 是不够的,强制跳转要一起摘掉。
因为跳转规则是 if ($blog_force_https) { return 301 https://... },443 已经没监听,所有请求还是会被跳到一个不存在的 https 端口,全站 301 进黑洞。所以 restore.sh 里两件事是绑在一起的:
if [ "$TLS_KEEP" != 1 ]; then
SED_EXPR="$SED_EXPR;/listen 443 ssl/d;/http2 on;/d;/ssl_/d"
SED_EXPR="$SED_EXPR;/if (\$blog_force_https)/,/^ }/d"
SED_EXPR="$SED_EXPR;/map \\\$request_uri \\\$redirect_to_https/,/^}/d"
SED_EXPR="$SED_EXPR;/map \\\$host \\\$is_blog_host/,/^}/d"
fi
DNS 指向新机器之后,再跑一次签发,然后重新生成 vhost 把跳转恢复。
小结
把这次的经验收成几条:
证书本身很简单,联动的部分才花时间。 签发一条命令,但跳转的例外、评论反代的头、公众号回调的地址、换机时证书与跳转的绑定关系——每一处漏了都会坏,且多数是静默的。
/.well-known/ 的例外要在配跳转之前就写好。 它的故障延迟 90 天,是最难排查的一类问题。
证书文件不在源码目录里。 只备份源码的迁移方案,到这里会直接起不来站。
别同时用两套证书工具。 面板和 acme.sh 选一个,另一个彻底不用。
HSTS 可以晚点再上,但要从很短的 max-age 起步。 它有 90 天不可逆窗口的味道,先确认全站和老设备都没问题。
现在的状态:http:// 访问自动跳 https://,证书 90 天自动续,blog tls status 一条命令能看到剩余天数和续签健康度。整条链路——静态站、评论反代、公众号回调——都跑在同一张证书下。
加入讨论
新评论 — 条评论保存在本站自托管服务里,无需注册登录,填个昵称就能留言;不依赖任何第三方 Cookie 或账号。请尽量说点有用的 :)