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:
1 parent
1427c47dcf
commit
740d77e4cb
20 files changed
+1662
-177
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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回退完成。');
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 才真的删)');
|
||||
}
|
||||
Reference in new issue
Block a user