Files
blog/blog-admin/tools/seed-ssl-config.mjs
T
zqlit 1427c47dcf feat(ssl): 证书签发链路搬到国内机 Docker,清理 1Panel 过期证书
架构定案(B+C):CF Worker 免费版 CPU 硬顶 10ms(cron 同),
不再购买 Paid($5/月≈¥36),改为——
  B. Worker 留免费版 + 代码优化(把 CPU 压进预算)
  C. 签发+部署整条链路搬国内机 Docker 容器

代码
- acme.ts: 缓存 signingKey 为 Promise,单次签发 importKey 12→1 次
  (实测 importKey 126µs / sign 84µs;一次签发 3.3ms → 1.4ms)
- deployer.ts: 新增 DogeCloudDeployer.ping();修正 cert_id → id 的注释
- dnsprovider.ts: 新增 RemoteDns(把 DNS-01 写 TXT 委托给国内机 cn-dns-helper)
- tools/certkeeper-config.mjs: 域名配置抽为唯一事实源(两个消费方共用)
- tools/export-certkeeper-data.mjs: 导出国内机数据目录

新增部署单元
- deploy/cn-certkeeper: 签发+部署容器(只绑 127.0.0.1:8019,compose 管理)
  含 FileKV(文件系统版 KVNamespace)、带鉴权 HTTP、每日 4:10 续期
- deploy/cn-dns-helper: DNS-01 写 TXT 助手(只绑 127.0.0.1:8018)

文档
- 函数版证书管家-方案.md 新增第九章:B+C 定案、实测 CPU 数据、
  容器验收记录、1Panel 过期证书清理记录、t-t.live 两套管理冲突
- 标注旧 8.3 节「免费版跑不了签发」为未实测误判

一并纳入:.gitignore 忽略 deploy/cn-certkeeper/lib/(tsc 编译产物)
2026-10-06 18:28:55 +08:00

166 lines
7.8 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 文件里同样只收了国内机那条,与当初迁移时的取舍一致。)
// ★ CONFIG 已抽到 ./certkeeper-config.mjs(唯一事实源,国内机导出脚本也用同一份)
// 见那个文件的头注释:原先是内联在这里,但 export-certkeeper-data.mjs
// 需要复用却又不能 import 本脚本(本脚本一 import 就会连 KV 开始写)。
import { CONFIG } from './certkeeper-config.mjs';
// ---------------------------------------------------------------- 写入
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('完成。');