一、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),
保留三条纯监控告警。
458 lines
18 KiB
JavaScript
458 lines
18 KiB
JavaScript
/**
|
||
* 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();
|
||
});
|
||
}
|