Files
blog/blog-admin/src/routes/ssl.ts
T
zqlit a40a92f526 feat: SSL 证书管家 —— 后台面板 + 专属角色 + 国内机放行
集成在 api.200181.xyz(同一个 Worker),国内机 writeapi.usj.cc 同步可用。

新增第三档后台角色 'ssl':
- 只拿证书管家钥匙,看不到评论/文章/用户等模块
- 判定正着枚举放行(canManageSSL = admin|ssl),不用排除法,
  免得以后新增角色静默获得私钥权限
- 鉴权只认 Bearer 会话,绝不走 isAdminRequest —— 后者有 Artalk
  老客户端的 query 兜底,一旦进来的就是 TLS 私钥

数据(复用 RSS_KV,前缀 certkeeper:):
- 凭据一条一键,避开 KV 读-改-写无事务导致的并发丢数据
- 私钥/AK-SK 一律 AES-GCM 密文(TOKEN_SECRET 经 PBKDF2 派生)
- 列表接口只回显前 4 后 4 位,明文不进内存
- 配置读失败抛错而非返回空,避免一次保存覆盖线上配置

只读监控:
- probeTls 走 cloudflare:sockets 拿证书正文,实现到期分级
  与「库里记录 vs 线上实测」对比(match/mismatch/live-only/unreachable)
- 到期提醒邮件(HTML 已转义)

国内机 editor-api:
- identify 识别 ssl 角色;业务分支前白名单,ssl 只能碰 /health、
  /admin/session、/admin/logout 与 /api/v2/ssl*
- /api/v2/ssl* 反代放行 admin + ssl(RSS 仍是 admin-only)
- posts.mjs 越权兜底方向修正:owns() 从「非 editor 即放行」改为
  「只有 admin 不受限」,漏进来的 ssl 被当受限编辑而非管理员全放行
- createPost 显式拒绝非写作角色

验证:typecheck ✓ / selftest:ssl 117 项 ✓ / 浏览器 37 项 ✓
2026-10-06 14:19:01 +08:00

634 lines
25 KiB
TypeScript
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.
/**
* 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<SslIdentity | null> {
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<string, any> } | { 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<void> {
await appendLog(ctx.env, { at: now(), level, action, domain, message: `${ident.name}: ${message}` });
}
// ==================================================================== 概览
/**
* 证书总览 —— 后台首屏用。
* 每个域名给:配置 / 库里的记录 / **实测**状态三份信息,前端能一眼看出
* 「配了没」「有证没」「线上挂的到底是不是这张」。
*/
export async function overview(ctx: Ctx): Promise<Response> {
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<Response> {
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<Response> {
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<string>();
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<boolean> {
return (await env.RSS_KV.get('certkeeper:access:' + name)) !== null;
}
// ==================================================================== 凭据
export async function accessList(ctx: Ctx): Promise<Response> {
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<Response> {
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<string, string> = {};
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<AccessRecord | null> {
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<AccessRecord>(env, raw);
} catch {
return null;
}
}
export async function accessDelete(ctx: Ctx): Promise<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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 `<tr><td style="padding:6px 10px;border-bottom:1px solid #eee">${esc(r.domain)}</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee;color:${color};font-weight:600">${text}</td></tr>`;
};
return `<div style="font-family:-apple-system,BlinkMacSystemFont,'PingFang SC',sans-serif;font-size:14px;color:#333">
<h2 style="font-size:16px;margin:0 0 12px">证书到期汇总</h2>
<table style="border-collapse:collapse;width:100%;max-width:520px">
<thead><tr><th align="left" style="padding:6px 10px;border-bottom:2px solid #ddd">域名</th>
<th align="left" style="padding:6px 10px;border-bottom:2px solid #ddd">状态</th></tr></thead>
<tbody>${rows.map(line).join('')}</tbody>
</table>
<p style="color:#888;font-size:12px;margin-top:14px">由 api.200181.xyz 的证书管家发出 · 提前提醒阈值 ${warnDays} 天</p>
</div>`;
}
function esc(s: unknown): string {
return String(s ?? '').replace(/[&<>"']/g, (m) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m] as string),
);
}
// ==================================================================== 日志
export async function logList(ctx: Ctx): Promise<Response> {
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<Response> {
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<Response> {
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<Response> }[] = [
{ 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 };