Files
blog/blog-admin/tools/selftest-ssl.mjs
T
zqlit 740d77e4cb 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),
保留三条纯监控告警。
2026-10-06 20:08:28 +08:00

528 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 证书管家本地自测 —— 不启动 wrangler,直接把编译后的 routes/ssl.ts 跑一遍。
*
* 为什么不用 wrangler dev:本项目的历史教训是「本地 workerd 的行为和线上不一致」
* (例如 PBKDF2 迭代数、Workers Cache),所以凡是**纯逻辑**的部分(鉴权矩阵、
* 配置校验、加密往返、脱敏)都在这里用假 KV/假 D1 跑,跑得快也跑得准;
* 真正需要边界环境的(TLS 探测、Workers Cache)放到线上验证。
*
* 用法(两步,先编译再跑):
* npm run selftest:ssl
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const outDir = path.join(root, '.selftest-ssl');
const toFileUrl = (p) => pathToFileURL(p).href;
// 编译 src → CJS(只编需要的几个文件;types.ts 是纯类型,会被擦掉)
// ★ 必须带 --strict:KVNamespaceListResult 是「完成/未完成」的联合类型,
// 只有 strict 下的控制流分析才能按 list_complete 把它收窄(`strict:false`
// 时 TS 会把两支合并,读 page.cursor 直接报 TS2339)。
// 这也说明「编译过了」和「按项目配置编译过了」是两件事。
//
// ★ 编译交给**外层 shell** 做(package.json 里的 selftest:ssl),不在这里
// spawn —— 本机的沙箱会让 spawnSync 直接 EBUSY(status=null、stdout 全是
// undefined,报错信息毫无指向性)。这里只做一件事:产物在不在。
if (!fs.existsSync(path.join(outDir, 'routes', 'ssl.js'))) {
console.error('找不到编译产物 ' + path.join(outDir, 'routes/ssl.js'));
console.error('请先跑:npx tsc src/routes/ssl.ts src/lib/{role,certstore,certvault,certprobe}.ts \\');
console.error(' --outDir .selftest-ssl --module commonjs --target es2022 \\');
console.error(' --moduleResolution node --strict \\');
console.error(' --types ./node_modules/@cloudflare/workers-types \\');
console.error(' --skipLibCheck --esModuleInterop --resolveJsonModule');
process.exit(1);
}
/** ★ toFileUrl 定义在文件顶部(pathToFileURL)—— Windows 路径必须走它,
* 手写 `'file://' + p` 在盘符前少一个斜杠,import 会报 ERR_UNSUPPORTED_ESM_URL_SCHEME。 */
// ---------------------------------------------------------------- 测试脚手架
let pass = 0;
let fail = 0;
const failures = [];
function t(name, ok, extra) {
if (ok) { pass++; console.log(' ✓ ' + name); }
else { fail++; failures.push(name); console.log(' ✗ ' + name + (extra ? ' → ' + extra : '')); }
}
function eq(name, got, want) {
t(name + `(期望 ${JSON.stringify(want)},实得 ${JSON.stringify(got)})`, JSON.stringify(got) === JSON.stringify(want));
}
/** 内存版 KVNamespace,够用(get / put / delete / list) */
function memKV() {
const m = new Map();
return {
_m: m,
async get(k) { return m.has(k) ? m.get(k) : null; },
async put(k, v) { m.set(k, String(v)); },
async delete(k) { m.delete(k); },
async list({ prefix = '', cursor } = {}) {
const keys = [...m.keys()].filter((k) => k.startsWith(prefix)).sort();
return { keys: keys.map((name) => ({ name })), list_complete: true, cursor: undefined, cacheStatus: null };
},
};
}
const KV = memKV();
const ENV = {
RSS_KV: KV,
TOKEN_SECRET: 'test-secret-please-rotate',
ALLOWED_ORIGINS: 'https://api.200181.xyz',
DB: {
// getAdminUsers 会查 D1;这里给一张空表
prepare: () => ({ bind: () => ({ all: async () => ({ results: [] }), first: async () => null }) }),
},
};
const ssl = await import(toFileUrl(path.join(outDir, 'routes', 'ssl.js')));
const store = await import(toFileUrl(path.join(outDir, 'lib', 'certstore.js')));
const vault = await import(toFileUrl(path.join(outDir, 'lib', 'certvault.js')));
const role = await import(toFileUrl(path.join(outDir, 'lib', 'role.js')));
const probe = await import(toFileUrl(path.join(outDir, 'lib', 'certprobe.js')));
/** 造一个 Ctx */
function ctx({ method = 'GET', url = 'https://api.200181.xyz/api/v2/ssl/x', user = null, body, origin } = {}) {
const headers = new Headers();
if (origin) headers.set('Origin', origin);
if (body !== undefined) headers.set('Content-Type', 'application/json');
return {
env: ENV,
req: new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }),
url: new URL(url),
params: {},
user,
};
}
const ADMIN = { id: 1, name: '群林', email: 'a@b.c', is_admin: 1, role: 'admin' };
const SSLU = { id: 2, name: '阿美', email: 's@b.c', is_admin: 0, role: 'ssl' };
const EDITOR = { id: 3, name: '小明', email: 'e@b.c', is_admin: 0, role: 'editor' };
const PLAIN = { id: 4, name: '路人', email: 'p@b.c', is_admin: 0, role: '' };
const j = async (res) => ({ status: res.status, body: await res.json() });
// ================================================================ 1. 角色
console.log('\n[1] 角色归一化与权限判定');
eq('roleOf(admin)', role.roleOf(ADMIN), 'admin');
eq('roleOf(ssl)', role.roleOf(SSLU), 'ssl');
eq('roleOf(editor)', role.roleOf(EDITOR), 'editor');
eq('roleOf(普通)', role.roleOf(PLAIN), 'user');
eq('roleOf(null)', role.roleOf(null), 'user');
t('is_admin=1 优先于 role 列', role.roleOf({ is_admin: 1, role: '' }) === 'admin');
t('canManageSSL(admin)=true', role.canManageSSL(ADMIN) === true);
t('canManageSSL(ssl)=true', role.canManageSSL(SSLU) === true);
t('★ canManageSSL(editor)=false', role.canManageSSL(EDITOR) === false);
t('★ canManageSSL(普通)=false', role.canManageSSL(PLAIN) === false);
t('canWritePosts(ssl)=false', role.canWritePosts(SSLU) === false);
eq('normalizeRole("ssl")', role.normalizeRole('ssl', undefined), 'ssl');
eq('normalizeRole("SSL")(大小写容错)', role.normalizeRole('SSL', undefined), 'ssl');
eq('normalizeRole("胡说")', role.normalizeRole('胡说', undefined), '');
eq('normalizeRole(undefined, true)', role.normalizeRole(undefined, true), 'admin');
eq('roleLabel("ssl")', role.roleLabel('ssl'), 'SSL 管理员');
// ================================================================ 2. 保险箱
console.log('\n[2] AES-GCM 保险箱');
const secretObj = { type: 'tencentcloud', secretId: 'AKIDabcdefghijklmn', secretKey: 'verysecretkey12345' };
const sealed = await vault.sealJson(ENV, secretObj);
t('密文带 v1. 前缀', sealed.startsWith('v1.'));
t('密文里看不到明文', !sealed.includes('AKIDabcdefghijklmn') && !sealed.includes('verysecretkey'));
t('isSealed 认得出', vault.isSealed(sealed) === true);
eq('解密往返一致', await vault.openJson(ENV, sealed), secretObj);
{
const sealed2 = await vault.sealJson(ENV, secretObj);
t('★ 同一明文两次加密结果不同(IV 随机)', sealed !== sealed2);
}
t('isSealed(乱码)=false', vault.isSealed('hello') === false);
{
const wrongKeyEnv = { ...ENV, TOKEN_SECRET: 'another-secret' };
let threw = false;
try { await vault.openJson(wrongKeyEnv, sealed); } catch { threw = true; }
t('★ 换密钥后解密抛错(不返回半成品)', threw);
eq('peekJson 解不开返回 null', await vault.peekJson(wrongKeyEnv, sealed), null);
}
eq('maskSecret 长串', vault.maskSecret('AKIDabcdefghijklmn'), 'AKID********klmn');
eq('maskSecret 短串', vault.maskSecret('abc'), '***');
eq('maskSecret 空', vault.maskSecret(''), '');
// ================================================================ 3. 存储层
console.log('\n[3] 存储层(配置 / 凭据 / 证书 / 日志)');
{
const c0 = await store.loadConfig(ENV);
eq('空 KV → 默认配置 version', c0.version, 1);
eq('空 KV → 空域名列表', c0.domains, []);
eq('空 KV → 默认提醒 30 天', c0.notify.daysBefore, 30);
await store.saveConfig(ENV, {
version: 1,
notify: { emails: ['a@b.c'], daysBefore: 21 },
domains: [{ name: 'usj.cc', san: ['usj.cc', '*.usj.cc'], dns: 'tc', deploy: ['1panel'] }],
});
const c1 = await store.loadConfig(ENV);
eq('保存后读回域名数', c1.domains.length, 1);
eq('保存后读回提醒天数', c1.notify.daysBefore, 21);
// 老配置缺字段的迁移
await KV.put('certkeeper:config', JSON.stringify({ domains: [{ name: 'x.cc' }] }));
const c2 = await store.loadConfig(ENV);
eq('缺 notify 时补默认', c2.notify.daysBefore, 30);
eq('域名缺 san 时补空数组', c2.domains[0].san, []);
eq('域名缺 deploy 时补空数组', c2.domains[0].deploy, []);
await KV.put('certkeeper:config', '{坏 JSON');
let threw = false;
try { await store.loadConfig(ENV); } catch { threw = true; }
t('★ 配置坏 JSON 时抛错(不返回空配置)', threw);
// 复位:上面故意写坏的配置要清掉,否则后面每个接口都会 500
await KV.delete('certkeeper:config');
eq('复位后能正常读配置', (await store.loadConfig(ENV)).version, 1);
}
{
await store.putAccess(ENV, 'tc-main', { type: 'tencentcloud', note: '主号', secretId: 'AKIDxyz123456789', secretKey: 'kkkkkkkkkkkk' });
const raw = await KV.get('certkeeper:access:tc-main');
t('★ KV 里落的是密文(不含明文密钥)', !raw.includes('AKIDxyz123456789') && vault.isSealed(raw));
const got = await store.getAccess(ENV, 'tc-main');
eq('getAccess 解出明文 type', got.type, 'tencentcloud');
eq('getAccess 解出明文 secretId', got.secretId, 'AKIDxyz123456789');
const list = await store.listAccess(ENV);
eq('列表有一条', list.length, 1);
eq('★ 列表回显 type', list[0].type, 'tencentcloud');
eq('★ 列表回显脱敏 secretId', list[0].fields.secretId, 'AKID********6789');
t('★ 列表里没有明文密钥', !JSON.stringify(list).includes('AKIDxyz123456789'));
t('★ 列表里没有明文 secretKey', !JSON.stringify(list).includes('kkkkkkkkkkkk'));
// 解不开的凭据也要列出来(密钥轮换后的场景)
await KV.put('certkeeper:access:broken', 'v1.AAAA.BBBB');
const list2 = await store.listAccess(ENV);
const broken = list2.find((x) => x.name === 'broken');
t('★ 解不开的凭据仍出现在列表里', !!broken);
t('★ 解不开的凭据被标 unreadable', broken && broken.unreadable === true);
await KV.delete('certkeeper:access:broken');
}
{
const rec = { cert: 'PEM', key: 'KEY', expireAt: Date.now() + 86400000, updatedAt: Date.now(), issuer: 'LiteSSL' };
await store.putCert(ENV, 'USJ.CC', rec); // 大小写混写
eq('★ 证书域名自动小写化后读得到', (await store.getCert(ENV, 'usj.cc')).issuer, 'LiteSSL');
eq('大写也读得到同一张', (await store.getCert(ENV, 'USJ.CC')).issuer, 'LiteSSL');
const raw = await KV.get('certkeeper:cert:usj.cc');
t('★ 证书(含私钥)落 KV 是密文', vault.isSealed(raw) && !raw.includes('KEY'));
eq('listCertNames', await store.listCertNames(ENV), ['usj.cc']);
await KV.put('certkeeper:cert:bad.cc', 'v1.XXXX.YYYY');
eq('★ 证书解不开时返回 null 而不是抛', await store.getCert(ENV, 'bad.cc'), null);
await KV.delete('certkeeper:cert:bad.cc');
}
{
for (let i = 0; i < 205; i++) {
await store.appendLog(ENV, { at: i, level: 'info', action: 'check', message: 'm' + i });
}
const logs = await store.loadLog(ENV);
eq('★ 日志环形缓冲上限 200', logs.length, 200);
eq('日志最新在最前', logs[0].message, 'm204');
await store.clearLog(ENV);
eq('清空后为空', (await store.loadLog(ENV)).length, 0);
}
// ================================================================ 4. 鉴权矩阵
console.log('\n[4] HTTP 鉴权矩阵(这是最关键的一组)');
{
const cases = [
['匿名 GET /overview', null, 403],
['普通用户 GET /overview', PLAIN, 403],
['★ 编辑 GET /overview(不该有证书权限)', EDITOR, 403],
['SSL 管理员 GET /overview', SSLU, 200],
['管理员 GET /overview', ADMIN, 200],
];
for (const [name, user, want] of cases) {
const r = await ssl.overview(ctx({ user }));
eq(name, r.status, want);
}
// 写接口
const wcases = [
['匿名 POST /config', null, 403],
['★ 编辑 POST /config', EDITOR, 403],
['SSL 管理员 POST /config(合法体)', SSLU, 200],
];
for (const [name, user, want] of wcases) {
const r = await ssl.configSave(ctx({
method: 'POST', user,
body: { version: 1, notify: { emails: [], daysBefore: 30 }, domains: [] },
}));
eq(name, r.status, want);
}
// CSRF
{
const r = await ssl.configSave(ctx({
method: 'POST', user: ADMIN, origin: 'https://evil.example.com',
body: { version: 1, notify: { emails: [], daysBefore: 30 }, domains: [] },
}));
eq('★ 跨站 Origin 的写请求被拒', r.status, 403);
}
{
const r = await ssl.configSave(ctx({
method: 'POST', user: ADMIN, origin: 'https://api.200181.xyz',
body: { version: 1, notify: { emails: [], daysBefore: 30 }, domains: [] },
}));
eq('同源写请求放行', r.status, 200);
}
}
// ================================================================ 5. 配置校验
console.log('\n[5] 配置校验(错了要在保存时就报,别等 90 天后)');
{
await store.putAccess(ENV, 'tc1', { type: 'tencentcloud', secretId: 'a', secretKey: 'b' });
const bad = [
['缺 name', { domains: [{ san: ['a.cc'], dns: 'tc1' }] }],
['不像域名', { domains: [{ name: 'not a domain!', san: ['a.cc'], dns: 'tc1' }] }],
['缺 SAN', { domains: [{ name: 'a.cc', san: [], dns: 'tc1' }] }],
['缺 DNS 凭据名', { domains: [{ name: 'a.cc', san: ['a.cc'] }] }],
['★ 引用了不存在的凭据', { domains: [{ name: 'a.cc', san: ['a.cc'], dns: 'nope' }] }],
['不认识的部署目标', { domains: [{ name: 'a.cc', san: ['a.cc'], dns: 'tc1', deploy: ['k8s'] }] }],
['重复域名', { domains: [
{ name: 'a.cc', san: ['a.cc'], dns: 'tc1' },
{ name: 'a.cc', san: ['a.cc'], dns: 'tc1' },
] }],
];
for (const [name, body] of bad) {
const r = await ssl.configSave(ctx({
method: 'POST', user: ADMIN,
body: { version: 1, notify: { emails: [], daysBefore: 30 }, ...body },
}));
t('拒绝:' + name, r.status === 400, 'got ' + r.status);
}
const okBody = {
version: 1,
notify: { emails: ['me@example.com'], daysBefore: 45 },
domains: [{ name: 'USJ.CC', san: ['USJ.CC', '*.USJ.CC'], dns: 'tc1', deploy: ['1panel', 'dogecloud'] }],
};
const r = await ssl.configSave(ctx({ method: 'POST', user: ADMIN, body: okBody }));
eq('合法配置保存成功', r.status, 200);
const saved = await store.loadConfig(ENV);
eq('★ 域名自动小写化', saved.domains[0].name, 'usj.cc');
eq('★ SAN 自动小写化', saved.domains[0].san, ['usj.cc', '*.usj.cc']);
eq('提醒阈值保存正确', saved.notify.daysBefore, 45);
// 提醒天数越界
for (const days of [0, -5, 400]) {
const rr = await ssl.configSave(ctx({
method: 'POST', user: ADMIN,
body: { version: 1, notify: { emails: [], daysBefore: days }, domains: [] },
}));
t('拒绝越界提醒天数 ' + days, rr.status === 400, 'got ' + rr.status);
}
// 邮箱格式
{
const rr = await ssl.configSave(ctx({
method: 'POST', user: ADMIN,
body: { version: 1, notify: { emails: ['not-an-email'], daysBefore: 30 }, domains: [] },
}));
eq('拒绝非法邮箱', rr.status, 400);
}
}
// ================================================================ 6. 凭据接口
console.log('\n[6] 凭据接口');
{
const r = await ssl.accessSave(ctx({
method: 'POST', user: ADMIN,
body: { name: 'new-acc', type: 'cloudflare', note: 'CF', fields: { apiToken: 'cf-token-abcdefgh' } },
}));
eq('新建凭据', r.status, 200);
eq('凭据类型落库', (await store.getAccess(ENV, 'new-acc')).type, 'cloudflare');
// 只改备注:不该把脱敏值(含 ****)写进去
const r2 = await ssl.accessSave(ctx({
method: 'POST', user: ADMIN,
body: { name: 'new-acc', type: 'cloudflare', note: '改过备注', fields: { apiToken: 'cf-t********gh' } },
}));
eq('只改备注返回 200', r2.status, 200);
const after = await store.getAccess(ENV, 'new-acc');
eq('★ 脱敏值没被当成新密钥写进去', after.apiToken, 'cf-token-abcdefgh');
eq('备注已更新', after.note, '改过备注');
// 非法类型 / 名字
const r3 = await ssl.accessSave(ctx({ method: 'POST', user: ADMIN, body: { name: 'x', type: '随便', fields: { a: 'b' } } }));
eq('拒绝非法类型', r3.status, 400);
const r4 = await ssl.accessSave(ctx({ method: 'POST', user: ADMIN, body: { name: '有空格 和/斜杠', type: 'cloudflare', fields: { a: 'b' } } }));
eq('拒绝非法凭据名', r4.status, 400);
const r5 = await ssl.accessSave(ctx({ method: 'POST', user: ADMIN, body: { name: 'never-existed', type: 'cloudflare', fields: {} } }));
eq('拒绝「新建但没填密钥」', r5.status, 400);
// 被引用的凭据不能删
const r6 = await ssl.accessDelete(ctx({ method: 'POST', user: ADMIN, body: { name: 'tc1' } }));
eq('★ 被域名引用的凭据删不掉', r6.status, 409);
const r7 = await ssl.accessDelete(ctx({ method: 'POST', user: ADMIN, body: { name: 'new-acc' } }));
eq('未被引用的凭据可删', r7.status, 200);
eq('删除后读不到', await store.getAccess(ENV, 'new-acc'), null);
}
// ================================================================ 7. 证书登记
console.log('\n[7] 证书登记 / 删除');
{
const pem = '-----BEGIN CERTIFICATE-----\nTUlJQmRENDAdGVzdA==\n-----END CERTIFICATE-----';
const r = await ssl.certImport(ctx({
method: 'POST', user: ADMIN,
body: { domain: 'usj.cc', cert: pem, key: '', expireAt: Date.UTC(2027, 0, 5), issuer: 'LiteSSL' },
}));
eq('登记证书', r.status, 200);
const rec = await store.getCert(ENV, 'usj.cc');
eq('到期时间落库', rec.expireAt, Date.UTC(2027, 0, 5));
eq('签发方落库', rec.issuer, 'LiteSSL');
const r2 = await ssl.certImport(ctx({
method: 'POST', user: ADMIN, body: { domain: 'x.cc', cert: '不是 PEM', expireAt: 1 },
}));
eq('拒绝非 PEM 内容', r2.status, 400);
const r3 = await ssl.certImport(ctx({
method: 'POST', user: ADMIN, body: { domain: 'y.cc', cert: pem, expireAt: 0 },
}));
eq('★ 读不出到期时间且没填 → 拒绝', r3.status, 400);
// certList
const rl = await j(await ssl.certList(ctx({ user: ADMIN })));
eq('certList 状态码', rl.status, 200);
const item = rl.body.items.find((x) => x.domain === 'usj.cc');
t('certList 带上 configured 标记', item && item.configured === true);
t('certList 算出剩余天数', item && typeof item.daysLeft === 'number' && item.daysLeft > 0);
t('certList 按剩余天数升序', rl.body.items.every((x, i, a) => i === 0 || (a[i - 1].daysLeft ?? 9999) <= (x.daysLeft ?? 9999)));
const rd = await ssl.certDelete(ctx({ method: 'POST', user: ADMIN, body: { domain: 'usj.cc' } }));
eq('删除证书', rd.status, 200);
eq('删除后读不到', await store.getCert(ENV, 'usj.cc'), null);
}
// ================================================================ 8. 日志接口
console.log('\n[8] 日志 / whoami');
{
await store.appendLog(ENV, { at: Date.now(), level: 'warn', action: 'check', message: '测试日志' });
const r = await j(await ssl.logList(ctx({ user: SSLU })));
eq('SSL 管理员能读日志', r.status, 200);
t('日志有内容', r.body.items.length >= 1);
const rw = await j(await ssl.whoami(ctx({ user: SSLU })));
eq('whoami 对 SSL 管理员返回 ok', rw.status, 200);
eq('whoami 带出角色', rw.body.user.role, 'ssl');
const rw2 = await j(await ssl.whoami(ctx({ user: EDITOR })));
eq('★ whoami 对编辑返回 401', rw2.status, 401);
eq('whoami 明确 canManage=false', rw2.body.canManage, false);
}
// ================================================================ 9. 概览分级
console.log('\n[9] 概览的到期分级');
{
await store.saveConfig(ENV, {
version: 1,
notify: { emails: [], daysBefore: 30 },
domains: [
{ name: 'ok.cc', san: ['ok.cc'], dns: 'tc1', deploy: [] },
{ name: 'soon.cc', san: ['soon.cc'], dns: 'tc1', deploy: [] },
{ name: 'dead.cc', san: ['dead.cc'], dns: 'tc1', deploy: [] },
{ name: 'off.cc', san: ['off.cc'], dns: 'tc1', deploy: [], disabled: true },
{ name: 'none.cc', san: ['none.cc'], dns: 'tc1', deploy: [] },
],
});
const day = 86400000;
await store.putCert(ENV, 'ok.cc', { cert: '', key: '', expireAt: Date.now() + 80 * day, updatedAt: Date.now() });
await store.putCert(ENV, 'soon.cc', { cert: '', key: '', expireAt: Date.now() + 10 * day, updatedAt: Date.now() });
await store.putCert(ENV, 'dead.cc', { cert: '', key: '', expireAt: Date.now() - 3 * day, updatedAt: Date.now() });
await store.putCert(ENV, 'off.cc', { cert: '', key: '', expireAt: Date.now() + 5 * day, updatedAt: Date.now() });
const r = await j(await ssl.overview(ctx({ user: ADMIN })));
const by = Object.fromEntries(r.body.domains.map((d) => [d.name, d]));
eq('80 天 → ok', by['ok.cc'].level, 'ok');
eq('10 天 → warn', by['soon.cc'].level, 'warn');
eq('已过期 → danger', by['dead.cc'].level, 'danger');
eq('★ 已停用的域名不报临期', by['off.cc'].level, 'none');
eq('★ 已过期剩余天数为负', by['dead.cc'].daysLeft, -3);
eq('没证的 → none', by['none.cc'].level, 'none');
eq('没证的 hasCert=false', by['none.cc'].hasCert, false);
}
// ================================================================ 10. 邮件 HTML
console.log('\n[10] 提醒邮件 HTML 转义');
{
const html = ssl.certMailHtml([{ domain: '<script>alert(1)</script>.cc', days: -2 }], 30);
t('★ 邮件里域名被 HTML 转义', !html.includes('<script>') && html.includes('&lt;script&gt;'));
t('邮件含「已过期」文案', html.includes('已过期'));
}
// ================================================================ 11. PEM 解析
console.log('\n[11] parsePemInfo(叶证书 vs 完整链)');
{
// 样本是测试期用 openssl 现场生成的两张自签名 EC P-256 证书(固定内容,写死在这里):
// · LEAF —— CN=leaf.test.example,SAN 两条,notAfter = 2027-01-04T11:49:46Z
// (用 `openssl x509 -enddate` 核对过)
// · INTER —— CN=Test Intermediate CA,notAfter 10 年后
// ★ 两段的 base64 都**以 `=` 结尾** —— 这正是 2026-10-06 那个 bug 的要害:
// 旧实现把两段 base64 直接拼起来再 atob,中间夹着的 `=` 让它抛错并返回 {},
// 于是调用方静默拿到 notAfter=undefined,多吉云的「已有证书够不够新」比对
// 永远为假、续期每次都白传一张新证书。
const LEAF = [
'MIIBwzCCAWigAwIBAgIUcRvLNWnNvGcdn8d/ItEBgEayxGAwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRbGVhZi50ZXN0LmV4YW1wbGUwHhcNMjYxMDA2MTE0OTQ2WhcNMjcwMTA0MTE0OTQ2WjAcMRowGAYDVQQDDBFsZWFmLnRlc3QuZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABDX4LLFxjz8KNv99ZGBC+4nqmB2Xiy99QJQjdoPCrUJ0QBQBoiqCej6GNMv95YHhHHmt1G8UoLUQVIcov50XEMmjgYcwgYQwHQYDVR0OBBYEFA8Vyep8ig9Jz74fE6V+AATEjoZCMB8GA1UdIwQYMBaAFA8Vyep8ig9Jz74fE6V+AATEjoZCMA8GA1UdEwEB/wQFMAMBAf8wMQYDVR0RBCowKIIRbGVhZi50ZXN0LmV4YW1wbGWCEyoubGVhZi50ZXN0LmV4YW1wbGUwCgYIKoZIzj0EAwIDSQAwRgIhAIYiU4/+Nq3040H048wAZspmjEre0OARL/Q3lPWHM7QeAiEAp4T10yOpim1yuWMWUvtfpZFpcbQ06hY2QF3BGUC1BJk=',
].join('');
const INTER = [
'MIIBkzCCATmgAwIBAgIUeQUqWUR70p3dpsmPFDMnnNTtGegwCgYIKoZIzj0EAwIwHzEdMBsGA1UEAwwUVGVzdCBJbnRlcm1lZGlhdGUgQ0EwHhcNMjYxMDA2MTE0OTQ2WhcNMzYxMDAzMTE0OTQ2WjAfMR0wGwYDVQQDDBRUZXN0IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPQR/JXD6rfVFjw/7irBNHZDEPC0kDTY12k3EEN0WdLm9f96sFz/vnYpBK9VOhGmCj7KwUtnmdTk3D4rgTZyOh2jUzBRMB0GA1UdDgQWBBRxPnrfHxRLS/9e1V04zolxn56XLDAfBgNVHSMEGDAWgBRxPnrfHxRLS/9e1V04zolxn56XLDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIH9r1Gm8GnYgqGb4QVAfivYdtdq0FLt67nvIcrY9a0XkAiEAl3MH0wOcns5wAclTlJ5cg2/nJ5LenoTe6mDQglgNubg=',
].join('');
const wrap = (b) => `-----BEGIN CERTIFICATE-----\n${b}\n-----END CERTIFICATE-----\n`;
const leafPem = wrap(LEAF);
const chainPem = leafPem + wrap(INTER);
const EXPECT = Date.UTC(2027, 0, 4, 11, 49, 46); // openssl x509 -enddate 核对过
const leaf = probe.parsePemInfo(leafPem);
const chain = probe.parsePemInfo(chainPem);
// ① 关键回归:多段链必须能解析出 notAfter(旧实现在这里返回 {})
t('★ 完整链也能解析出 notAfter(旧实现返回 {})', typeof chain.notAfter === 'number');
eq('★ 完整链的 notAfter 精确等于叶证书 notAfter', chain.notAfter, EXPECT);
eq('单段与完整链的 notAfter 一致', leaf.notAfter, chain.notAfter);
// ② 只认叶证书的 SAN,不要把中间证书的名字带进来
eq('SAN 精确解析(叶证书两条)', JSON.stringify(chain.altNames), JSON.stringify(['leaf.test.example', '*.leaf.test.example']));
t('★ 链解析不混入中间证书主体名', !(chain.altNames || []).some((n) => /intermediate/i.test(n)));
// ③ 非法输入不抛错、返回不完整对象
t('空串 → 没 notAfter', probe.parsePemInfo('').notAfter === undefined);
t('非 PEM → 没 notAfter', probe.parsePemInfo('hello world').notAfter === undefined);
t('只写 BEGIN 没有 END → 没 notAfter', probe.parsePemInfo('-----BEGIN CERTIFICATE-----\nAAAA\n').notAfter === undefined);
}
// ================================================================ 汇总
console.log('\n' + '='.repeat(56));
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
if (fail) {
console.log('\n失败清单:');
for (const f of failures) console.log(' · ' + f);
process.exit(1);
}
console.log('全部通过 ✓');
// ★ 这里**故意不再删 `.selftest-ssl/`**(2026-10-06 改)。
//
// 原来结尾有一句 `fs.rmSync(outDir)`,本意是「别把测试产物留在树里」。
// 但 `deploy/cn-certkeeper/` 的构建流程恰恰要用这份产物:
// npm run selftest:ssl:build
// cp -r blog-admin/.selftest-ssl/lib deploy/cn-certkeeper/lib
// 于是「跑完自测」= 「产物被删」= 后面的 cp 直接失败(报错还算温和),
// 更糟的是有人 cp 到一半就跳过 → **镜像里打进一份陈旧的 acme.js**,
// 排查时会以为「改了代码没生效」。构建产物本来就已 gitignore,留着无害。
console.log(`(编译产物保留在 ${path.relative(process.cwd(), outDir)}/,cn-certkeeper 要用)`);