/** * 证书管家初始配置 —— 把 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..`(标准 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('完成。');