一、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),
保留三条纯监控告警。
91 lines
4.2 KiB
JavaScript
91 lines
4.2 KiB
JavaScript
/**
|
|
* 应急回退:把多吉云 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回退完成。');
|