Files
blog/blog-admin/tools/seed-ssl-config.mjs
T
zqlit 43dbc8b75f feat(ssl): ACME 自动签发与自动续期,实现证书全生命周期闭环
证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 certimate(MIT)的 DNS-01 流程自行实现,不再依赖闭源 certd。

新增(纯 WebCrypto,零 npm 依赖):
- lib/acme.ts        ACME v2 客户端:ES256 JWS(原始 r||s)、RFC7638
                     thumbprint、EAB、badNonce 重试、DNS-01、手写 DER CSR
- lib/dnsprovider.ts DNS-01 适配:DNSPod(TC3-HMAC-SHA256)、Cloudflare
- lib/deployer.ts    部署适配:多吉云 CDN、1Panel 站点(幂等换证书)
- lib/certissue.ts   编排:探针判剩余天数 → 注册/复用账户 → 签发 → 落库
                     → 逐目标部署;RENEW_BEFORE_DAYS=30
- routes/ssl.ts      新增 POST /ssl/issue、GET /ssl/renew-check、
                     POST /ssl/selfcheck(环境自检,只读不签发)
- index.ts + cron    每日 04:10 自动续期检查;cpu_ms 提到 60s

与 certimate 的差异:certimate 每个 workflow 每天无条件重跑,
这里改为先探针查剩余天数、低于阈值才签,省 CA 限速额度。

实测修正(易误判,勿回退):
- 多吉云 bind 参数是 {id, domain},非 {cert_id}(用假 id 对照实验确认:
  cert_id 回「域名不存在」= 参数被无视)
- 多吉云上传私钥字段是 private;列域名用 /cdn/domain/list.json
- 1Panel 必须用 /api/v2/(v1 返回 HTTP 200 但正文是 HTML 停用页)
- 1Panel HTTPS 配置字段是 SSL(大写),写错会导致每次续期都重绑
- LiteSSL ACME 目录须带 /v2:acme.trustasia.com/acme/v2/directory

测试:selftest-acme 16/16(CSR 过 openssl 验签、JWS 过 Node crypto 验签)、
selftest-deploy 18/18、selftest:ssl 117/117、UI 全过、tsc 干净
2026-10-06 16:59:37 +08:00

202 lines
8.7 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.
/**
* 证书管家初始配置 —— 把 certimate 里的真实凭据与域名配置迁移进 KV。
*
* 为什么单独写个脚本、不走后台页面:
* 凭据有 7 条、域名 3 组,手点一遍容易漏;脚本还能**当场验一遍**(解密回读)。
*
* ★ 加密必须与本项目 src/lib/certvault.ts 完全一致,否则 Worker 解不开:
* · PBKDF2(SHA-256, 100000 次, 固定盐 'artalk-cf:certvault:v1') 从 TOKEN_SECRET 派生
* · AES-GCM 256,每条自带 12 字节随机 IV
* · 格式 `v1.<iv_b64>.<ct_b64>`(标准 base64,不是 base64url)
*
* ★ 本脚本**不含任何真实凭据**(要进 git)。两个输入都从仓库外读:
* · 凭据:E:/GitHub/secrets-backup/certkeeper-seeds.json(7 条 access,含明文底账)
* · TOKEN_SECRET:blog-admin/.dev.vars(与线上 secret 同值)
*
* 用法:
* node tools/seed-ssl-config.mjs # 预演,只打印不写入
* node tools/seed-ssl-config.mjs --apply # 真正写入 KV
*/
import { readFileSync } from 'node:fs';
import { webcrypto as crypto } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const APPLY = process.argv.includes('--apply');
const NS = 'd7f86a0fb43f4450a10b786fc2635128';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
const SEEDS_FILE = 'E:/GitHub/secrets-backup/certkeeper-seeds.json';
const SALT = 'artalk-cf:certvault:v1';
const VERSION = 'v1';
const b64 = (bytes) => Buffer.from(bytes).toString('base64');
const unb64 = (s) => new Uint8Array(Buffer.from(s, 'base64'));
let _key = null;
async function vaultKey(secret) {
if (_key) return _key;
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), 'PBKDF2', false, ['deriveKey']);
_key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: new TextEncoder().encode(SALT), iterations: 100000 },
base,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
return _key;
}
async function sealJson(secret, value) {
const key = await vaultKey(secret);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(value)));
return `${VERSION}.${b64(iv)}.${b64(new Uint8Array(ct))}`;
}
async function openJson(secret, sealed) {
const key = await vaultKey(secret);
const p = String(sealed).split('.');
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: unb64(p[1]) }, key, unb64(p[2]));
return JSON.parse(new TextDecoder().decode(pt));
}
// ---------------------------------------------------------------- 配置数据
// 来源:certimate 数据库(/1panel/1panel/apps/certimate/certimate/data/data.db)
// access 表 11 条凭据 + workflow 表 3 个证书申请工作流
// 命名沿用 certimate 的语义,方便两边对照排查。
// ★ 真实凭据在 SEEDS_FILE(仓库外),域名结构属于非敏感信息,直接写在下面。
const SEEDS = JSON.parse(readFileSync(SEEDS_FILE, 'utf8'));
const ACCESS = SEEDS.access; // [{ name, type, note, fields: {...} }]
// ★ 注意:certimate 里那两条 1panel 凭据指向 `9.t-t.live:3721`(团团机器)和
// 119.29.215.187:3721(国内机),本配置只收国内机那条 —— 它才是证书真正落地的地方。
// Cloudflare 的 `imql`(apiTokenForZone)没进来:现有 `cloudflare` 那条已够用。
// (seeds 文件里同样只收了国内机那条,与当初迁移时的取舍一致。)
const CONFIG = {
version: 1,
notify: { emails: ['177018615@qq.com'], daysBefore: 30 },
domains: [
{
name: 'usj.cc',
san: ['usj.cc', '*.usj.cc'],
dns: 'tencent-usj',
deploy: ['dogecloud', '1panel'],
// ★ 站点名必须与 1Panel 里的 `primaryDomain` 或 `alias` 精确对上,
// 否则部署时会被跳过。下面这些是 2026-10-06 从面板实测出来的
// (面板上**没有** primaryDomain 为 `usj.cc` 的网站 —— 它只是证书名)。
dogecloud_domains: ['usj.cc', 'www.usj.cc', 'artalk.usj.cc'],
one_panel_sites: [
'artalk.usj.cc', // blog 评论后端
'openlist.usj.cc', // 网盘
'wifi.usj.cc',
'openwrt.usj.cc',
'vw.usj.cc', // vaultwarden(alias 才是这个名字)
],
disabled: false,
},
{
name: 't-t.live',
san: ['t-t.live', '*.t-t.live'],
dns: 'tencent-tt',
deploy: ['1panel'],
one_panel_sites: ['t-t.live', 'www.t-t.live', 'pl.t-t.live', 'pwd.t-t.live', 'certd.t-t.live'],
disabled: false,
},
{
name: '200181.xyz',
san: ['200181.xyz', '*.200181.xyz'],
dns: 'cloudflare',
deploy: ['1panel'],
one_panel_sites: ['ssh.200181.xyz'],
disabled: false,
},
],
};
// ---------------------------------------------------------------- 写入
const devVars = readFileSync(join(ROOT, '.dev.vars'), 'utf8');
const secret = (devVars.match(/^TOKEN_SECRET\s*=\s*"?([^"\r\n]+)"?/m) || [])[1];
const envText = readFileSync(join(ROOT, '.env'), 'utf8');
const accountId = (envText.match(/CLOUDFLARE_ACCOUNT_ID\s*=\s*"?([^"\r\n]+)"?/) || [])[1];
const apiToken = (envText.match(/CLOUDFLARE_API_TOKEN\s*=\s*"?([^"\r\n]+)"?/) || [])[1];
if (!accountId || !apiToken) throw new Error('没读到 Cloudflare 凭据(检查 blog-admin/.env)');
if (!secret) throw new Error('没读到 TOKEN_SECRET(检查 blog-admin/.dev.vars)');
const KV = `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${NS}/values/`;
const put = async (key, value) => {
const r = await fetch(KV + encodeURIComponent(key), {
method: 'PUT',
headers: { Authorization: 'Bearer ' + apiToken, 'Content-Type': 'text/plain' },
body: value,
});
if (!r.ok) throw new Error(`写 ${key} 失败: ${r.status} ${await r.text()}`);
};
const getVal = async (key) => {
const r = await fetch(KV + encodeURIComponent(key), { headers: { Authorization: 'Bearer ' + apiToken } });
return r.ok ? await r.text() : null;
};
console.log('模式:', APPLY ? '★ 写入' : '预演(加 --apply 才真正写)');
console.log('TOKEN_SECRET 长度:', secret.length);
console.log();
// ── 凭据:加密后写 ──
console.log('凭据(' + ACCESS.length + ' 条,AES-GCM 加密):');
const sealedMap = new Map();
for (const a of ACCESS) {
const rec = { type: a.type, note: a.note, ...a.fields };
const sealed = await sealJson(secret, rec);
sealedMap.set(a.name, sealed);
// 当场回读验一次:解不开就说明格式和 Worker 不一致,宁可不写
const back = await openJson(secret, sealed);
const ok = JSON.stringify(back) === JSON.stringify(rec);
console.log(` ${ok ? '✓' : '✗'} ${a.name.padEnd(14)} ${a.type.padEnd(14)} ${sealed.slice(0, 22)}…`);
if (!ok) throw new Error('自校验失败:加解密往返不一致 —— 格式与 certvault.ts 不匹配');
}
// ── 域名配置:明文写(不含敏感信息)──
console.log();
console.log('域名配置(明文):');
for (const d of CONFIG.domains) {
const dnsOk = ACCESS.some((a) => a.name === d.dns);
console.log(` ${dnsOk ? '✓' : '✗'} ${d.name.padEnd(14)} dns=${d.dns.padEnd(12)} deploy=[${d.deploy.join(', ')}] SAN=${d.san.join(';')}`);
// ★ 把「要绑到哪些站点」也打出来 —— 这是最容易配错、且错了之后
// 只会在部署时才暴露的一环(站点名对不上 = 静默跳过,站上还是旧证书)。
if (d.dogecloud_domains?.length) console.log(` 多吉云域名: ${d.dogecloud_domains.join(', ')}`);
if (d.one_panel_sites?.length) console.log(` 1Panel 站点: ${d.one_panel_sites.join(', ')}`);
if (!dnsOk) throw new Error(`域名「${d.name}」引用的 DNS 凭据「${d.dns}」不在凭据清单里`);
}
if (!APPLY) {
console.log();
console.log('(预演结束,未写入任何数据)');
process.exit(0);
}
console.log();
console.log('写入 KV …');
for (const [name, sealed] of sealedMap) {
await put('certkeeper:access:' + name, sealed);
console.log(' ✓ certkeeper:access:' + name);
}
await put('certkeeper:config', JSON.stringify(CONFIG));
console.log(' ✓ certkeeper:config');
// ── 回读验证:确认 Worker 能解开 ──
console.log();
console.log('回读验证:');
const cfgBack = JSON.parse(await getVal('certkeeper:config'));
console.log(' config 域名数:', cfgBack.domains.length, '| 收件人:', cfgBack.notify.emails.join(','), '| 阈值:', cfgBack.notify.daysBefore, '天');
for (const name of sealedMap.keys()) {
const raw = await getVal('certkeeper:access:' + name);
const back = await openJson(secret, raw);
console.log(` ✓ ${name.padEnd(14)} 解出 type=${back.type}`);
}
console.log();
console.log('完成。');