/** * 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(); }); }