Files
blog/deploy/cn-certkeeper/src/serve.mjs
T
zqlit 740d77e4cb fix(ssl): 修掉 5 处静默失败,本项目全面接管签发部署,Worker 退回只读
一、1Panel 部署器三个缺陷(其中两个此前完全不可见)

1. `POST /websites/{id}/https` 的字段名是 `websiteSSLId`,不是 `sslId`。
   发 `sslId` 会被 Go 静默忽略成零值 0,于是
   `websiteSSLRepo.GetFirst(WithByID(0))` → 返回
   `HTTP 200 + code 500「服务错误: record not found」`,
   报错文案落在 DB 层,完全指不到参数名 —— 整个 t-t.live 部署被这条卡住。
   对照实验:`sslId=13 → 500` / `websiteSSLId=13 → 200 code=200`。

2. 换证书内容的接口选错。`POST /websites/ssl/update` 的结构体
   `WebsiteSSLUpdate` **根本没有** `certificate` / `privateKey` 字段,
   传了被丢弃、且 `domains` 只从 `otherDomains` 取(不传就清空),
   还会顺带把 `autoRenew` 置 false —— 而它**照样返回 200 success**。
   实测:原样 update 后 5 个站点的 `ssl/*.pem` mtime+md5 一个都没变。
   正确接口是 `POST /websites/ssl/upload` + `sslID > 0`:取记录 → 覆盖
   → 重算 ExpireDate/domains → `UpdateSSLConfig()` → 重新物化站点文件。
   副作用:`Upload()` 把 `primaryDomain` 重算成证书第一个 SAN
   (#11 因此从 `usj.cc` 漂成 `*.usj.cc`)→ 必须补 `domains` 兜底匹配,
   否则每次续期都新建一条重复记录。

3. `deploy()` 幂等捷径漏了「记录被换过」这一维:`sslId` 没变但内容变了时
   会跳过绑定,站点文件就停留在旧证书。改为引入 `replaced` 标志强制重绑。

二、另外两处静默失败

4. `parsePemInfo()` 对**完整链**返回 `{}`:旧实现把 PEM 各段 base64 拼接后
   一次 `atob`,中间段尾部的 `=` 填充导致抛错,整函数返回空 →
   `rec.expireAt` 退化成「签发时刻 + 90 天」。而完整链恰恰是部署器最常
   拿到的形态。改为只解析第一段(叶证书)。
   顺带新增 `derLen()` / `parseSanFromDer()`,精确定位 SAN 扩展
   OID `2.5.29.17` 再读 `[2] dNSName`,替掉原来的字节扫描启发式。
   这条同时是「多吉云复用失效」的根因:判据缺到期时间,只看域名集合
   就永远认为已覆盖 → 续期静默空转。修好后判据带上 `notAfter` 比对(1 天容差)。

5. DNS-01 挑战通知会撞 `400 authorization must be pending`(200181.xyz 连中两次)。
   这是**竞态**不是逻辑错:「先读状态再 POST」挡不住毫秒级窗口。
   已在 POST 侧做幂等容错(只认这一句),最终由 `pollAuthz` 定论。

三、Worker 退回只读

- `crons` 去掉 `10 4 * * *`,`index.ts` 里 renew 分支整体删除
- `POST /ssl/issue` 改 **501 硬拒绝**(而不是静默降级),响应给出国内机命令
- 签发 + 部署整条链路跑在国内机容器 `cn-certkeeper`

四、顺带修掉的两个「配置被悄悄抹掉」

- `configSave()` 不再丢掉表单不管理的 `probe_connect` / `probe_sni`。
  之前管理员在面板改任何一项,这两个字段就会被清空,
  后果是挂在 CDN 后的 t-t.live 探针退回公网、被误判成「还剩 80 多天」,
  源站证书到期也不续。现在保存时从旧配置带过来。

五、新增 4 个常驻运维工具(deploy/cn-certkeeper/src/)

- `renew-one.mjs`      只对单个域名签发+部署(原 `/renew` 无域名过滤,会全量重签)
- `redeploy.mjs`        复用已签好的证书只重跑部署(不碰 ACME,不白烧配额)
- `rollback-dogecloud.mjs` 应急把 CDN 域名绑回指定证书 id
- `txt-inspect.mjs`     `_acme-challenge` 下的 TXT 残留盘点/清理
- `selfcheck-acme.mjs`  CA 层诊断:只读目录 + 复用账户,不签发不部署

六、测试与文档

- 自测新增 [11] 节 8 项 PEM 解析回归(样本是 openssl 现场生成、内联写死的
  叶+中间证书,两段都以 `=` 结尾,正是 bug 现场),含精确值断言:
  > 125 项通过,0 失败
- `npm run typecheck` 零错误
- 方案文档:§9.11 由「待验证」改为定案(`ssl/update` 不物化、
  `ssl/upload+sslID` 才物化);新增 §9.12「本轮又修掉的 5 个静默失败」、
  §9.13「本轮最终状态」、§9.14「certimate 工作流清查」

线上验收:三个域名(t-t.live / usj.cc / 200181.xyz)线上证书均为
LiteSSL ECC、2027-01-04 到期、daysLeft=90、needRenew=false;
1Panel 证书库 5 条精简为 3 条且全部在用;
certimate 停掉全部「会签发并部署」的工作流(团团 / 优世界 / 200181.xyz),
保留三条纯监控告警。
2026-10-06 20:08:28 +08:00

458 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* cn-certkeeper —— 国内机上的证书自动续期服务。
*
* 为什么要有它(2026-10-06 定):
* Workers 免费版 CPU 硬顶 10 ms,付费版 $5/月(≈¥36)。实测一次完整签发
* 的密码学工作量约 3~4 ms(本地 Node),乘上「Workers 比本地慢」的系数后
* 正好擦着 10 ms 的上限跑 —— 与其花每月 36 块去买「不擦边」,不如把签发
* 这一坨放到**本来就在跑的国内机**上(那边 CPU 不受限,Docker 也现成)。
* 于是:Worker 只留探针 / UI / 环境自检(轻量,免费版绰绰有余),
* 签发 + 部署整条链路搬到这台机器。
*
* ★ 业务代码一行没改:直接复用 blog-admin 编译出来的 lib/*.js
* (`npm run selftest:ssl:build` 的产物)。那套代码只用
* crypto / fetch / btoa / atob / Request / Response / TextEncoder,
* 全是 Node 20+ 的全局 API;存储那层用 kv-file.mjs 补了个文件版 KVNamespace。
*
* 职责:
* · 每天定时(默认 04:10)跑一次续期检查 —— 先探针查剩余天数,
* ≤ RENEW_BEFORE_DAYS(30) 才真去签发,签完自动部署到多吉云 / 1Panel
* · 带鉴权的 HTTP 接口,供人工触发与查看状态
* · 探针走**本机 node:tls**(国内机是真 Node,getPeerCertificate 可用),
* 所以能拿到线上证书的真实到期日 —— 这一步在 Worker 上做不到
* (Workers 的 node:tls 是桩函数,实测抛 "getPeerCertificate is not implemented")
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { FileKV } from './kv-file.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const LIB_DIR = path.resolve(HERE, '../lib');
const require = createRequire(import.meta.url);
const lib = (name) => require(path.join(LIB_DIR, `${name}.js`));
// ---------------------------------------------------------------- 配置
const DATA_DIR = process.env.DATA_DIR || '/data';
const PORT = Number(process.env.PORT || 8019);
const HOST = process.env.HOST || '127.0.0.1';
const AUTH_TOKEN = String(process.env.AUTH_TOKEN || '').trim();
const TOKEN_SECRET = String(process.env.TOKEN_SECRET || '').trim();
const RENEW_HOUR = Number(process.env.RENEW_HOUR ?? 4);
const RENEW_MINUTE = Number(process.env.RENEW_MINUTE ?? 10);
const RUN_ON_START = String(process.env.RUN_ON_START ?? 'check').toLowerCase(); // check | renew | off
const PROBE_TIMEOUT_MS = Number(process.env.PROBE_TIMEOUT_MS || 8000);
if (!AUTH_TOKEN) {
console.error('[cn-certkeeper] 缺少 AUTH_TOKEN —— 拒绝以无鉴权状态启动');
process.exit(1);
}
if (!TOKEN_SECRET) {
console.error('[cn-certkeeper] 缺少 TOKEN_SECRET —— 保险箱解不开凭据,签发必然失败');
process.exit(1);
}
// ---------------------------------------------------------------- 加载 lib
let renewAll;
let RENEW_BEFORE_DAYS;
let loadConfig;
let loadLog;
let probeTls;
let daysLeft;
let parsePemInfo;
try {
({ renewAll, RENEW_BEFORE_DAYS } = lib('certissue'));
({ loadConfig, loadLog } = lib('certstore'));
({ probeTls, daysLeft, parsePemInfo } = lib('certprobe'));
} catch (e) {
console.error('[cn-certkeeper] 加载 lib 失败:', e instanceof Error ? e.message : e);
console.error(' —— 确认 lib/ 目录已随镜像一起打进(编译产物来自 `npm run selftest:ssl:build`)');
process.exit(1);
}
// ---------------------------------------------------------------- env(对齐 Worker 的 Env)
const kv = new FileKV(DATA_DIR);
const env = {
RSS_KV: kv,
TOKEN_SECRET,
// ★ 留空 → probeTls 跳过「调国内机 editor-api」那一跳,直接走本机 node:tls。
// 这台机器就是「国内机」本身,没有理由再绕一次 HTTP。
EDITOR_API_BASE: '',
EDITOR_TOKEN: '',
};
// ---------------------------------------------------------------- 工具
const pad = (n) => String(n).padStart(2, '0');
/** 按**本地时区**格式化 —— 容器里设了 TZ=Asia/Shanghai,所以打出来就是北京时间。
* ★ 别用 toISOString():那是 UTC,日志写着「04:10 续期」而人看到的是 12:10,对不上。 */
const fmt = (d) =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
const ts = () => fmt(new Date());
const log = (...a) => console.log(`[${ts()}] [cn-certkeeper]`, ...a);
const j = (obj) => JSON.stringify(obj, null, 1);
/** 只读检查:逐域名握手看真实剩余天数,**不签发、不写 DNS、不改站点** */
async function checkAll() {
const cfg = await loadConfig(env);
const out = [];
for (const d of cfg.domains) {
if (d.disabled) {
out.push({ name: d.name, skipped: true, reason: '域名已停用' });
continue;
}
const host = d.probe_sni || d.san.find((s) => !s.startsWith('*.')) || d.name;
const t0 = Date.now();
// ★ 配了 probe_connect 就连源站 IP(SNI 仍是 host)—— 域名挂在 CDN/边缘
// 加速后面时,公网握手量的边缘证书,会让续期判定永远「还很新」。
const live = await probeTls(host, PROBE_TIMEOUT_MS, undefined, undefined, d.probe_connect);
const left = daysLeft(live.notAfter);
out.push({
name: d.name,
host,
probeVia: d.probe_connect ? `${d.probe_connect}(SNI ${host})` : `公网 ${host}:443`,
ok: !!live.ok,
probeMs: Date.now() - t0,
notAfter: live.notAfter ? new Date(live.notAfter).toISOString() : null,
daysLeft: left,
issuer: live.issuer || null,
subject: live.subject || null,
tlsVersion: live.tlsVersion || null,
needRenew: left === null ? true : left <= RENEW_BEFORE_DAYS,
error: live.error || null,
});
}
return out;
}
/**
* 凭据预检(**只读**):确认每条凭据真的能调通对面的 API。
*
* 为什么必须有:配错了凭据,症状是「等到续期那天才发现签不出来」——
* 而那时候线上证书可能已经只剩几天。这里把它提前到「随时可查」。
* 对应 Worker 侧的 `/ssl/selfcheck`,但那边受 Workers 运行时限制
* (拿不到证书正文、CF token 缺 DNS 权限),这边的检查更完整。
*
* 全程不做任何写操作:CA 只读目录、DNS 只 list、部署目标只 ping。
*/
async function preflight() {
const { AcmeClient } = lib('acme');
const { getAccess, listAccess } = lib('certstore');
const { makeDnsProvider } = lib('dnsprovider');
const { makeDeployer } = lib('deployer');
const errText = (e) => (e instanceof Error ? e.message : String(e));
const out = [];
const cfg = await loadConfig(env);
// ---- ① CA:目录可达性 + 是否强制 EAB ----
const all = await listAccess(env);
for (const item of all) {
if (item.type !== 'acme-eab') continue;
const rec = await getAccess(env, item.name);
const url = String(rec?.directoryUrl || '');
try {
if (!url) throw new Error('缺少 directoryUrl');
const c = new AcmeClient(url, { jwk: {}, kid: '' });
const needEab = await c.externalAccountRequired();
const kid = String(rec?.eabKid || '');
out.push({
kind: 'ca',
name: item.name,
ok: true,
detail: `目录可达(${url.replace(/^https?:\/\//, '').split('/')[0]});EAB ${
needEab ? `必需,kid=${kid.slice(0, 8)}…` : '非必需'
}`,
});
} catch (e) {
out.push({ kind: 'ca', name: item.name, ok: false, detail: `${errText(e)}(${url || '未配 URL'})` });
}
}
// ---- ② DNS:只 list 一条 _acme-challenge ----
for (const d of cfg.domains) {
const host = d.san.find((s) => !s.startsWith('*.')) || d.name;
const rec = await getAccess(env, d.dns);
if (!rec) {
out.push({ kind: 'dns', name: d.dns, ok: false, detail: '凭据不存在' });
continue;
}
try {
const p = makeDnsProvider(rec);
const vals = await p.listTxt(`_acme-challenge.${host}`);
out.push({ kind: 'dns', name: `${d.dns} → ${host}`, ok: true, detail: `可读,现存 TXT ${vals.length} 条` });
} catch (e) {
const m = errText(e);
// DNSPod 在「这个子域下没有 TXT」时回 ResourceNotFound.NoDataOfRecord ——
// 这是**健康**信号(上次签完清干净了),不是错误。
const healthy = /NoDataOfRecord/i.test(m);
out.push({
kind: 'dns',
name: `${d.dns} → ${host}`,
ok: healthy,
detail: healthy ? '可读,该名字下无现存 TXT(正常)' : m,
});
}
}
// ---- ③ 部署目标:只 ping ----
const targets = new Set();
for (const d of cfg.domains) for (const t of d.deploy || []) targets.add(t);
for (const target of targets) {
const credName = target === '1panel' ? '1panel-cn' : target;
const rec = await getAccess(env, credName);
if (!rec) {
out.push({ kind: 'deploy', name: credName, ok: false, detail: '凭据不存在' });
continue;
}
try {
const dp = makeDeployer(rec);
if (typeof dp.ping === 'function') {
// ★ ping 的约定是「返回 {ok,error,hint,detail} 而**不抛错**」。
// 第一版直接 String(r) 打成了 `[object Object]`,更糟的是把 r.ok=false
// 也记成通过 —— 那这个预检就等于白做。必须读 r.ok。
const r = await dp.ping();
const detail = r.ok
? r.detail || 'API 可达'
: `${r.error || '未知错误'}${r.hint ? ' —— ' + r.hint : ''}`;
out.push({ kind: 'deploy', name: credName, ok: !!r.ok, detail });
} else {
out.push({ kind: 'deploy', name: credName, ok: true, detail: '(该适配器未实现 ping,已跳过)' });
}
} catch (e) {
out.push({ kind: 'deploy', name: credName, ok: false, detail: errText(e) });
}
}
return out;
}
/** 真跑一次续期(内部分支:探针→签发→部署) */
async function runRenew(opts = {}) {
const started = Date.now();
log(`开始续期检查(by=${opts.by || 'cron'}${opts.force ? ' force' : ''}${opts.noDeploy ? ' noDeploy' : ''})`);
const results = await renewAll(env, { by: opts.by || 'cn-certkeeper', force: opts.force, noDeploy: opts.noDeploy });
for (const r of results) {
const tag = r.skipped ? '跳过' : r.ok ? '成功' : '失败';
log(` ${r.domain}: ${tag} — ${r.reason}`);
}
const ms = Date.now() - started;
log(`续期检查结束,用时 ${(ms / 1000).toFixed(1)}s`);
return { results, ms };
}
// ---------------------------------------------------------------- 并发闸
let busy = null; // Promise | null
function withLock(name, fn) {
if (busy) return Promise.reject(new Error(`已有任务在执行中(${busy.name}),请稍后再试`));
const p = (async () => {
try {
return await fn();
} finally {
busy = null;
}
})();
p.name = name;
busy = p;
return p;
}
// ---------------------------------------------------------------- HTTP
function readBody(req, limit = 64 * 1024) {
return new Promise((resolve, reject) => {
let n = 0;
const chunks = [];
req.on('data', (c) => {
n += c.length;
if (n > limit) {
reject(new Error('请求体过大'));
req.destroy();
return;
}
chunks.push(c);
});
req.on('end', () => {
const s = Buffer.concat(chunks).toString('utf8').trim();
if (!s) return resolve({});
try {
resolve(JSON.parse(s));
} catch {
reject(new Error('请求体不是合法 JSON'));
}
});
req.on('error', reject);
});
}
function authed(req) {
const h = req.headers['x-auth-token'];
if (h && h === AUTH_TOKEN) return true;
const a = String(req.headers.authorization || '');
if (a.startsWith('Bearer ') && a.slice(7) === AUTH_TOKEN) return true;
return false;
}
function send(res, code, obj) {
const body = j(obj);
res.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store',
});
res.end(body);
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', 'http://localhost');
const p = url.pathname.replace(/\/+$/, '') || '/';
try {
// health 免鉴权(探活/监控用,不吐任何敏感信息)
if (p === '/health' && req.method === 'GET') {
let domains = null;
try {
domains = (await loadConfig(env)).domains.length;
} catch {
/* 配置还没建好也要能探活 */
}
return send(res, 200, { ok: true, service: 'cn-certkeeper', dataDir: DATA_DIR, domains, busy: !!busy });
}
if (!authed(req)) return send(res, 401, { ok: false, error: '未授权(需要 X-Auth-Token 或 Bearer)' });
// 只读:查每个域名的真实剩余天数
if (p === '/status' && req.method === 'GET') {
const domains = await checkAll();
return send(res, 200, { ok: true, renewBeforeDays: RENEW_BEFORE_DAYS, domains });
}
// 执行续期
if (p === '/renew' && req.method === 'POST') {
const body = await readBody(req);
const out = await withLock('renew', () => runRenew({ by: 'http', force: !!body.force, noDeploy: !!body.noDeploy }));
return send(res, 200, { ok: true, ...out });
}
// 只要探针判断(不签发)
if (p === '/renew-check' && req.method === 'GET') {
const domains = await checkAll();
const need = domains.filter((d) => d.needRenew && !d.skipped);
return send(res, 200, {
ok: true,
renewBeforeDays: RENEW_BEFORE_DAYS,
needRenew: need.map((d) => d.name),
domains,
});
}
// 凭据预检(只读):CA 目录 / DNS 只读列举 / 部署目标 ping
if (p === '/preflight' && req.method === 'GET') {
const items = await preflight();
const bad = items.filter((x) => !x.ok);
return send(res, 200, { ok: bad.length === 0, passed: items.length - bad.length, total: items.length, items });
}
// 执行日志
if (p === '/log' && req.method === 'GET') {
const limit = Math.min(Number(url.searchParams.get('limit') || 50), 200);
const entries = (await loadLog(env)).slice(0, limit);
return send(res, 200, { ok: true, count: entries.length, entries });
}
// 数据目录概览(不返回任何凭据内容)
if (p === '/data' && req.method === 'GET') {
const names = (await kv.list({ prefix: 'certkeeper:access:' })).keys.map((k) =>
k.name.replace('certkeeper:access:', ''),
);
const certs = (await kv.list({ prefix: 'certkeeper:cert:' })).keys.map((k) =>
k.name.replace('certkeeper:cert:', ''),
);
let cfg = null;
try {
const c = await loadConfig(env);
cfg = { version: c.version, domains: c.domains.map((d) => d.name), notifyTo: c.notify.emails };
} catch (e) {
cfg = { error: e instanceof Error ? e.message : String(e) };
}
return send(res, 200, { ok: true, dataDir: DATA_DIR, access: names, certs, config: cfg });
}
return send(res, 404, { ok: false, error: `没有这个接口:${req.method} ${p}` });
} catch (e) {
log('请求出错:', e instanceof Error ? e.message : e);
return send(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
}
});
// ---------------------------------------------------------------- 定时
function scheduleDaily(hour, minute, fn) {
const arm = () => {
const now = new Date();
const next = new Date(now);
next.setHours(hour, minute, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
const delay = next.getTime() - now.getTime();
log(`下次自动续期:${fmt(next)}(${(delay / 3600000).toFixed(1)} 小时后)`);
// setTimeout 的上限是 2^31-1 ms ≈ 24.8 天,一天一次不会碰到
setTimeout(() => {
withLock('cron', () => fn()).catch((e) => log('定时任务出错:', e instanceof Error ? e.message : e));
arm(); // 跑完再排下一次(不用 setInterval:避免上一次没跑完就叠上下一次)
}, delay);
};
arm();
}
// ---------------------------------------------------------------- 启动
process.on('unhandledRejection', (e) => log('未处理的 Promise 拒绝(已忽略,不退出进程):', e));
process.on('uncaughtException', (e) => log('未捕获异常(已忽略,不退出进程):', e));
server.listen(PORT, HOST, () => {
log(`已启动,监听 http://${HOST}:${PORT}`);
log(`数据目录 ${DATA_DIR};续期阈值 ${RENEW_BEFORE_DAYS} 天;每日 ${RENEW_HOUR}:${String(RENEW_MINUTE).padStart(2, '0')}`);
// 启动自检:先确认数据目录读得出、凭据解得开 —— 配错了现在就要吼,别等到凌晨
(async () => {
try {
const cfg = await loadConfig(env);
log(`配置就绪:${cfg.domains.length} 组域名 → ${cfg.domains.map((d) => d.name).join(', ')}`);
const names = (await kv.list({ prefix: 'certkeeper:access:' })).keys.map((k) =>
k.name.replace('certkeeper:access:', ''),
);
log(`凭据 ${names.length} 条:${names.join(', ')}`);
if (RUN_ON_START !== 'off') {
const st = await checkAll();
for (const d of st) {
if (d.skipped) {
log(` ${d.name}: 已停用`);
} else if (d.ok) {
log(` ${d.name}: 线上证书 ${d.daysLeft} 天后到期(${d.notAfter},${d.issuer || '?'})`);
} else {
log(` ${d.name}: 探测失败 — ${d.error}`);
}
}
if (RUN_ON_START === 'renew') {
await withLock('onstart', () => runRenew({ by: 'onstart' })).catch((e) => log('启动续期出错:', e.message));
}
}
scheduleDaily(RENEW_HOUR, RENEW_MINUTE, () => runRenew({ by: 'cron' }));
} catch (e) {
log('★ 启动自检失败:', e instanceof Error ? e.message : e);
log(' 如果是「配置不是合法 JSON」或「找不到 config」,先跑一次导出脚本:');
log(' node tools/export-certkeeper-data.mjs --out <数据目录>');
}
})();
});
// 优雅退出:容器 stop 时别留下半个临时文件
for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, () => {
log(`收到 ${sig},退出`);
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 3000).unref();
});
}