一、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),
保留三条纯监控告警。
528 lines
26 KiB
JavaScript
528 lines
26 KiB
JavaScript
/**
|
||
* 证书管家本地自测 —— 不启动 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('<script>'));
|
||
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 要用)`);
|