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