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

+39 -7
View File
@@ -109,6 +109,23 @@ export class AcmeClient {
private nonce: string | null = null;
private readonly log: AcmeLogger;
/**
* ★ 账户私钥的 CryptoKey 缓存(2026-10-06 加)。
*
* 原来 signJws() 每次调用都 `importKey('jwk', ...)`。一次签发有 ~11 次 JWS,
* 本地实测(.editor-tmp/cpu-bench4.mjs,3000 次迭代):
* importKey('jwk') 单次 126 µs;若密钥已就绪,纯 sign 只要 84 µs。
* 也就是说每次签发白烧 ≈ 1.4 ms。Workers 免费版 CPU 硬顶 10 ms,
* 这 1.4 ms 值得省;就算跑在国内机,少一次密钥解析也没坏处。
*
* 缓存安全性:账户密钥(this.account.jwk)在实例生命周期内**不变**,
* 而一次签发自始至终用同一个实例(见 certissue.ts 的 issueDomain)。
*
* 存 Promise 而不是 CryptoKey:并发调用时只真正 import 一次
* (存 CryptoKey 的话,两个并发请求会各 import 一次,结果一样但白花 CPU)。
*/
private signingKey: Promise<CryptoKey> | null = null;
constructor(
private readonly directoryUrl: string,
private readonly account: { jwk: JsonWebKey; kid: string },
@@ -168,14 +185,29 @@ export class AcmeClient {
return base;
}
/**
* 取(并缓存)账户签名密钥 —— 见 signingKey 字段注释。
* 只在第一次调用时真的 importKey,之后复用同一个 CryptoKey。
*/
private importSigningKey(): Promise<CryptoKey> {
if (!this.signingKey) {
// 失败时清掉缓存,否则一次网络/参数抖动会被永久缓存成 reject
this.signingKey = (crypto.subtle.importKey(
'jwk',
this.account.jwk,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign'],
) as Promise<CryptoKey>).catch((e) => {
this.signingKey = null;
throw e;
});
}
return this.signingKey;
}
private async signJws(protectedHeader: Record<string, unknown>, payload: unknown): Promise<string> {
const key = await crypto.subtle.importKey(
'jwk',
this.account.jwk,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign'],
);
const key = await this.importSigningKey();
const signingInput = `${b64uJson(protectedHeader)}.${b64uJson(payload)}`;
const sig = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
+26 -1
View File
@@ -134,6 +134,30 @@ export class DogeCloudDeployer implements Deployer {
return d.data as T;
}
/**
* 只读探活:列一次 CDN 域名,验证 AK/SK 与连通性。
*
* ★ 与 OnePanelDeployer.ping 保持**同一签名**(返回对象、不抛错),
* 这样调用方(cn-certkeeper 的 /preflight、Worker 的 /ssl/selfcheck)
* 能统一处理,不必为每种部署器各写一套判错逻辑。
*/
async ping(): Promise<{ ok: boolean; error?: string; hint?: string; detail?: string }> {
try {
const d = await this.call<{ domains?: unknown[] }>('/cdn/domain/list.json', {});
const n = Array.isArray(d?.domains) ? d.domains.length : 0;
return { ok: true, detail: `${n} 个 CDN 域名` };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
ok: false,
error: msg,
hint: /签名|signature|auth|TOKEN/i.test(msg)
? 'AK/SK 不对或签名算法有变(多吉云 → 个人中心 → API 密钥)'
: undefined,
};
}
}
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
const log = opts.log || (() => {});
const details: string[] = [];
@@ -142,7 +166,8 @@ export class DogeCloudDeployer implements Deployer {
const certId = await this.uploadOrReuse(cert, opts.dogecloudDomains || [], log);
details.push(`证书 #${certId}`);
// ② 逐个域名绑定(★ 字段名是 cert_id,下划线)
// ② 逐个域名绑定(★ 字段名是 `id` —— 实测传 `cert_id` 会被服务端**无视**,
// 见本文件顶部「用假 id 999999 做对照实验」那段)
const domains = (opts.dogecloudDomains || []).map((s) => s.trim()).filter(Boolean);
if (!domains.length) {
log('多吉云:没有配置要绑定的域名,只上传不绑定');
+106 -2
View File
@@ -278,11 +278,107 @@ export class CloudflareDns implements DnsProvider {
}
}
// ==================================================================== 远程转发(国内机)
/**
* 把 TXT 增删**委托给国内机上的 cn-dns-helper**(Docker 容器)。
*
* ★ 为什么需要这么一层:
* Worker 侧的 `cloudflare` 凭据是 Workers/KV/D1 专用的,没有 Zone/DNS 权限,
* 直接做 DNS-01 会被 CF 回 403。国内机的 certimate 里有一条**有完整 DNS 权限**
* 的 token —— 与其让用户去 CF 后台重新签发 token、再往 Worker 塞一份写权限凭据,
* 不如把「写 TXT」的执行权留在国内机,Worker 只持有一个**只能改 DNS 的窄权限**
* 共享密钥。权限面更小,且复用现有基础设施。
*
* 契约见 deploy/cn-dns-helper/src/server.mjs。
*/
export class RemoteDns implements DnsProvider {
readonly kind = 'remote';
constructor(
private readonly baseUrl: string,
private readonly helperToken: string,
) {
if (!baseUrl) throw new Error('远程 DNS 凭据缺少 serverUrl');
if (!helperToken) throw new Error('远程 DNS 凭据缺少 helperToken');
}
private async call(path: string, init: RequestInit): Promise<any> {
const url = this.baseUrl.replace(/\/+$/, '') + path;
let resp: Response;
try {
resp = await fetch(url, {
...init,
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': this.helperToken,
...(init.headers || {}),
},
});
} catch (e) {
// 网络层错误单独包装:国内机不可达时,报错要能一眼看出是「通道」问题
throw new Error(`连不上 DNS 助手(${url}):${e instanceof Error ? e.message : String(e)}`);
}
const text = await resp.text();
let data: any;
try {
data = JSON.parse(text);
} catch {
throw new Error(`DNS 助手返回非 JSON(HTTP ${resp.status}):${text.slice(0, 200)}`);
}
if (resp.status === 401) throw new Error('DNS 助手鉴权失败(helperToken 不匹配)');
if (!resp.ok) throw new Error(`DNS 助手出错(HTTP ${resp.status}):${data?.error || text.slice(0, 200)}`);
return data;
}
/** `_acme-challenge.200181.xyz` → zone=`200181.xyz`、记录名保持全名(CF 接受 FQDN) */
private split(fqdn: string): { zone: string; name: string } {
const name = fqdn.replace(/\.$/, '').toLowerCase();
return { zone: this.rootOf(name), name };
}
private rootOf(name: string): string {
const parts = name.split('.');
if (parts.length <= 2) return name;
return parts.slice(-2).join('.');
}
async addTxt(fqdn: string, value: string): Promise<void> {
const { zone, name } = this.split(fqdn);
await this.call('/dns/txt', { method: 'POST', body: JSON.stringify({ zone, name, value }) });
}
async delTxt(fqdn: string, value: string): Promise<void> {
const { zone, name } = this.split(fqdn);
await this.call('/dns/del', { method: 'POST', body: JSON.stringify({ zone, name, value }) });
}
async listTxt(fqdn: string): Promise<string[]> {
const { zone, name } = this.split(fqdn);
const out = await this.call(`/dns/list?zone=${encodeURIComponent(zone)}&name=${encodeURIComponent(name)}`, {
method: 'GET',
});
return (out?.records || []).map((r: any) => String(r.value));
}
/** 只读探活:selfcheck 用,验证国内机通道 + token 是否都好使 */
async ping(): Promise<string> {
const zone = this.pingZone;
const out = await this.call(`/dns/list?zone=${encodeURIComponent(zone)}&name=_acme-selfcheck.${zone}`, {
method: 'GET',
});
return `可达(zone ${zone} 下现有 ${(out?.records || []).length} 条 TXT)`;
}
/** ping 时用哪个 zone 探(构造时按凭据的 zoneName 指定,默认 200181.xyz) */
pingZone = '200181.xyz';
}
// ==================================================================== 工厂
/**
* 按凭据记录造一个 DNS 客户端。
* ★ 只认识 tencentcloud / cloudflare 两类;其余类型抛错而不是静默返回 null ——
* ★ 只认识 tencentcloud / cloudflare / remote 三类;其余类型抛错而不是静默返回 null ——
* 静默返回会让「配置错了」表现为「验证一直 pending 到超时」,排查起来很痛苦。
*/
export function makeDnsProvider(rec: AccessRecord): DnsProvider {
@@ -303,7 +399,15 @@ export function makeDnsProvider(rec: AccessRecord): DnsProvider {
rec.zoneName ? String(rec.zoneName) : undefined,
);
}
throw new Error(`DNS-01 不支持凭据类型「${t}」(目前只支持 tencentcloud / cloudflare)`);
if (t === 'remote') {
// 走国内机 cn-dns-helper 转发(用于 CF token 无 DNS 权限的场景)
const base = String(rec.serverUrl || '');
const ht = String(rec.helperToken || '');
const p = new RemoteDns(base, ht);
if (rec.zoneName) p.pingZone = String(rec.zoneName);
return p;
}
throw new Error(`DNS-01 不支持凭据类型「${t}」(支持 tencentcloud / cloudflare / remote)`);
}
// ==================================================================== crypto 小工具