feat(server): W2-A login gate, secrets, settings, instances, audit, engine panel API and PG-backed SSE bus
- Admin login gate (docs/design/auth-reference.md): admin_user/admin_session/login_attempt
(migration 0015), one-time setup token logged once + conditional UPDATE, argon2id via
hash-wasm (WASM; compiled binary has no --allow-ffi), sha256-stored sessions in HttpOnly
SameSite=Lax cookie umt_session (fixed 12h TTL, soft revoke), HMAC-derived double-submit
CSRF, IP (5) + account (15) failure limits per 15 min, password change revokes other
sessions, optional TOTP with replay counter, AuditAction enum.
- Unified auth middleware: cookie session or Bearer API key -> c.var.actor
{type: admin|api_key, id, name, scopes}; requireScope works for both; requireAdminSession.
- ctx.secrets: AES-256-GCM, per-secret HKDF-SHA256 key from SECRET_MASTER_KEY, AAD bound
to (id, purpose); ctx.settings: 30s cache invalidated by settings.changed events.
- Endpoints: /auth/*, /settings, /api-keys, /secrets, /audit-events, library-roots,
downloader/media-server instances (+test), path-mappings (+verify),
PUT /sites/{id}/credentials, /engine/runs|stats|cancel (read-only openworkflow schema).
- SSE: PgEventBus (sse_event table, migration 0016, LISTEN/NOTIFY umt_events, catch-up by
seq, pruning, resync); EventBusPort is now async.
- Tests: login/rate limit/revocation/CSRF/TOTP, secrets round trip and no log leakage,
admin APIs against mock qB/Emby, SSE across two Deno subprocesses.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
07db0dac5b
commit
ff230e5da7
34 files changed
+4363
-78
No files matched your search
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
AdminSetupRequest,
|
||||
CSRF_COOKIE,
|
||||
LoginRequest,
|
||||
LoginResult,
|
||||
PasswordChangeRequest,
|
||||
PasswordChangeResult,
|
||||
SESSION_COOKIE,
|
||||
SessionInfo,
|
||||
TotpAction,
|
||||
TotpActionResult,
|
||||
TotpStatus,
|
||||
} from "@umt/contracts";
|
||||
import type { Context } from "hono";
|
||||
import { deleteCookie, setCookie } from "hono/cookie";
|
||||
import { z } from "zod";
|
||||
import { type AuthEnv, deriveCsrfToken, requireAdminSession } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import {
|
||||
adminActor,
|
||||
type CreatedSession,
|
||||
changePassword,
|
||||
createSession,
|
||||
loadAdmin,
|
||||
revokeSession,
|
||||
sessionInfo,
|
||||
setupAdmin,
|
||||
totpBegin,
|
||||
totpDisable,
|
||||
totpEnable,
|
||||
totpStatus,
|
||||
verifyLogin,
|
||||
} from "./auth-service.ts";
|
||||
import { audit, type Def, endpoint } from "./endpoint.ts";
|
||||
|
||||
// 登录门禁端点:/auth/setup、/auth/login、/auth/session 免鉴权(见 http/auth.ts PUBLIC_AUTH_PATHS),
|
||||
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
|
||||
|
||||
const tags = ["auth"];
|
||||
const OkResult = z.object({ ok: z.literal(true) });
|
||||
|
||||
const D = {
|
||||
setup: {
|
||||
method: "post",
|
||||
path: "/auth/setup",
|
||||
scope: "read",
|
||||
summary: "首次初始化管理员",
|
||||
body: AdminSetupRequest,
|
||||
response: SessionInfo,
|
||||
tags,
|
||||
},
|
||||
login: {
|
||||
method: "post",
|
||||
path: "/auth/login",
|
||||
scope: "read",
|
||||
summary: "管理员登录",
|
||||
body: LoginRequest,
|
||||
response: LoginResult,
|
||||
tags,
|
||||
},
|
||||
logout: { method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult, tags },
|
||||
session: {
|
||||
method: "get",
|
||||
path: "/auth/session",
|
||||
scope: "read",
|
||||
summary: "当前登录态(免鉴权)",
|
||||
response: SessionInfo,
|
||||
tags,
|
||||
},
|
||||
password: {
|
||||
method: "post",
|
||||
path: "/auth/password",
|
||||
scope: "admin",
|
||||
summary: "修改密码(吊销其它会话)",
|
||||
body: PasswordChangeRequest,
|
||||
response: PasswordChangeResult,
|
||||
tags,
|
||||
},
|
||||
totpGet: { method: "get", path: "/auth/totp", scope: "admin", summary: "TOTP 状态", response: TotpStatus, tags },
|
||||
totpPost: {
|
||||
method: "post",
|
||||
path: "/auth/totp",
|
||||
scope: "admin",
|
||||
summary: "TOTP 绑定/启用/停用",
|
||||
body: TotpAction,
|
||||
response: TotpActionResult,
|
||||
tags,
|
||||
},
|
||||
} as const satisfies Record<string, Def>;
|
||||
|
||||
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i;
|
||||
|
||||
/**
|
||||
* 客户端 IP:只有直连对端是回环/私网(即前面有自己的反代)时才信 X-Real-IP / X-Forwarded-For 最后一跳,
|
||||
* 否则用对端地址——防止客户端伪造头绕过 IP 限速(对应 Caddy trusted_proxies_strict)。
|
||||
*/
|
||||
export function clientIp(c: Context<AuthEnv>): string {
|
||||
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
|
||||
if (remote && !PRIVATE_IP.test(remote)) return remote;
|
||||
const xri = c.req.header("x-real-ip")?.trim();
|
||||
const xff = c.req
|
||||
.header("x-forwarded-for")
|
||||
?.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.at(-1);
|
||||
return xri || xff || remote || "unknown";
|
||||
}
|
||||
|
||||
function isHttps(c: Context<AuthEnv>): boolean {
|
||||
if (new URL(c.req.url).protocol === "https:") return true;
|
||||
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
|
||||
if (remote && !PRIVATE_IP.test(remote)) return false;
|
||||
return c.req.header("x-forwarded-proto")?.split(",")[0]?.trim() === "https";
|
||||
}
|
||||
|
||||
function issueCookies(c: Context<AuthEnv>, s: { token: string; expiresAt: Date }) {
|
||||
const secure = isHttps(c);
|
||||
setCookie(c, SESSION_COOKIE, s.token, { httpOnly: true, sameSite: "Lax", path: "/", secure, expires: s.expiresAt });
|
||||
setCookie(c, CSRF_COOKIE, deriveCsrfToken(s.token), {
|
||||
httpOnly: false,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
secure,
|
||||
expires: s.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
function clearCookies(c: Context<AuthEnv>) {
|
||||
deleteCookie(c, SESSION_COOKIE, { path: "/" });
|
||||
deleteCookie(c, CSRF_COOKIE, { path: "/" });
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
const deps = () => ({ db: ctx.db, logger: ctx.logger, secrets: ctx.secrets });
|
||||
const meta = (c: Context<AuthEnv>) => ({ ip: clientIp(c), userAgent: c.req.header("user-agent") ?? null });
|
||||
|
||||
const startSession = async (c: Context<AuthEnv>, adminId: string): Promise<CreatedSession> => {
|
||||
const s = await createSession(ctx.db, adminId, meta(c));
|
||||
issueCookies(c, s);
|
||||
return s;
|
||||
};
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.setup,
|
||||
async ({ body, c }) => {
|
||||
const admin = await setupAdmin(deps(), body, clientIp(c));
|
||||
const s = await startSession(c, admin.id);
|
||||
const actor = adminActor(admin);
|
||||
await audit(ctx, actor, "admin.setup", { type: "admin_user", id: admin.id }, { username: admin.username });
|
||||
ctx.logger.info({ username: admin.username }, "管理员已初始化");
|
||||
return await sessionInfo(ctx.db, actor, s.expiresAt);
|
||||
},
|
||||
{ public: true },
|
||||
);
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.login,
|
||||
async ({ body, c }) => {
|
||||
const r = await verifyLogin(deps(), body, clientIp(c));
|
||||
if (r.status === "totp_required") return { status: "totp_required" as const };
|
||||
const s = await startSession(c, r.admin.id);
|
||||
const actor = adminActor(r.admin);
|
||||
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
|
||||
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
||||
},
|
||||
{ public: true },
|
||||
);
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.session,
|
||||
async ({ c }) => {
|
||||
const session = c.get("session");
|
||||
// CSRF cookie 丢了(被清)时顺手补发
|
||||
if (session) {
|
||||
setCookie(c, CSRF_COOKIE, deriveCsrfToken(session.token), {
|
||||
httpOnly: false,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
secure: isHttps(c),
|
||||
expires: session.expiresAt,
|
||||
});
|
||||
}
|
||||
return await sessionInfo(ctx.db, c.get("actor"), session?.expiresAt ?? null);
|
||||
},
|
||||
{ public: true },
|
||||
);
|
||||
|
||||
endpoint(app, D.logout, async ({ c, actor }) => {
|
||||
const session = c.get("session");
|
||||
if (session) {
|
||||
await revokeSession(ctx.db, session.id, "logout");
|
||||
await audit(ctx, actor, "admin.logout", { type: "admin_session", id: session.id });
|
||||
}
|
||||
clearCookies(c);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.password,
|
||||
async ({ body, c, actor }) => {
|
||||
const session = c.get("session");
|
||||
if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
|
||||
const revoked = await changePassword(deps(), body, {
|
||||
adminUserId: session.adminUserId,
|
||||
sessionId: session.id,
|
||||
ip: clientIp(c),
|
||||
});
|
||||
await audit(ctx, actor, "admin.password_change", { type: "admin_user", id: session.adminUserId }, { revoked });
|
||||
return { revokedSessions: revoked };
|
||||
},
|
||||
{ middleware: [requireAdminSession()] },
|
||||
);
|
||||
|
||||
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)), { middleware: [requireAdminSession()] });
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.totpPost,
|
||||
async ({ body, actor }) => {
|
||||
const admin = await loadAdmin(ctx.db);
|
||||
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
|
||||
if (body.action === "begin") {
|
||||
const r = await totpBegin(deps(), admin);
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
|
||||
}
|
||||
if (body.action === "enable") {
|
||||
await totpEnable(deps(), admin, body.code);
|
||||
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
|
||||
} else {
|
||||
await totpDisable(deps(), admin, body);
|
||||
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
|
||||
}
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)) };
|
||||
},
|
||||
{ middleware: [requireAdminSession()] },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { Actor, SessionInfo } from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import type { Logger } from "pino";
|
||||
import { hashSessionToken } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { SecretStore } from "../types.ts";
|
||||
import {
|
||||
base32Encode,
|
||||
dummyPasswordHash,
|
||||
hashPassword,
|
||||
randomToken,
|
||||
sha256Hex,
|
||||
verifyPassword,
|
||||
verifyTotp,
|
||||
} from "./crypto.ts";
|
||||
|
||||
// 管理员登录门禁的业务逻辑(照 docs/design/auth-reference.md):
|
||||
// setup token 一次性初始化、argon2id、服务端会话(sha256 存库、固定 TTL、软删除吊销)、
|
||||
// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)。
|
||||
|
||||
/** 固定会话时长 12 小时,无滑动续期 */
|
||||
export const SESSION_TTL_MS = 12 * 3600_000;
|
||||
/** 失败限速窗口与阈值(IP、账号各自独立计数) */
|
||||
export const LOGIN_WINDOW_MS = 15 * 60_000;
|
||||
export const MAX_FAILURES_PER_IP = 5;
|
||||
/** 账号维度阈值取 IP 的 3 倍(同 uhd_doc):等于 IP 阈值时,单个 IP 就能把管理员锁在门外 */
|
||||
export const MAX_FAILURES_PER_ACCOUNT = 15;
|
||||
const TOTP_ISSUER = "UHD Media Tool";
|
||||
|
||||
export interface AuthDeps {
|
||||
db: Kysely<DB>;
|
||||
logger: Logger;
|
||||
secrets: SecretStore;
|
||||
}
|
||||
|
||||
export interface AdminRow {
|
||||
id: string;
|
||||
username: string | null;
|
||||
password_hash: string | null;
|
||||
totp_secret_ref: string | null;
|
||||
totp_pending_secret_ref: string | null;
|
||||
totp_enabled_at: Date | null;
|
||||
totp_last_counter: number | null;
|
||||
}
|
||||
|
||||
export async function loadAdmin(db: Kysely<DB>): Promise<AdminRow | null> {
|
||||
const r = await db
|
||||
.selectFrom("admin_user")
|
||||
.select([
|
||||
"id",
|
||||
"username",
|
||||
"password_hash",
|
||||
"totp_secret_ref",
|
||||
"totp_pending_secret_ref",
|
||||
"totp_enabled_at",
|
||||
"totp_last_counter",
|
||||
])
|
||||
.executeTakeFirst();
|
||||
return r ?? null;
|
||||
}
|
||||
|
||||
export const adminActor = (a: { id: string; username: string | null }): Actor => ({
|
||||
type: "admin",
|
||||
id: a.id,
|
||||
name: a.username ?? "admin",
|
||||
scopes: ["admin"],
|
||||
});
|
||||
|
||||
/**
|
||||
* 首次启动:还没有管理员时生成一次性 setup token,只存 sha256,明文打印到日志一次。
|
||||
* 条件 UPSERT:已初始化(password_hash 非空)的行不会被改写;并发启动时各自覆盖,以最后打印的为准。
|
||||
* 返回明文 token(已初始化则 null)。
|
||||
*/
|
||||
export async function ensureSetupToken(deps: Pick<AuthDeps, "db" | "logger">): Promise<string | null> {
|
||||
const token = randomToken(24);
|
||||
const r = await sql<{ id: string }>`
|
||||
insert into admin_user (singleton, setup_token_hash) values (true, ${sha256Hex(token)})
|
||||
on conflict (singleton) do update set setup_token_hash = excluded.setup_token_hash
|
||||
where admin_user.password_hash is null
|
||||
returning id`.execute(deps.db);
|
||||
if (!r.rows.length) return null;
|
||||
// 注意:logger 会把名为 *token* 的字段脱敏,所以 token 放在消息文本里
|
||||
deps.logger.warn(`管理员尚未初始化。一次性 setup token(打开控制台登录页完成初始化;重启会重新生成):${token}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
// ── 限速 ──
|
||||
|
||||
async function recentFailures(db: Kysely<DB>, col: "ip" | "username", value: string): Promise<number> {
|
||||
const since = new Date(Date.now() - LOGIN_WINDOW_MS);
|
||||
const c = sql.ref(col);
|
||||
const r = await sql<{ n: number }>`
|
||||
select count(*)::int as n from login_attempt
|
||||
where ${c} = ${value} and not success and at > ${since}
|
||||
and at > coalesce((select max(at) from login_attempt where ${c} = ${value} and success), '-infinity')`.execute(
|
||||
db,
|
||||
);
|
||||
return r.rows[0]?.n ?? 0;
|
||||
}
|
||||
|
||||
/** 已达阈值 → 429;被拦下的请求不记尝试、不写审计(防扫描器撑爆表) */
|
||||
export async function assertNotRateLimited(db: Kysely<DB>, ip: string, username: string): Promise<void> {
|
||||
const [byIp, byAccount] = await Promise.all([recentFailures(db, "ip", ip), recentFailures(db, "username", username)]);
|
||||
if (byIp >= MAX_FAILURES_PER_IP || byAccount >= MAX_FAILURES_PER_ACCOUNT) {
|
||||
throw new ApiException("rate_limited", "失败次数过多,请 15 分钟后再试");
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordAttempt(
|
||||
db: Kysely<DB>,
|
||||
a: { ip: string; username: string; success: boolean; reason?: string },
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insertInto("login_attempt")
|
||||
.values({ ip: a.ip, username: a.username, success: a.success, reason: a.reason ?? null })
|
||||
.execute();
|
||||
}
|
||||
|
||||
// ── 会话 ──
|
||||
|
||||
export interface CreatedSession {
|
||||
id: string;
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
db: Kysely<DB>,
|
||||
adminUserId: string,
|
||||
meta: { ip: string; userAgent: string | null },
|
||||
): Promise<CreatedSession> {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||||
const row = await db
|
||||
.insertInto("admin_session")
|
||||
.values({
|
||||
admin_user_id: adminUserId,
|
||||
session_hash: hashSessionToken(token),
|
||||
ip: meta.ip,
|
||||
user_agent: meta.userAgent?.slice(0, 400) ?? null,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id, token, expiresAt };
|
||||
}
|
||||
|
||||
export async function revokeSession(db: Kysely<DB>, id: string, reason: string): Promise<void> {
|
||||
await db
|
||||
.updateTable("admin_session")
|
||||
.set({ revoked_at: new Date(), revoke_reason: reason })
|
||||
.where("id", "=", id)
|
||||
.where("revoked_at", "is", null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** 吊销该管理员除 keepId 以外的全部有效会话,返回吊销条数 */
|
||||
export async function revokeOtherSessions(
|
||||
db: Kysely<DB>,
|
||||
adminUserId: string,
|
||||
keepId: string | null,
|
||||
reason: string,
|
||||
): Promise<number> {
|
||||
let q = db
|
||||
.updateTable("admin_session")
|
||||
.set({ revoked_at: new Date(), revoke_reason: reason })
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.where("revoked_at", "is", null);
|
||||
if (keepId) q = q.where("id", "!=", keepId);
|
||||
const r = await q.executeTakeFirst();
|
||||
return Number(r.numUpdatedRows);
|
||||
}
|
||||
|
||||
// ── setup / login / 改密 ──
|
||||
|
||||
export async function setupAdmin(
|
||||
deps: AuthDeps,
|
||||
input: { setupToken: string; username: string; password: string },
|
||||
ip: string,
|
||||
): Promise<AdminRow> {
|
||||
await assertNotRateLimited(deps.db, ip, "(setup)");
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
const r = await sql<{ id: string }>`
|
||||
update admin_user set username = ${input.username}, password_hash = ${passwordHash},
|
||||
setup_token_hash = null, password_changed_at = now()
|
||||
where password_hash is null and setup_token_hash = ${sha256Hex(input.setupToken)}
|
||||
returning id`.execute(deps.db);
|
||||
if (!r.rows.length) {
|
||||
const admin = await loadAdmin(deps.db);
|
||||
if (admin?.password_hash) throw new ApiException("conflict", "管理员已初始化");
|
||||
await recordAttempt(deps.db, { ip, username: "(setup)", success: false, reason: "bad_setup_token" });
|
||||
throw new ApiException("unauthorized", "setup token 无效(以服务端日志最新打印的为准)");
|
||||
}
|
||||
const admin = await loadAdmin(deps.db);
|
||||
if (!admin) throw new Error("admin_user 行丢失");
|
||||
return admin;
|
||||
}
|
||||
|
||||
export type LoginOutcome = { status: "ok"; admin: AdminRow } | { status: "totp_required" };
|
||||
|
||||
export async function verifyLogin(
|
||||
deps: AuthDeps,
|
||||
input: { username: string; password: string; totpCode?: string | undefined },
|
||||
ip: string,
|
||||
): Promise<LoginOutcome> {
|
||||
await assertNotRateLimited(deps.db, ip, input.username);
|
||||
const admin = await loadAdmin(deps.db);
|
||||
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化,请先完成初始化");
|
||||
const userOk = admin.username === input.username;
|
||||
// 用户名不对也跑一次 argon2,避免用时差探测账号
|
||||
let pwOk = false;
|
||||
if (userOk) pwOk = await verifyPassword(input.password, admin.password_hash);
|
||||
else await verifyPassword(input.password, await dummyPasswordHash());
|
||||
if (!pwOk) {
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_password" });
|
||||
throw new ApiException("unauthorized", "用户名或密码错误");
|
||||
}
|
||||
if (admin.totp_enabled_at && admin.totp_secret_ref) {
|
||||
// 密码正确但缺第二步:不计失败
|
||||
if (!input.totpCode) return { status: "totp_required" };
|
||||
if (!(await consumeTotp(deps, admin, input.totpCode))) {
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_totp" });
|
||||
throw new ApiException("unauthorized", "验证码错误");
|
||||
}
|
||||
}
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: true });
|
||||
return { status: "ok", admin };
|
||||
}
|
||||
|
||||
/** 校验并消费 TOTP 计数器(条件更新防并发重放) */
|
||||
async function consumeTotp(deps: AuthDeps, admin: AdminRow, code: string): Promise<boolean> {
|
||||
if (!admin.totp_secret_ref) return false;
|
||||
const secret = await deps.secrets.resolve(admin.totp_secret_ref);
|
||||
const counter = verifyTotp(secret, code, admin.totp_last_counter);
|
||||
if (counter === null) return false;
|
||||
const r = await sql`update admin_user set totp_last_counter = ${counter}
|
||||
where id = ${admin.id} and (totp_last_counter is null or totp_last_counter < ${counter})`.execute(deps.db);
|
||||
return Number(r.numAffectedRows ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
deps: AuthDeps,
|
||||
input: { currentPassword: string; newPassword: string },
|
||||
ctx: { adminUserId: string; sessionId: string; ip: string },
|
||||
): Promise<number> {
|
||||
const admin = await loadAdmin(deps.db);
|
||||
if (!admin?.password_hash || admin.id !== ctx.adminUserId) throw new ApiException("unauthorized", "会话无效");
|
||||
await assertNotRateLimited(deps.db, ctx.ip, admin.username ?? "");
|
||||
if (!(await verifyPassword(input.currentPassword, admin.password_hash))) {
|
||||
await recordAttempt(deps.db, {
|
||||
ip: ctx.ip,
|
||||
username: admin.username ?? "",
|
||||
success: false,
|
||||
reason: "bad_current_password",
|
||||
});
|
||||
throw new ApiException("unauthorized", "当前密码错误");
|
||||
}
|
||||
const hash = await hashPassword(input.newPassword);
|
||||
await deps.db
|
||||
.updateTable("admin_user")
|
||||
.set({ password_hash: hash, password_changed_at: new Date() })
|
||||
.where("id", "=", admin.id)
|
||||
.execute();
|
||||
return await revokeOtherSessions(deps.db, admin.id, ctx.sessionId, "password_change");
|
||||
}
|
||||
|
||||
// ── TOTP 管理 ──
|
||||
|
||||
export function totpStatus(admin: AdminRow | null) {
|
||||
return { enabled: !!admin?.totp_enabled_at, pending: !!admin?.totp_pending_secret_ref };
|
||||
}
|
||||
|
||||
export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<{ uri: string; secret: string }> {
|
||||
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
|
||||
const secret = base32Encode(randomBytes(20));
|
||||
const ref = await deps.secrets.create({ purpose: "other", plaintext: secret, label: `admin-totp:${randomToken(6)}` });
|
||||
const old = admin.totp_pending_secret_ref;
|
||||
await deps.db.updateTable("admin_user").set({ totp_pending_secret_ref: ref }).where("id", "=", admin.id).execute();
|
||||
if (old) await deps.secrets.remove(old).catch(() => {});
|
||||
const label = encodeURIComponent(`${TOTP_ISSUER}:${admin.username ?? "admin"}`);
|
||||
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(TOTP_ISSUER)}&algorithm=SHA1&digits=6&period=30`;
|
||||
return { uri, secret };
|
||||
}
|
||||
|
||||
export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise<void> {
|
||||
if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "请先生成 TOTP 密钥");
|
||||
const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref);
|
||||
const counter = verifyTotp(secret, code, null);
|
||||
if (counter === null) throw new ApiException("unauthorized", "验证码错误");
|
||||
const old = admin.totp_secret_ref;
|
||||
await deps.db
|
||||
.updateTable("admin_user")
|
||||
.set({
|
||||
totp_secret_ref: admin.totp_pending_secret_ref,
|
||||
totp_pending_secret_ref: null,
|
||||
totp_enabled_at: new Date(),
|
||||
totp_last_counter: counter,
|
||||
})
|
||||
.where("id", "=", admin.id)
|
||||
.execute();
|
||||
if (old) await deps.secrets.remove(old).catch(() => {});
|
||||
}
|
||||
|
||||
export async function totpDisable(
|
||||
deps: AuthDeps,
|
||||
admin: AdminRow,
|
||||
input: { password: string; code: string },
|
||||
): Promise<void> {
|
||||
if (!admin.totp_enabled_at || !admin.totp_secret_ref) throw new ApiException("precondition_failed", "TOTP 未启用");
|
||||
if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) {
|
||||
throw new ApiException("unauthorized", "密码错误");
|
||||
}
|
||||
if (!(await consumeTotp(deps, admin, input.code))) throw new ApiException("unauthorized", "验证码错误");
|
||||
const ref = admin.totp_secret_ref;
|
||||
await deps.db
|
||||
.updateTable("admin_user")
|
||||
.set({ totp_secret_ref: null, totp_enabled_at: null, totp_last_counter: null })
|
||||
.where("id", "=", admin.id)
|
||||
.execute();
|
||||
await deps.secrets.remove(ref).catch(() => {});
|
||||
}
|
||||
|
||||
export async function sessionInfo(
|
||||
db: Kysely<DB>,
|
||||
actor: Actor | undefined,
|
||||
expiresAt: Date | null,
|
||||
): Promise<SessionInfo> {
|
||||
const admin = await loadAdmin(db);
|
||||
return {
|
||||
initialized: !!admin?.password_hash,
|
||||
authenticated: !!actor,
|
||||
actor: actor ?? null,
|
||||
expiresAt: expiresAt?.toISOString() ?? null,
|
||||
totpEnabled: !!admin?.totp_enabled_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createHash, createHmac, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { argon2id, argon2Verify } from "hash-wasm";
|
||||
|
||||
// 认证用密码学原语。argon2id 用 hash-wasm(WASM,纯 JS 装载):
|
||||
// @node-rs/argon2 / @denosaurs/argon2 都是原生库,需要 --allow-ffi,而编译后的 umt-server 只授予
|
||||
// net/env/sys/read 权限;hash-wasm 输出标准 PHC 串,与 Bun.password / @node-rs/argon2 互认。
|
||||
|
||||
/** OWASP 推荐参数(与 uhd_doc 相同):m=19 MiB, t=2, p=1 */
|
||||
export const ARGON2_PARAMS = { memorySize: 19_456, iterations: 2, parallelism: 1, hashLength: 32 } as const;
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await argon2id({ password, salt: randomBytes(16), ...ARGON2_PARAMS, outputType: "encoded" });
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, digest: string): Promise<boolean> {
|
||||
try {
|
||||
return await argon2Verify({ password, hash: digest });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 预先算好的假哈希:账号不存在时也跑一次校验,避免用时差探测账号 */
|
||||
let dummyHash: Promise<string> | null = null;
|
||||
export function dummyPasswordHash(): Promise<string> {
|
||||
dummyHash ??= hashPassword(randomToken(16));
|
||||
return dummyHash;
|
||||
}
|
||||
|
||||
export const sha256Hex = (s: string) => createHash("sha256").update(s, "utf8").digest("hex");
|
||||
|
||||
/** 高熵随机 token(base64url) */
|
||||
export const randomToken = (bytes = 32) => randomBytes(bytes).toString("base64url");
|
||||
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
/** HKDF-SHA256 从主密钥派生 32 字节子密钥 */
|
||||
export function deriveKey(masterKey: string, salt: Uint8Array, info: string): Buffer {
|
||||
return Buffer.from(hkdfSync("sha256", Buffer.from(masterKey, "utf8"), salt, Buffer.from(info, "utf8"), 32));
|
||||
}
|
||||
|
||||
// ── TOTP(RFC 6238:SHA1 / 6 位 / 30 秒),纯 node:crypto ──
|
||||
|
||||
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
export function base32Encode(buf: Uint8Array): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let out = "";
|
||||
for (const b of buf) {
|
||||
value = (value << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += B32[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function base32Decode(s: string): Buffer {
|
||||
const clean = s.replace(/=+$/, "").replace(/\s+/g, "").toUpperCase();
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out: number[] = [];
|
||||
for (const ch of clean) {
|
||||
const idx = B32.indexOf(ch);
|
||||
if (idx < 0) throw new Error("invalid base32");
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 255);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
export const TOTP_STEP_SEC = 30;
|
||||
|
||||
export function totpAt(secretB32: string, counter: number): string {
|
||||
const msg = Buffer.alloc(8);
|
||||
msg.writeBigUInt64BE(BigInt(counter));
|
||||
const h = createHmac("sha1", base32Decode(secretB32)).update(msg).digest();
|
||||
const off = h[h.length - 1]! & 0xf;
|
||||
const code = ((h.readUInt32BE(off) & 0x7fffffff) % 1_000_000).toString();
|
||||
return code.padStart(6, "0");
|
||||
}
|
||||
|
||||
export const totpCounter = (nowMs = Date.now()) => Math.floor(nowMs / 1000 / TOTP_STEP_SEC);
|
||||
|
||||
/**
|
||||
* 校验验证码(容忍前后各 1 个步长);返回命中的计数器,调用方需持久化并拒绝 ≤ 已用计数器(防重放)。
|
||||
*/
|
||||
export function verifyTotp(
|
||||
secretB32: string,
|
||||
code: string,
|
||||
lastCounter: number | null,
|
||||
nowMs = Date.now(),
|
||||
): number | null {
|
||||
const now = totpCounter(nowMs);
|
||||
for (const c of [now - 1, now, now + 1]) {
|
||||
if (lastCounter !== null && c <= lastCounter) continue;
|
||||
if (safeEqual(totpAt(secretB32, c), code)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
|
||||
import {
|
||||
type Actor,
|
||||
type ApiScope,
|
||||
type AuditAction,
|
||||
apiResponse,
|
||||
CursorQuery,
|
||||
cursorPage,
|
||||
type EndpointDef,
|
||||
IdParam,
|
||||
} from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import type { Logger } from "pino";
|
||||
import type { z } from "zod";
|
||||
import { type AuthEnv, requireScope } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp } from "../types.ts";
|
||||
|
||||
// 本模块路由的薄封装:用 contracts 的 EndpointDef(Api.xxx 或本地同形定义)注册 OpenAPI 路由,
|
||||
// 自动挂 requireScope、校验入参,并按响应 schema parse 出参(剥掉多余列,防止 key_hash 之类泄出)。
|
||||
|
||||
export type Def = Omit<EndpointDef, "scope"> & { scope: ApiScope; tags?: string[] };
|
||||
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
|
||||
|
||||
export interface HandlerInput<D extends Def> {
|
||||
params: Infer<D["params"]>;
|
||||
query: Infer<D["query"]>;
|
||||
body: Infer<D["body"]>;
|
||||
c: Context<AuthEnv>;
|
||||
actor: Actor;
|
||||
}
|
||||
|
||||
export function endpoint<D extends Def>(
|
||||
app: ModuleApp,
|
||||
def: D,
|
||||
/** 返回值按 def.response parse(剥掉多余列;不符合合同即 500) */
|
||||
handler: (input: HandlerInput<D>) => Promise<unknown>,
|
||||
extra: {
|
||||
middleware?: MiddlewareHandler<AuthEnv>[];
|
||||
status?: 200 | 201;
|
||||
/** 免鉴权端点(必须同时列在 http/auth.ts 的 PUBLIC_AUTH_PATHS),不挂 requireScope */
|
||||
public?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const request: RouteConfig["request"] = {};
|
||||
if (def.params) request.params = def.params as never;
|
||||
if (def.query) request.query = def.query as never;
|
||||
if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true };
|
||||
const status = extra.status ?? 200;
|
||||
const route = createRoute({
|
||||
method: def.method,
|
||||
path: def.path,
|
||||
summary: def.summary,
|
||||
tags: def.tags ?? ["auth-settings"],
|
||||
middleware: [...(extra.public ? [] : [requireScope(def.scope)]), ...(extra.middleware ?? [])] as never,
|
||||
request,
|
||||
responses: {
|
||||
[status]: {
|
||||
description: def.summary,
|
||||
content: { "application/json": { schema: apiResponse(def.response) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
|
||||
app.openapi(route, (async (c: any) => {
|
||||
const input = {
|
||||
params: def.params ? c.req.valid("param") : {},
|
||||
query: def.query ? c.req.valid("query") : {},
|
||||
body: def.body ? c.req.valid("json") : {},
|
||||
c,
|
||||
actor: c.get("actor"),
|
||||
} as HandlerInput<D>;
|
||||
const data = def.response.parse(await handler(input));
|
||||
return c.json({ ok: true, data }, status);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
}) as any);
|
||||
}
|
||||
|
||||
/** 标准资源端点定义(与 contracts Api 里 crud() 生成的路径/scope 一致,但保留具体 schema 类型) */
|
||||
export function crudDefs<R extends z.ZodType, C extends z.ZodType, U extends z.ZodType>(
|
||||
base: string,
|
||||
name: string,
|
||||
s: { row: R; create: C; update: U },
|
||||
) {
|
||||
const tags = [base.slice(1)];
|
||||
const item = `${base}/{id}`;
|
||||
return {
|
||||
list: {
|
||||
method: "get" as const,
|
||||
path: base,
|
||||
scope: "read" as const,
|
||||
summary: `${name}列表`,
|
||||
query: CursorQuery,
|
||||
response: cursorPage(s.row),
|
||||
tags,
|
||||
},
|
||||
get: {
|
||||
method: "get" as const,
|
||||
path: item,
|
||||
scope: "read" as const,
|
||||
summary: `${name}详情`,
|
||||
params: IdParam,
|
||||
response: s.row,
|
||||
tags,
|
||||
},
|
||||
create: {
|
||||
method: "post" as const,
|
||||
path: base,
|
||||
scope: "admin" as const,
|
||||
summary: `新建${name}`,
|
||||
body: s.create,
|
||||
response: s.row,
|
||||
tags,
|
||||
},
|
||||
update: {
|
||||
method: "patch" as const,
|
||||
path: item,
|
||||
scope: "admin" as const,
|
||||
summary: `修改${name}`,
|
||||
params: IdParam,
|
||||
body: s.update,
|
||||
response: s.row,
|
||||
tags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 行映射 ──
|
||||
|
||||
const camel = (s: string) => s.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
|
||||
|
||||
/** DB 行 → API 对象:snake_case → camelCase,Date → ISO 串 */
|
||||
export function toApi<T = Record<string, unknown>>(row: object): T {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(row)) out[camel(k)] = v instanceof Date ? v.toISOString() : v;
|
||||
return out as T;
|
||||
}
|
||||
|
||||
const snake = (s: string) => s.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
||||
|
||||
/** API 输入 → DB 列(跳过 undefined;对象/数组值 JSON 化以写 jsonb) */
|
||||
export function toDb(input: Record<string, unknown>, jsonCols: readonly string[] = []): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (v === undefined) continue;
|
||||
const col = snake(k);
|
||||
out[col] = jsonCols.includes(col) && v !== null ? JSON.stringify(v) : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 游标(<时间列> desc, id desc;时间按微秒编码,避免 JS Date 毫秒精度截断导致跳行)──
|
||||
|
||||
export interface Cursor {
|
||||
us: number;
|
||||
id: string;
|
||||
}
|
||||
export const encodeCursor = (c: Cursor) => Buffer.from(JSON.stringify(c), "utf8").toString("base64url");
|
||||
export function decodeCursor(s: string | undefined): Cursor | null {
|
||||
if (!s) return null;
|
||||
try {
|
||||
const v = JSON.parse(Buffer.from(s, "base64url").toString("utf8")) as Cursor;
|
||||
if (!Number.isSafeInteger(v.us) || typeof v.id !== "string" || !v.id) throw new Error();
|
||||
return v;
|
||||
} catch {
|
||||
throw new ApiException("bad_request", "cursor 无效");
|
||||
}
|
||||
}
|
||||
|
||||
/** select 里加上 `cursorCol("created_at")`,得到 cursor_us 列 */
|
||||
export const cursorCol = (col: string) =>
|
||||
sql<number>`(extract(epoch from ${sql.ref(col)}) * 1000000)::bigint`.as("cursor_us");
|
||||
|
||||
/** where 条件:(<col>, id) < 游标位置;idType 为 id 列的类型(本地表 uuid,引擎表 text) */
|
||||
export const afterCursor = (col: string, cur: Cursor, idCol = "id", idType: "uuid" | "text" = "uuid") =>
|
||||
sql<boolean>`(${sql.ref(col)}, ${sql.ref(idCol)}) < (timestamptz 'epoch' + ${cur.us} * interval '1 microsecond', ${cur.id}::${sql.raw(idType)})`;
|
||||
|
||||
/** 取 limit+1 行判断是否还有下一页;去掉 cursor_us 辅助列 */
|
||||
export function pageOf<R extends { cursor_us: number; id: string }>(rows: R[], limit: number) {
|
||||
const items = rows.slice(0, limit);
|
||||
const last = items[items.length - 1];
|
||||
const nextCursor = rows.length > limit && last ? encodeCursor({ us: Number(last.cursor_us), id: last.id }) : null;
|
||||
return {
|
||||
items: items.map(({ cursor_us: _c, ...rest }) => rest as Omit<R, "cursor_us">),
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 审计 ──
|
||||
|
||||
/** contracts ActorType:控制台管理员记为 user */
|
||||
export const auditActorType = (a: Actor | undefined) => (a?.type === "api_key" ? "api_key" : a ? "user" : "system");
|
||||
|
||||
/** 写审计;失败只记日志,绝不影响主流程 */
|
||||
export async function audit(
|
||||
deps: { db: Kysely<DB>; logger: Logger },
|
||||
actor: Actor | undefined,
|
||||
action: AuditAction,
|
||||
target: { type?: string; id?: string | null } = {},
|
||||
payload: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await deps.db
|
||||
.insertInto("audit_event")
|
||||
.values({
|
||||
actor_type: auditActorType(actor),
|
||||
actor_id: actor?.id ?? null,
|
||||
action,
|
||||
target_type: target.type ?? null,
|
||||
target_id: target.id ?? null,
|
||||
payload: JSON.stringify(payload),
|
||||
})
|
||||
.execute();
|
||||
} catch (err) {
|
||||
deps.logger.warn({ err, action }, "审计写入失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** PG 唯一/外键冲突 → 409 */
|
||||
export function pgConflict(err: unknown, message: string): never {
|
||||
const code = (err as { code?: string })?.code;
|
||||
if (code === "23505" || code === "23503") throw new ApiException("conflict", message);
|
||||
throw err;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
cursorPage,
|
||||
EngineCancelRequest,
|
||||
EngineCancelResult,
|
||||
EngineRun,
|
||||
EngineRunDetail,
|
||||
EngineRunQuery,
|
||||
EngineStats,
|
||||
} from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import { z } from "zod";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import { redact } from "../../logger.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import { audit, type Cursor, type Def, decodeCursor, encodeCursor, endpoint } from "./endpoint.ts";
|
||||
|
||||
// 引擎面板只读 API(仅 admin):直查 openworkflow schema(docs/design/openworkflow-dashboard-reference.md)。
|
||||
// 不返回 config/context;input/output/error/signal data 过敏感字段脱敏;取消走 TaskEngine.cancel(先写业务侧取消标志)。
|
||||
|
||||
const tags = ["engine"];
|
||||
const RunIdParam = z.object({ id: z.string().min(1).max(200) });
|
||||
const D = {
|
||||
runs: {
|
||||
method: "get",
|
||||
path: "/engine/runs",
|
||||
scope: "admin",
|
||||
summary: "引擎 runs(状态/名称/时间筛选,新→旧)",
|
||||
query: EngineRunQuery,
|
||||
response: cursorPage(EngineRun),
|
||||
tags,
|
||||
},
|
||||
run: {
|
||||
method: "get",
|
||||
path: "/engine/runs/{id}",
|
||||
scope: "admin",
|
||||
summary: "run 详情(step attempts + signals)",
|
||||
params: RunIdParam,
|
||||
response: EngineRunDetail,
|
||||
tags,
|
||||
},
|
||||
stats: {
|
||||
method: "get",
|
||||
path: "/engine/stats",
|
||||
scope: "admin",
|
||||
summary: "按状态计数 / 最老待处理 / 活跃 worker 快照",
|
||||
response: EngineStats,
|
||||
tags,
|
||||
},
|
||||
cancel: {
|
||||
method: "post",
|
||||
path: "/engine/runs/{id}/cancel",
|
||||
scope: "admin",
|
||||
summary: "取消 run",
|
||||
params: RunIdParam,
|
||||
body: EngineCancelRequest,
|
||||
response: EngineCancelResult,
|
||||
tags,
|
||||
},
|
||||
} as const satisfies Record<string, Def>;
|
||||
|
||||
const NAMESPACE = "default";
|
||||
const iso = (d: Date | null) => (d ? d.toISOString() : null);
|
||||
|
||||
type RunRow = {
|
||||
id: string;
|
||||
workflow_name: string;
|
||||
version: string | null;
|
||||
status: string;
|
||||
attempts: number;
|
||||
worker_id: string | null;
|
||||
parent_step_attempt_id: string | null;
|
||||
available_at: Date | null;
|
||||
deadline_at: Date | null;
|
||||
started_at: Date | null;
|
||||
finished_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
cursor_us: number;
|
||||
};
|
||||
|
||||
const runView = (r: RunRow) => ({
|
||||
id: r.id,
|
||||
workflowName: r.workflow_name,
|
||||
version: r.version,
|
||||
status: r.status,
|
||||
attempts: r.attempts,
|
||||
workerId: r.worker_id,
|
||||
parentStepAttemptId: r.parent_step_attempt_id,
|
||||
availableAt: iso(r.available_at),
|
||||
deadlineAt: iso(r.deadline_at),
|
||||
startedAt: iso(r.started_at),
|
||||
finishedAt: iso(r.finished_at),
|
||||
createdAt: r.created_at.toISOString(),
|
||||
updatedAt: r.updated_at.toISOString(),
|
||||
});
|
||||
|
||||
const RUN_COLS = sql.raw(`id, workflow_name, version, status, attempts, worker_id, parent_step_attempt_id,
|
||||
available_at, deadline_at, started_at, finished_at, created_at, updated_at,
|
||||
(extract(epoch from created_at) * 1000000)::bigint as cursor_us`);
|
||||
|
||||
export interface EngineQueries {
|
||||
listRuns(
|
||||
q: z.infer<typeof EngineRunQuery>,
|
||||
): Promise<{ items: ReturnType<typeof runView>[]; nextCursor: string | null }>;
|
||||
getRun(id: string): Promise<z.input<typeof EngineRunDetail> | null>;
|
||||
stats(): Promise<z.input<typeof EngineStats>>;
|
||||
}
|
||||
|
||||
/** 直查引擎表;schema 默认 openworkflow(测试可覆盖) */
|
||||
export function engineQueries(db: Kysely<DB>, schema = "openworkflow"): EngineQueries {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(schema)) throw new Error(`非法 schema 名 ${schema}`);
|
||||
const t = (name: string) => sql.raw(`"${schema}".${name}`);
|
||||
return {
|
||||
async listRuns(q) {
|
||||
const cur: Cursor | null = decodeCursor(q.cursor);
|
||||
const conds = [sql`namespace_id = ${NAMESPACE}`];
|
||||
if (q.status) conds.push(sql`status = ${q.status}`);
|
||||
if (q.workflowName) conds.push(sql`workflow_name = ${q.workflowName}`);
|
||||
if (q.from) conds.push(sql`created_at >= ${new Date(q.from)}`);
|
||||
if (q.to) conds.push(sql`created_at < ${new Date(q.to)}`);
|
||||
if (cur) {
|
||||
conds.push(sql`(created_at, id) < (timestamptz 'epoch' + ${cur.us} * interval '1 microsecond', ${cur.id})`);
|
||||
}
|
||||
const rows = await sql<RunRow>`select ${RUN_COLS} from ${t("workflow_runs")}
|
||||
where ${sql.join(conds, sql` and `)}
|
||||
order by created_at desc, id desc limit ${q.limit + 1}`.execute(db);
|
||||
const items = rows.rows.slice(0, q.limit);
|
||||
const last = items[items.length - 1];
|
||||
return {
|
||||
items: items.map(runView),
|
||||
nextCursor:
|
||||
rows.rows.length > q.limit && last ? encodeCursor({ us: Number(last.cursor_us), id: last.id }) : null,
|
||||
};
|
||||
},
|
||||
|
||||
async getRun(id) {
|
||||
const r = await sql<RunRow & { input: unknown; output: unknown; error: unknown }>`
|
||||
select ${RUN_COLS}, input, output, error from ${t("workflow_runs")}
|
||||
where namespace_id = ${NAMESPACE} and id = ${id}`.execute(db);
|
||||
const run = r.rows[0];
|
||||
if (!run) return null;
|
||||
const steps = await sql<{
|
||||
id: string;
|
||||
step_name: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
output: unknown;
|
||||
error: unknown;
|
||||
wait_signal: string | null;
|
||||
timeout_at: string | null;
|
||||
child_workflow_run_id: string | null;
|
||||
started_at: Date | null;
|
||||
finished_at: Date | null;
|
||||
created_at: Date;
|
||||
}>`select id, step_name, kind, status, output, error,
|
||||
case when kind = 'signal-wait' then context->>'signal' end as wait_signal,
|
||||
context->>'timeoutAt' as timeout_at,
|
||||
child_workflow_run_id, started_at, finished_at, created_at
|
||||
from ${t("step_attempts")} where namespace_id = ${NAMESPACE} and workflow_run_id = ${id}
|
||||
order by created_at, id limit 1000`.execute(db);
|
||||
const signals = await sql<{
|
||||
id: string;
|
||||
signal: string;
|
||||
data: unknown;
|
||||
step_attempt_id: string;
|
||||
created_at: Date;
|
||||
}>`
|
||||
select id, signal, data, step_attempt_id, created_at from ${t("workflow_signals")}
|
||||
where namespace_id = ${NAMESPACE} and workflow_run_id = ${id} order by created_at, id limit 1000`.execute(db);
|
||||
return {
|
||||
...runView(run),
|
||||
input: redact(run.input),
|
||||
output: redact(run.output),
|
||||
error: redact(run.error),
|
||||
stepAttempts: steps.rows.map((s) => ({
|
||||
id: s.id,
|
||||
stepName: s.step_name,
|
||||
kind: s.kind,
|
||||
status: s.status,
|
||||
output: redact(s.output),
|
||||
error: redact(s.error),
|
||||
waitSignal: s.wait_signal,
|
||||
timeoutAt: s.timeout_at,
|
||||
childWorkflowRunId: s.child_workflow_run_id,
|
||||
startedAt: iso(s.started_at),
|
||||
finishedAt: iso(s.finished_at),
|
||||
createdAt: s.created_at.toISOString(),
|
||||
})),
|
||||
signals: signals.rows.map((s) => ({
|
||||
id: s.id,
|
||||
signal: s.signal,
|
||||
data: redact(s.data),
|
||||
stepAttemptId: s.step_attempt_id,
|
||||
createdAt: s.created_at.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
async stats() {
|
||||
const [counts, oldest, workers] = await Promise.all([
|
||||
sql<{ status: string; n: number }>`select status, count(*)::int as n from ${t("workflow_runs")}
|
||||
where namespace_id = ${NAMESPACE} group by status`.execute(db),
|
||||
sql<{ id: string; workflow_name: string; since: Date }>`
|
||||
select id, workflow_name, coalesce(available_at, created_at) as since from ${t("workflow_runs")}
|
||||
where namespace_id = ${NAMESPACE} and status = 'pending'
|
||||
order by coalesce(available_at, created_at), id limit 1`.execute(db),
|
||||
sql<{ worker_id: string; running: number; since: Date | null }>`
|
||||
select worker_id, count(*)::int as running, min(started_at) as since from ${t("workflow_runs")}
|
||||
where namespace_id = ${NAMESPACE} and status = 'running' and worker_id is not null
|
||||
group by worker_id order by worker_id`.execute(db),
|
||||
]);
|
||||
const byStatus: Record<string, number> = { pending: 0, running: 0, completed: 0, failed: 0, canceled: 0 };
|
||||
for (const r of counts.rows) byStatus[r.status] = (byStatus[r.status] ?? 0) + r.n;
|
||||
const o = oldest.rows[0];
|
||||
return {
|
||||
byStatus,
|
||||
oldestPending: o
|
||||
? {
|
||||
id: o.id,
|
||||
workflowName: o.workflow_name,
|
||||
since: o.since.toISOString(),
|
||||
ageSec: Math.max(0, (Date.now() - o.since.getTime()) / 1000),
|
||||
}
|
||||
: null,
|
||||
activeWorkers: workers.rows.map((w) => ({ workerId: w.worker_id, running: w.running, since: iso(w.since) })),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: { schema?: string } = {}) {
|
||||
let q: EngineQueries | null = null;
|
||||
const queries = () => {
|
||||
q ??= engineQueries(ctx.db, opts.schema);
|
||||
return q;
|
||||
};
|
||||
endpoint(app, D.runs, async ({ query }) => await queries().listRuns(query));
|
||||
endpoint(app, D.run, async ({ params }) => {
|
||||
const r = await queries().getRun(params.id);
|
||||
if (!r) throw new ApiException("not_found", `run ${params.id} 不存在`);
|
||||
return r;
|
||||
});
|
||||
endpoint(app, D.stats, async () => await queries().stats());
|
||||
endpoint(app, D.cancel, async ({ params, body, actor }) => {
|
||||
const exists = await queries().getRun(params.id);
|
||||
if (!exists) throw new ApiException("not_found", `run ${params.id} 不存在`);
|
||||
const canceled = await ctx.engine.cancel(params.id, body.reason ?? `canceled by ${actor.type}:${actor.name}`);
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
"engine.run_cancel",
|
||||
{ type: "engine_run", id: params.id },
|
||||
{
|
||||
workflowName: exists.workflowName,
|
||||
canceled,
|
||||
},
|
||||
);
|
||||
return { canceled };
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,59 @@
|
||||
import type { ServerModule } from "../types.ts";
|
||||
import { sql } from "@umt/db";
|
||||
import type { ServerContext, ServerModule } from "../types.ts";
|
||||
import { registerAuthRoutes } from "./auth-routes.ts";
|
||||
import { ensureSetupToken } from "./auth-service.ts";
|
||||
import { registerEngineRoutes } from "./engine-routes.ts";
|
||||
import { registerInstanceRoutes } from "./instances-routes.ts";
|
||||
import { registerKeyRoutes } from "./keys-routes.ts";
|
||||
import { registerSettingsRoutes } from "./settings-routes.ts";
|
||||
|
||||
/**
|
||||
* W2-A 认证/设置/实例/引擎面板:管理员登录门禁、settings、api_key、secret 加密服务、
|
||||
* downloader/media_server 实例与 library_root/path_mapping、审计查询、SSE 总线改 PG 通知、引擎面板只读 API、自动化总开关。
|
||||
* secrets / settings 的实现放在 ./provided.ts(context 从这里取),SSE 总线在 http/sse.ts,鉴权在 http/auth.ts。
|
||||
* downloader/media_server 实例与 library_root/path_mapping、站点凭证录入、审计查询、引擎面板只读 API。
|
||||
* secrets / settings 的实现在 ./provided.ts(context 从这里取),SSE 总线在 http/sse*.ts,鉴权中间件在 http/auth.ts。
|
||||
* 用法见 docs/runbook/auth.md。
|
||||
*/
|
||||
export const authSettingsModule: ServerModule = {
|
||||
name: "auth-settings",
|
||||
registerRoutes() {},
|
||||
registerTasks() {},
|
||||
};
|
||||
export interface AuthSettingsModuleOptions {
|
||||
/** 引擎表所在 schema,默认 openworkflow */
|
||||
engineSchema?: string;
|
||||
/** api 角色启动时若未初始化管理员则生成并打印 setup token(默认 true;测试关掉自己调 ensureSetupToken) */
|
||||
setupTokenOnStart?: boolean;
|
||||
/** 过期会话/登录尝试清理间隔,默认 1 小时 */
|
||||
cleanupIntervalMs?: number;
|
||||
}
|
||||
|
||||
/** 清理:登录尝试保留 7 天;已过期/已吊销会话保留 30 天(审计追溯) */
|
||||
export async function cleanupAuthTables(ctx: Pick<ServerContext, "db">): Promise<void> {
|
||||
await sql`delete from login_attempt where at < now() - interval '7 days'`.execute(ctx.db);
|
||||
await sql`delete from admin_session
|
||||
where coalesce(revoked_at, expires_at) < now() - interval '30 days'`.execute(ctx.db);
|
||||
}
|
||||
|
||||
export function createAuthSettingsModule(opts: AuthSettingsModuleOptions = {}): ServerModule {
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
return {
|
||||
name: "auth-settings",
|
||||
registerRoutes(app, ctx) {
|
||||
registerAuthRoutes(app, ctx);
|
||||
registerSettingsRoutes(app, ctx);
|
||||
registerKeyRoutes(app, ctx);
|
||||
registerInstanceRoutes(app, ctx);
|
||||
registerEngineRoutes(app, ctx, opts.engineSchema ? { schema: opts.engineSchema } : {});
|
||||
if (opts.setupTokenOnStart !== false && ctx.db) {
|
||||
ensureSetupToken(ctx).catch((err) => ctx.logger.error({ err }, "生成 setup token 失败"));
|
||||
}
|
||||
},
|
||||
registerTasks() {},
|
||||
start(ctx) {
|
||||
const run = () => cleanupAuthTables(ctx).catch((err) => ctx.logger.warn({ err }, "认证表清理失败"));
|
||||
timer = setInterval(run, opts.cleanupIntervalMs ?? 3600_000);
|
||||
void run();
|
||||
},
|
||||
stop() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const authSettingsModule: ServerModule = createAuthSettingsModule();
|
||||
@@ -0,0 +1,557 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { checkRoot, DEFAULT_ROOT_SENTINEL, EmbyClient, QbClient, writeRootSentinel } from "@umt/adapters";
|
||||
import {
|
||||
Api,
|
||||
type AuditAction,
|
||||
DownloaderInstance,
|
||||
DownloaderInstanceCreate,
|
||||
DownloaderInstanceUpdate,
|
||||
IdParam,
|
||||
InstanceTestResult,
|
||||
LibraryRoot,
|
||||
LibraryRootCreate,
|
||||
LibraryRootUpdate,
|
||||
MediaServerInstance,
|
||||
MediaServerInstanceCreate,
|
||||
MediaServerInstanceUpdate,
|
||||
PathMapping,
|
||||
PathMappingCreate,
|
||||
PathMappingUpdate,
|
||||
type SecretPurpose,
|
||||
SiteCredentialsPut,
|
||||
SiteCredentialsResult,
|
||||
} from "@umt/contracts";
|
||||
import type { DB } from "@umt/db";
|
||||
import { z } from "zod";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import { redact, redactString } from "../../logger.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import {
|
||||
afterCursor,
|
||||
audit,
|
||||
crudDefs,
|
||||
cursorCol,
|
||||
type Def,
|
||||
decodeCursor,
|
||||
endpoint,
|
||||
pageOf,
|
||||
pgConflict,
|
||||
toApi,
|
||||
toDb,
|
||||
} from "./endpoint.ts";
|
||||
|
||||
// 实例与路径:library_root / downloader_instance / media_server_instance / path_mapping 的 CRUD + 连通性测试,
|
||||
// 以及站点账号凭证录入 PUT /sites/{id}/credentials。凭证明文只在请求里出现,存为 secret 后只回 secretRef。
|
||||
|
||||
const OkResult = z.object({ ok: z.literal(true) });
|
||||
const del = (path: string, summary: string, tag: string) => ({
|
||||
method: "delete" as const,
|
||||
path,
|
||||
scope: "admin" as const,
|
||||
summary,
|
||||
params: IdParam,
|
||||
response: OkResult,
|
||||
tags: [tag],
|
||||
});
|
||||
const test = (path: string, summary: string, tag: string) => ({
|
||||
method: "post" as const,
|
||||
path,
|
||||
scope: "admin" as const,
|
||||
summary,
|
||||
params: IdParam,
|
||||
response: InstanceTestResult,
|
||||
tags: [tag],
|
||||
});
|
||||
|
||||
type Table = "library_root" | "downloader_instance" | "media_server_instance" | "path_mapping";
|
||||
const JSON_COLS = ["settings", "version_info", "health_detail", "verify_detail"] as const;
|
||||
|
||||
const errMessage = (e: unknown) => redactString(String((e as Error)?.message ?? e)).slice(0, 500);
|
||||
|
||||
const LR = crudDefs("/library-roots", "媒体库根", {
|
||||
row: LibraryRoot,
|
||||
create: LibraryRootCreate,
|
||||
update: LibraryRootUpdate,
|
||||
});
|
||||
const DL = crudDefs("/downloader-instances", "下载器实例", {
|
||||
row: DownloaderInstance,
|
||||
create: DownloaderInstanceCreate,
|
||||
update: DownloaderInstanceUpdate,
|
||||
});
|
||||
const MS = crudDefs("/media-server-instances", "媒体服务器实例", {
|
||||
row: MediaServerInstance,
|
||||
create: MediaServerInstanceCreate,
|
||||
update: MediaServerInstanceUpdate,
|
||||
});
|
||||
const PM = crudDefs("/path-mappings", "路径映射", {
|
||||
row: PathMapping,
|
||||
create: PathMappingCreate,
|
||||
update: PathMappingUpdate,
|
||||
});
|
||||
|
||||
export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
const db = () => ctx.db;
|
||||
|
||||
/** 通用列表 / 详情 / 删除 */
|
||||
const list = (table: Table, tag: string, def: Def) =>
|
||||
endpoint(app, { ...def, tags: [tag] }, async ({ query }) => {
|
||||
const q0 = query as { cursor?: string; limit: number };
|
||||
const cur = decodeCursor(q0.cursor);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 四张表共用的通用查询
|
||||
let q = (db() as any).selectFrom(table).selectAll().select(cursorCol("created_at"));
|
||||
if (cur) q = q.where(afterCursor("created_at", cur));
|
||||
const rows = await q
|
||||
.orderBy("created_at", "desc")
|
||||
.orderBy("id", "desc")
|
||||
.limit(q0.limit + 1)
|
||||
.execute();
|
||||
const page = pageOf(rows as { cursor_us: number; id: string }[], q0.limit);
|
||||
return { items: page.items.map((r) => toApi(r)), nextCursor: page.nextCursor };
|
||||
});
|
||||
const getRow = async (table: Table, id: string) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
const r = await (db() as any).selectFrom(table).selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`);
|
||||
return r as Record<string, unknown> & { id: string; secret_ref?: string | null };
|
||||
};
|
||||
const get = (table: Table, tag: string, def: Def) =>
|
||||
endpoint(app, { ...def, tags: [tag] }, async ({ params }) =>
|
||||
toApi(await getRow(table, (params as { id: string }).id)),
|
||||
);
|
||||
const remove = (table: Table, tag: string, path: string, action: AuditAction) =>
|
||||
endpoint(app, del(path, `删除 ${table}`, tag), async ({ params, actor }) => {
|
||||
const id = (params as { id: string }).id;
|
||||
const row = await getRow(table, id);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
await (db() as any)
|
||||
.deleteFrom(table)
|
||||
.where("id", "=", id)
|
||||
.execute()
|
||||
.catch((e: unknown) => pgConflict(e, `${table} 仍被引用,不能删除`));
|
||||
if (row.secret_ref) await ctx.secrets.remove(row.secret_ref).catch(() => {});
|
||||
await audit(ctx, actor, action, { type: table, id });
|
||||
return { ok: true as const };
|
||||
});
|
||||
const insert = async (table: Table, values: Record<string, unknown>) =>
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
(await (db() as any)
|
||||
.insertInto(table)
|
||||
.values(values)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow()
|
||||
.catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`))) as Record<string, unknown> & {
|
||||
id: string;
|
||||
};
|
||||
const update = async (table: Table, id: string, values: Record<string, unknown>) => {
|
||||
if (!Object.keys(values).length) return await getRow(table, id);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
const r = await (db() as any)
|
||||
.updateTable(table)
|
||||
.set(values)
|
||||
.where("id", "=", id)
|
||||
.returningAll()
|
||||
.executeTakeFirst()
|
||||
.catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`));
|
||||
if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`);
|
||||
return r as Record<string, unknown> & { id: string };
|
||||
};
|
||||
|
||||
/** 凭证:有旧 ref 就原地替换,否则新建 */
|
||||
const putCredential = async (oldRef: string | null | undefined, purpose: SecretPurpose, plaintext: string) => {
|
||||
if (oldRef) {
|
||||
await ctx.secrets.replace(oldRef, plaintext);
|
||||
return oldRef;
|
||||
}
|
||||
return await ctx.secrets.create({ purpose, plaintext });
|
||||
};
|
||||
|
||||
// ── library_root ──
|
||||
const sentinel = async (localPath: string, file: string | null | undefined) => {
|
||||
const name = file || DEFAULT_ROOT_SENTINEL;
|
||||
try {
|
||||
const st = await stat(localPath);
|
||||
if (!st.isDirectory()) throw new Error("不是目录");
|
||||
await writeRootSentinel(localPath, name);
|
||||
} catch (e) {
|
||||
throw new ApiException(
|
||||
"precondition_failed",
|
||||
`库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`,
|
||||
);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
list("library_root", "library-roots", LR.list);
|
||||
get("library_root", "library-roots", LR.get);
|
||||
endpoint(app, LR.create, async ({ body, actor }) => {
|
||||
const file = await sentinel(body.localPath, body.sentinelFile);
|
||||
const row = await insert("library_root", {
|
||||
...toDb(body, JSON_COLS),
|
||||
sentinel_file: file,
|
||||
health_status: "healthy",
|
||||
last_health_at: new Date(),
|
||||
});
|
||||
await audit(ctx, actor, "library_root.create", { type: "library_root", id: row.id }, { localPath: body.localPath });
|
||||
return toApi(row);
|
||||
});
|
||||
endpoint(app, LR.update, async ({ params, body, actor }) => {
|
||||
const values = toDb(body, JSON_COLS);
|
||||
if (body.localPath !== undefined || body.sentinelFile !== undefined) {
|
||||
const cur = await getRow("library_root", params.id);
|
||||
values.sentinel_file = await sentinel(
|
||||
body.localPath ?? String(cur.local_path),
|
||||
body.sentinelFile ?? (cur.sentinel_file as string | null),
|
||||
);
|
||||
}
|
||||
const row = await update("library_root", params.id, values);
|
||||
await audit(ctx, actor, "library_root.update", { type: "library_root", id: row.id }, { fields: Object.keys(body) });
|
||||
return toApi(row);
|
||||
});
|
||||
remove("library_root", "library-roots", "/library-roots/{id}", "library_root.delete");
|
||||
endpoint(
|
||||
app,
|
||||
test("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)", "library-roots"),
|
||||
async ({ params, actor }) => {
|
||||
const row = await getRow("library_root", params.id);
|
||||
const h = await checkRoot(String(row.local_path), (row.sentinel_file as string | null) ?? DEFAULT_ROOT_SENTINEL);
|
||||
const ok = h.kind === "online";
|
||||
await update("library_root", row.id, {
|
||||
health_status: ok ? "healthy" : "offline",
|
||||
last_health_at: new Date(),
|
||||
health_detail: JSON.stringify(h),
|
||||
});
|
||||
await audit(ctx, actor, "library_root.test", { type: "library_root", id: row.id }, { ok });
|
||||
return {
|
||||
ok,
|
||||
version: null,
|
||||
message: ok ? null : `offline: ${"reason" in h ? h.reason : "unknown"}`,
|
||||
healthStatus: ok ? "healthy" : "offline",
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ── downloader_instance ──
|
||||
list("downloader_instance", "downloader-instances", DL.list);
|
||||
get("downloader_instance", "downloader-instances", DL.get);
|
||||
endpoint(app, DL.create, async ({ body, actor }) => {
|
||||
const { credential, ...rest } = body;
|
||||
const ref = credential ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
|
||||
try {
|
||||
const row = await insert("downloader_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref });
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
"downloader_instance.create",
|
||||
{ type: "downloader_instance", id: row.id },
|
||||
{ name: row.name },
|
||||
);
|
||||
return toApi(row);
|
||||
} catch (e) {
|
||||
if (ref) await ctx.secrets.remove(ref).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
endpoint(app, DL.update, async ({ params, body, actor }) => {
|
||||
const { credential, ...rest } = body;
|
||||
const values = toDb(rest, JSON_COLS);
|
||||
if (credential) {
|
||||
const cur = await getRow("downloader_instance", params.id);
|
||||
values.secret_ref = await putCredential(cur.secret_ref, "downloader_password", credential);
|
||||
}
|
||||
const row = await update("downloader_instance", params.id, values);
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
"downloader_instance.update",
|
||||
{ type: "downloader_instance", id: row.id },
|
||||
{
|
||||
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
|
||||
},
|
||||
);
|
||||
return toApi(row);
|
||||
});
|
||||
remove("downloader_instance", "downloader-instances", "/downloader-instances/{id}", "downloader_instance.delete");
|
||||
endpoint(
|
||||
app,
|
||||
{
|
||||
...test("/downloader-instances/{id}/test", "测试下载器连接", "downloader-instances"),
|
||||
response: InstanceTestResult,
|
||||
},
|
||||
async ({ params, actor }) => {
|
||||
const row = await getRow("downloader_instance", params.id);
|
||||
const secret = row.secret_ref ? await ctx.secrets.resolve(row.secret_ref) : undefined;
|
||||
const username = (row.username as string | null) ?? undefined;
|
||||
// 有用户名走 WebUI 登录(密码);没有用户名但有凭证视为 qB 5.2+ WebUI API Key
|
||||
const client = new QbClient({
|
||||
instanceId: row.id,
|
||||
baseUrl: String(row.base_url),
|
||||
...(username ? { username, ...(secret ? { password: secret } : {}) } : secret ? { apiKey: secret } : {}),
|
||||
timeoutMs: 10_000,
|
||||
retries: 0,
|
||||
});
|
||||
let result: z.infer<typeof InstanceTestResult>;
|
||||
try {
|
||||
const v = await client.version();
|
||||
result = { ok: true, version: v.app, message: null, healthStatus: "healthy" };
|
||||
await update("downloader_instance", row.id, {
|
||||
health_status: "healthy",
|
||||
last_seen_at: new Date(),
|
||||
version_info: JSON.stringify({ app: v.app, webApi: v.webApi }),
|
||||
});
|
||||
} catch (e) {
|
||||
result = { ok: false, version: null, message: errMessage(e), healthStatus: "offline" };
|
||||
await update("downloader_instance", row.id, { health_status: "offline" });
|
||||
}
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
"downloader_instance.test",
|
||||
{ type: "downloader_instance", id: row.id },
|
||||
{ ok: result.ok },
|
||||
);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// ── media_server_instance ──
|
||||
list("media_server_instance", "media-server-instances", MS.list);
|
||||
get("media_server_instance", "media-server-instances", MS.get);
|
||||
endpoint(app, MS.create, async ({ body, actor }) => {
|
||||
const { credential, ...rest } = body;
|
||||
const ref = credential
|
||||
? await ctx.secrets.create({ purpose: "media_server_api_key", plaintext: credential })
|
||||
: null;
|
||||
try {
|
||||
const row = await insert("media_server_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref });
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
"media_server_instance.create",
|
||||
{ type: "media_server_instance", id: row.id },
|
||||
{ name: row.name },
|
||||
);
|
||||
return toApi(row);
|
||||
} catch (e) {
|
||||
if (ref) await ctx.secrets.remove(ref).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
endpoint(app, MS.update, async ({ params, body, actor }) => {
|
||||
const { credential, ...rest } = body;
|
||||
const values = toDb(rest, JSON_COLS);
|
||||
if (credential) {
|
||||
const cur = await getRow("media_server_instance", params.id);
|
||||
values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
|
||||
}
|
||||
const row = await update("media_server_instance", params.id, values);
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
"media_server_instance.update",
|
||||
{ type: "media_server_instance", id: row.id },
|
||||
{
|
||||
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
|
||||
},
|
||||
);
|
||||
return toApi(row);
|
||||
});
|
||||
remove(
|
||||
"media_server_instance",
|
||||
"media-server-instances",
|
||||
"/media-server-instances/{id}",
|
||||
"media_server_instance.delete",
|
||||
);
|
||||
endpoint(
|
||||
app,
|
||||
test("/media-server-instances/{id}/test", "测试媒体服务器连接", "media-server-instances"),
|
||||
async ({ params, actor }) => {
|
||||
const row = await getRow("media_server_instance", params.id);
|
||||
let result: z.infer<typeof InstanceTestResult>;
|
||||
if (!row.secret_ref) {
|
||||
result = { ok: false, version: null, message: "未配置 API Key", healthStatus: "unknown" };
|
||||
} else {
|
||||
const client = new EmbyClient({
|
||||
instanceId: row.id,
|
||||
baseUrl: String(row.base_url),
|
||||
apiKey: await ctx.secrets.resolve(row.secret_ref),
|
||||
flavor: row.kind === "jellyfin" ? "jellyfin" : "emby",
|
||||
...(row.user_id ? { userId: String(row.user_id) } : {}),
|
||||
timeoutMs: 10_000,
|
||||
retries: 0,
|
||||
});
|
||||
const r = await client.ping();
|
||||
if (r.status === "ok") {
|
||||
result = {
|
||||
ok: true,
|
||||
version: r.value.version ?? null,
|
||||
message: r.value.serverName ?? null,
|
||||
healthStatus: "healthy",
|
||||
};
|
||||
await update("media_server_instance", row.id, {
|
||||
health_status: "healthy",
|
||||
last_seen_at: new Date(),
|
||||
version_info: JSON.stringify(redact(r.value)),
|
||||
});
|
||||
} else {
|
||||
result = {
|
||||
ok: false,
|
||||
version: null,
|
||||
message: `${r.reason}: ${redactString(r.message)}`,
|
||||
healthStatus: "offline",
|
||||
};
|
||||
await update("media_server_instance", row.id, { health_status: "offline" });
|
||||
}
|
||||
}
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
"media_server_instance.test",
|
||||
{ type: "media_server_instance", id: row.id },
|
||||
{ ok: result.ok },
|
||||
);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// ── path_mapping ──
|
||||
list("path_mapping", "path-mappings", PM.list);
|
||||
get("path_mapping", "path-mappings", PM.get);
|
||||
endpoint(app, PM.create, async ({ body, actor }) => {
|
||||
const row = await insert("path_mapping", toDb(body, JSON_COLS));
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
"path_mapping.create",
|
||||
{ type: "path_mapping", id: row.id },
|
||||
{
|
||||
remotePrefix: body.remotePrefix,
|
||||
localPrefix: body.localPrefix,
|
||||
},
|
||||
);
|
||||
return toApi(row);
|
||||
});
|
||||
endpoint(app, PM.update, async ({ params, body, actor }) => {
|
||||
const values = toDb(body, JSON_COLS);
|
||||
if (body.localPrefix !== undefined || body.remotePrefix !== undefined) values.verify_status = "unverified";
|
||||
const row = await update("path_mapping", params.id, values);
|
||||
await audit(ctx, actor, "path_mapping.update", { type: "path_mapping", id: row.id }, { fields: Object.keys(body) });
|
||||
return toApi(row);
|
||||
});
|
||||
endpoint(app, { ...Api.pathMappings.remove, tags: ["path-mappings"] }, async ({ params, actor }) => {
|
||||
const r = await db().deleteFrom("path_mapping").where("id", "=", params.id).returning("id").executeTakeFirst();
|
||||
if (!r) throw new ApiException("not_found", "路径映射不存在");
|
||||
await audit(ctx, actor, "path_mapping.delete", { type: "path_mapping", id: params.id });
|
||||
return { ok: true as const };
|
||||
});
|
||||
endpoint(app, { ...Api.pathMappings.verify, tags: ["path-mappings"] }, async ({ params, actor }) => {
|
||||
const row = await getRow("path_mapping", params.id);
|
||||
let detail: Record<string, unknown>;
|
||||
let ok = false;
|
||||
try {
|
||||
const st = await stat(String(row.local_prefix));
|
||||
ok = st.isDirectory();
|
||||
detail = ok ? { localPrefix: "directory" } : { error: "localPrefix 不是目录" };
|
||||
} catch (e) {
|
||||
detail = { error: errMessage(e), code: (e as { code?: string })?.code ?? null };
|
||||
}
|
||||
const updated = await update("path_mapping", row.id, {
|
||||
verify_status: ok ? "ok" : "failed",
|
||||
verified_at: new Date(),
|
||||
verify_detail: JSON.stringify(detail),
|
||||
});
|
||||
await audit(ctx, actor, "path_mapping.verify", { type: "path_mapping", id: row.id }, { ok });
|
||||
return toApi(updated);
|
||||
});
|
||||
|
||||
// ── 站点账号凭证 ──
|
||||
const PURPOSE: Record<SiteCredentialsPut["authKind"], SecretPurpose> = {
|
||||
cookie: "site_cookie",
|
||||
passkey: "site_passkey",
|
||||
api_key: "site_api_key",
|
||||
password: "site_password",
|
||||
};
|
||||
endpoint(
|
||||
app,
|
||||
{
|
||||
method: "put",
|
||||
path: "/sites/{id}/credentials",
|
||||
scope: "admin",
|
||||
summary: "录入站点账号凭证(cookie/passkey/apiKey/密码 → secret_ref,不回显)",
|
||||
params: IdParam,
|
||||
body: SiteCredentialsPut,
|
||||
response: SiteCredentialsResult,
|
||||
tags: ["sites"],
|
||||
},
|
||||
async ({ params, body, actor }) => {
|
||||
const site = await db().selectFrom("site").select("id").where("id", "=", params.id).executeTakeFirst();
|
||||
if (!site) throw new ApiException("not_found", "站点不存在");
|
||||
const newRef = await ctx.secrets.create({ purpose: PURPOSE[body.authKind], plaintext: body.credential });
|
||||
let oldRef: string | null = null;
|
||||
let created = false;
|
||||
let accountId: string;
|
||||
try {
|
||||
accountId = await db()
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const cur = await trx
|
||||
.selectFrom("site_account")
|
||||
.select(["id", "secret_ref", "user_info"])
|
||||
.where("site_id", "=", params.id)
|
||||
.where("label", "=", body.label)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
const userInfo = {
|
||||
...((cur?.user_info as Record<string, unknown> | undefined) ?? {}),
|
||||
...(body.username ? { username: body.username } : {}),
|
||||
};
|
||||
const common: Partial<Record<keyof DB["site_account"], unknown>> = {
|
||||
auth_kind: body.authKind,
|
||||
secret_ref: newRef,
|
||||
status: "unknown",
|
||||
user_info: JSON.stringify(userInfo),
|
||||
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
||||
};
|
||||
if (cur) {
|
||||
oldRef = cur.secret_ref;
|
||||
await trx
|
||||
.updateTable("site_account")
|
||||
.set(common as never)
|
||||
.where("id", "=", cur.id)
|
||||
.execute();
|
||||
return cur.id;
|
||||
}
|
||||
created = true;
|
||||
const r = await trx
|
||||
.insertInto("site_account")
|
||||
.values({ site_id: params.id, label: body.label, ...common } as never)
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return r.id;
|
||||
});
|
||||
} catch (e) {
|
||||
await ctx.secrets.remove(newRef).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
if (oldRef) await ctx.secrets.remove(oldRef).catch(() => {});
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
"site_account.credentials_set",
|
||||
{ type: "site_account", id: accountId },
|
||||
{
|
||||
siteId: params.id,
|
||||
label: body.label,
|
||||
authKind: body.authKind,
|
||||
created,
|
||||
},
|
||||
);
|
||||
return {
|
||||
siteAccountId: accountId,
|
||||
siteId: params.id,
|
||||
label: body.label,
|
||||
authKind: body.authKind,
|
||||
secretRef: newRef,
|
||||
created,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Api, AuditEvent, AuditEventQuery, cursorPage, IdParam, Secret } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { z } from "zod";
|
||||
import { hashApiKey } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import { randomToken } from "./crypto.ts";
|
||||
import {
|
||||
afterCursor,
|
||||
audit,
|
||||
cursorCol,
|
||||
type Def,
|
||||
decodeCursor,
|
||||
endpoint,
|
||||
pageOf,
|
||||
pgConflict,
|
||||
toApi,
|
||||
} from "./endpoint.ts";
|
||||
import { createSecretStore } from "./provided.ts";
|
||||
|
||||
// API Key 管理(创建时一次性返回明文,只存 sha256)、凭证元数据管理(/secrets,明文只写)、审计查询。
|
||||
|
||||
const OkResult = z.object({ ok: z.literal(true) });
|
||||
const D = {
|
||||
revokeKey: {
|
||||
method: "delete",
|
||||
path: "/api-keys/{id}",
|
||||
scope: "admin",
|
||||
summary: "撤销 API Key",
|
||||
params: IdParam,
|
||||
response: OkResult,
|
||||
tags: ["api-keys"],
|
||||
},
|
||||
deleteSecret: {
|
||||
method: "delete",
|
||||
path: "/secrets/{id}",
|
||||
scope: "admin",
|
||||
summary: "删除凭证(被引用时 409)",
|
||||
params: IdParam,
|
||||
response: OkResult,
|
||||
tags: ["secrets"],
|
||||
},
|
||||
audit: {
|
||||
method: "get",
|
||||
path: "/audit-events",
|
||||
scope: "admin",
|
||||
summary: "审计事件(按 actor/action/target 过滤,新→旧)",
|
||||
query: AuditEventQuery,
|
||||
response: cursorPage(AuditEvent),
|
||||
tags: ["audit"],
|
||||
},
|
||||
} as const satisfies Record<string, Def>;
|
||||
|
||||
const KEY_COLS = ["id", "name", "scopes", "revoked_at", "last_used_at", "created_at", "updated_at"] as const;
|
||||
const SECRET_COLS = [
|
||||
"id",
|
||||
"name",
|
||||
"purpose",
|
||||
"algorithm",
|
||||
"kdf",
|
||||
"kdf_params",
|
||||
"key_version",
|
||||
"rotated_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
] as const;
|
||||
|
||||
export function registerKeyRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
// ── API Key ──
|
||||
endpoint(app, { ...Api.apiKeys.list, tags: ["api-keys"] }, async ({ query }) => {
|
||||
const cur = decodeCursor(query.cursor);
|
||||
let q = ctx.db.selectFrom("api_key").select([...KEY_COLS, cursorCol("created_at")]);
|
||||
if (cur) q = q.where(afterCursor("created_at", cur));
|
||||
const rows = await q
|
||||
.orderBy("created_at", "desc")
|
||||
.orderBy("id", "desc")
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const page = pageOf(rows, query.limit);
|
||||
return { items: page.items.map((r) => toApi(r)), nextCursor: page.nextCursor };
|
||||
});
|
||||
|
||||
endpoint(app, { ...Api.apiKeys.create, tags: ["api-keys"] }, async ({ body, actor }) => {
|
||||
const plaintextKey = `umt_${randomToken(32)}`;
|
||||
const row = await ctx.db
|
||||
.insertInto("api_key")
|
||||
.values({ name: body.name, key_hash: hashApiKey(plaintextKey), scopes: [...new Set(body.scopes)] })
|
||||
.returning(KEY_COLS)
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(ctx, actor, "api_key.create", { type: "api_key", id: row.id }, { name: row.name, scopes: row.scopes });
|
||||
return { apiKey: toApi(row), plaintextKey };
|
||||
});
|
||||
|
||||
endpoint(app, { ...Api.apiKeys.update, tags: ["api-keys"] }, async ({ params, body, actor }) => {
|
||||
const set: Record<string, unknown> = {};
|
||||
if (body.name !== undefined) set.name = body.name;
|
||||
if (body.scopes !== undefined) set.scopes = [...new Set(body.scopes)];
|
||||
if (body.revoked) set.revoked_at = sql`coalesce(revoked_at, now())`;
|
||||
if (!Object.keys(set).length) throw new ApiException("bad_request", "没有要修改的字段");
|
||||
const row = await ctx.db
|
||||
.updateTable("api_key")
|
||||
.set(set)
|
||||
.where("id", "=", params.id)
|
||||
.returning(KEY_COLS)
|
||||
.executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "API Key 不存在");
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
body.revoked ? "api_key.revoke" : "api_key.update",
|
||||
{ type: "api_key", id: row.id },
|
||||
{
|
||||
name: body.name,
|
||||
scopes: body.scopes,
|
||||
},
|
||||
);
|
||||
return toApi(row);
|
||||
});
|
||||
|
||||
endpoint(app, D.revokeKey, async ({ params, actor }) => {
|
||||
const r = await ctx.db
|
||||
.updateTable("api_key")
|
||||
.set({ revoked_at: sql`coalesce(revoked_at, now())` })
|
||||
.where("id", "=", params.id)
|
||||
.returning("id")
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new ApiException("not_found", "API Key 不存在");
|
||||
await audit(ctx, actor, "api_key.revoke", { type: "api_key", id: params.id });
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// ── 凭证元数据 ──
|
||||
const secrets = createSecretStore({ db: ctx.db, masterKey: ctx.config?.SECRET_MASTER_KEY ?? "" });
|
||||
const secretView = (r: Record<string, unknown>) => Secret.parse(toApi(r));
|
||||
|
||||
endpoint(app, { ...Api.secrets.list, tags: ["secrets"] }, async ({ query }) => {
|
||||
const cur = decodeCursor(query.cursor);
|
||||
let q = ctx.db.selectFrom("secret").select([...SECRET_COLS, cursorCol("created_at")]);
|
||||
if (cur) q = q.where(afterCursor("created_at", cur));
|
||||
const rows = await q
|
||||
.orderBy("created_at", "desc")
|
||||
.orderBy("id", "desc")
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const page = pageOf(rows, query.limit);
|
||||
return { items: page.items.map(secretView), nextCursor: page.nextCursor };
|
||||
});
|
||||
|
||||
endpoint(app, { ...Api.secrets.create, tags: ["secrets"] }, async ({ body, actor }) => {
|
||||
const id = await secrets
|
||||
.create({ purpose: body.purpose, plaintext: body.plaintext, label: body.name })
|
||||
.catch((e) => pgConflict(e, "凭证名称已存在"));
|
||||
await audit(ctx, actor, "secret.create", { type: "secret", id }, { name: body.name, purpose: body.purpose });
|
||||
const row = await ctx.db.selectFrom("secret").select(SECRET_COLS).where("id", "=", id).executeTakeFirstOrThrow();
|
||||
return secretView(row);
|
||||
});
|
||||
|
||||
endpoint(app, { ...Api.secrets.update, tags: ["secrets"] }, async ({ params, body, actor }) => {
|
||||
const exists = await secrets.meta(params.id);
|
||||
if (!exists) throw new ApiException("not_found", "凭证不存在");
|
||||
await secrets.rotate(params.id, body.plaintext);
|
||||
await audit(ctx, actor, "secret.rotate", { type: "secret", id: params.id });
|
||||
const row = await ctx.db
|
||||
.selectFrom("secret")
|
||||
.select(SECRET_COLS)
|
||||
.where("id", "=", params.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
return secretView(row);
|
||||
});
|
||||
|
||||
endpoint(app, D.deleteSecret, async ({ params, actor }) => {
|
||||
const r = await ctx.db
|
||||
.deleteFrom("secret")
|
||||
.where("id", "=", params.id)
|
||||
.returning("id")
|
||||
.executeTakeFirst()
|
||||
.catch((e) => pgConflict(e, "凭证仍被引用(实例/站点账号/Feed/设置),请先解除引用"));
|
||||
if (!r) throw new ApiException("not_found", "凭证不存在");
|
||||
await audit(ctx, actor, "secret.delete", { type: "secret", id: params.id });
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// ── 审计查询 ──
|
||||
endpoint(app, D.audit, async ({ query }) => {
|
||||
const cur = decodeCursor(query.cursor);
|
||||
let q = ctx.db.selectFrom("audit_event").selectAll().select(cursorCol("at"));
|
||||
if (query.actorType) q = q.where("actor_type", "=", query.actorType);
|
||||
if (query.actorId) q = q.where("actor_id", "=", query.actorId);
|
||||
if (query.action) {
|
||||
q = query.action.endsWith(".*")
|
||||
? q.where("action", "like", `${query.action.slice(0, -2).replace(/[\\%_]/g, "\\$&")}.%`)
|
||||
: q.where("action", "=", query.action);
|
||||
}
|
||||
if (query.targetType) q = q.where("target_type", "=", query.targetType);
|
||||
if (query.targetId) q = q.where("target_id", "=", query.targetId);
|
||||
if (cur) q = q.where(afterCursor("at", cur));
|
||||
const rows = await q
|
||||
.orderBy("at", "desc")
|
||||
.orderBy("id", "desc")
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const page = pageOf(rows, query.limit);
|
||||
return { items: page.items.map((r) => toApi(r)), nextCursor: page.nextCursor };
|
||||
});
|
||||
}
|
||||
@@ -1,21 +1,222 @@
|
||||
import { DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
|
||||
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
|
||||
import { DEFAULT_SETTINGS, GlobalSettings, type SecretPurpose, type SettingsKey } from "@umt/contracts";
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import type { Logger } from "pino";
|
||||
import type { EventBusPort } from "../../http/sse.ts";
|
||||
import type { SecretStore, SettingsReader } from "../types.ts";
|
||||
import { deriveKey } from "./crypto.ts";
|
||||
|
||||
// auth-settings 模块向 ServerContext 提供的基础服务。W2-A 在这里替换实现,其它模块只用 ctx 上的接口。
|
||||
// auth-settings 模块向 ServerContext 提供的基础服务:ctx.secrets / ctx.settings。
|
||||
|
||||
/** 占位:W2-A 实现 AES-GCM 加密存取(secret 表,SECRET_MASTER_KEY) */
|
||||
export function createSecretStore(_deps: { db: Kysely<DB>; masterKey: string }): SecretStore {
|
||||
const notYet = async (): Promise<never> => {
|
||||
throw new Error("SecretStore 尚未实现(W2-A)");
|
||||
};
|
||||
return { resolve: notYet, create: notYet, replace: notYet, remove: notYet };
|
||||
// ── 密钥服务 ──
|
||||
|
||||
export const SECRET_ALGORITHM = "aes-256-gcm";
|
||||
export const SECRET_KDF = "hkdf-sha256";
|
||||
/** 当前主密钥版本;轮换主密钥时 +1,并用 rewrapAll 重新加密 */
|
||||
export const SECRET_KEY_VERSION = 1;
|
||||
|
||||
/** 找不到 secret 或无法解密;消息里只有 ref,不含任何明文 */
|
||||
export class SecretError extends Error {
|
||||
constructor(
|
||||
readonly kind: "not_found" | "decrypt_failed",
|
||||
readonly secretRef: string,
|
||||
) {
|
||||
super(
|
||||
kind === "not_found" ? `secret ${secretRef} 不存在` : `secret ${secretRef} 解密失败(主密钥不匹配或密文被篡改)`,
|
||||
);
|
||||
this.name = "SecretError";
|
||||
}
|
||||
}
|
||||
|
||||
/** settings 表(key → jsonb)按分组合并缺省值;每次读库,不缓存(设置变更立即生效) */
|
||||
export function createSettingsReader(deps: { db: Kysely<DB>; logger: Logger }): SettingsReader {
|
||||
const all = async (): Promise<GlobalSettings> => {
|
||||
export interface SecretMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
purpose: SecretPurpose;
|
||||
algorithm: string;
|
||||
kdf: string;
|
||||
kdfParams: Record<string, unknown>;
|
||||
keyVersion: number;
|
||||
rotatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** SecretStore(put/get/rotate/delete = create/resolve/replace/remove)+ 管理面需要的元数据操作 */
|
||||
export interface SecretService extends SecretStore {
|
||||
/** 同 replace:换新明文(新盐、新 nonce),记 rotated_at */
|
||||
rotate(secretRef: string, plaintext: string): Promise<void>;
|
||||
meta(secretRef: string): Promise<SecretMeta | null>;
|
||||
}
|
||||
|
||||
interface Sealed {
|
||||
ciphertext: Buffer;
|
||||
nonce: Buffer;
|
||||
authTag: Buffer;
|
||||
kdfParams: { salt: string; info: string };
|
||||
}
|
||||
|
||||
/** AAD 绑定行 id 与用途:密文被挪到别的行/改用途即解密失败 */
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
function assertRef(ref: string) {
|
||||
if (!UUID_RE.test(ref)) throw new SecretError("not_found", String(ref).slice(0, 64));
|
||||
}
|
||||
|
||||
const aad = (id: string, purpose: string) => Buffer.from(`umt-secret:${id}:${purpose}`, "utf8");
|
||||
const infoFor = (keyVersion: number) => `umt/secret/v${keyVersion}`;
|
||||
|
||||
function seal(masterKey: string, id: string, purpose: string, plaintext: string): Sealed {
|
||||
const salt = randomBytes(16);
|
||||
const info = infoFor(SECRET_KEY_VERSION);
|
||||
const key = deriveKey(masterKey, salt, info);
|
||||
const nonce = randomBytes(12);
|
||||
const cipher = createCipheriv(SECRET_ALGORITHM, key, nonce);
|
||||
cipher.setAAD(aad(id, purpose));
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
return { ciphertext, nonce, authTag: cipher.getAuthTag(), kdfParams: { salt: salt.toString("base64"), info } };
|
||||
}
|
||||
|
||||
function open(
|
||||
masterKey: string,
|
||||
row: { id: string; purpose: string; ciphertext: Buffer; nonce: Buffer; auth_tag: Buffer | null; kdf_params: unknown },
|
||||
): string {
|
||||
const params = (row.kdf_params ?? {}) as { salt?: string; info?: string };
|
||||
if (!params.salt || !params.info || !row.auth_tag) throw new SecretError("decrypt_failed", row.id);
|
||||
try {
|
||||
const key = deriveKey(masterKey, Buffer.from(params.salt, "base64"), params.info);
|
||||
const decipher = createDecipheriv(SECRET_ALGORITHM, key, row.nonce);
|
||||
decipher.setAAD(aad(row.id, row.purpose));
|
||||
decipher.setAuthTag(row.auth_tag);
|
||||
return Buffer.concat([decipher.update(row.ciphertext), decipher.final()]).toString("utf8");
|
||||
} catch {
|
||||
throw new SecretError("decrypt_failed", row.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* secret 表 + AES-256-GCM:每条 secret 用随机盐经 HKDF-SHA256 从 SECRET_MASTER_KEY 派生独立密钥,
|
||||
* 随机 12 字节 nonce,AAD 绑定 (id, purpose)。明文只在返回值里出现,不缓存、不打日志。
|
||||
*/
|
||||
export function createSecretStore(deps: { db: Kysely<DB>; masterKey: string }): SecretService {
|
||||
const { db, masterKey } = deps;
|
||||
const replace = async (secretRef: string, plaintext: string) => {
|
||||
assertRef(secretRef);
|
||||
const row = await db.selectFrom("secret").select(["id", "purpose"]).where("id", "=", secretRef).executeTakeFirst();
|
||||
if (!row) throw new SecretError("not_found", secretRef);
|
||||
const s = seal(masterKey, row.id, row.purpose, plaintext);
|
||||
await db
|
||||
.updateTable("secret")
|
||||
.set({
|
||||
ciphertext: s.ciphertext,
|
||||
nonce: s.nonce,
|
||||
auth_tag: s.authTag,
|
||||
kdf_params: JSON.stringify(s.kdfParams),
|
||||
key_version: SECRET_KEY_VERSION,
|
||||
rotated_at: new Date(),
|
||||
})
|
||||
.where("id", "=", secretRef)
|
||||
.execute();
|
||||
};
|
||||
return {
|
||||
async create({ purpose, plaintext, label }) {
|
||||
const id = randomUUID();
|
||||
const s = seal(masterKey, id, purpose, plaintext);
|
||||
await db
|
||||
.insertInto("secret")
|
||||
.values({
|
||||
id,
|
||||
name: label ?? `${purpose}:${id}`,
|
||||
purpose,
|
||||
ciphertext: s.ciphertext,
|
||||
nonce: s.nonce,
|
||||
auth_tag: s.authTag,
|
||||
algorithm: SECRET_ALGORITHM,
|
||||
kdf: SECRET_KDF,
|
||||
kdf_params: JSON.stringify(s.kdfParams),
|
||||
key_version: SECRET_KEY_VERSION,
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
},
|
||||
async resolve(secretRef) {
|
||||
assertRef(secretRef);
|
||||
const row = await db
|
||||
.selectFrom("secret")
|
||||
.select(["id", "purpose", "ciphertext", "nonce", "auth_tag", "kdf_params"])
|
||||
.where("id", "=", secretRef)
|
||||
.executeTakeFirst();
|
||||
if (!row) throw new SecretError("not_found", secretRef);
|
||||
return open(masterKey, row);
|
||||
},
|
||||
replace,
|
||||
rotate: replace,
|
||||
async remove(secretRef) {
|
||||
if (!UUID_RE.test(secretRef)) return;
|
||||
await db.deleteFrom("secret").where("id", "=", secretRef).execute();
|
||||
},
|
||||
async meta(secretRef) {
|
||||
if (!UUID_RE.test(secretRef)) return null;
|
||||
const r = await db
|
||||
.selectFrom("secret")
|
||||
.select([
|
||||
"id",
|
||||
"name",
|
||||
"purpose",
|
||||
"algorithm",
|
||||
"kdf",
|
||||
"kdf_params",
|
||||
"key_version",
|
||||
"rotated_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
])
|
||||
.where("id", "=", secretRef)
|
||||
.executeTakeFirst();
|
||||
if (!r) return null;
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
purpose: r.purpose as SecretPurpose,
|
||||
algorithm: r.algorithm,
|
||||
kdf: r.kdf,
|
||||
kdfParams: (r.kdf_params ?? {}) as Record<string, unknown>,
|
||||
keyVersion: r.key_version,
|
||||
rotatedAt: r.rotated_at,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 设置读取 ──
|
||||
|
||||
/** SSE / 进程间广播的设置变更事件 */
|
||||
export const SETTINGS_CHANGED_EVENT = "settings.changed";
|
||||
|
||||
export interface CachedSettingsReader extends SettingsReader {
|
||||
/** 丢弃缓存(本进程写库后立即调用;其它进程经 settings.changed 事件失效) */
|
||||
invalidate(): void;
|
||||
/** 订阅变更(失效后触发,参数为变更的分组);返回退订函数 */
|
||||
onChange(fn: (keys: SettingsKey[]) => void): () => void;
|
||||
/** 绕过缓存读库 */
|
||||
fresh(): Promise<GlobalSettings>;
|
||||
}
|
||||
|
||||
/**
|
||||
* settings 表(key → jsonb)按分组合并缺省值后校验;表里值非法时回退缺省并记警告。
|
||||
* 结果缓存 ttlMs(默认 30s);收到 settings.changed 事件(经 PG 总线,跨进程)立即失效。
|
||||
*/
|
||||
export function createSettingsReader(deps: {
|
||||
db: Kysely<DB>;
|
||||
logger: Logger;
|
||||
events?: EventBusPort;
|
||||
ttlMs?: number;
|
||||
}): CachedSettingsReader {
|
||||
const ttl = deps.ttlMs ?? 30_000;
|
||||
let cache: { at: number; value: Promise<GlobalSettings> } | null = null;
|
||||
const listeners = new Set<(keys: SettingsKey[]) => void>();
|
||||
|
||||
const load = async (): Promise<GlobalSettings> => {
|
||||
const rows = await deps.db.selectFrom("settings").select(["key", "value"]).execute();
|
||||
const stored = new Map(rows.map((r) => [r.key, r.value]));
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -29,5 +230,45 @@ export function createSettingsReader(deps: { db: Kysely<DB>; logger: Logger }):
|
||||
}
|
||||
return out as GlobalSettings;
|
||||
};
|
||||
return { all, get: async (key) => (await all())[key] };
|
||||
|
||||
const all = (): Promise<GlobalSettings> => {
|
||||
const now = Date.now();
|
||||
if (!cache || now - cache.at > ttl) {
|
||||
const value = load();
|
||||
cache = { at: now, value };
|
||||
// 读失败不缓存
|
||||
value.catch(() => {
|
||||
if (cache?.value === value) cache = null;
|
||||
});
|
||||
}
|
||||
return cache.value;
|
||||
};
|
||||
|
||||
const invalidate = () => {
|
||||
cache = null;
|
||||
};
|
||||
|
||||
deps.events?.subscribe((e) => {
|
||||
if (e.type !== SETTINGS_CHANGED_EVENT) return;
|
||||
invalidate();
|
||||
const keys = ((e.payload as { keys?: SettingsKey[] } | null)?.keys ?? []) as SettingsKey[];
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l(keys);
|
||||
} catch (err) {
|
||||
deps.logger.warn({ err }, "settings 变更监听器异常");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
all,
|
||||
get: async (key) => (await all())[key],
|
||||
invalidate,
|
||||
fresh: load,
|
||||
onChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Api, GlobalSettings, type GlobalSettingsUpdate, mergeSettings, type SettingsKey } from "@umt/contracts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import { audit, endpoint } from "./endpoint.ts";
|
||||
import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "./provided.ts";
|
||||
|
||||
// GET/PATCH /settings:按分组部分更新 → mergeSettings → GlobalSettings 整体校验 → 逐组 upsert;
|
||||
// Pi 上游密钥(pi.apiKey 只写)存为 secret,设置里只留 apiKeySecretRef;变更经 SSE 总线广播 settings.changed,
|
||||
// 各进程的 ctx.settings 缓存随之失效。自动化总开关 automation.enabled / 只读对账 automation.readOnlyReconcile 也走这里。
|
||||
|
||||
const isCached = (s: unknown): s is CachedSettingsReader =>
|
||||
typeof (s as CachedSettingsReader)?.invalidate === "function";
|
||||
|
||||
export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
endpoint(app, { ...Api.settings.get, tags: ["settings"] }, async () => {
|
||||
if (isCached(ctx.settings)) return await ctx.settings.fresh();
|
||||
return await ctx.settings.all();
|
||||
});
|
||||
|
||||
endpoint(app, { ...Api.settings.update, tags: ["settings"] }, async ({ body, actor }) => {
|
||||
const patch = body as GlobalSettingsUpdate;
|
||||
const current = isCached(ctx.settings) ? await ctx.settings.fresh() : await ctx.settings.all();
|
||||
const merged = mergeSettings(current, patch);
|
||||
const oldRef = current.pi.apiKeySecretRef;
|
||||
let dropRef: string | null = null;
|
||||
let newRef: string | null = null;
|
||||
const apiKey = patch.pi?.apiKey;
|
||||
if (apiKey === null) {
|
||||
merged.pi.apiKeySecretRef = null;
|
||||
dropRef = oldRef;
|
||||
}
|
||||
const parsed = GlobalSettings.safeParse(merged);
|
||||
if (!parsed.success) {
|
||||
const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
||||
throw new ApiException("validation_failed", msg);
|
||||
}
|
||||
const next = parsed.data;
|
||||
if (typeof apiKey === "string") {
|
||||
if (oldRef) {
|
||||
await ctx.secrets.replace(oldRef, apiKey);
|
||||
next.pi.apiKeySecretRef = oldRef;
|
||||
} else {
|
||||
newRef = await ctx.secrets.create({ purpose: "llm_api_key", plaintext: apiKey });
|
||||
next.pi.apiKeySecretRef = newRef;
|
||||
}
|
||||
}
|
||||
const keys = (Object.keys(patch) as SettingsKey[]).filter((k) => patch[k] !== undefined);
|
||||
try {
|
||||
await ctx.db.transaction().execute(async (trx) => {
|
||||
for (const key of keys) {
|
||||
await trx
|
||||
.insertInto("settings")
|
||||
.values({ key, value: JSON.stringify(next[key]), updated_at: new Date() })
|
||||
.onConflict((oc) =>
|
||||
oc.column("key").doUpdateSet({ value: JSON.stringify(next[key]), updated_at: new Date() }),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (newRef) await ctx.secrets.remove(newRef).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {});
|
||||
if (isCached(ctx.settings)) ctx.settings.invalidate();
|
||||
void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys });
|
||||
// 审计里不留明文密钥
|
||||
const logged: Record<string, unknown> = { ...patch };
|
||||
if (patch.pi && "apiKey" in patch.pi) logged.pi = { ...patch.pi, apiKey: apiKey === null ? "(cleared)" : "(set)" };
|
||||
await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user