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 项 ✓
This commit is contained in:
1 parent
636b19d46d
commit
a7c90fe336
16 files changed
+2791
-59
No files matched your search
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 证书探针 —— 从**外部**实测域名的证书状态。
|
||||
*
|
||||
* 为什么不信 KV 里自己存的那份:KV 里的是「我们上次签发的」,而线上真实
|
||||
* 挂着的可能是别的 CA 签的、可能是手工换过的、可能部署失败了根本没上。
|
||||
* 只有真去握一次手,才知道读者看到的证书是什么。
|
||||
*
|
||||
* 手段:`https://<host>/.well-known/artalk-cf-probe` 发一次 HEAD 请求,
|
||||
* Cloudflare Workers 的 `fetch` 走 TLS,我们可以从 `Response` 上拿不到
|
||||
* 证书 —— 所以换一条路:**用一个只回证书信息的第三方回显接口**并不存在,
|
||||
* 于是这里退一步,只用 `fetch` 的成败 + `cf-*` 不可得的信息做「可达性」判断,
|
||||
* 真正的证书详情(到期日/签发方)由 **KV 里的记录** + 手动上传/续期时的
|
||||
* 解析结果提供。
|
||||
*
|
||||
* ★ 实际上 Workers **确实**提供了 `fetch` 的 TLS 信息:通过
|
||||
* `https://<host>` 建立连接后,响应头里没有证书字段;但 Workers 的
|
||||
* `connect()` socket API 可以拿到 peer certificate。为保持零依赖与
|
||||
* 最小攻击面,这里用 socket 方案(见 probeTls)。
|
||||
*/
|
||||
|
||||
export interface TlsInfo {
|
||||
ok: boolean;
|
||||
/** 实际协商到的 TLS 版本,如 TLSv1.3 */
|
||||
tlsVersion?: string;
|
||||
/** 证书到期时间(毫秒时间戳) */
|
||||
notAfter?: number;
|
||||
notBefore?: number;
|
||||
subject?: string;
|
||||
issuer?: string;
|
||||
/** 证书覆盖的域名 */
|
||||
altNames?: string[];
|
||||
/** 失败原因(ok=false 时) */
|
||||
error?: string;
|
||||
/** 是否在握手时就被拒绝(SNI 不匹配 / 证书过期) */
|
||||
handshakeFailed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 Workers 的 TCP socket 直连 443 拉对端证书。
|
||||
*
|
||||
* ★ 为什么不用 fetch:fetch 成功只说明 TLS 握手过了,拿不到证书正文,
|
||||
* 而这正是证书管家最需要的东西(到期日、SAN、签发方)。
|
||||
* `cloudflare:sockets` 的 `connect()` 返回的对象上有 `getPeerCertificate()`,
|
||||
* 是 Workers 里唯一能拿到证书的官方途径。
|
||||
*
|
||||
* 注意:这个 API 在本地 `wrangler dev` 的部分版本里不可用,会抛错 ——
|
||||
* 所以调用方必须能接受 `ok:false, error:'...'`,不能让它把整个接口带崩。
|
||||
*/
|
||||
export async function probeTls(host: string, timeoutMs = 8000): Promise<TlsInfo> {
|
||||
const h = String(host || '').trim().toLowerCase();
|
||||
if (!h || !/^[a-z0-9.*-]+$/.test(h)) return { ok: false, error: '域名不合法' };
|
||||
|
||||
let socket: { opened: Promise<unknown>; close: () => void; getPeerCertificate?: () => unknown } | null = null;
|
||||
try {
|
||||
// 动态 import:本地 dev 环境没有这个模块时,不至于整个 Worker 起不来
|
||||
const mod = (await import('cloudflare:sockets')) as {
|
||||
connect: (addr: { hostname: string; port: number }, opts?: { secureTransport?: string; allowHalfOpen?: boolean }) => unknown;
|
||||
};
|
||||
const raw = mod.connect(
|
||||
{ hostname: h, port: 443 },
|
||||
{ secureTransport: 'on', allowHalfOpen: false },
|
||||
) as unknown as {
|
||||
opened: Promise<unknown>;
|
||||
close: () => void;
|
||||
getPeerCertificate?: () => unknown;
|
||||
};
|
||||
socket = raw;
|
||||
|
||||
const timeout = new Promise<never>((_, rej) =>
|
||||
setTimeout(() => rej(new Error('握手超时')), timeoutMs),
|
||||
);
|
||||
await Promise.race([raw.opened, timeout]);
|
||||
|
||||
const certOf = raw.getPeerCertificate;
|
||||
if (typeof certOf !== 'function') {
|
||||
raw.close();
|
||||
return { ok: true, handshakeFailed: false, error: '当前运行时拿不到证书正文(只确认了可达)' };
|
||||
}
|
||||
const cert = certOf.call(raw) as {
|
||||
notAfter?: number | string;
|
||||
notBefore?: number | string;
|
||||
subject?: string;
|
||||
issuer?: string;
|
||||
subjectaltname?: string;
|
||||
} | null;
|
||||
raw.close();
|
||||
if (!cert) return { ok: false, error: '对端没返回证书' };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
notAfter: toMs(cert.notAfter),
|
||||
notBefore: toMs(cert.notBefore),
|
||||
subject: String(cert.subject || ''),
|
||||
issuer: String(cert.issuer || ''),
|
||||
altNames: parseAltNames(String(cert.subjectaltname || '')),
|
||||
};
|
||||
} catch (e) {
|
||||
try {
|
||||
socket?.close();
|
||||
} catch {
|
||||
/* 已经关了 */
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: msg, handshakeFailed: /handshake|tls|certificate/i.test(msg) };
|
||||
}
|
||||
}
|
||||
|
||||
function toMs(v: number | string | undefined): number | undefined {
|
||||
if (v == null) return undefined;
|
||||
if (typeof v === 'number') return v;
|
||||
const t = Date.parse(v);
|
||||
return Number.isFinite(t) ? t : undefined;
|
||||
}
|
||||
|
||||
/** `DNS:usj.cc, DNS:*.usj.cc` → `['usj.cc','*.usj.cc']` */
|
||||
function parseAltNames(s: string): string[] {
|
||||
return s
|
||||
.split(',')
|
||||
.map((x) => x.trim().replace(/^DNS:/i, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 PEM 证书正文里读到期日 / SAN。
|
||||
* ACME 签发完、或者用户手工粘贴一张证书时用它落库(不用再连一次网络)。
|
||||
*
|
||||
* 实现上走 `crypto.subtle` 的 X.509 解析没有标准 API,
|
||||
* 所以这里只做**最小解析**:从 base64 DER 里定位 UTCTime/GeneralizedTime。
|
||||
* 够用且不引依赖 —— 真正的权威判断还是 probeTls 的实测。
|
||||
*/
|
||||
export function parsePemInfo(pem: string): { notAfter?: number; altNames?: string[] } {
|
||||
const b64 = String(pem || '')
|
||||
.replace(/-----BEGIN CERTIFICATE-----/g, '')
|
||||
.replace(/-----END CERTIFICATE-----/g, '')
|
||||
.replace(/\s+/g, '');
|
||||
if (!b64) return {};
|
||||
let der: string;
|
||||
try {
|
||||
der = atob(b64);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
// 取最后一个「看起来像时间」的序列:证书里 validity 是 notBefore / notAfter 一对,
|
||||
// 直接按 ASN.1 精解太重,这里取两个 UTC/Generalized 时间里的后一个。
|
||||
const times: number[] = [];
|
||||
const re = /(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z/g;
|
||||
const reG = /(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z/g;
|
||||
for (const m of der.matchAll(re)) {
|
||||
const y = 2000 + Number(m[1]);
|
||||
times.push(Date.UTC(y, Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])));
|
||||
}
|
||||
for (const m of der.matchAll(reG)) {
|
||||
times.push(
|
||||
Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])),
|
||||
);
|
||||
}
|
||||
// SAN 的 DNS 名字在 DER 里是可见 ASCII(标签 0x82 后跟长度)
|
||||
const alt: string[] = [];
|
||||
const san = der.match(/[a-z0-9*.-]+\.[a-z]{2,}/gi) || [];
|
||||
for (const s of san) {
|
||||
const v = s.toLowerCase();
|
||||
if (!alt.includes(v) && !v.includes('..')) alt.push(v);
|
||||
}
|
||||
const sorted = times.filter((t) => t > Date.UTC(2000, 0, 1)).sort((a, b) => a - b);
|
||||
return {
|
||||
notAfter: sorted.length ? sorted[sorted.length - 1] : undefined,
|
||||
altNames: alt.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
/** 剩余天数(向上取整;已过期返回负数) */
|
||||
export function daysLeft(expireAt: number | undefined, at = Date.now()): number | null {
|
||||
if (!expireAt) return null;
|
||||
return Math.ceil((expireAt - at) / 86400000);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 证书管家的**唯一数据入口** —— 所有 KV 读写都收在这里。
|
||||
*
|
||||
* 为什么不直接在路由里 `env.RSS_KV.get(...)`:证书管家的数据分四类
|
||||
* (域名配置 / 凭据 / 证书 / 日志),每类的加密要求、缺省值、迁移逻辑都不一样。
|
||||
* 散在路由里写,迟早出现「某处读配置忘了兜默认值 → 线上配置被空对象覆盖」。
|
||||
*
|
||||
* KV 键位(复用已有的 RSS_KV namespace,不新建 —— 免费额度共用一份就够):
|
||||
* certkeeper:config 域名配置 + 通知设置 明文(无敏感信息)
|
||||
* certkeeper:access:<name> 单条凭据 ★ AES-GCM 密文
|
||||
* certkeeper:cert:<domain> 签发的证书与私钥 ★ AES-GCM 密文
|
||||
* certkeeper:log 最近 N 条执行记录 明文
|
||||
*
|
||||
* ★ 为什么凭据**一条一键**,不合成一个大对象:
|
||||
* KV 的「读-改-写」没有事务。合成一个对象意味着「改 A 的凭据」要
|
||||
* 读全量 → 改 → 写回全量,两个管理员同时改不同凭据必然丢一个。
|
||||
* 一条一键的最坏情况是「同一把凭据被同时改」。更关键的是:不返回明文的
|
||||
* 凭据列表接口**根本不需要把私钥读进内存**,泄露面小一个数量级。
|
||||
*/
|
||||
import type { Env } from '../types';
|
||||
import { isSealed, maskSecret, openJson, peekJson, sealJson } from './certvault';
|
||||
|
||||
const P = 'certkeeper:';
|
||||
|
||||
/** 列 key(KV list 分页),只回名字 */
|
||||
async function listKeys(env: Env, prefix: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (;;) {
|
||||
const page = await env.RSS_KV.list({ prefix: P + prefix, cursor });
|
||||
for (const k of page.keys) out.push(k.name.slice((P + prefix).length));
|
||||
// ★ KVNamespaceListResult 是「完成/未完成」的联合类型,只有未完成那支才有
|
||||
// cursor 字段。写成 `cursor = page.list_complete ? undefined : page.cursor`
|
||||
// 收窄不了(三元表达式里 page 不会按分支收窄),必须用 if 让 TS 认得出来。
|
||||
if (page.list_complete) break;
|
||||
cursor = page.cursor;
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
// ==================================================================== 类型
|
||||
|
||||
/** 单条凭据的类型标签 —— 决定加密之外还有哪些必填字段 */
|
||||
export type AccessType =
|
||||
| 'tencentcloud' // 腾讯云 CAM(DNSPod DNS-01 用)
|
||||
| 'cloudflare' // CF API Token(DNS + 部署)
|
||||
| 'dogecloud' // 多吉云 AK/SK(CDN 证书部署)
|
||||
| '1panel' // 1Panel 面板 API
|
||||
| 'acme-eab'; // ACME External Account Binding(LiteSSL / ZeroSSL)
|
||||
|
||||
export interface AccessRecord {
|
||||
type: AccessType;
|
||||
/** 人类可读的备注,列表里显示 */
|
||||
note?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DomainConfig {
|
||||
name: string;
|
||||
san: string[];
|
||||
/** 引用凭据名(不是凭据本身),见 AccessRecord */
|
||||
dns: string;
|
||||
/** 部署目标 */
|
||||
deploy: string[];
|
||||
dogecloud_domains?: string[];
|
||||
one_panel_sites?: string[];
|
||||
/** 关掉这个域名的一切自动动作(保留配置,只是不跑) */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface NotifyConfig {
|
||||
emails: string[];
|
||||
/** 剩余多少天开始提醒 */
|
||||
daysBefore: number;
|
||||
}
|
||||
|
||||
export interface KeeperConfig {
|
||||
/** ★ 前向兼容:加字段时递增,读取侧按版本迁移 */
|
||||
version: number;
|
||||
notify: NotifyConfig;
|
||||
domains: DomainConfig[];
|
||||
}
|
||||
|
||||
export interface CertRecord {
|
||||
cert: string;
|
||||
key: string;
|
||||
/** 到期时间(毫秒时间戳) */
|
||||
expireAt: number;
|
||||
/** 签发时间 */
|
||||
updatedAt: number;
|
||||
issuer?: string;
|
||||
/** 覆盖的域名(从证书里解析) */
|
||||
san?: string[];
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
at: number;
|
||||
level: 'info' | 'warn' | 'error';
|
||||
/** 动作:check / renew / deploy / notify */
|
||||
action: string;
|
||||
domain?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const CONFIG_VERSION = 1;
|
||||
|
||||
const DEFAULT_CONFIG: KeeperConfig = {
|
||||
version: CONFIG_VERSION,
|
||||
notify: { emails: [], daysBefore: 30 },
|
||||
domains: [],
|
||||
};
|
||||
|
||||
const LOG_MAX = 200;
|
||||
|
||||
// ==================================================================== 配置
|
||||
|
||||
/**
|
||||
* 读配置。
|
||||
* ★ 读失败(KV 抖动 / 密文格式不对)时**抛错**,不要 return 默认值 ——
|
||||
* 调用方拿到「空配置」之后一路正常,接着一个保存就把它写回去了。
|
||||
* 「读不到」和「本来就是空」必须区分开。
|
||||
*/
|
||||
export async function loadConfig(env: Env): Promise<KeeperConfig> {
|
||||
const raw = await env.RSS_KV.get(P + 'config');
|
||||
if (!raw) return { ...DEFAULT_CONFIG, notify: { ...DEFAULT_CONFIG.notify }, domains: [] };
|
||||
let parsed: KeeperConfig;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as KeeperConfig;
|
||||
} catch {
|
||||
throw new Error('证书配置不是合法 JSON(KV 里可能被写坏了)');
|
||||
}
|
||||
return migrateConfig(parsed);
|
||||
}
|
||||
|
||||
/** 老版本配置 → 当前版本。每次加字段都在这里补一次默认值 */
|
||||
function migrateConfig(c: KeeperConfig): KeeperConfig {
|
||||
const out: KeeperConfig = {
|
||||
version: CONFIG_VERSION,
|
||||
notify: {
|
||||
emails: Array.isArray(c?.notify?.emails) ? c.notify.emails.filter(Boolean) : [],
|
||||
daysBefore: Number(c?.notify?.daysBefore) > 0 ? Number(c.notify.daysBefore) : 30,
|
||||
},
|
||||
domains: Array.isArray(c?.domains) ? c.domains.map(normalizeDomain) : [],
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeDomain(d: DomainConfig): DomainConfig {
|
||||
return {
|
||||
name: String(d?.name || '').trim().toLowerCase(),
|
||||
san: Array.isArray(d?.san) ? d.san.map((s) => String(s).trim().toLowerCase()).filter(Boolean) : [],
|
||||
dns: String(d?.dns || '').trim(),
|
||||
deploy: Array.isArray(d?.deploy) ? d.deploy.map((s) => String(s).trim()).filter(Boolean) : [],
|
||||
...(d?.dogecloud_domains ? { dogecloud_domains: d.dogecloud_domains.map(String) } : {}),
|
||||
...(d?.one_panel_sites ? { one_panel_sites: d.one_panel_sites.map(String) } : {}),
|
||||
...(d?.disabled ? { disabled: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveConfig(env: Env, cfg: KeeperConfig): Promise<KeeperConfig> {
|
||||
const clean = migrateConfig(cfg);
|
||||
await env.RSS_KV.put(P + 'config', JSON.stringify(clean));
|
||||
return clean;
|
||||
}
|
||||
|
||||
// ==================================================================== 凭据
|
||||
|
||||
export async function putAccess(env: Env, name: string, rec: AccessRecord): Promise<void> {
|
||||
const key = String(name || '').trim();
|
||||
if (!key) throw new Error('凭据名不能为空');
|
||||
await env.RSS_KV.put(P + 'access:' + key, await sealJson(env, rec));
|
||||
}
|
||||
|
||||
/** 取凭据明文。**只给真正要调用外部 API 的代码用**,不要往 HTTP 响应里塞 */
|
||||
export async function getAccess(env: Env, name: string): Promise<AccessRecord | null> {
|
||||
const raw = await env.RSS_KV.get(P + 'access:' + String(name || '').trim());
|
||||
if (!raw) return null;
|
||||
if (!isSealed(raw)) {
|
||||
// 兼容:如果之前手工塞过明文 JSON(调试期),也认,但下次保存会转成密文
|
||||
try {
|
||||
return JSON.parse(raw) as AccessRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return openJson<AccessRecord>(env, raw);
|
||||
}
|
||||
|
||||
export async function delAccess(env: Env, name: string): Promise<void> {
|
||||
await env.RSS_KV.delete(P + 'access:' + String(name || '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有凭据的**脱敏摘要**。
|
||||
*
|
||||
* ★ 返回值里**没有明文密钥**:只保留 type / note 这类非敏感字段,
|
||||
* 以及各个密钥字段的「尾 4 位」(如 `AKID****3f2a`),让管理员认得出
|
||||
* 是哪把钥匙,又拿不到完整值。要改就是整条重填。
|
||||
*
|
||||
* ★ 为什么不像注释里说的那样「不解密」:type 和 note 本身就在密文里。
|
||||
* 这里必须逐个解密,但**只在内存里存在一瞬**,且返回值是脱敏结构 ——
|
||||
* 比解密后原样吐给浏览器安全得多。唯一真正的替代方案是把 type/note
|
||||
* 另存一份明文,那样反而多一处不一致的来源。
|
||||
*/
|
||||
export async function listAccess(env: Env): Promise<
|
||||
{ name: string; type: string; note: string; fields: Record<string, string>; unreadable?: boolean }[]
|
||||
> {
|
||||
const names = await listKeys(env, 'access:');
|
||||
const out: {
|
||||
name: string;
|
||||
type: string;
|
||||
note: string;
|
||||
fields: Record<string, string>;
|
||||
unreadable?: boolean;
|
||||
}[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
const raw = await env.RSS_KV.get(P + 'access:' + name);
|
||||
if (!raw) continue;
|
||||
const rec = isSealed(raw)
|
||||
? await peekJson<AccessRecord>(env, raw)
|
||||
: (() => {
|
||||
try {
|
||||
return JSON.parse(raw) as AccessRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!rec) {
|
||||
// 解不开也要列出来 —— 否则密钥轮换后管理员看不到「这里还有一条凭据」,
|
||||
// 会以为是空的,然后新建一条重名覆盖。
|
||||
out.push({ name, type: '', note: '', fields: {}, unreadable: true });
|
||||
continue;
|
||||
}
|
||||
const fields: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(rec)) {
|
||||
if (k === 'type' || k === 'note') continue;
|
||||
if (typeof v === 'string' || typeof v === 'number') fields[k] = maskSecret(v);
|
||||
}
|
||||
out.push({ name, type: String(rec.type || ''), note: String(rec.note || ''), fields });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ==================================================================== 证书
|
||||
|
||||
export async function putCert(env: Env, domain: string, rec: CertRecord): Promise<void> {
|
||||
const key = String(domain || '').trim().toLowerCase();
|
||||
if (!key) throw new Error('域名不能为空');
|
||||
await env.RSS_KV.put(P + 'cert:' + key, await sealJson(env, rec));
|
||||
}
|
||||
|
||||
export async function getCert(env: Env, domain: string): Promise<CertRecord | null> {
|
||||
const raw = await env.RSS_KV.get(P + 'cert:' + String(domain || '').trim().toLowerCase());
|
||||
if (!raw) return null;
|
||||
if (!isSealed(raw)) return null;
|
||||
try {
|
||||
return await openJson<CertRecord>(env, raw);
|
||||
} catch {
|
||||
// 密钥换了之后老密文解不开。当作「没有这张证书」比让整个列表接口 500 好,
|
||||
// 上层看到「无证书」会提示重签,实际也是对的处置。
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function delCert(env: Env, domain: string): Promise<void> {
|
||||
await env.RSS_KV.delete(P + 'cert:' + String(domain || '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
export async function listCertNames(env: Env): Promise<string[]> {
|
||||
return listKeys(env, 'cert:');
|
||||
}
|
||||
|
||||
// ==================================================================== 日志
|
||||
|
||||
/**
|
||||
* 追加一条执行日志(环形缓冲,最多 200 条)。
|
||||
*
|
||||
* ★ 这个函数是**并发不安全**的(读-改-写)。但日志丢一条无关紧要,
|
||||
* 绝不能为了它引入锁或者 D1 表 —— 证书续期一天才跑一次。
|
||||
* 真正的账要记在「证书本身」上(cert:<domain> 的 updatedAt),日志只做参考。
|
||||
*/
|
||||
export async function appendLog(env: Env, entry: LogEntry): Promise<void> {
|
||||
try {
|
||||
const cur = await loadLog(env);
|
||||
cur.unshift(entry);
|
||||
await env.RSS_KV.put(P + 'log', JSON.stringify(cur.slice(0, LOG_MAX)));
|
||||
} catch (e) {
|
||||
console.error('[certkeeper] 写日志失败:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLog(env: Env): Promise<LogEntry[]> {
|
||||
const raw = await env.RSS_KV.get(P + 'log');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
return Array.isArray(v) ? (v as LogEntry[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearLog(env: Env): Promise<void> {
|
||||
await env.RSS_KV.delete(P + 'log');
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 证书管家的「保险箱」—— 用 TOKEN_SECRET 派生密钥做 AES-GCM 加解密。
|
||||
*
|
||||
* 为什么必须有这一层:KV 里要存**私钥**和**云厂商 AK/SK**。
|
||||
* KV 本身是明文的,一旦某个有 KV 读权限的 token 泄露,等于把 TLS 私钥
|
||||
* 和腾讯云 CAM 密钥一起送出去。加密之后泄露的只是密文。
|
||||
*
|
||||
* 设计取舍:
|
||||
* · 密钥不另外配 secret —— 复用 TOKEN_SECRET(已经是 wrangler secret)。
|
||||
* 多一个 secret 就多一处「忘了配 → 线上 500」的坑。
|
||||
* · 用 PBKDF2 从 TOKEN_SECRET 派生,而不是直接拿它当 AES key:
|
||||
* TOKEN_SECRET 是给人看的字符串,长度/熵都不确定;派生一步拿到确定的
|
||||
* 256 bit,也顺手把「同密钥别跨用途复用」这件事做干净。
|
||||
* · 每条密文带**自己的随机 IV**(12 字节 GCM 标准长度),拼成
|
||||
* `v1.<iv_b64>.<ct_b64>`。带版本前缀是为了将来换算法时能识别老数据。
|
||||
*
|
||||
* ★ 别把这里的东西拿去加密用户密码 —— 那是 PBKDF2 单向哈希的活(见 session.ts)。
|
||||
*/
|
||||
import type { Env } from '../types';
|
||||
|
||||
const VERSION = 'v1';
|
||||
const IV_BYTES = 12;
|
||||
const SALT = 'artalk-cf:certvault:v1'; // 固定盐:只要密钥不变就能解开历史密文
|
||||
|
||||
function b64(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
function unb64(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function vaultKey(env: Env): Promise<CryptoKey> {
|
||||
const secret = String(env.TOKEN_SECRET || '');
|
||||
if (!secret) throw new Error('TOKEN_SECRET 未配置,保险箱无法工作');
|
||||
const base = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
hash: 'SHA-256',
|
||||
salt: new TextEncoder().encode(SALT),
|
||||
iterations: 100000,
|
||||
},
|
||||
base,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
/** 明文对象 → `v1.<iv>.<ct>` 密文字符串(可直接落 KV) */
|
||||
export async function sealJson(env: Env, value: unknown): Promise<string> {
|
||||
const key = await vaultKey(env);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
||||
const pt = new TextEncoder().encode(JSON.stringify(value ?? null));
|
||||
const ct = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv as unknown as BufferSource },
|
||||
key,
|
||||
pt as unknown as BufferSource,
|
||||
);
|
||||
return `${VERSION}.${b64(iv)}.${b64(new Uint8Array(ct))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 密文字符串 → 明文对象。
|
||||
* 解不开(密钥换了 / 数据被改 / 格式不对)一律抛错 —— **绝不返回半成品**,
|
||||
* 否则调用方会拿一个空对象当「配置为空」把线上配置覆盖掉。
|
||||
*/
|
||||
export async function openJson<T>(env: Env, sealed: string): Promise<T> {
|
||||
const parts = String(sealed || '').split('.');
|
||||
if (parts.length !== 3 || parts[0] !== VERSION) {
|
||||
throw new Error('密文格式不认识(可能来自更早的版本)');
|
||||
}
|
||||
const key = await vaultKey(env);
|
||||
const iv = unb64(parts[1]);
|
||||
const ct = unb64(parts[2]);
|
||||
const pt = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv as unknown as BufferSource },
|
||||
key,
|
||||
ct as unknown as BufferSource,
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(pt)) as T;
|
||||
}
|
||||
|
||||
/** 是不是本模块产出的密文(判断 KV 里存的是明文老数据还是密文) */
|
||||
export function isSealed(v: unknown): boolean {
|
||||
return typeof v === 'string' && v.startsWith(VERSION + '.') && v.split('.').length === 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读**明文摘要**:密文解不开时也要能列出「有哪些凭据」,不能整个接口 500。
|
||||
* 返回 null 表示这条读不出来(密文格式不认识 / 密钥已轮换)。
|
||||
*
|
||||
* ★ 注意与 openJson 的区别:openJson 解不开就抛(用于**要拿值去用**的场景),
|
||||
* 这里解不开返回 null(用于**只看名字和类型**的场景)。混用会很危险。
|
||||
*/
|
||||
export async function peekJson<T>(env: Env, sealed: string): Promise<T | null> {
|
||||
try {
|
||||
return await openJson<T>(env, sealed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏:只保留前 4 位 + 后 4 位,中间打星。
|
||||
* 用于「凭据列表」接口 —— 要让用户认得出是哪把钥匙,又不能让他(或者
|
||||
* 偷看他屏幕的人)拿到完整值。
|
||||
*/
|
||||
export function maskSecret(v: unknown): string {
|
||||
const s = String(v ?? '');
|
||||
if (!s) return '';
|
||||
if (s.length <= 8) return '*'.repeat(s.length);
|
||||
return s.slice(0, 4) + '*'.repeat(Math.min(8, s.length - 8)) + s.slice(-4);
|
||||
}
|
||||
@@ -7,10 +7,12 @@
|
||||
*
|
||||
* 两处事实源的关系:
|
||||
* · `users.is_admin` 是历史字段,评论/通知/设置等一大堆既有逻辑都在用它 —— 不动。
|
||||
* · `users.role` 是这次新增的('' 普通评论用户 | 'editor' 编辑 | 'admin' 管理员),
|
||||
* 只有它才能表达「编辑」这一级。
|
||||
* · `users.role` 是这次新增的('' 普通评论用户 | 'editor' 编辑 | 'admin' 管理员
|
||||
* | 'ssl' SSL 管理员),只有它才能表达「编辑」「SSL」这两级。
|
||||
* · 两者必须同步:role='admin' ⇔ is_admin=1。写入走 `normalizeRole()`,
|
||||
* 读取走 `roleOf()`(以 is_admin 优先,兜住没同步过的历史行)。
|
||||
* · ★ 'ssl' 是**平级**角色而不是某种编辑:它只拿证书管家的钥匙,
|
||||
* 写作后台、评论管理一个都进不去。判定要正着写,别用「非 admin 即放行」。
|
||||
*/
|
||||
import type { UserRole } from '../types';
|
||||
|
||||
@@ -21,20 +23,22 @@ export interface RoleCarrier {
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化读取:只认三种结果。
|
||||
* 归一化读取:只认四种结果。
|
||||
* ★ is_admin 优先 —— 历史行可能没有 role 列的值(ALTER 之前建的账号),
|
||||
* 但 is_admin=1 是确定的,不能因为 role 是空串就把老管理员降级。
|
||||
*/
|
||||
export function roleOf(u: RoleCarrier | null | undefined): UserRole {
|
||||
if (!u) return 'user';
|
||||
if (u.is_admin) return 'admin';
|
||||
return String(u.role || '') === 'editor' ? 'editor' : 'user';
|
||||
const r = String(u.role || '');
|
||||
if (r === 'editor' || r === 'ssl') return r;
|
||||
return 'user';
|
||||
}
|
||||
|
||||
/** 写入方向:把前端传来的一对(role / is_admin)折叠成一个规范值 */
|
||||
export function normalizeRole(role: unknown, isAdmin: unknown): 'admin' | 'editor' | '' {
|
||||
export function normalizeRole(role: unknown, isAdmin: unknown): 'admin' | 'editor' | 'ssl' | '' {
|
||||
const r = String(role ?? '').trim().toLowerCase();
|
||||
if (r === 'admin' || r === 'editor') return r;
|
||||
if (r === 'admin' || r === 'editor' || r === 'ssl') return r;
|
||||
if (r === '' && isAdmin === undefined) return '';
|
||||
// 没给 role(老前端只给 is_admin)时按 is_admin 推
|
||||
if (role === undefined || role === null) return isAdmin ? 'admin' : '';
|
||||
@@ -42,12 +46,25 @@ export function normalizeRole(role: unknown, isAdmin: unknown): 'admin' | 'edito
|
||||
return isAdmin ? 'admin' : '';
|
||||
}
|
||||
|
||||
/** 能进写作后台的角色(管理员 + 编辑) */
|
||||
/** 能进写作后台的角色(管理员 + 编辑)—— SSL 管理员**不在其列** */
|
||||
export function canWritePosts(u: RoleCarrier | null | undefined): boolean {
|
||||
const r = roleOf(u);
|
||||
return r === 'admin' || r === 'editor';
|
||||
}
|
||||
|
||||
/**
|
||||
* 能开证书管家后台的角色(管理员 + SSL 管理员)。
|
||||
*
|
||||
* ★ 判定必须**正着写**(枚举放行),不要写成 `role !== 'editor'`。
|
||||
* 血泪教训同 editor.ts 里的 isAdminRequest 兜底:一旦用了「排除法」,
|
||||
* 以后新增任何角色都会静默获得证书管家的权限 —— 而这里能拿到
|
||||
* TLS 私钥和云厂商 AK/SK,越权的代价太大。
|
||||
*/
|
||||
export function canManageSSL(u: RoleCarrier | null | undefined): boolean {
|
||||
const r = roleOf(u);
|
||||
return r === 'admin' || r === 'ssl';
|
||||
}
|
||||
|
||||
/** 编辑角色在文章维度上只能碰自己的(管理员不受限) */
|
||||
export function isScopedEditor(u: RoleCarrier | null | undefined): boolean {
|
||||
return roleOf(u) === 'editor';
|
||||
@@ -55,5 +72,8 @@ export function isScopedEditor(u: RoleCarrier | null | undefined): boolean {
|
||||
|
||||
/** 给前端展示的角色名(后台用户管理列表用) */
|
||||
export function roleLabel(role: UserRole): string {
|
||||
return role === 'admin' ? '管理员' : role === 'editor' ? '编辑' : '';
|
||||
if (role === 'admin') return '管理员';
|
||||
if (role === 'editor') return '编辑';
|
||||
if (role === 'ssl') return 'SSL 管理员';
|
||||
return '';
|
||||
}
|
||||
Reference in new issue
Block a user