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,217 @@
|
||||
import type { EventEnvelope } from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import pg from "pg";
|
||||
import type { Logger } from "pino";
|
||||
import type { BusListener, EventBusPort, SinceResult } from "./sse.ts";
|
||||
|
||||
/** 默认通知通道 */
|
||||
export const EVENTS_CHANNEL = "umt_events";
|
||||
/** 发布时串行化 seq 分配与提交的咨询锁键('umte') */
|
||||
const PUBLISH_LOCK_KEY = 0x756d7465;
|
||||
|
||||
export interface PgEventBusOptions {
|
||||
db: Kysely<DB>;
|
||||
/** LISTEN 专用连接(不走连接池) */
|
||||
connectionString: string;
|
||||
logger: Logger;
|
||||
channel?: string;
|
||||
/** sse_event 保留最近多少条供 ?after= 补读,默认 10000 */
|
||||
retain?: number;
|
||||
/** 每发布多少条裁剪一次,默认 200 */
|
||||
pruneEvery?: number;
|
||||
/** LISTEN 断线重连退避上限,默认 10s */
|
||||
maxReconnectMs?: number;
|
||||
/** 仅测试:LISTEN 连接的 search_path 等 pg 启动参数 */
|
||||
pgOptions?: string;
|
||||
}
|
||||
|
||||
type Row = { seq: number; type: string; at: Date; payload: unknown };
|
||||
const toEnvelope = (r: Row): EventEnvelope => ({
|
||||
seq: Number(r.seq),
|
||||
type: r.type,
|
||||
at: r.at.toISOString(),
|
||||
payload: r.payload,
|
||||
});
|
||||
|
||||
/**
|
||||
* 多进程 SSE 总线:发布 = 在 sse_event 追加一行(持咨询锁,保证 seq 顺序即提交顺序)+ pg_notify(channel, seq);
|
||||
* 每个进程一条 LISTEN 连接,收到通知后按 seq 从表里补读并分发给本进程订阅者(包括自己发布的)。
|
||||
* LISTEN 断线期间的事件在重连后按 lastDispatched 补读,不丢。
|
||||
*/
|
||||
export class PgEventBus implements EventBusPort {
|
||||
private readonly listeners = new Set<BusListener>();
|
||||
private readonly channel: string;
|
||||
private readonly retain: number;
|
||||
private readonly pruneEvery: number;
|
||||
private client: pg.Client | null = null;
|
||||
private lastDispatched = 0;
|
||||
private draining: Promise<void> | null = null;
|
||||
private drainAgain = false;
|
||||
private published = 0;
|
||||
private stopped = false;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private constructor(private readonly opts: PgEventBusOptions) {
|
||||
this.channel = opts.channel ?? EVENTS_CHANNEL;
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(this.channel)) throw new Error(`非法通道名 ${this.channel}`);
|
||||
this.retain = opts.retain ?? 10_000;
|
||||
this.pruneEvery = opts.pruneEvery ?? 200;
|
||||
}
|
||||
|
||||
/** 建总线并开始 LISTEN;从当前最大 seq 之后开始分发 */
|
||||
static async start(opts: PgEventBusOptions): Promise<PgEventBus> {
|
||||
const bus = new PgEventBus(opts);
|
||||
bus.lastDispatched = await bus.maxSeq();
|
||||
await bus.connect();
|
||||
return bus;
|
||||
}
|
||||
|
||||
private get log() {
|
||||
return this.opts.logger;
|
||||
}
|
||||
|
||||
private async maxSeq(): Promise<number> {
|
||||
const r = await sql<{ m: number | null }>`select max(seq) as m from sse_event`.execute(this.opts.db);
|
||||
return Number(r.rows[0]?.m ?? 0);
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
const client = new pg.Client({
|
||||
connectionString: this.opts.connectionString,
|
||||
...(this.opts.pgOptions ? { options: this.opts.pgOptions } : {}),
|
||||
});
|
||||
client.on("notification", (msg) => {
|
||||
if (msg.channel === this.channel) this.scheduleDrain();
|
||||
});
|
||||
const onLost = (err?: Error) => {
|
||||
if (this.client !== client) return;
|
||||
this.client = null;
|
||||
client.removeAllListeners("notification");
|
||||
client.end().catch(() => {});
|
||||
if (!this.stopped) {
|
||||
this.log.warn({ err }, "SSE 总线 LISTEN 连接断开,准备重连");
|
||||
this.reconnect(500);
|
||||
}
|
||||
};
|
||||
client.on("error", onLost);
|
||||
client.on("end", () => onLost());
|
||||
await client.connect();
|
||||
await client.query(`listen ${this.channel}`);
|
||||
this.client = client;
|
||||
// 断线期间可能漏掉通知:连上后补读一次
|
||||
this.scheduleDrain();
|
||||
}
|
||||
|
||||
private reconnect(delayMs: number) {
|
||||
if (this.stopped || this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
this.reconnectTimer = null;
|
||||
try {
|
||||
await this.connect();
|
||||
this.log.info("SSE 总线 LISTEN 已重连");
|
||||
} catch (err) {
|
||||
this.log.warn({ err }, "SSE 总线重连失败");
|
||||
this.reconnect(Math.min(delayMs * 2, this.opts.maxReconnectMs ?? 10_000));
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private scheduleDrain() {
|
||||
if (this.draining) {
|
||||
this.drainAgain = true;
|
||||
return;
|
||||
}
|
||||
this.draining = (async () => {
|
||||
try {
|
||||
do {
|
||||
this.drainAgain = false;
|
||||
await this.drainOnce();
|
||||
} while (this.drainAgain && !this.stopped);
|
||||
} catch (err) {
|
||||
this.log.warn({ err }, "SSE 总线补读失败");
|
||||
} finally {
|
||||
this.draining = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private async drainOnce() {
|
||||
for (;;) {
|
||||
const rows = await sql<Row>`select seq, type, at, payload from sse_event
|
||||
where seq > ${this.lastDispatched} order by seq limit 500`.execute(this.opts.db);
|
||||
for (const r of rows.rows) {
|
||||
const e = toEnvelope(r);
|
||||
this.lastDispatched = e.seq;
|
||||
for (const l of this.listeners) {
|
||||
try {
|
||||
l(e);
|
||||
} catch (err) {
|
||||
this.log.warn({ err, type: e.type }, "SSE 订阅者异常");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rows.rows.length < 500) return;
|
||||
}
|
||||
}
|
||||
|
||||
async publish<P>(type: string, payload: P): Promise<EventEnvelope<P> | null> {
|
||||
try {
|
||||
const row = await this.opts.db.transaction().execute(async (trx) => {
|
||||
await sql`select pg_advisory_xact_lock(${PUBLISH_LOCK_KEY})`.execute(trx);
|
||||
const r = await sql<Row>`insert into sse_event (type, payload)
|
||||
values (${type}, ${JSON.stringify(payload ?? null)}::jsonb) returning seq, type, at, payload`.execute(trx);
|
||||
const inserted = r.rows[0]!;
|
||||
// NOTIFY 在提交时才投递
|
||||
await sql`select pg_notify(${this.channel}, ${String(inserted.seq)})`.execute(trx);
|
||||
return inserted;
|
||||
});
|
||||
this.published += 1;
|
||||
if (this.published % this.pruneEvery === 0) void this.prune();
|
||||
return toEnvelope(row) as EventEnvelope<P>;
|
||||
} catch (err) {
|
||||
this.log.error({ err, type }, "SSE 事件发布失败");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 只保留最近 retain 条 */
|
||||
async prune(): Promise<void> {
|
||||
try {
|
||||
await sql`delete from sse_event where seq <= (select max(seq) from sse_event) - ${this.retain}`.execute(
|
||||
this.opts.db,
|
||||
);
|
||||
} catch (err) {
|
||||
this.log.warn({ err }, "sse_event 裁剪失败");
|
||||
}
|
||||
}
|
||||
|
||||
async since(after: number, limit = 1000): Promise<SinceResult> {
|
||||
const [rows, bounds] = await Promise.all([
|
||||
sql<Row>`select seq, type, at, payload from sse_event where seq > ${after} order by seq limit ${limit}`.execute(
|
||||
this.opts.db,
|
||||
),
|
||||
sql<{ lo: number | null; hi: number | null }>`select min(seq) as lo, max(seq) as hi from sse_event`.execute(
|
||||
this.opts.db,
|
||||
),
|
||||
]);
|
||||
const lo = bounds.rows[0]?.lo;
|
||||
const hi = Number(bounds.rows[0]?.hi ?? 0);
|
||||
// 裁剪过:请求起点早于最老保留条目
|
||||
const gap = after > 0 && lo != null && after < Number(lo) - 1;
|
||||
return { events: rows.rows.map(toEnvelope), gap, latestSeq: Math.max(hi, after) };
|
||||
}
|
||||
|
||||
subscribe(l: BusListener): () => void {
|
||||
this.listeners.add(l);
|
||||
return () => this.listeners.delete(l);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
const c = this.client;
|
||||
this.client = null;
|
||||
await c?.end().catch(() => {});
|
||||
await this.draining;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user