2026-10-06 14:19:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
2026-10-06 16:59:37 +08:00
|
|
|
|
getAccess,
|
2026-10-06 14:19:01 +08:00
|
|
|
|
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';
|
2026-10-06 16:59:37 +08:00
|
|
|
|
import { issueDomain, RENEW_BEFORE_DAYS, type IssueOutcome } from '../lib/certissue';
|
|
|
|
|
|
import { AcmeClient } from '../lib/acme';
|
|
|
|
|
|
import { makeDnsProvider } from '../lib/dnsprovider';
|
|
|
|
|
|
import { makeDeployer, OnePanelDeployer } from '../lib/deployer';
|
2026-10-06 14:19:01 +08:00
|
|
|
|
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)');
|
|
|
|
|
|
|
2026-10-06 15:55:22 +08:00
|
|
|
|
// ★ 探测走国内机 editor-api:Workers 拿不到对端证书正文(node:tls 是桩函数),
|
|
|
|
|
|
// 本机是真 Node 才行。传 base + 共享令牌,失败时 probeTls 内部自动降级。
|
|
|
|
|
|
const info = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN);
|
2026-10-06 14:19:01 +08:00
|
|
|
|
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;
|
2026-10-06 15:55:22 +08:00
|
|
|
|
const live = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN);
|
2026-10-06 14:19:01 +08:00
|
|
|
|
|
|
|
|
|
|
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) =>
|
|
|
|
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 16:59:37 +08:00
|
|
|
|
// ==================================================================== 签发 / 续期
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 手动签发一个域名(或强制续期)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 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 };
|
|
|
|
|
|
|
|
|
|
|
|
const cfg = await loadConfig(ctx.env);
|
|
|
|
|
|
const only = String(body.domain || '').trim();
|
|
|
|
|
|
const targets = cfg.domains.filter((d) => !only || d.name === only);
|
|
|
|
|
|
if (!targets.length) return fail(400, only ? `配置里没有域名「${only}」` : '配置里还没有域名');
|
|
|
|
|
|
|
|
|
|
|
|
await log(ctx, w.ident, 'issue', `${only || '全部域名'}:开始${body.force ? '强制' : ''}签发`, 'info', only);
|
|
|
|
|
|
const results: IssueOutcome[] = [];
|
|
|
|
|
|
for (const d of targets) {
|
|
|
|
|
|
const r = await issueDomain(ctx.env, d, {
|
|
|
|
|
|
force: !!body.force,
|
|
|
|
|
|
noDeploy: !!body.noDeploy,
|
|
|
|
|
|
by: w.ident.name || '管理员',
|
|
|
|
|
|
});
|
|
|
|
|
|
results.push(r);
|
|
|
|
|
|
}
|
|
|
|
|
|
return ok({ results });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 只看「该不该续期」,不签发 —— 给 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);
|
|
|
|
|
|
const host = d.san.find((s) => !s.startsWith('*.')) || d.name;
|
|
|
|
|
|
const live = await probeTls(host, 8000, ctx.env.EDITOR_API_BASE, ctx.env.EDITOR_TOKEN);
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 14:19:01 +08:00
|
|
|
|
// ==================================================================== 会话
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 「我是谁 + 我能不能用证书管家」。
|
|
|
|
|
|
* 后台前端在决定要不要画「证书管家」这一项时调它 —— 与 /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 },
|
2026-10-06 16:59:37 +08:00
|
|
|
|
// ★ 签发/续期: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 },
|
2026-10-06 14:19:01 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
export type { UserRow };
|