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 编译产物)
This commit is contained in:
zqlit committed 2026-10-06 18:28:55 +08:00
1 parent a3bc10c68c
commit 1427c47dcf
15 files changed
+1387 -52

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
/**
* 证书管家的**域名配置唯一事实源**。
*
* 为什么单独抽出来(2026-10-06):
* 这份配置有两个消费方 ——
* ① `seed-ssl-config.mjs`:写进 Cloudflare KV(Worker 侧 UI / 探针 / 自检用)
* ② `export-certkeeper-data.mjs`:生成国内机 cn-certkeeper 的数据目录
* 原先 CONFIG 内联在 seed 脚本里,而那个脚本**一被 import 就执行**
* (读 .env、连 KV 开始写),第 ② 个消费方没法安全复用。
* 抽出来之后两边共用一份定义,不会出现「改了 KV 忘了改国内机」。
*
* ★ 站点名必须与 1Panel 里的 `primaryDomain` 或 `alias` **精确对上**,
* 否则部署时会被跳过。下面这些是 2026-10-06 从面板实测出来的。
*/
export 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,
},
],
};
+128
View File
@@ -0,0 +1,128 @@
/**
* 把证书管家的配置 + 凭据导出成**国内机 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);
+4 -40
View File
@@ -76,46 +76,10 @@ const ACCESS = SEEDS.access; // [{ name, type, note, fields: {...} }]
// 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,
},
],
};
// ★ 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');