Files
blog/blog-admin/src/routes/ssl.ts
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

964 lines
39 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,
getAccess,
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 { RENEW_BEFORE_DAYS } from '../lib/certissue';
import { AcmeClient } from '../lib/acme';
import { makeDnsProvider } from '../lib/dnsprovider';
import { makeDeployer, OnePanelDeployer } from '../lib/deployer';
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[] = [];
// ★ 后台的域名表单**不管理** `probe_connect` / `probe_sni`(它们是给运维用的
// 源站探针开关)。保存时把旧值带过来,否则管理员在面板上改一次别的东西,
// 这两个字段就被悄悄抹掉了 —— 后果是 t-t.live 这类挂在 CDN 后的域名
// 探针退回公网、被误判成「还剩 80 多天」,源站证书到期也不续。
const prev = await loadConfig(ctx.env);
const prevByName = new Map(prev.domains.map((d) => [d.name, d]));
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}」`);
}
const old = prevByName.get(name);
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) : [],
// 表单不管这两项 → 沿用旧值(见上面 prevByName 的注释)
...((d?.probe_connect ?? old?.probe_connect)
? { probe_connect: String(d?.probe_connect ?? old?.probe_connect).trim() }
: {}),
...((d?.probe_sni ?? old?.probe_sni)
? { probe_sni: String(d?.probe_sni ?? old?.probe_sni).trim().toLowerCase() }
: {}),
...(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)');
// ★ 探测走国内机 editor-api:Workers 拿不到对端证书正文(node:tls 是桩函数),
// 本机是真 Node 才行。传 base + 共享令牌,失败时 probeTls 内部自动降级。
const info = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN);
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.probe_sni || d.san.find((s) => !s.startsWith('*.')) || d.name;
const live = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN, d.probe_connect);
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 });
}
// ==================================================================== 签发 / 续期
/**
* 手动签发一个域名(或强制续期)。
*
* body: { domain?: string, force?: boolean, noDeploy?: boolean }
* · domain 省略 → 对所有启用的域名跑一遍续期检查
* · force=true → 忽略剩余天数,强制重签
* · noDeploy → 只签不部署(调试)
*
* ★ 这是本模块唯一会**真正签发证书**的入口,也是耗时最长的(DNS 传播等待
* 30s × 授权数 + 轮询),单个域名通常 1~3 分钟。前端要给出明确的进行中提示。
*/
export async function certIssue(ctx: Ctx): Promise<Response> {
const w = await writeAuth(ctx);
if ('deny' in w) return w.deny;
const body = w.body as { domain?: string; force?: boolean; noDeploy?: boolean };
// ★★ 2026-10-06:Worker 侧**不再承担签发**,这里明确拒绝(501)。
//
// 为什么不是「留着,跑失败再说」:
// · 免费版 CPU 硬顶 10ms,签发属于擦边(实测优化后 2.8~4.2ms,但没余量);
// · CF 出口 IP 是海量动态段,进不了 1Panel 的 API 白名单 → 部署必失败;
// · Worker 运行时**拿不到对端证书正文** → 探针判不准要不要签。
// 整条“探针 → 签发 → 部署”链路已搬到国内机的 Docker 容器 `cn-certkeeper`
// (deploy/cn-certkeeper/,见 函数版证书管家-方案.md 第九章)。
// 两边同时签发会**重复下单**(白耗 CA 配额)并争抢同一批站点部署,
// 所以这里是硬拒绝,而不是静默降级。
await log(ctx, w.ident, 'issue', `拒绝签发请求(已迁至国内机):${body.domain || '全部域名'}`, 'warn', body.domain);
return fail(
501,
'证书签发已迁移到国内机容器 cn-certkeeper(Worker 只留只读监控)。' +
'请在服务器上执行:curl -s -X POST -H "X-Auth-Token: <AUTH_TOKEN>" http://127.0.0.1:8019/renew',
);
}
/** 只看「该不该续期」,不签发 —— 给 UI 的「检查」按钮用,秒回 */
export async function certRenewCheck(ctx: Ctx): Promise<Response> {
const a = await auth(ctx);
if ('deny' in a) return a.deny;
const cfg = await loadConfig(ctx.env);
const items = [];
for (const d of cfg.domains) {
if (d.disabled) {
items.push({ domain: d.name, action: 'skip', reason: '已停用' });
continue;
}
const rec = await getCert(ctx.env, d.name);
// ★ 用 probe_sni / probe_connect 与国内机保持一致:域名挂在 CDN/边缘
// 加速后面时,公网握手量到的是**边缘证书**,判「还剩多少天」会失真。
const host = d.probe_sni || d.san.find((s) => !s.startsWith('*.')) || d.name;
const live = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN, d.probe_connect);
const left = daysLeft(live.notAfter);
// ★ 判定「线上真实剩余天数」而不是 KV 里那份:KV 可能过期/失同步,
// 线上才决定读者会不会看到证书过期。
const base = left !== null ? left : daysLeft(rec?.expireAt);
items.push({
domain: d.name,
host,
source: left !== null ? 'live' : 'stored',
daysLeft: base,
action: base === null ? 'issue' : base <= RENEW_BEFORE_DAYS ? 'renew' : 'ok',
threshold: RENEW_BEFORE_DAYS,
issuer: live.issuer || rec?.issuer || '',
error: live.ok ? null : live.error || null,
});
}
return ok({ items, threshold: RENEW_BEFORE_DAYS });
}
// ==================================================================== 自检
interface CheckItem {
name: string;
kind: 'ca' | 'dns' | 'deploy' | 'target';
ok: boolean;
detail: string;
/** 出问题时的处置建议 */
hint?: string;
}
/**
* 环境自检 —— 把所有「续期时才可能暴露」的配置问题提前查出来。
*
* ★ 为什么需要这个:证书续期是**无人值守**的。一次配置错误(目录地址少个
* `/v2`、API Key 过期、1Panel 忘了加 IP 白名单)在平时完全看不出来,
* 等到证书真过期那天才发现 —— 那时站点已经在报错了。
* 这个接口让管理员在配完之后立刻能验证全套链路。
*
* ★ 这里**故意不真正签发**(不消耗 CA 配额、不改 DNS):只做
* 「能不能连上 / 认不认凭据」级别的探测。
* - CA:拉一次目录,看结构是否完整、是否要求 EAB
* - DNS:只做一次只读列举(不写 TXT),验证签名与权限
* - 部署:只读列举(多吉云不做写操作、1Panel 不绑站点)
*/
export async function certSelfCheck(ctx: Ctx): Promise<Response> {
const a = await auth(ctx);
if ('deny' in a) return a.deny;
const env = ctx.env;
const cfg = await loadConfig(env);
const items: CheckItem[] = [];
// ---- ① 各 CA(acme-eab 凭据)----
const accesses = await listAccess(env);
const eabNames = accesses.filter((x) => x.type === 'acme-eab' && !x.unreadable).map((x) => x.name);
for (const name of eabNames) {
const rec = await getAccess(env, name);
const url = String(rec?.directoryUrl || '');
if (!url) {
items.push({ name: `CA ${name}`, kind: 'ca', ok: false, detail: '缺少 directoryUrl' });
continue;
}
try {
// 用一个临时账户密钥探测目录(不注册,纯读)
const probe = new AcmeClient(url, { jwk: { kty: 'EC', crv: 'P-256', x: 'AA', y: 'AA' } as JsonWebKey, kid: '' });
const needEab = await probe.externalAccountRequired();
const hasEab = !!(rec?.eabKid && rec?.eabHmacKey);
if (needEab && !hasEab) {
items.push({
name: `CA ${name}`,
kind: 'ca',
ok: false,
detail: `目录可达,但该 CA 要求 EAB 而凭据里没有 eabKid/eabHmacKey`,
hint: '到 CA 后台重新生成 EAB 凭据并补进这条凭据',
});
} else {
items.push({
name: `CA ${name}`,
kind: 'ca',
ok: true,
detail: `目录可达${needEab ? ',EAB 已配对' : ',无需 EAB'}`,
});
}
} catch (e) {
items.push({
name: `CA ${name}`,
kind: 'ca',
ok: false,
detail: errMsg(e),
hint: '核对 directoryUrl 是否完整(多数 CA 需要 /v2 之类的版本段)',
});
}
}
if (!eabNames.length) {
items.push({ name: 'CA', kind: 'ca', ok: false, detail: '没有任何 acme-eab 凭据,无法签发' });
}
// ---- ② 各 DNS 凭据(只读列举,验证签名/权限)----
for (const d of cfg.domains) {
const key = `DNS ${d.dns}`;
if (items.some((x) => x.name === key)) continue;
const rec = await getAccess(env, d.dns);
if (!rec) {
items.push({ name: key, kind: 'dns', ok: false, detail: `凭据「${d.dns}」不存在` });
continue;
}
const host = d.san.find((s) => !s.startsWith('*.')) || d.name;
try {
const dns = makeDnsProvider(rec);
// ★ 用 _acme-challenge.<主域> 做只读列举:既验证签名有效,
// 也验证「这条凭据确实管得着这个域名」——后者才是真正会翻车的点
const existing = await dns.listTxt(`_acme-challenge.${host}`);
items.push({
name: key,
kind: 'dns',
ok: true,
detail: `${rec.type} 凭据可用,能读取 ${host} 的 TXT(现存 ${existing.length} 条)`,
});
} catch (e) {
items.push({
name: key,
kind: 'dns',
ok: false,
detail: errMsg(e),
hint: '确认密钥有效、且该域名确实在这条凭据的账号下',
});
}
}
// ---- ③ 各部署目标 ----
const targets = new Set<string>();
for (const d of cfg.domains) for (const t of d.deploy) targets.add(t);
for (const t of targets) {
const credName = t === '1panel' ? '1panel-cn' : t;
const rec = await getAccess(env, credName);
if (!rec) {
items.push({ name: `部署 ${t}`, kind: 'deploy', ok: false, detail: `凭据「${credName}」不存在` });
continue;
}
try {
const dp = makeDeployer(rec);
if (t === '1panel') {
// ★ 这里要**真**打一次 1Panel 接口 —— 只看「凭据能构造出来」是不够的:
// 1Panel 开了「安全登录」之后,面板 API 会搬到随机入口路径下,
// 根路径只回一个 HTML 提示页(HTTP 200,不是错误码)。
// 不实探的话,这个问题要到证书该续期那天才会暴露。
const panel = new OnePanelDeployer(
String((rec as { serverUrl?: string }).serverUrl || ''),
String((rec as { apiKey?: string }).apiKey || ''),
String((rec as { apiVersion?: string }).apiVersion || 'v1') === 'v2' ? 'v2' : 'v1',
);
const probe = await panel.ping();
if (!probe.ok) {
items.push({
name: `部署 ${t}`,
kind: 'deploy',
ok: false,
detail: probe.error || '1Panel 不可用',
hint: probe.hint,
});
} else {
const sites = new Set<string>();
for (const d of cfg.domains) for (const s of d.one_panel_sites || []) sites.add(s);
const found: string[] = [];
for (const s of sites) {
try {
const w = await panel.inspectSite(s);
found.push(`${s}→#${w.id}${w.enable ? `(现绑 ${w.certCN || '?'})` : '(未开 HTTPS)'}`);
} catch {
found.push(`${s}→未找到`);
}
}
const bad = found.filter((x) => x.includes('未找到'));
items.push({
name: `部署 ${t}`,
kind: 'deploy',
ok: bad.length === 0,
detail:
`1Panel 可达(API ${String((rec as { apiVersion?: string }).apiVersion || 'v2')});` +
(found.length ? `站点匹配:${found.join(',')}` : '未配置待绑定站点'),
hint: bad.length
? `这些站点名在 1Panel 里找不到,部署会跳过:${bad.map((x) => x.split('→')[0]).join(', ')}`
: undefined,
});
}
} else {
items.push({
name: `部署 ${t}`,
kind: 'deploy',
ok: true,
detail: `${dp.kind} 凭据已构造成功${
(rec as { accessKey?: string }).accessKey
? `(AK ${String((rec as { accessKey?: string }).accessKey).slice(0, 4)}…)`
: ''
}`,
});
}
} catch (e) {
items.push({ name: `部署 ${t}`, kind: 'deploy', ok: false, detail: errMsg(e) });
}
}
// ---- ④ 域名配置本身的完整性 ----
for (const d of cfg.domains) {
if (d.disabled) {
items.push({ name: `域名 ${d.name}`, kind: 'target', ok: true, detail: '已停用(不参与自动续期)' });
continue;
}
if (!d.san.length) {
items.push({
name: `域名 ${d.name}`,
kind: 'target',
ok: false,
detail: '没有配置 SAN,签发时只会覆盖主域名',
hint: '需要覆盖子域时在 SAN 里补上(如 usj.cc, *.usj.cc)',
});
continue;
}
if (!d.deploy.length) {
items.push({
name: `域名 ${d.name}`,
kind: 'target',
ok: true,
detail: '只签发不部署(deploy 为空)',
});
continue;
}
items.push({
name: `域名 ${d.name}`,
kind: 'target',
ok: true,
detail: `${d.san.length} 个 SAN,部署到 ${d.deploy.join(' + ')}`,
});
}
return ok({
items,
summary: {
total: items.length,
failed: items.filter((x) => !x.ok).length,
},
threshold: RENEW_BEFORE_DAYS,
});
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
// ==================================================================== 会话
/**
* 「我是谁 + 我能不能用证书管家」。
* 后台前端在决定要不要画「证书管家」这一项时调它 —— 与 /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 },
// ★ 签发/续期:POST /ssl/issue 是**真正下单**的那个(慢,1~3 分钟/域名)
{ method: 'GET', path: '/ssl/renew-check', handler: certRenewCheck },
{ method: 'POST', path: '/ssl/renew-check', handler: certRenewCheck },
{ method: 'POST', path: '/ssl/issue', handler: certIssue },
// ★ 环境自检:不签发、不写 DNS,只验证全套凭据「连得上、认得对」
{ method: 'GET', path: '/ssl/selfcheck', handler: certSelfCheck },
{ method: 'POST', path: '/ssl/selfcheck', handler: certSelfCheck },
];
export type { UserRow };