证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 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 干净
202 lines
8.7 KiB
JavaScript
202 lines
8.7 KiB
JavaScript
/**
|
||
* 证书管家初始配置 —— 把 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('完成。');
|