/** * SSL 证书管家 —— Worker 侧的全部 HTTP 接口。 * * ★★ 本文件是「谁能碰证书」的唯一闸门,动它之前先读完下面两段。 * * 一、鉴权:**只认真实登录会话(Bearer token)**,绝不能用 isAdminRequest。 * 理由与 routes/editor.ts 完全一致:isAdminRequest 里有一条 Artalk 为老 * 客户端留的兜底 —— 请求带 `?name=<管理员名>&email=<管理员邮箱>` 就视为管理员, * 而这两个值是写死在 wrangler.toml、并暴露在后台页面里的公开信息。 * 一旦这条兜底泄漏到这个文件,任何人拼个 query 就能读走 **TLS 私钥**。 * * 二、角色:放行 admin + ssl(见 lib/role.ts 的 canManageSSL)。 * ★ 判定必须正着写(枚举放行)。写成 `role !== 'editor'` 这类排除法, * 以后每加一个角色都会静默获得证书权限 —— 而这里握着私钥和云厂商 AK/SK。 * * 路径挂在 /api/v2/ssl/*:Router.dispatch 会同时尝试 `/api/v2/x` 和 `/x`, * 而 /api/*(非 v2)已经被 RSS 模块整个接走(见 src/index.ts),所以必须走 v2 前缀。 */ import type { Env, UserRow } from '../types'; import type { Ctx } from '../router'; import { fail, isEmail, json, now, ok, readBody, trimTo } from '../lib/util'; import { userFromToken } from '../lib/session'; import { canManageSSL, roleOf } from '../lib/role'; import { getAdminUsers } from '../lib/db'; import { appendLog, clearLog, delAccess, delCert, getCert, listAccess, listCertNames, loadConfig, loadLog, putAccess, putCert, saveConfig, type AccessRecord, type AccessType, type DomainConfig, type KeeperConfig, } from '../lib/certstore'; import { daysLeft, parsePemInfo, probeTls } from '../lib/certprobe'; import { mailEnabled, sendMail } from '../lib/mail'; import { formatDateCN } from '../lib/util'; const ACCESS_TYPES: AccessType[] = ['tencentcloud', 'cloudflare', 'dogecloud', '1panel', 'acme-eab']; const DEPLOY_TARGETS = ['dogecloud', '1panel', 'tencentcloud-eo']; // ==================================================================== 鉴权 interface SslIdentity { id: number; name: string; role: 'admin' | 'ssl'; } /** * 解析操作者。返回 null = 不是管理员也不是 SSL 管理员。 * 与 editor.ts 的 requireEditorSession 逐字同构,只换角色判定 —— * **刻意不抽公共函数**:两处的「兜底」语义将来很可能分化 * (编辑要邮箱兜底认老管理员,证书这边绝不要),共享一个函数会更危险。 */ async function requireSslSession(ctx: Ctx): Promise { const user = ctx.user ?? (await userFromToken(ctx.env, ctx.req.headers.get('Authorization'))); if (!user) return null; const role = roleOf(user); if (role === 'admin') return { id: user.id, name: user.name, role: 'admin' }; if (role === 'ssl') return { id: user.id, name: user.name, role: 'ssl' }; // 已登录但没打 is_admin 标的老管理员账号:邮箱命中配置里的管理员也算。 // ★ 只在「本来就有 users 行、且邮箱是后台管理员邮箱」时生效 —— // 攻击者拿不到这个前提(他得先有一条能登录的用户行,还要邮箱正好对上)。 const admins = await getAdminUsers(ctx.env); if (admins.some((a) => a.email && a.email.toLowerCase() === String(user.email || '').toLowerCase())) { return { id: user.id, name: user.name, role: 'admin' }; } return null; } async function auth(ctx: Ctx): Promise<{ ident: SslIdentity } | { deny: Response }> { const ident = await requireSslSession(ctx); if (!ident) return { deny: fail(403, '需要管理员或 SSL 管理员权限') }; return { ident }; } /** 写操作额外校验 Origin,防 CSRF(读操作不做,免得把只读接口也搞脆) */ function checkOrigin(ctx: Ctx): Response | null { const origin = ctx.req.headers.get('Origin'); if (!origin) return null; // 同源 fetch 在部分浏览器不带 Origin,放过 const allow = (ctx.env.ALLOWED_ORIGINS || '') .split(',') .map((s) => s.trim()) .filter(Boolean); let host = ''; try { host = new URL(origin).host; } catch { return fail(403, 'Origin 不合法'); } const selfHost = new URL(ctx.req.url).host; if (host === selfHost) return null; if (allow.some((a) => a === '*' || a.includes(host))) return null; return fail(403, '拒绝跨站写入(Origin 不在白名单)'); } /** 写操作统一入口:鉴权 + Origin + 读 body */ async function writeAuth( ctx: Ctx, ): Promise<{ ident: SslIdentity; body: Record } | { deny: Response }> { const a = await auth(ctx); if ('deny' in a) return a; const csrf = checkOrigin(ctx); if (csrf) return { deny: csrf }; const body = await readBody(ctx.req); return { ident: a.ident, body }; } /** 写日志的语法糖(带上操作者,方便追责) */ async function log( ctx: Ctx, ident: SslIdentity, action: string, message: string, level: 'info' | 'warn' | 'error' = 'info', domain?: string, ): Promise { await appendLog(ctx.env, { at: now(), level, action, domain, message: `${ident.name}: ${message}` }); } // ==================================================================== 概览 /** * 证书总览 —— 后台首屏用。 * 每个域名给:配置 / 库里的记录 / **实测**状态三份信息,前端能一眼看出 * 「配了没」「有证没」「线上挂的到底是不是这张」。 */ export async function overview(ctx: Ctx): Promise { const a = await auth(ctx); if ('deny' in a) return a.deny; const cfg = await loadConfig(ctx.env); const certNames = await listCertNames(ctx.env); const rows = await Promise.all( cfg.domains.map(async (d) => { const rec = await getCert(ctx.env, d.name); const left = daysLeft(rec?.expireAt); return { name: d.name, san: d.san, dns: d.dns, deploy: d.deploy, disabled: !!d.disabled, hasCert: !!rec, expireAt: rec?.expireAt || null, expireText: rec?.expireAt ? formatDateCN(rec.expireAt) : null, issuer: rec?.issuer || '', updatedAt: rec?.updatedAt || null, daysLeft: left, level: levelOf(left, cfg.notify.daysBefore, !!d.disabled), }; }), ); // 库里有、配置里没有的证书(删域名时留下的孤儿)也列出来,免得悄悄占着空间 const orphans = certNames.filter((n) => !cfg.domains.some((d) => d.name === n)); return ok({ domains: rows, orphans, notify: cfg.notify, mailEnabled: mailEnabled(ctx.env), serverTime: formatDateCN(now()), }); } /** 把「剩余天数」翻译成前端要用的颜色档位 */ function levelOf(days: number | null, warnDays: number, disabled: boolean): 'ok' | 'warn' | 'danger' | 'none' { if (disabled) return 'none'; if (days == null) return 'none'; if (days < 0) return 'danger'; if (days <= warnDays) return 'warn'; return 'ok'; } // ==================================================================== 域名配置 export async function configGet(ctx: Ctx): Promise { const a = await auth(ctx); if ('deny' in a) return a.deny; return ok(await loadConfig(ctx.env)); } /** * 保存整份域名配置。 * * ★ 这里做**完整校验**而不是信任前端:配置错了的后果是「下次续期时写到 * 错误的 DNS 记录 / 把证书部署到别的站点」,而且往往 90 天后才发现。 * 宁可在保存时就报错。 */ export async function configSave(ctx: Ctx): Promise { const w = await writeAuth(ctx); if ('deny' in w) return w.deny; const { body, ident } = w; const domainsRaw = Array.isArray(body.domains) ? body.domains : null; if (!domainsRaw) return fail(400, 'domains 必须是数组'); const seen = new Set(); const domains: DomainConfig[] = []; for (const [i, d] of domainsRaw.entries()) { const name = String(d?.name || '').trim().toLowerCase(); if (!name) return fail(400, `第 ${i + 1} 个域名缺少 name`); if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(name.replace(/^\*\./, ''))) { return fail(400, `「${name}」不像一个域名`); } if (seen.has(name)) return fail(400, `域名「${name}」重复了`); seen.add(name); const san = Array.isArray(d?.san) ? d.san.map((s: unknown) => String(s).trim().toLowerCase()).filter(Boolean) : []; if (!san.length) return fail(400, `「${name}」至少要有一个 SAN(填域名本身也行)`); const dns = String(d?.dns || '').trim(); if (!dns) return fail(400, `「${name}」没指定 DNS 凭据`); if (!(await hasAccess(ctx.env, dns))) return fail(400, `「${name}」引用的 DNS 凭据「${dns}」不存在`); const deploy = Array.isArray(d?.deploy) ? d.deploy.map((s: unknown) => String(s).trim()).filter(Boolean) : []; for (const t of deploy) { if (!DEPLOY_TARGETS.includes(t)) return fail(400, `不认识的部署目标「${t}」`); } domains.push({ name, san, dns, deploy, dogecloud_domains: Array.isArray(d?.dogecloud_domains) ? d.dogecloud_domains.map(String).filter(Boolean) : [], one_panel_sites: Array.isArray(d?.one_panel_sites) ? d.one_panel_sites.map(String).filter(Boolean) : [], ...(d?.disabled ? { disabled: true } : {}), }); } const notifyEmails = Array.isArray(body?.notify?.emails) ? body.notify.emails.map((s: unknown) => String(s).trim()).filter(Boolean) : []; for (const e of notifyEmails) { if (!isEmail(e)) return fail(400, `通知邮箱「${e}」格式不对`); } const daysBefore = Number(body?.notify?.daysBefore); if (!Number.isFinite(daysBefore) || daysBefore < 1 || daysBefore > 365) { return fail(400, '提前提醒天数要在 1–365 之间'); } const saved = await saveConfig(ctx.env, { version: Number(body?.version) || 1, notify: { emails: notifyEmails, daysBefore: Math.floor(daysBefore) }, domains, }); await log(ctx, ident, 'config', `保存配置:${domains.length} 个域名,提醒邮箱 ${notifyEmails.length} 个`); return ok(saved); } async function hasAccess(env: Env, name: string): Promise { return (await env.RSS_KV.get('certkeeper:access:' + name)) !== null; } // ==================================================================== 凭据 export async function accessList(ctx: Ctx): Promise { const a = await auth(ctx); if ('deny' in a) return a.deny; return ok({ items: await listAccess(ctx.env), types: ACCESS_TYPES }); } /** * 保存凭据。 * ★ 允许「只改备注」:body.fields 为空且这是已存在的凭据时,保留原密文。 * 否则管理员想给凭据加个说明,就得把整串密钥重新填一遍。 */ export async function accessSave(ctx: Ctx): Promise { const w = await writeAuth(ctx); if ('deny' in w) return w.deny; const { body, ident } = w; const name = trimTo(String(body.name || '').trim(), 60); if (!name) return fail(400, '凭据名不能为空'); if (!/^[A-Za-z0-9._-]+$/.test(name)) return fail(400, '凭据名只能用字母、数字、点、下划线、短横线'); const type = String(body.type || '').trim() as AccessType; if (!ACCESS_TYPES.includes(type)) return fail(400, `不认识的凭据类型「${type}」`); const fields = body.fields && typeof body.fields === 'object' ? body.fields : {}; const cleaned: Record = {}; for (const [k, v] of Object.entries(fields)) { const key = String(k).trim(); const val = String(v ?? '').trim(); // 前端回显的是脱敏值(AKID****3f2a),管理员没改它时别把星号存进去 if (val && !/^\*+$/.test(val) && !val.includes('****')) cleaned[key] = val; } const existed = await ctx.env.RSS_KV.get('certkeeper:access:' + name); if (!Object.keys(cleaned).length) { if (!existed) return fail(400, '新建凭据必须填至少一个密钥字段'); // 只更新备注:读旧值 → 改 note → 写回 const raw = existed; const old = await readAccessRaw(ctx.env, name); if (!old) return fail(409, '原凭据读不出来(密钥可能已轮换),请整条重填'); await putAccess(ctx.env, name, { ...old, type, note: trimTo(String(body.note || ''), 120) }); await log(ctx, ident, 'access', `更新凭据「${name}」的说明`); return ok({ name, updated: true }); } const rec: AccessRecord = { type, note: trimTo(String(body.note || ''), 120), ...cleaned }; await putAccess(ctx.env, name, rec); await log(ctx, ident, 'access', `${existed ? '更新' : '新建'}凭据「${name}」(${type})`); return ok({ name }); } async function readAccessRaw(env: Env, name: string): Promise { const raw = await env.RSS_KV.get('certkeeper:access:' + name); if (!raw) return null; const { isSealed, openJson } = await import('../lib/certvault'); if (!isSealed(raw)) { try { return JSON.parse(raw) as AccessRecord; } catch { return null; } } try { return await openJson(env, raw); } catch { return null; } } export async function accessDelete(ctx: Ctx): Promise { const w = await writeAuth(ctx); if ('deny' in w) return w.deny; const name = trimTo(String(w.body.name || '').trim(), 60); if (!name) return fail(400, '缺少凭据名'); // 被域名配置引用着的凭据不允许直接删 —— 删了之后续期会在 90 天后才炸 const cfg = await loadConfig(ctx.env); const used = cfg.domains.filter((d) => d.dns === name).map((d) => d.name); if (used.length) return fail(409, `凭据「${name}」正被 ${used.join('、')} 使用,先改掉那些域名的 DNS 设置`); await delAccess(ctx.env, name); await log(ctx, w.ident, 'access', `删除凭据「${name}」`, 'warn'); return ok({ name }); } // ==================================================================== 证书 export async function certList(ctx: Ctx): Promise { const a = await auth(ctx); if ('deny' in a) return a.deny; const cfg = await loadConfig(ctx.env); const names = await listCertNames(ctx.env); const items = ( await Promise.all( names.map(async (n) => { const rec = await getCert(ctx.env, n); if (!rec) { return { domain: n, hasCert: false, expireAt: null, daysLeft: null, issuer: '', updatedAt: null }; } const left = daysLeft(rec.expireAt); return { domain: n, hasCert: true, expireAt: rec.expireAt, daysLeft: left, issuer: rec.issuer || '', updatedAt: rec.updatedAt, configured: cfg.domains.some((d) => d.name === n), level: levelOf(left, cfg.notify.daysBefore, false), }; }), ) ).sort((x, y) => (x.daysLeft ?? 9999) - (y.daysLeft ?? 9999)); return ok({ items, warnDays: cfg.notify.daysBefore }); } /** * 实测某个域名的**线上**证书。 * ★ 这是本模块唯一会对外发起网络连接的地方,也是价值最高的一个: * 只有它才能回答「用户打不开是因为证书过期了」。 */ export async function certProbe(ctx: Ctx): Promise { const a = await auth(ctx); if ('deny' in a) return a.deny; let host = String(ctx.url.searchParams.get('host') || '').trim(); const domain = String(ctx.url.searchParams.get('domain') || '').trim(); if (!host && domain) { const cfg = await loadConfig(ctx.env); const d = cfg.domains.find((x) => x.name === domain); // 泛域名没法直接握手(`*.usj.cc` 不是合法主机名),挑 SAN 里第一个不带通配的 host = (d?.san || []).find((s) => !s.startsWith('*.')) || d?.name || ''; } if (!host) return fail(400, '缺少 host 参数(或指定的域名没有可探测的 SAN)'); const info = await probeTls(host); if (!info.ok) { await log(ctx, a.ident, 'probe', `探测 ${host} 失败:${info.error || '未知原因'}`, 'warn', domain || host); } return ok({ host, ...info, daysLeft: daysLeft(info.notAfter), notAfterText: info.notAfter ? formatDateCN(info.notAfter) : null, }); } /** * 手工登记一张证书(粘贴 PEM)。 * 用途:ACME 自动化还没接上时,先把线上证书录进来,让到期监控先跑起来。 */ export async function certImport(ctx: Ctx): Promise { const w = await writeAuth(ctx); if ('deny' in w) return w.deny; const { body, ident } = w; const domain = trimTo(String(body.domain || '').trim().toLowerCase(), 120); if (!domain) return fail(400, '缺少 domain'); const cert = String(body.cert || ''); const key = String(body.key || ''); if (!cert.includes('-----BEGIN CERTIFICATE-----')) return fail(400, 'cert 要填 PEM 格式的证书链'); if (key && !key.includes('-----BEGIN')) return fail(400, 'key 看起来不是 PEM 私钥'); const parsed = parsePemInfo(cert); const expireAt = Number(body.expireAt) || parsed.notAfter || 0; if (!expireAt) return fail(400, '读不出到期时间,请手工填 expireAt(毫秒时间戳或 ISO 时间)'); await putCert(ctx.env, domain, { cert, key, expireAt, updatedAt: now(), issuer: trimTo(String(body.issuer || ''), 120), san: parsed.altNames || [], }); await log(ctx, ident, 'import', `登记证书 ${domain}(到期 ${formatDateCN(expireAt)})`, 'info', domain); return ok({ domain, expireAt }); } export async function certDelete(ctx: Ctx): Promise { const w = await writeAuth(ctx); if ('deny' in w) return w.deny; const domain = trimTo(String(w.body.domain || '').trim().toLowerCase(), 120); if (!domain) return fail(400, '缺少 domain'); await delCert(ctx.env, domain); await log(ctx, w.ident, 'cert', `删除证书记录「${domain}」`, 'warn', domain); return ok({ domain }); } // ==================================================================== 检查 / 通知 /** * 手动跑一轮检查(只读,不改任何东西)。 * 对比「库里记录的到期日」与「线上实测」,把不一致的地方标出来 —— * 这正是 certimate 那几条「过期预警」工作流在做的事,且做得更细。 */ export async function certCheck(ctx: Ctx): Promise { const a = await auth(ctx); if ('deny' in a) return a.deny; const cfg = await loadConfig(ctx.env); const only = String(ctx.url.searchParams.get('domain') || '').trim(); const targets = cfg.domains.filter((d) => !d.disabled && (!only || d.name === only)); if (!targets.length) return ok({ items: [], message: only ? `配置里没有域名「${only}」` : '没有启用的域名' }); const items = []; for (const d of targets) { const rec = await getCert(ctx.env, d.name); const storedLeft = daysLeft(rec?.expireAt); const host = d.san.find((s) => !s.startsWith('*.')) || d.name; const live = await probeTls(host); let verdict = 'unknown'; if (!live.ok) verdict = 'unreachable'; else if (live.notAfter && rec?.expireAt) { // 差 1 天以内算同一张(时间戳精度/时区差异),否则说明线上换了证书 verdict = Math.abs(live.notAfter - rec.expireAt) < 86400000 ? 'match' : 'mismatch'; } else if (live.notAfter) verdict = 'live-only'; items.push({ name: d.name, host, stored: rec ? { expireAt: rec.expireAt, daysLeft: storedLeft, issuer: rec.issuer || '' } : null, live: live.ok ? { notAfter: live.notAfter || null, daysLeft: daysLeft(live.notAfter), issuer: live.issuer || '', subject: live.subject || '', altNames: live.altNames || [], } : null, error: live.error || null, verdict, }); } const bad = items.filter((i) => i.verdict !== 'match'); await log( ctx, a.ident, 'check', `检查 ${items.length} 个域名,${items.length - bad.length} 个一致${bad.length ? ',' + bad.length + ' 个需关注' : ''}`, bad.length ? 'warn' : 'info', ); return ok({ items, warnDays: cfg.notify.daysBefore, checkedAt: now() }); } /** 发一封「到期汇总」邮件(手动触发,用于验证通知链路) */ export async function certNotify(ctx: Ctx): Promise { const a = await auth(ctx); if ('deny' in a) return a.deny; const cfg = await loadConfig(ctx.env); if (!cfg.notify.emails.length) return fail(400, '还没配置通知邮箱'); if (!mailEnabled(ctx.env)) return fail(503, '邮件未配置(缺少 RESEND_API_KEY)'); const names = await listCertNames(ctx.env); const rows: { domain: string; days: number | null }[] = []; for (const n of names) { const rec = await getCert(ctx.env, n); rows.push({ domain: n, days: daysLeft(rec?.expireAt) }); } rows.sort((x, y) => (x.days ?? 9999) - (y.days ?? 9999)); const html = certMailHtml(rows, cfg.notify.daysBefore); let sent = 0; for (const to of cfg.notify.emails) { if (await sendMail(ctx.env, { to, subject: '证书到期汇总 · 证书管家', html })) sent += 1; } await log(ctx, a.ident, 'notify', `发送到期汇总给 ${sent}/${cfg.notify.emails.length} 个收件人`, sent ? 'info' : 'error'); return ok({ sent, total: cfg.notify.emails.length }); } export function certMailHtml(rows: { domain: string; days: number | null }[], warnDays: number): string { const line = (r: { domain: string; days: number | null }) => { const d = r.days; const color = d == null ? '#888' : d < 0 ? '#d33' : d <= warnDays ? '#e80' : '#2a2'; const text = d == null ? '无证书记录' : d < 0 ? `已过期 ${-d} 天` : `剩余 ${d} 天`; return `${esc(r.domain)} ${text}`; }; return `

证书到期汇总

${rows.map(line).join('')}
域名 状态

由 api.200181.xyz 的证书管家发出 · 提前提醒阈值 ${warnDays} 天

`; } function esc(s: unknown): string { return String(s ?? '').replace(/[&<>"']/g, (m) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m] as string), ); } // ==================================================================== 日志 export async function logList(ctx: Ctx): Promise { const a = await auth(ctx); if ('deny' in a) return a.deny; const limit = Math.min(Math.max(Number(ctx.url.searchParams.get('limit')) || 50, 1), 200); return ok({ items: (await loadLog(ctx.env)).slice(0, limit) }); } export async function logClear(ctx: Ctx): Promise { const w = await writeAuth(ctx); if ('deny' in w) return w.deny; await clearLog(ctx.env); await log(ctx, w.ident, 'log', '清空日志'); return ok({ cleared: true }); } // ==================================================================== 会话 /** * 「我是谁 + 我能不能用证书管家」。 * 后台前端在决定要不要画「证书管家」这一项时调它 —— 与 /admin/session * 那条探测路径区分开:那条是给**国内机 editor-api** 用的,形状不能动。 */ export async function whoami(ctx: Ctx): Promise { const ident = await requireSslSession(ctx); if (!ident) return json({ ok: false, canManage: false, need_login: true }, { status: 401 }); return ok({ ok: true, canManage: true, user: { id: ident.id, name: ident.name, role: ident.role } }); } /** 给「用户管理」页的角色下拉用的角色说明(前端只读,不做逻辑) */ export function roleHints(): { value: string; label: string; hint: string }[] { return [ { value: 'user', label: '普通用户', hint: '只能评论,进不了后台' }, { value: 'editor', label: '编辑', hint: '只能写 / 发布自己的文章' }, { value: 'ssl', label: 'SSL 管理员', hint: '只能配 SSL 证书,碰不到评论和文章' }, { value: 'admin', label: '管理员', hint: '全部权限' }, ]; } /** 供 index.ts 注册用(避免路由文件里散落一堆字符串) */ export const SSL_ROUTES: { method: string; path: string; handler: (ctx: Ctx) => Promise }[] = [ { method: 'GET', path: '/ssl/whoami', handler: whoami }, { method: 'GET', path: '/ssl/overview', handler: overview }, { method: 'GET', path: '/ssl/config', handler: configGet }, { method: 'POST', path: '/ssl/config', handler: configSave }, { method: 'GET', path: '/ssl/access', handler: accessList }, { method: 'POST', path: '/ssl/access', handler: accessSave }, { method: 'POST', path: '/ssl/access/delete', handler: accessDelete }, { method: 'GET', path: '/ssl/certs', handler: certList }, { method: 'GET', path: '/ssl/probe', handler: certProbe }, { method: 'POST', path: '/ssl/probe', handler: certProbe }, { method: 'POST', path: '/ssl/cert/import', handler: certImport }, { method: 'POST', path: '/ssl/cert/delete', handler: certDelete }, { method: 'GET', path: '/ssl/check', handler: certCheck }, { method: 'POST', path: '/ssl/check', handler: certCheck }, { method: 'POST', path: '/ssl/notify', handler: certNotify }, { method: 'GET', path: '/ssl/log', handler: logList }, { method: 'POST', path: '/ssl/log/clear', handler: logClear }, ]; export type { UserRow };