/** * 证书管家本地自测 —— 不启动 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'))); /** 造一个 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: '.cc', days: -2 }], 30); t('★ 邮件里域名被 HTML 转义', !html.includes('