329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
import type { IPty } from "node-pty";
|
|
import type { WebSocket } from "ws";
|
|
import { SessionStore } from "./session-store.js";
|
|
import type { AuthUser } from "./auth/adapter.js";
|
|
|
|
export type { SessionInfo } from "./session-store.js";
|
|
|
|
// ── Per-user hourly rate limiter ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Tracks new-session creations per user within a rolling 1-hour window.
|
|
*
|
|
* `allow(userId)` is a non-destructive peek so callers can check eligibility
|
|
* before committing. `record(userId)` commits an attempt (call only on
|
|
* successful creation).
|
|
*/
|
|
export class UserHourlyRateLimiter {
|
|
private readonly attempts = new Map<string, number[]>();
|
|
private readonly maxPerHour: number;
|
|
|
|
constructor(maxPerHour: number) {
|
|
this.maxPerHour = maxPerHour;
|
|
setInterval(() => this.cleanup(), 5 * 60_000).unref();
|
|
}
|
|
|
|
allow(userId: string): boolean {
|
|
return this.recent(userId).length < this.maxPerHour;
|
|
}
|
|
|
|
record(userId: string): void {
|
|
const r = this.recent(userId);
|
|
r.push(Date.now());
|
|
this.attempts.set(userId, r);
|
|
}
|
|
|
|
/** Seconds until the oldest attempt in the window falls off (for Retry-After). */
|
|
retryAfterSeconds(userId: string): number {
|
|
const r = this.recent(userId);
|
|
if (r.length === 0) return 0;
|
|
return Math.ceil((Math.min(...r) + 3_600_000 - Date.now()) / 1000);
|
|
}
|
|
|
|
private recent(userId: string): number[] {
|
|
const cutoff = Date.now() - 3_600_000;
|
|
const filtered = (this.attempts.get(userId) ?? []).filter((t) => t > cutoff);
|
|
this.attempts.set(userId, filtered);
|
|
return filtered;
|
|
}
|
|
|
|
private cleanup(): void {
|
|
const cutoff = Date.now() - 3_600_000;
|
|
for (const [id, ts] of this.attempts) {
|
|
const r = ts.filter((t) => t > cutoff);
|
|
if (r.length === 0) this.attempts.delete(id);
|
|
else this.attempts.set(id, r);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── SessionManager ────────────────────────────────────────────────────────────
|
|
|
|
export class SessionManager {
|
|
private store: SessionStore;
|
|
private maxSessions: number;
|
|
private maxSessionsPerUser: number;
|
|
private spawnPty: (cols: number, rows: number, user?: AuthUser) => IPty;
|
|
private rateLimiter: UserHourlyRateLimiter;
|
|
// Tracks which sessions have already had their PTY event listeners wired,
|
|
// so we don't double-register on reconnect.
|
|
private wiredPtys = new Set<string>();
|
|
|
|
constructor(
|
|
maxSessions: number,
|
|
spawnPty: (cols: number, rows: number, user?: AuthUser) => IPty,
|
|
gracePeriodMs?: number,
|
|
scrollbackBytes?: number,
|
|
maxSessionsPerUser?: number,
|
|
maxSessionsPerHour?: number,
|
|
) {
|
|
this.maxSessions = maxSessions;
|
|
this.maxSessionsPerUser = maxSessionsPerUser ?? maxSessions;
|
|
this.spawnPty = spawnPty;
|
|
this.store = new SessionStore(gracePeriodMs, scrollbackBytes);
|
|
this.rateLimiter = new UserHourlyRateLimiter(maxSessionsPerHour ?? 100);
|
|
}
|
|
|
|
get activeCount(): number {
|
|
return this.store.size;
|
|
}
|
|
|
|
get isFull(): boolean {
|
|
return this.store.size >= this.maxSessions;
|
|
}
|
|
|
|
getSession(token: string) {
|
|
return this.store.get(token);
|
|
}
|
|
|
|
listSessions() {
|
|
return this.store.list();
|
|
}
|
|
|
|
/** All sessions in the shape expected by the admin dashboard. */
|
|
getAllSessions(): Array<{ id: string; userId: string; createdAt: number }> {
|
|
return this.store.getAll().map((s) => ({
|
|
id: s.token,
|
|
userId: s.userId,
|
|
createdAt: s.createdAt.getTime(),
|
|
}));
|
|
}
|
|
|
|
/** Sessions owned by a specific user — used by the per-user API. */
|
|
getUserSessions(userId: string) {
|
|
return this.store.listByUser(userId);
|
|
}
|
|
|
|
isUserAtConcurrentLimit(userId: string): boolean {
|
|
return this.store.countByUser(userId) >= this.maxSessionsPerUser;
|
|
}
|
|
|
|
isUserRateLimited(userId: string): boolean {
|
|
return !this.rateLimiter.allow(userId);
|
|
}
|
|
|
|
retryAfterSeconds(userId: string): number {
|
|
return this.rateLimiter.retryAfterSeconds(userId);
|
|
}
|
|
|
|
/**
|
|
* Spawns a new PTY, registers it in the session store, and wires up all
|
|
* event plumbing between the PTY and the WebSocket.
|
|
*
|
|
* Returns the session token, or null if at capacity or PTY spawn fails.
|
|
* When `user` is provided the session is associated with that user and
|
|
* per-user limits are enforced.
|
|
*/
|
|
create(ws: WebSocket, cols = 80, rows = 24, user?: AuthUser): string | null {
|
|
if (this.isFull) return null;
|
|
|
|
const userId = user?.id ?? "default";
|
|
|
|
if (this.isUserAtConcurrentLimit(userId)) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "error",
|
|
message: `Session limit reached for your account (max ${this.maxSessionsPerUser}).`,
|
|
}),
|
|
);
|
|
ws.close(1013, "Per-user session limit reached");
|
|
return null;
|
|
}
|
|
|
|
if (this.isUserRateLimited(userId)) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "error",
|
|
message: "Too many sessions created recently. Please wait before starting a new session.",
|
|
}),
|
|
);
|
|
ws.close(1013, "Rate limited");
|
|
return null;
|
|
}
|
|
|
|
let pty: IPty;
|
|
try {
|
|
pty = this.spawnPty(cols, rows, user);
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : "Unknown PTY spawn error";
|
|
ws.send(
|
|
JSON.stringify({ type: "error", message: `PTY spawn failed: ${message}` }),
|
|
);
|
|
ws.close(1011, "PTY spawn failure");
|
|
return null;
|
|
}
|
|
|
|
// Record the creation only after a successful spawn.
|
|
this.rateLimiter.record(userId);
|
|
|
|
const session = this.store.register(pty, userId);
|
|
session.ws = ws;
|
|
const { token } = session;
|
|
|
|
this.wirePtyEvents(token, pty);
|
|
this.wireWsEvents(token, ws, pty);
|
|
|
|
console.log(
|
|
`[session ${token.slice(0, 8)}] Created for user ${userId} (active: ${this.store.size}/${this.maxSessions})`,
|
|
);
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* Attaches a new WebSocket to an existing session identified by `token`.
|
|
*
|
|
* - Cancels the grace timer
|
|
* - Sends `{ type: "resumed", token }` to the client
|
|
* - Replays the scrollback buffer so the user sees their conversation
|
|
* - Resizes the PTY to the client's current terminal dimensions
|
|
*
|
|
* Returns true if the session was found, false otherwise.
|
|
*/
|
|
resume(token: string, ws: WebSocket, cols: number, rows: number): boolean {
|
|
const session = this.store.reattach(token, ws);
|
|
if (!session) return false;
|
|
|
|
console.log(
|
|
`[session ${token.slice(0, 8)}] Resumed (active: ${this.store.size}/${this.maxSessions})`,
|
|
);
|
|
|
|
// Tell the client it's a resumed session BEFORE sending scrollback bytes.
|
|
// The client uses this to clear the terminal first.
|
|
ws.send(JSON.stringify({ type: "resumed", token }));
|
|
|
|
// Replay buffered output
|
|
const scrollback = session.scrollback.read();
|
|
if (scrollback.length > 0) {
|
|
ws.send(scrollback);
|
|
}
|
|
|
|
// Sync PTY dimensions to the reconnected client
|
|
try {
|
|
session.pty.resize(cols, rows);
|
|
} catch {
|
|
// PTY may have exited
|
|
}
|
|
|
|
this.wireWsEvents(token, ws, session.pty);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Wire PTY → scrollback + WebSocket.
|
|
* Called once per session lifetime (idempotent via `wiredPtys` guard).
|
|
*/
|
|
private wirePtyEvents(token: string, pty: IPty): void {
|
|
if (this.wiredPtys.has(token)) return;
|
|
this.wiredPtys.add(token);
|
|
|
|
const session = this.store.get(token);
|
|
if (!session) return;
|
|
|
|
pty.onData((data: string) => {
|
|
// Always capture to scrollback for future replay
|
|
session.scrollback.write(data);
|
|
// Forward to the currently attached WebSocket, if any
|
|
const ws = session.ws;
|
|
if (ws && ws.readyState === 1 /* OPEN */) {
|
|
ws.send(data);
|
|
}
|
|
});
|
|
|
|
pty.onExit(({ exitCode, signal }) => {
|
|
this.wiredPtys.delete(token);
|
|
console.log(
|
|
`[session ${token.slice(0, 8)}] PTY exited: code=${exitCode}, signal=${signal}`,
|
|
);
|
|
const ws = session.ws;
|
|
if (ws && ws.readyState === 1 /* OPEN */) {
|
|
ws.send(JSON.stringify({ type: "exit", exitCode, signal }));
|
|
ws.close(1000, "PTY exited");
|
|
}
|
|
this.store.destroy(token);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wire WebSocket → PTY (input, resize, ping).
|
|
* On close/error, start the grace period instead of immediately destroying
|
|
* the session — this keeps the PTY alive for reconnection.
|
|
* Called once per WebSocket connection (safe to call again on reconnect).
|
|
*/
|
|
private wireWsEvents(token: string, ws: WebSocket, pty: IPty): void {
|
|
ws.on("message", (data: Buffer | string) => {
|
|
const str = data.toString();
|
|
if (str.startsWith("{")) {
|
|
try {
|
|
const msg = JSON.parse(str) as Record<string, unknown>;
|
|
if (
|
|
msg.type === "resize" &&
|
|
typeof msg.cols === "number" &&
|
|
typeof msg.rows === "number"
|
|
) {
|
|
pty.resize(msg.cols as number, msg.rows as number);
|
|
return;
|
|
}
|
|
if (msg.type === "ping") {
|
|
if (ws.readyState === 1 /* OPEN */) {
|
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
}
|
|
return;
|
|
}
|
|
} catch {
|
|
// Not JSON — treat as terminal input
|
|
}
|
|
}
|
|
pty.write(str);
|
|
});
|
|
|
|
const handleClose = () => {
|
|
console.log(`[session ${token.slice(0, 8)}] WebSocket closed`);
|
|
const session = this.store.get(token);
|
|
// Only start grace if this WS is still the one attached to the session
|
|
if (session && session.ws === ws) {
|
|
this.store.startGrace(token, () => {
|
|
/* logged inside startGrace */
|
|
});
|
|
}
|
|
};
|
|
|
|
ws.on("close", handleClose);
|
|
ws.on("error", (err) => {
|
|
console.error(`[session ${token.slice(0, 8)}] WebSocket error:`, err.message);
|
|
handleClose();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Force-kill a session immediately (used by the REST API).
|
|
*/
|
|
destroySession(token: string): void {
|
|
this.store.destroy(token);
|
|
}
|
|
|
|
destroyAll(): void {
|
|
this.store.destroyAll();
|
|
}
|
|
}
|