fix(ssl): 修掉 5 处静默失败,本项目全面接管签发部署,Worker 退回只读

一、1Panel 部署器三个缺陷(其中两个此前完全不可见)

1. `POST /websites/{id}/https` 的字段名是 `websiteSSLId`,不是 `sslId`。
   发 `sslId` 会被 Go 静默忽略成零值 0,于是
   `websiteSSLRepo.GetFirst(WithByID(0))` → 返回
   `HTTP 200 + code 500「服务错误: record not found」`,
   报错文案落在 DB 层,完全指不到参数名 —— 整个 t-t.live 部署被这条卡住。
   对照实验:`sslId=13 → 500` / `websiteSSLId=13 → 200 code=200`。

2. 换证书内容的接口选错。`POST /websites/ssl/update` 的结构体
   `WebsiteSSLUpdate` **根本没有** `certificate` / `privateKey` 字段,
   传了被丢弃、且 `domains` 只从 `otherDomains` 取(不传就清空),
   还会顺带把 `autoRenew` 置 false —— 而它**照样返回 200 success**。
   实测:原样 update 后 5 个站点的 `ssl/*.pem` mtime+md5 一个都没变。
   正确接口是 `POST /websites/ssl/upload` + `sslID > 0`:取记录 → 覆盖
   → 重算 ExpireDate/domains → `UpdateSSLConfig()` → 重新物化站点文件。
   副作用:`Upload()` 把 `primaryDomain` 重算成证书第一个 SAN
   (#11 因此从 `usj.cc` 漂成 `*.usj.cc`)→ 必须补 `domains` 兜底匹配,
   否则每次续期都新建一条重复记录。

3. `deploy()` 幂等捷径漏了「记录被换过」这一维:`sslId` 没变但内容变了时
   会跳过绑定,站点文件就停留在旧证书。改为引入 `replaced` 标志强制重绑。

二、另外两处静默失败

4. `parsePemInfo()` 对**完整链**返回 `{}`:旧实现把 PEM 各段 base64 拼接后
   一次 `atob`,中间段尾部的 `=` 填充导致抛错,整函数返回空 →
   `rec.expireAt` 退化成「签发时刻 + 90 天」。而完整链恰恰是部署器最常
   拿到的形态。改为只解析第一段(叶证书)。
   顺带新增 `derLen()` / `parseSanFromDer()`,精确定位 SAN 扩展
   OID `2.5.29.17` 再读 `[2] dNSName`,替掉原来的字节扫描启发式。
   这条同时是「多吉云复用失效」的根因:判据缺到期时间,只看域名集合
   就永远认为已覆盖 → 续期静默空转。修好后判据带上 `notAfter` 比对(1 天容差)。

5. DNS-01 挑战通知会撞 `400 authorization must be pending`(200181.xyz 连中两次)。
   这是**竞态**不是逻辑错:「先读状态再 POST」挡不住毫秒级窗口。
   已在 POST 侧做幂等容错(只认这一句),最终由 `pollAuthz` 定论。

三、Worker 退回只读

- `crons` 去掉 `10 4 * * *`,`index.ts` 里 renew 分支整体删除
- `POST /ssl/issue` 改 **501 硬拒绝**(而不是静默降级),响应给出国内机命令
- 签发 + 部署整条链路跑在国内机容器 `cn-certkeeper`

四、顺带修掉的两个「配置被悄悄抹掉」

- `configSave()` 不再丢掉表单不管理的 `probe_connect` / `probe_sni`。
  之前管理员在面板改任何一项,这两个字段就会被清空,
  后果是挂在 CDN 后的 t-t.live 探针退回公网、被误判成「还剩 80 多天」,
  源站证书到期也不续。现在保存时从旧配置带过来。

五、新增 4 个常驻运维工具(deploy/cn-certkeeper/src/)

- `renew-one.mjs`      只对单个域名签发+部署(原 `/renew` 无域名过滤,会全量重签)
- `redeploy.mjs`        复用已签好的证书只重跑部署(不碰 ACME,不白烧配额)
- `rollback-dogecloud.mjs` 应急把 CDN 域名绑回指定证书 id
- `txt-inspect.mjs`     `_acme-challenge` 下的 TXT 残留盘点/清理
- `selfcheck-acme.mjs`  CA 层诊断:只读目录 + 复用账户,不签发不部署

六、测试与文档

- 自测新增 [11] 节 8 项 PEM 解析回归(样本是 openssl 现场生成、内联写死的
  叶+中间证书,两段都以 `=` 结尾,正是 bug 现场),含精确值断言:
  > 125 项通过,0 失败
- `npm run typecheck` 零错误
- 方案文档:§9.11 由「待验证」改为定案(`ssl/update` 不物化、
  `ssl/upload+sslID` 才物化);新增 §9.12「本轮又修掉的 5 个静默失败」、
  §9.13「本轮最终状态」、§9.14「certimate 工作流清查」

线上验收:三个域名(t-t.live / usj.cc / 200181.xyz)线上证书均为
LiteSSL ECC、2027-01-04 到期、daysLeft=90、needRenew=false;
1Panel 证书库 5 条精简为 3 条且全部在用;
certimate 停掉全部「会签发并部署」的工作流(团团 / 优世界 / 200181.xyz),
保留三条纯监控告警。
This commit is contained in:
zqlit committed 2026-10-06 20:08:28 +08:00
1 parent 1427c47dcf
commit 740d77e4cb
20 files changed
+1662 -177

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
/**
* 只重跑「部署」这一步 —— 复用 KV 里**已经签好的**证书,不碰 ACME。
*
* 为什么需要它(2026-10-06 加):
* 签发的成败与部署的成败是**两件独立的事**。一旦部署侧出问题(1Panel 接口
* 改结构、站点名对不上、面板限流…),重新走 `/renew` 会**再签一张新证书**
* —— 白白消耗 CA 配额,而证书本身根本没出问题。
* 这个脚本直接把 KV 里的证书 + 配置里的部署目标重跑一遍。
*
* 用法:
* docker exec cn-certkeeper node src/redeploy.mjs t-t.live
* docker exec cn-certkeeper node src/redeploy.mjs t-t.live --dry # 只看会绑哪些站点
*/
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { FileKV } from './kv-file.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const lib = (n) => require(path.resolve(HERE, '../lib', `${n}.js`));
const argv = process.argv.slice(2);
const NAME = argv.find((a) => !a.startsWith('--'));
const DRY = argv.includes('--dry');
if (!NAME) {
console.error('用法:node src/redeploy.mjs <域名> [--dry]');
process.exit(2);
}
const kv = new FileKV(process.env.DATA_DIR || '/data');
const env = { RSS_KV: kv, TOKEN_SECRET: String(process.env.TOKEN_SECRET || '').trim(), EDITOR_API_BASE: '', EDITOR_TOKEN: '' };
const { loadConfig, getCert, getAccess } = lib('certstore');
const { makeDeployer } = lib('deployer');
const cfg = await loadConfig(env);
const d = cfg.domains.find((x) => x.name === NAME);
if (!d) {
console.error(`配置里没有域名「${NAME}」(现有:${cfg.domains.map((x) => x.name).join(', ')})`);
process.exit(2);
}
const rec = await getCert(env, NAME);
if (!rec?.cert || !rec?.key) {
console.error(`KV 里没有「${NAME}」的证书内容(先跑一次签发)`);
process.exit(2);
}
console.log(`域名 ${NAME}:证书到期 ${new Date(rec.expireAt).toISOString()}(${Math.round((rec.expireAt - Date.now()) / 86400000)} 天)`);
console.log(`部署目标 ${(d.deploy || []).join(', ')};1Panel 站点 ${(d.one_panel_sites || []).join(', ')}`);
if (DRY) process.exit(0);
for (const target of d.deploy || []) {
const credName = target === '1panel' ? '1panel-cn' : target;
const cred = await getAccess(env, credName);
if (!cred) {
console.log(` ${target}: ★ 找不到凭据「${credName}」`);
continue;
}
try {
const dp = makeDeployer(cred);
const res = await dp.deploy(
{ domain: NAME, cert: rec.cert, key: rec.key, notAfter: rec.expireAt },
{ dogecloudDomains: d.dogecloud_domains, onePanelSites: d.one_panel_sites, log: (m) => console.log(` [${target}] ${m}`) },
);
console.log(` ${target}: 完成 —— ${res.details.join(';')}`);
} catch (e) {
console.log(` ${target}: ★ 失败 —— ${e instanceof Error ? e.message : e}`);
process.exitCode = 1;
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* 只对**单个域名**做「签发 + 部署」,别的域名一律不碰。
*
* 为什么需要它(2026-10-06 加):
* 容器只有一个 `POST /renew`,而它调的是 `renewAll` —— **没有域名过滤**。
* 想单独把一个域名提前换掉时,`{force:true}` 会把配置里**所有**域名
* 一起重签一遍:多烧 2 个 CA 订单,还会把本来好好的 t-t.live / 200181.xyz
* 一起换掉、增加无关的线上变更面。这个脚本把「只动一个域名」变成可选操作。
*
* 与 `redeploy.mjs` 的分工:
* · `redeploy.mjs` —— **复用已签好的证书**,只重跑部署(不碰 ACME)
* · `renew-one.mjs` —— 真的去 CA **签一张新的**,然后部署
*
* 用法:
* docker exec cn-certkeeper node src/renew-one.mjs usj.cc
* docker exec cn-certkeeper node src/renew-one.mjs usj.cc --no-deploy # 只签不部署
* docker exec cn-certkeeper node src/renew-one.mjs usj.cc --dry # 只看会做什么
*
* ★ 默认 `force: true`(线上还剩 60 天也要换)—— 这正是本脚本的用途。
* 想走「按阈值判断」请直接调 /renew。
*/
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { FileKV } from './kv-file.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const lib = (n) => require(path.resolve(HERE, '../lib', `${n}.js`));
const argv = process.argv.slice(2);
const NAME = argv.find((a) => !a.startsWith('--'));
const DRY = argv.includes('--dry');
const NO_DEPLOY = argv.includes('--no-deploy');
if (!NAME) {
console.error('用法:node src/renew-one.mjs <域名> [--no-deploy] [--dry]');
process.exit(2);
}
const kv = new FileKV(process.env.DATA_DIR || '/data');
const env = {
RSS_KV: kv,
TOKEN_SECRET: String(process.env.TOKEN_SECRET || '').trim(),
// 留空 → 探针直接走本机 node:tls,不再绕 editor-api
EDITOR_API_BASE: '',
EDITOR_TOKEN: '',
};
const { loadConfig } = lib('certstore');
const { issueDomain, RENEW_BEFORE_DAYS } = lib('certissue');
const cfg = await loadConfig(env);
const d = cfg.domains.find((x) => x.name === NAME);
if (!d) {
console.error(`配置里没有域名「${NAME}」(现有:${cfg.domains.map((x) => x.name).join(', ')})`);
process.exit(2);
}
console.log(`域名 ${NAME}`);
console.log(` SAN : ${(d.san || []).join(', ')}`);
console.log(` DNS 凭据 : ${d.dns}`);
console.log(` 部署目标 : ${(d.deploy || []).join(', ')}`);
console.log(` 1Panel 站点: ${(d.one_panel_sites || []).join(', ')}`);
console.log(` 多吉云域名 : ${(d.dogecloud_domains || []).join(', ')}`);
console.log(` 续期阈值 : ${RENEW_BEFORE_DAYS} 天(本脚本 force,忽略阈值)`);
console.log(` 部署 : ${NO_DEPLOY ? '否(--no-deploy)' : '是'}`);
if (DRY) {
console.log('\n[dry] 不实际签发。');
process.exit(0);
}
const out = await issueDomain(env, d, { force: true, noDeploy: NO_DEPLOY, by: 'renew-one' });
console.log('\n=== 结果 ===');
console.log(JSON.stringify(out, null, 2));
if (!out.ok) process.exitCode = 1;
@@ -0,0 +1,90 @@
/**
* 应急回退:把多吉云 CDN 的加速域名绑回**指定的证书 id**。
*
* 什么时候用:换了新证书之后 CDN 侧表现异常(比如最终端不吃 ECDSA),
* 需要立刻把域名绑回上一代证书恢复服务。
*
* 用法:
* docker exec cn-certkeeper node src/rollback-dogecloud.mjs usj.cc 40948
* docker exec cn-certkeeper node src/rollback-dogecloud.mjs usj.cc # 只列候选
*
* 绑定的域名取自**配置里该域名的 `dogecloud_domains`**,所以不会误伤别的域名。
*/
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { FileKV } from './kv-file.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const lib = (n) => require(path.resolve(HERE, '../lib', `${n}.js`));
const argv = process.argv.slice(2);
const NAME = argv.find((a) => !a.startsWith('--'));
const WANT_ID = argv.filter((a) => !a.startsWith('--'))[1];
if (!NAME) {
console.error('用法:node src/rollback-dogecloud.mjs <域名> [证书id]');
process.exit(2);
}
const kv = new FileKV(process.env.DATA_DIR || '/data');
const env = { RSS_KV: kv, TOKEN_SECRET: String(process.env.TOKEN_SECRET || '').trim(), EDITOR_API_BASE: '', EDITOR_TOKEN: '' };
const { loadConfig, getAccess } = lib('certstore');
const { makeDeployer } = lib('deployer');
const cfg = await loadConfig(env);
const d = cfg.domains.find((x) => x.name === NAME);
if (!d) { console.error(`配置里没有域名「${NAME}」`); process.exit(2); }
const domains = (d.dogecloud_domains || []).map((s) => s.trim()).filter(Boolean);
if (!domains.length) { console.error('该域名没配 dogecloud_domains'); process.exit(2); }
const cred = await getAccess(env, 'dogecloud');
const dp = makeDeployer(cred);
// 复用部署器的私有 api:通过 re-deploy 的 ping 拿不到列表,这里直接照签名规则自己调。
// (把 deployer 当黑盒的话拿不到 list,所以就地实现一次只读列举)
const nc = await import('node:crypto');
const enc = new TextEncoder();
async function dapi(path_, body) {
const bodyStr = body === null ? '' : JSON.stringify(body);
const key = await nc.subtle.importKey('raw', enc.encode(cred.secretKey), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const sig = Buffer.from(await nc.subtle.sign('HMAC', key, enc.encode(`${path_}\n${bodyStr}`))).toString('hex');
const r = await fetch('https://api.dogecloud.com' + path_, {
method: 'POST',
headers: { Authorization: `TOKEN ${cred.accessKey}:${sig}`, 'Content-Type': 'application/json', Accept: 'application/json' },
body: bodyStr || undefined,
});
return JSON.parse(await r.text());
}
const cl = await dapi('/cdn/cert/list.json', {});
const certs = cl?.data?.certs || [];
const dl = await dapi('/cdn/domain/list.json', {});
const inUse = new Map((dl?.data?.domains || []).map((x) => [x.name, x.cert_id]));
console.log(`域名 ${NAME};将操作的加速域名:${domains.join(', ')}\n`);
console.log('当前绑定:');
for (const n of domains) console.log(` ${n.padEnd(20)} cert_id=${inUse.get(n)}`);
console.log('\n候选证书(域名集含上述域名的):');
const want = new Set(domains.map((s) => s.toLowerCase()));
for (const c of certs) {
const have = new Set((c.domains || []).map((x) => String(x.name).toLowerCase()));
let covers = true;
for (const w of want) if (!have.has(w)) { covers = false; break; }
if (!covers) continue;
console.log(` #${String(c.id).padEnd(8)} note=${JSON.stringify(c.note)} issue=${c.issueDate} expire=${c.expireText} SAN=${JSON.stringify(c.info?.SAN || [])} algo=${c.info?.encryptAlgorithm || '?'}`);
}
if (!WANT_ID) {
console.log('\n(未指定证书 id,只列举,不做改动)');
console.log('回退用法:node src/rollback-dogecloud.mjs ' + NAME + ' <上面的某个 id>');
process.exit(0);
}
const id = Number(WANT_ID);
if (!certs.some((c) => Number(c.id) === id)) { console.error(`\n★ 证书 #${id} 不在列表里,拒绝执行`); process.exit(1); }
for (const n of domains) {
const r = await dapi('/cdn/cert/bind.json', { id, domain: n });
console.log(` bind ${n} → #${id} code=${r.code} msg=${r.msg || ''}`);
}
console.log('\n回退完成。');
+102
View File
@@ -0,0 +1,102 @@
/**
* ACME 账户自检 —— 只跑「读目录 → 注册/找回账户」,**不签发、不写 DNS、不部署**。
*
* 为什么要有它(2026-10-06 加):
* 续期链路有三大层,失败时的报错很容易串味 —— CA 层 / DNS 层 / 部署层。
* 最坑的一次是:LiteSSL 回
* `403 {"detail":"newAccount JWS signature is invalid"}`
* 报错写得像「你的签名算法错了」,实际根因是**我们把带私钥 `d` 的 JWK 原样
* 塞进了 protected.jwk**(RFC 8555 §6.2 要求只放公钥)。
* 有了这个脚本,这类问题 3 秒定位,不必拿一次真签发去赌。
*
* 用法(容器内):
* docker exec cn-certkeeper node src/selfcheck-acme.mjs
* docker exec cn-certkeeper node src/selfcheck-acme.mjs --reuse # 复用已存账户,不新建
*
* ★ 默认会**新建一把账户密钥**去注册(也就是在 CA 上多一个账户)。
* 如果只想验证「已存账户还能不能签」,用 --reuse。
* 加 --full 才会真去签一张(会给 `_acme-challenge` 写 TXT,别乱跑)。
*/
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { FileKV } from './kv-file.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const LIB_DIR = path.resolve(HERE, '../lib');
const require = createRequire(import.meta.url);
const lib = (name) => require(path.join(LIB_DIR, `${name}.js`));
const DATA_DIR = process.env.DATA_DIR || '/data';
const argv = process.argv.slice(2);
const REUSE = argv.includes('--reuse');
const CA_NAME = (() => {
const i = argv.indexOf('--ca');
return i >= 0 && argv[i + 1] ? argv[i + 1] : 'litessl';
})();
const kv = new FileKV(DATA_DIR);
const env = { RSS_KV: kv, TOKEN_SECRET: String(process.env.TOKEN_SECRET || '').trim(), EDITOR_API_BASE: '', EDITOR_TOKEN: '' };
const mask = (s, n = 8) => (s ? `${String(s).slice(0, n)}…(${String(s).length})` : '(空)');
(async () => {
const { AcmeClient, newAccountKey } = lib('acme');
const { getAccess } = lib('certstore');
const rec = await getAccess(env, CA_NAME);
if (!rec) {
console.error(`★ 找不到凭据「${CA_NAME}」—— 先确认数据目录里 certkeeper/access/${CA_NAME}.kv 存在`);
process.exit(2);
}
const url = String(rec.directoryUrl || '');
const eabKid = String(rec.eabKid || '');
const eabHmac = String(rec.eabHmacKey || '');
console.log(`CA 凭据「${CA_NAME}」`);
console.log(` directoryUrl = ${url}`);
console.log(` eabKid = ${mask(eabKid)}`);
console.log(` eabHmacKey = ${mask(eabHmac)}(长度 ${eabHmac.length})`);
if (!url) { console.error('★ 缺少 directoryUrl'); process.exit(2); }
// ---- ① 目录与 EAB 要求 ----
const probe = new AcmeClient(url, { jwk: {}, kid: '' });
const needEab = await probe.externalAccountRequired();
console.log(`\n① 目录可达;externalAccountRequired = ${needEab}`);
if (needEab && !(eabKid && eabHmac)) {
console.error('★ 这个 CA 强制 EAB,但凭据里 eabKid / eabHmacKey 不全');
process.exit(2);
}
// ---- ② 账户 ----
let account;
if (REUSE) {
const raw = await env.RSS_KV.get('certkeeper:acme-account');
if (!raw) { console.error('★ 数据目录里没有已存账户(certkeeper/acme-account.kv),去掉 --reuse 再跑'); process.exit(2); }
account = JSON.parse(raw);
console.log(`\n② 复用已存账户:kid = ${account.kid}`);
if (account.directoryUrl !== url) {
console.error(`★ 已存账户是给别的 CA 注册的(${account.directoryUrl}),与新 URL 不符 —— 会被判无效`);
process.exit(2);
}
} else {
const jwk = await newAccountKey();
const c = new AcmeClient(url, { jwk, kid: '' });
// 只打印公开部分,顺带验证「发出去的确实是公钥」
const { kty, crv, x, y } = jwk;
console.log(`\n② 新建账户密钥:kty=${kty} crv=${crv} x.len=${(x || '').length} y.len=${(y || '').length}`);
console.log(' (注意:注册时发出去的 jwk 只应是 kty/crv/x/y 四个成员)');
const kid = await c.registerAccount([], eabKid && eabHmac ? { kid: eabKid, hmacKeyB64: eabHmac } : undefined);
account = { jwk, kid, directoryUrl: url };
console.log(` 注册成功 ✓ kid = ${kid}`);
}
// ---- ③ 用该账户做一次**只读**的 POST-as-GET(不产生任何订单)----
const c2 = new AcmeClient(url, { jwk: account.jwk, kid: account.kid });
const acctRes = await c2['postAsGet'](account.kid);
const acct = await acctRes.json();
console.log(`\n③ 用 kid 签名访问账户资源:HTTP ${acctRes.status},status=${acct.status}`);
console.log('\n结论:CA 账户层(目录 + 注册 + kid 签名)**全部打通**。');
})().catch((e) => {
console.error('\n★ 失败:', e instanceof Error ? e.message : e);
process.exit(1);
});
+5 -2
View File
@@ -102,13 +102,16 @@ async function checkAll() {
out.push({ name: d.name, skipped: true, reason: '域名已停用' });
continue;
}
const host = d.san.find((s) => !s.startsWith('*.')) || d.name;
const host = d.probe_sni || d.san.find((s) => !s.startsWith('*.')) || d.name;
const t0 = Date.now();
const live = await probeTls(host, PROBE_TIMEOUT_MS);
// ★ 配了 probe_connect 就连源站 IP(SNI 仍是 host)—— 域名挂在 CDN/边缘
// 加速后面时,公网握手量的边缘证书,会让续期判定永远「还很新」。
const live = await probeTls(host, PROBE_TIMEOUT_MS, undefined, undefined, d.probe_connect);
const left = daysLeft(live.notAfter);
out.push({
name: d.name,
host,
probeVia: d.probe_connect ? `${d.probe_connect}(SNI ${host})` : `公网 ${host}:443`,
ok: !!live.ok,
probeMs: Date.now() - t0,
notAfter: live.notAfter ? new Date(live.notAfter).toISOString() : null,
+51
View File
@@ -0,0 +1,51 @@
/**
* 只读检查 `_acme-challenge.200181.xyz` 下残留的 TXT(失败签发可能留下脏记录)。
* 用法:
* docker exec cn-certkeeper node src/txt-inspect.mjs 200181.xyz # 只列
* docker exec cn-certkeeper node src/txt-inspect.mjs 200181.xyz --clean # 清空该名字下所有 TXT
*/
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { FileKV } from './kv-file.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const lib = (n) => require(path.resolve(HERE, '../lib', `${n}.js`));
const argv = process.argv.slice(2);
const NAME = argv.find((a) => !a.startsWith('--'));
const CLEAN = argv.includes('--clean');
if (!NAME) {
console.error('用法:node src/txt-inspect.mjs <域名> [--clean]');
process.exit(2);
}
const kv = new FileKV(process.env.DATA_DIR || '/data');
const env = { RSS_KV: kv, TOKEN_SECRET: String(process.env.TOKEN_SECRET || '').trim(), EDITOR_API_BASE: '', EDITOR_TOKEN: '' };
const { loadConfig, getAccess } = lib('certstore');
const { makeDnsProvider } = lib('dnsprovider');
const cfg = await loadConfig(env);
const d = cfg.domains.find((x) => x.name === NAME);
if (!d) { console.error(`配置里没有域名「${NAME}」`); process.exit(2); }
const rec = await getAccess(env, d.dns);
if (!rec) { console.error(`找不到 DNS 凭据「${d.dns}」`); process.exit(2); }
const dns = makeDnsProvider(rec);
console.log(`DNS 凭据 ${d.dns} → ${dns.kind}`);
const fqdn = `_acme-challenge.${NAME}`;
const vals = await dns.listTxt(fqdn);
console.log(`\n${fqdn} 下的 TXT 记录 ${vals.length} 条:`);
for (const v of vals) console.log(` ${v.slice(0, 24)}… (${v.length} 字符)`);
if (CLEAN) {
for (const v of vals) {
await dns.delTxt(fqdn, v);
console.log(` 已删 ${v.slice(0, 16)}…`);
}
console.log(`\n清理后剩余:${(await dns.listTxt(fqdn)).length} 条`);
} else if (vals.length) {
console.log('\n(加了 --clean 才真的删)');
}