/** * 把证书管家的配置 + 凭据导出成**国内机 cn-certkeeper 可直接用的数据目录**。 * * 为什么需要它: * 签发链路搬到了国内机(Workers 免费版 CPU 只有 10ms,签发要 ~4ms 起步, * 擦着上限跑不划算)。国内机要独立跑完整的「探针 → 签发 → 部署」, * 就得有一份自己的配置。这里从**同一个事实源**生成,避免两边配置漂移: * · 凭据:`E:/GitHub/secrets-backup/certkeeper-seeds.json`(明文底账) * · 域名:`tools/certkeeper-config.mjs`(与 seed-ssl-config.mjs 共用) * * ★ 凭据用与 Worker 侧**完全相同**的 sealJson + 同一个 TOKEN_SECRET 加密, * 所以产物格式与线上 KV 一致 —— 将来要拿 Worker KV 的备份直接替换数据目录 * 也能读。反过来也一样。 * * 跑法: * node tools/export-certkeeper-data.mjs --out <目录> * TOKEN_SECRET 取值顺序:--token-secret 参数 > 环境变量 > .dev.vars */ import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { FileKV } from '../../deploy/cn-certkeeper/src/kv-file.mjs'; import { CONFIG } from './certkeeper-config.mjs'; const require = createRequire(import.meta.url); const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, '..'); // blog-admin/ const REPO = path.resolve(ROOT, '..'); // 仓库根 const { sealJson } = require(path.join(ROOT, '.selftest-ssl/lib/certvault.js')); // ---------------------------------------------------------------- 参数 const argv = process.argv.slice(2); const argOf = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; }; const OUT = argOf('--out'); if (!OUT) { console.error('用法:node tools/export-certkeeper-data.mjs --out <目录> [--token-secret <值>]'); process.exit(1); } // TOKEN_SECRET:参数 > 环境变量 > .dev.vars let secret = argOf('--token-secret') || process.env.TOKEN_SECRET || ''; if (!secret) { const devVars = path.join(ROOT, '.dev.vars'); if (fs.existsSync(devVars)) { const m = fs.readFileSync(devVars, 'utf8').match(/^TOKEN_SECRET\s*=\s*"?([^"\r\n]+)"?/m); if (m) secret = m[1].trim(); } } if (!secret) throw new Error('没拿到 TOKEN_SECRET(用 --token-secret / 环境变量 / blog-admin/.dev.vars 任一提供)'); // 编译产物必须先就位 if (!fs.existsSync(path.join(ROOT, '.selftest-ssl/lib/certvault.js'))) { throw new Error('缺少编译产物:先跑 `npm run selftest:ssl:build`(在 blog-admin 下)'); } // ---------------------------------------------------------------- seeds const seedsPath = path.join(REPO, '../secrets-backup/certkeeper-seeds.json'); const resolvedSeeds = fs.existsSync(seedsPath) ? seedsPath : path.resolve('E:/GitHub/secrets-backup/certkeeper-seeds.json'); if (!fs.existsSync(resolvedSeeds)) throw new Error(`找不到 seeds 文件:${resolvedSeeds}`); const seeds = JSON.parse(fs.readFileSync(resolvedSeeds, 'utf8')); if (!Array.isArray(seeds.access)) throw new Error('seeds 里没有 access 数组'); // ---------------------------------------------------------------- 生成 console.log('导出到:', path.resolve(OUT)); console.log('TOKEN_SECRET 长度:', secret.length, '(应与 Worker 侧一致)'); console.log('seeds:', resolvedSeeds); console.log(); const kv = new FileKV(OUT); const env = { TOKEN_SECRET: secret }; // ① 域名配置(明文,与 certstore.saveConfig 的写法一致) await kv.put('certkeeper:config', JSON.stringify(CONFIG)); console.log(`✓ certkeeper:config(${CONFIG.domains.length} 组域名)`); for (const d of CONFIG.domains) { console.log(` ${d.name.padEnd(12)} dns=${d.dns.padEnd(14)} deploy=[${d.deploy.join(', ')}]`); } console.log(); console.log('凭据(AES-GCM 加密,与 Worker 侧同一把密钥):'); for (const a of seeds.access) { const rec = { type: a.type, note: a.note, ...(a.fields || {}) }; await kv.put('certkeeper:access:' + a.name, await sealJson(env, rec)); console.log(` ✓ ${a.name.padEnd(16)} ${String(a.type).padEnd(14)} ${Object.keys(a.fields || {}).join(', ')}`); } // ② 回读校验:确保写得进去也读得出来(并且能解回明文) console.log(); console.log('回读校验:'); const back = await kv.get('certkeeper:config'); const parsed = JSON.parse(back); console.log(` ✓ config 回读:${parsed.domains.length} 组域名`); const { openJson } = require(path.join(ROOT, '.selftest-ssl/lib/certvault.js')); let okCount = 0; for (const a of seeds.access) { const raw = await kv.get('certkeeper:access:' + a.name); const opened = await openJson(env, raw); if (opened.type !== a.type) throw new Error(`凭据 ${a.name} 回读后 type 不一致`); okCount++; } console.log(` ✓ ${okCount} 条凭据全部解回明文(type 一致)`); // ③ list 语义校验(certstore.listAccess 会用到) const listed = await kv.list({ prefix: 'certkeeper:access:' }); console.log(` ✓ list 前缀查询命中 ${listed.keys.length} 条`); console.log(); console.log('完成。数据目录结构:'); const walk = (d, indent = ' ') => { for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { const full = path.join(d, e.name); if (e.isDirectory()) { console.log(indent + e.name + '/'); walk(full, indent + ' '); } else { console.log(indent + e.name); } } }; walk(OUT);