mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -23,7 +23,7 @@ import {
|
||||
collabRuntimeStore,
|
||||
type CollabControlRequest,
|
||||
} from "./runtime-store.js";
|
||||
import { sessionManager } from "../terminal/session-manager.js";
|
||||
import { liveTerminalSessions } from "../live-terminal-sessions.js";
|
||||
import {
|
||||
isLiveSession,
|
||||
isLiveSessionOwnedBy,
|
||||
@@ -79,9 +79,13 @@ async function revokeStageShare(room: CollabRoomRecord): Promise<void> {
|
||||
throw new Error("Failed to revoke the active stage share");
|
||||
}
|
||||
if (share.protocol === "ssh") {
|
||||
sessionManager.disconnectShareParticipants(share.sessionId, share.id, {
|
||||
reason: "The collaboration stage ended",
|
||||
});
|
||||
liveTerminalSessions.disconnectShareParticipants(
|
||||
share.sessionId,
|
||||
share.id,
|
||||
{
|
||||
reason: "The collaboration stage ended",
|
||||
},
|
||||
);
|
||||
void collabRuntimeStore.publish(room.id, {
|
||||
type: "collab_internal_stage_revoked",
|
||||
sessionId: share.sessionId,
|
||||
@@ -241,6 +245,8 @@ router.get(
|
||||
controlRequests: mayReviewControlRequests
|
||||
? controlRequests
|
||||
: controlRequests.filter((request) => request.userId === userId),
|
||||
// Room events ride on the terminal socket.
|
||||
eventsWsPath: "/plugin-ws/ssh-terminal/terminal",
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to get collab room", error, {
|
||||
@@ -387,7 +393,7 @@ router.delete(
|
||||
access.room.stageShareId,
|
||||
);
|
||||
if (share) {
|
||||
sessionManager.disconnectShareParticipants(
|
||||
liveTerminalSessions.disconnectShareParticipants(
|
||||
share.sessionId,
|
||||
share.id,
|
||||
{
|
||||
@@ -689,7 +695,7 @@ async function applyStageControl(
|
||||
room.stageShareId,
|
||||
);
|
||||
if (share) {
|
||||
sessionManager.setRoomShareControl(
|
||||
liveTerminalSessions.setRoomShareControl(
|
||||
share.sessionId,
|
||||
share.id,
|
||||
controllerUserId,
|
||||
@@ -722,7 +728,7 @@ collabRuntimeStore.onEvent((roomId, message) => {
|
||||
.findActiveById(room.stageShareId)
|
||||
.then((share) => {
|
||||
if (share) {
|
||||
sessionManager.setRoomShareControl(
|
||||
liveTerminalSessions.setRoomShareControl(
|
||||
share.sessionId,
|
||||
share.id,
|
||||
controllerUserId,
|
||||
@@ -739,7 +745,7 @@ collabRuntimeStore.onEvent((roomId, message) => {
|
||||
"shareId" in message &&
|
||||
typeof message.shareId === "string"
|
||||
) {
|
||||
sessionManager.disconnectShareParticipants(
|
||||
liveTerminalSessions.disconnectShareParticipants(
|
||||
message.sessionId,
|
||||
message.shareId,
|
||||
{
|
||||
@@ -1016,7 +1022,7 @@ router.post(
|
||||
access.room.stageShareId,
|
||||
);
|
||||
if (share) {
|
||||
sessionManager.disconnectShareParticipants(
|
||||
liveTerminalSessions.disconnectShareParticipants(
|
||||
share.sessionId,
|
||||
share.id,
|
||||
{
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* Keyboard-interactive answering over the terminal WebSocket.
|
||||
*
|
||||
* The classifier decides what a round is; this keeps the terminal's message
|
||||
* protocol (totp_required, totp_retry, password_required,
|
||||
* warpgate_auth_required) and its timeouts exactly as the client expects.
|
||||
*/
|
||||
|
||||
import type { WebSocket } from "ws";
|
||||
import { sshLogger, authLogger } from "../../utils/logger.js";
|
||||
import { ensureCoreSshAuthProviders } from "./core-providers.js";
|
||||
import {
|
||||
classifyKeyboardInteractive,
|
||||
responsesWithAnswer,
|
||||
} from "./keyboard-interactive.js";
|
||||
import type { KeyboardInteractivePrompt, SshConnectHost } from "./types.js";
|
||||
|
||||
interface ResolvedCredentials {
|
||||
username: string;
|
||||
password?: string;
|
||||
authType?: string;
|
||||
}
|
||||
|
||||
interface HostFlags {
|
||||
useWarpgate?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContext {
|
||||
userId: string;
|
||||
ws: WebSocket;
|
||||
hostId: number;
|
||||
isKeyboardInteractive: boolean;
|
||||
keyboardInteractiveResponded: boolean;
|
||||
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
|
||||
totpPromptSent: boolean;
|
||||
warpgateAuthPromptSent: boolean;
|
||||
totpTimeout: NodeJS.Timeout | null;
|
||||
warpgateAuthTimeout: NodeJS.Timeout | null;
|
||||
totpAttempts: number;
|
||||
}
|
||||
|
||||
const TOTP_TIMEOUT_MS = 180000;
|
||||
const PUSH_TIMEOUT_MS = 300000;
|
||||
const WARPGATE_TIMEOUT_MS = 300000;
|
||||
|
||||
export class SSHAuthManager {
|
||||
public context: AuthContext;
|
||||
|
||||
constructor(context: AuthContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
handleKeyboardInteractive(
|
||||
name: string,
|
||||
instructions: string,
|
||||
_instructionsLang: string,
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
hostConfig?: HostFlags,
|
||||
): void {
|
||||
ensureCoreSshAuthProviders();
|
||||
this.context.isKeyboardInteractive = true;
|
||||
|
||||
const host = {
|
||||
...(hostConfig ?? {}),
|
||||
id: this.context.hostId,
|
||||
ip: "",
|
||||
port: 22,
|
||||
username: resolvedCredentials.username,
|
||||
password: resolvedCredentials.password,
|
||||
authType: resolvedCredentials.authType,
|
||||
} as SshConnectHost;
|
||||
|
||||
const decision = classifyKeyboardInteractive(
|
||||
{ name, instructions, prompts },
|
||||
host,
|
||||
);
|
||||
|
||||
switch (decision.kind) {
|
||||
case "auto":
|
||||
finish(decision.responses);
|
||||
return;
|
||||
case "warpgate":
|
||||
this.handleWarpgate(decision, finish);
|
||||
return;
|
||||
case "totp":
|
||||
this.handleTotp(prompts, decision.promptIndex, finish, host.password);
|
||||
return;
|
||||
case "input":
|
||||
this.handleInput(
|
||||
prompts,
|
||||
decision.promptIndex,
|
||||
decision.isPush,
|
||||
finish,
|
||||
host.password,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleWarpgate(
|
||||
decision: { url: string | null; securityKey: string; instructions: string },
|
||||
finish: (responses: string[]) => void,
|
||||
): void {
|
||||
this.context.keyboardInteractiveFinish = () => {
|
||||
finish([""]);
|
||||
};
|
||||
this.context.warpgateAuthPromptSent = true;
|
||||
this.sendLog("auth", "info", "Warpgate authentication required");
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "warpgate_auth_required",
|
||||
url: decision.url,
|
||||
securityKey: decision.securityKey,
|
||||
instructions: decision.instructions,
|
||||
}),
|
||||
);
|
||||
|
||||
this.context.warpgateAuthTimeout = setTimeout(() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.warpgateAuthPromptSent = false;
|
||||
sshLogger.warn("Warpgate authentication timeout", {
|
||||
operation: "warpgate_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Warpgate authentication timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, WARPGATE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private handleTotp(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
promptIndex: number,
|
||||
finish: (responses: string[]) => void,
|
||||
password: string | undefined,
|
||||
): void {
|
||||
if (this.context.totpPromptSent) {
|
||||
sshLogger.warn("TOTP prompt asked again - invalid code", {
|
||||
operation: "ssh_keyboard_interactive_totp_retry",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
authLogger.warn("TOTP verification failed for SSH session", {
|
||||
operation: "terminal_totp_failed",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.sendLog("auth", "warning", "Invalid TOTP code");
|
||||
this.context.ws.send(JSON.stringify({ type: "totp_retry" }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.totpPromptSent = true;
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
this.context.keyboardInteractiveFinish = (answers: string[]) => {
|
||||
finish(
|
||||
responsesWithAnswer(
|
||||
prompts,
|
||||
promptIndex,
|
||||
(answers[0] || "").trim(),
|
||||
password,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
this.armTimeout(TOTP_TIMEOUT_MS, "totp");
|
||||
this.sendLog("auth", "info", "TOTP verification required");
|
||||
authLogger.info("TOTP verification prompt sent to client", {
|
||||
operation: "terminal_totp_prompt",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_required",
|
||||
prompt: prompts[promptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleInput(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
promptIndex: number,
|
||||
isPush: boolean,
|
||||
finish: (responses: string[]) => void,
|
||||
password: string | undefined,
|
||||
): void {
|
||||
if (this.context.keyboardInteractiveResponded) return;
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
|
||||
this.context.keyboardInteractiveFinish = (answers: string[]) => {
|
||||
finish(
|
||||
responsesWithAnswer(
|
||||
prompts,
|
||||
promptIndex,
|
||||
(answers[0] || "").trim(),
|
||||
password,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
this.armTimeout(isPush ? PUSH_TIMEOUT_MS : TOTP_TIMEOUT_MS, "password");
|
||||
this.sendLog("auth", "info", "Password authentication required");
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "password_required",
|
||||
prompt: prompts[promptIndex].prompt,
|
||||
echo: prompts[promptIndex].echo,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private armTimeout(ms: number, kind: "totp" | "password"): void {
|
||||
if (this.context.totpTimeout) clearTimeout(this.context.totpTimeout);
|
||||
this.context.totpTimeout = setTimeout(() => {
|
||||
if (!this.context.keyboardInteractiveFinish) return;
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
if (kind === "totp") {
|
||||
this.context.totpPromptSent = false;
|
||||
} else {
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
}
|
||||
sshLogger.warn(
|
||||
`${kind === "totp" ? "TOTP" : "Password"} prompt timeout`,
|
||||
{
|
||||
operation: `${kind}_timeout`,
|
||||
hostId: this.context.hostId,
|
||||
},
|
||||
);
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
kind === "totp"
|
||||
? "TOTP verification timeout. Please reconnect."
|
||||
: "Password verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
sendLog(
|
||||
stage: string,
|
||||
level: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "connection_log",
|
||||
data: { stage, level, message, details },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
this.context.totpTimeout = null;
|
||||
}
|
||||
if (this.context.warpgateAuthTimeout) {
|
||||
clearTimeout(this.context.warpgateAuthTimeout);
|
||||
this.context.warpgateAuthTimeout = null;
|
||||
}
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.totpPromptSent = false;
|
||||
this.context.warpgateAuthPromptSent = false;
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,12 @@ export interface SshAuthProvider {
|
||||
* transports that can show one (the terminal today).
|
||||
*/
|
||||
startInteraction?: (request: SshInteractionRequest) => Promise<void>;
|
||||
/** Cancels a pending browser step, by request id or by host. */
|
||||
cancelInteraction?: (request: {
|
||||
userId: string;
|
||||
hostId?: number;
|
||||
requestId?: string;
|
||||
}) => void | Promise<void>;
|
||||
prepare: (
|
||||
config: MutableConnectConfig,
|
||||
host: SshConnectHost,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* What core needs from live SSH terminal sessions, through the plugin
|
||||
* service "sessions.live" rather than an import.
|
||||
*
|
||||
* Session sharing, collab and the open-tabs route still live in core and
|
||||
* reach the sessions the ssh-terminal plugin holds. With no provider (the
|
||||
* plugin is off) a lookup finds nothing and a control call does nothing,
|
||||
* because "no such live session" and "the terminal is off" mean the same
|
||||
* thing to every caller here.
|
||||
*/
|
||||
|
||||
import { getServiceImplementation } from "../plugins/service-registry.js";
|
||||
|
||||
export const LIVE_SESSIONS_SERVICE = "sessions.live";
|
||||
|
||||
export interface LiveSessionInfo {
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
lastDetachedAt: number | null;
|
||||
tabInstanceId: string | null;
|
||||
tmuxSessionName: string | null;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
interface LiveSessionsV1 {
|
||||
getSession: (sessionId: string) => LiveSessionInfo | null;
|
||||
listForUser: (userId: string) => LiveSessionInfo[];
|
||||
ownerEndSession: (sessionId: string, reason: string) => void;
|
||||
disconnectParticipants: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { reason: string; userId?: string | null },
|
||||
) => number;
|
||||
setRoomShareControl: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
) => void;
|
||||
idleTimeoutMinutes: () => number;
|
||||
}
|
||||
|
||||
function provider(): LiveSessionsV1 | undefined {
|
||||
return getServiceImplementation<LiveSessionsV1>(
|
||||
LIVE_SESSIONS_SERVICE,
|
||||
"^1.0.0",
|
||||
);
|
||||
}
|
||||
|
||||
export const liveTerminalSessions = {
|
||||
getSession(sessionId: string): LiveSessionInfo | null {
|
||||
return provider()?.getSession(sessionId) ?? null;
|
||||
},
|
||||
listForUser(userId: string): LiveSessionInfo[] {
|
||||
return provider()?.listForUser(userId) ?? [];
|
||||
},
|
||||
ownerEndSession(sessionId: string, reason: string): void {
|
||||
provider()?.ownerEndSession(sessionId, reason);
|
||||
},
|
||||
disconnectShareParticipants(
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { reason: string; userId?: string | null },
|
||||
): number {
|
||||
return provider()?.disconnectParticipants(sessionId, shareId, options) ?? 0;
|
||||
},
|
||||
setRoomShareControl(
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
): void {
|
||||
provider()?.setRoomShareControl(sessionId, shareId, controllerUserId);
|
||||
},
|
||||
/** How long the terminal keeps a detached session, or null while it is off. */
|
||||
idleTimeoutMinutes(): number | null {
|
||||
return provider()?.idleTimeoutMinutes() ?? null;
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sessionManager } from "../terminal/session-manager.js";
|
||||
import { liveTerminalSessions } from "../live-terminal-sessions.js";
|
||||
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
|
||||
// through the same in-core bridge its router is mounted behind, since
|
||||
// plugins/ is compiled separately from src/backend/ and core cannot
|
||||
@@ -43,7 +43,7 @@ export function isLiveSessionOwnedBy(
|
||||
userId: string,
|
||||
): boolean {
|
||||
if (protocol === "ssh") {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
const session = liveTerminalSessions.getSession(sessionId);
|
||||
return !!session && session.isConnected && session.userId === userId;
|
||||
}
|
||||
const info = getGuacSessionInfo(sessionId);
|
||||
@@ -55,7 +55,7 @@ export function isLiveSession(
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
if (protocol === "ssh") {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
const session = liveTerminalSessions.getSession(sessionId);
|
||||
return !!session && session.isConnected;
|
||||
}
|
||||
return !!getGuacSessionInfo(sessionId);
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { sessionManager } from "../terminal/session-manager.js";
|
||||
import { liveTerminalSessions } from "../live-terminal-sessions.js";
|
||||
import {
|
||||
isLiveSession,
|
||||
isLiveSessionOwnedBy,
|
||||
@@ -326,7 +326,7 @@ router.delete(
|
||||
// guac link only blocks *future* resolves until the guest's own socket ends.
|
||||
if (share.protocol === "ssh") {
|
||||
try {
|
||||
sessionManager.ownerEndSession(
|
||||
liveTerminalSessions.ownerEndSession(
|
||||
share.sessionId,
|
||||
"Session share revoked by owner",
|
||||
);
|
||||
@@ -482,7 +482,7 @@ router.post(
|
||||
}
|
||||
|
||||
if (share.protocol === "ssh") {
|
||||
sessionManager.ownerEndSession(
|
||||
liveTerminalSessions.ownerEndSession(
|
||||
share.sessionId,
|
||||
"Session ended by owner",
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import ssh2Pkg, {
|
||||
|
||||
type KnownPublicKey = KnownPublicKeys[number];
|
||||
|
||||
const { AgentProtocol, BaseAgent } = ssh2Pkg;
|
||||
const { BaseAgent } = ssh2Pkg;
|
||||
const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000;
|
||||
|
||||
type Sleep = (ms: number) => Promise<void>;
|
||||
@@ -27,58 +27,6 @@ type PortKnockingOptions = {
|
||||
|
||||
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export class MemoryAgent extends BaseAgent {
|
||||
private key: ParsedKey;
|
||||
|
||||
constructor(key: ParsedKey) {
|
||||
super();
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
getIdentities(cb: IdentityCallback<ParsedKey>): void {
|
||||
cb(null, [this.key]);
|
||||
}
|
||||
|
||||
getStream(cb: GetStreamCallback): void {
|
||||
const protocol = new AgentProtocol(false);
|
||||
|
||||
protocol.on("identities", (request) => {
|
||||
protocol.getIdentitiesReply(request, [this.key]);
|
||||
});
|
||||
|
||||
protocol.on("sign", (request, publicKey, data, options) => {
|
||||
this.sign(publicKey, data, options, (error, signature) => {
|
||||
if (error || !signature) return protocol.failureReply(request);
|
||||
protocol.signReply(request, signature);
|
||||
});
|
||||
});
|
||||
|
||||
cb(null, protocol);
|
||||
}
|
||||
|
||||
sign(
|
||||
_pubKey: ParsedKey | Buffer | string,
|
||||
data: Buffer,
|
||||
optionsOrCb: SigningRequestOptions | SignCallback,
|
||||
cb?: SignCallback,
|
||||
): void {
|
||||
const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!;
|
||||
const options = typeof optionsOrCb === "function" ? {} : optionsOrCb;
|
||||
try {
|
||||
const algo =
|
||||
options.hash === "sha256"
|
||||
? "rsa-sha2-256"
|
||||
: options.hash === "sha512"
|
||||
? "rsa-sha2-512"
|
||||
: undefined;
|
||||
const signature = this.key.sign(data, algo);
|
||||
callback(null, signature);
|
||||
} catch (err) {
|
||||
callback(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveAgentSocket(
|
||||
terminalConfig: Record<string, unknown> | undefined,
|
||||
): Promise<{ socketPath: string } | { error: string }> {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,934 +0,0 @@
|
||||
import { type Client, type ClientChannel } from "ssh2";
|
||||
import { WebSocket } from "ws";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
getCurrentSettingValue,
|
||||
createCurrentSessionRecordingRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
|
||||
const MAX_BUFFER_BYTES = 512 * 1024;
|
||||
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
|
||||
const SESSION_LOGS_DIR = path.join(DATA_DIR, "session_logs");
|
||||
const DEFAULT_TIMEOUT_MINUTES = 30;
|
||||
const HEALTH_CHECK_INTERVAL_MS = 60_000;
|
||||
const MAX_SESSIONS_PER_USER = 10;
|
||||
// Coalesces recording writes: a chatty SSH stream can emit dozens of "data"
|
||||
// events per second, and appending to disk on every single one saturates the
|
||||
// libuv threadpool (default size 4), starving unrelated fs/DNS/crypto work
|
||||
// and stalling the WS ping/pong health check enough to look like connection
|
||||
// drops. Batch pending lines and flush on a short trailing edge instead.
|
||||
const RECORDING_FLUSH_INTERVAL_MS = 300;
|
||||
|
||||
export interface SessionParticipant {
|
||||
ws: WebSocket;
|
||||
userId: string | null; // null for anonymous link guests
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
isOwner: boolean;
|
||||
displayName?: string;
|
||||
guestLabel?: string;
|
||||
tabInstanceId?: string;
|
||||
joinedViaShareId?: string;
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
tabInstanceId?: string;
|
||||
attachedTabInstanceId?: string;
|
||||
|
||||
sshConn: Client | null;
|
||||
sshStream: ClientChannel | null;
|
||||
jumpClient: Client | null;
|
||||
|
||||
cols: number;
|
||||
rows: number;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
|
||||
participants: Map<string, SessionParticipant>;
|
||||
lastDetachedAt: number | null;
|
||||
detachTimeout: NodeJS.Timeout | null;
|
||||
|
||||
outputBuffer: string[];
|
||||
outputBufferBytes: number;
|
||||
recordingPath: string | null;
|
||||
recordingHeader: string | null;
|
||||
recordingBytes: number;
|
||||
recordingId: number | null;
|
||||
recordingWriteChain: Promise<void>;
|
||||
recordingPersistChain: Promise<void>;
|
||||
pendingRecordingData: string;
|
||||
recordingFlushTimer: NodeJS.Timeout | null;
|
||||
tmuxSessionName: string | null;
|
||||
sessionLoggingEnabled: boolean;
|
||||
sessionStartedAt: number;
|
||||
lastPersistedBytes: number;
|
||||
terminatedByOwner: boolean;
|
||||
terminationReason: string | null;
|
||||
}
|
||||
|
||||
/** Message types a non-owner participant may legally send. */
|
||||
const NON_OWNER_ALLOWED_MESSAGE_TYPES = new Set([
|
||||
"input",
|
||||
"ping",
|
||||
"disconnect",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Server-side gate for whether a participant may send a given WS message
|
||||
* type. The owner may send anything; non-owners are limited to input (if
|
||||
* read-write), ping, and disconnect. Pure function so read-only enforcement
|
||||
* is unit-testable without a real WebSocketServer.
|
||||
*/
|
||||
export function isMessageAllowedForParticipant(
|
||||
participant: Pick<SessionParticipant, "isOwner" | "permissionLevel"> | null,
|
||||
messageType: string,
|
||||
): boolean {
|
||||
if (!participant || participant.isOwner) return true;
|
||||
if (!NON_OWNER_ALLOWED_MESSAGE_TYPES.has(messageType)) return false;
|
||||
if (messageType === "input" && participant.permissionLevel === "read-only") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class TerminalSessionManager {
|
||||
private static instance: TerminalSessionManager;
|
||||
private sessions = new Map<string, TerminalSession>();
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.healthCheckTimer = setInterval(
|
||||
() => this.healthCheck(),
|
||||
HEALTH_CHECK_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
static getInstance(): TerminalSessionManager {
|
||||
if (!TerminalSessionManager.instance) {
|
||||
TerminalSessionManager.instance = new TerminalSessionManager();
|
||||
}
|
||||
return TerminalSessionManager.instance;
|
||||
}
|
||||
|
||||
createSession(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
tabInstanceId?: string,
|
||||
sessionLoggingEnabled = true,
|
||||
): string {
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
|
||||
const detached = userSessions
|
||||
.filter((s) => this.getOwnerParticipant(s) === null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.lastDetachedAt ?? a.createdAt) -
|
||||
(b.lastDetachedAt ?? b.createdAt),
|
||||
);
|
||||
if (detached.length > 0) {
|
||||
this.destroySession(detached[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
if (tabInstanceId) {
|
||||
const tabSessions = userSessions.filter(
|
||||
(s) => s.tabInstanceId === tabInstanceId,
|
||||
);
|
||||
for (const existing of tabSessions) {
|
||||
const isLiveSession =
|
||||
existing.isConnected &&
|
||||
existing.sshStream != null &&
|
||||
!existing.sshStream.destroyed;
|
||||
if (isLiveSession) {
|
||||
// Don't destroy a live session (even if detached) — the caller should attach instead
|
||||
sshLogger.warn(
|
||||
"Tab instance has live session, skipping duplicate create",
|
||||
{
|
||||
operation: "session_tab_duplicate_skip",
|
||||
existingSessionId: existing.id,
|
||||
tabInstanceId,
|
||||
hasAttachedWs: this.getOwnerParticipant(existing) !== null,
|
||||
},
|
||||
);
|
||||
return existing.id;
|
||||
}
|
||||
sshLogger.warn("Tab instance already has session, destroying old", {
|
||||
operation: "session_tab_duplicate_cleanup",
|
||||
existingSessionId: existing.id,
|
||||
tabInstanceId,
|
||||
});
|
||||
this.destroySession(existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
let recordingPath: string | null = null;
|
||||
let recordingHeader: string | null = null;
|
||||
if (sessionLoggingEnabled) {
|
||||
const userLogDir = path.join(SESSION_LOGS_DIR, userId);
|
||||
recordingPath = path.join(userLogDir, `${id}.cast`);
|
||||
recordingHeader = `${JSON.stringify({
|
||||
version: 2,
|
||||
width: cols,
|
||||
height: rows,
|
||||
timestamp: Math.floor(now / 1000),
|
||||
env: { TERM: "xterm-256color", SHELL: "/bin/sh" },
|
||||
})}\n`;
|
||||
}
|
||||
const session: TerminalSession = {
|
||||
id,
|
||||
userId,
|
||||
hostId,
|
||||
hostName,
|
||||
tabInstanceId,
|
||||
sshConn: null,
|
||||
sshStream: null,
|
||||
jumpClient: null,
|
||||
cols,
|
||||
rows,
|
||||
isConnected: false,
|
||||
createdAt: now,
|
||||
participants: new Map(),
|
||||
lastDetachedAt: null,
|
||||
detachTimeout: null,
|
||||
outputBuffer: [],
|
||||
outputBufferBytes: 0,
|
||||
recordingPath,
|
||||
recordingHeader,
|
||||
recordingBytes: 0,
|
||||
recordingId: null,
|
||||
recordingWriteChain: Promise.resolve(),
|
||||
recordingPersistChain: Promise.resolve(),
|
||||
pendingRecordingData: "",
|
||||
recordingFlushTimer: null,
|
||||
tmuxSessionName: null,
|
||||
sessionLoggingEnabled,
|
||||
sessionStartedAt: now,
|
||||
lastPersistedBytes: 0,
|
||||
terminatedByOwner: false,
|
||||
terminationReason: null,
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
|
||||
sshLogger.info("Terminal session created", {
|
||||
operation: "session_created",
|
||||
sessionId: id,
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
getSession(sessionId: string | null): TerminalSession | null {
|
||||
if (!sessionId) return null;
|
||||
return this.sessions.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
setSSHState(
|
||||
sessionId: string,
|
||||
conn: Client,
|
||||
stream: ClientChannel,
|
||||
jumpClient?: Client | null,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.sshConn = conn;
|
||||
session.sshStream = stream;
|
||||
session.jumpClient = jumpClient ?? null;
|
||||
session.isConnected = true;
|
||||
}
|
||||
|
||||
/** Finds the owner's participant entry, if currently attached. */
|
||||
private getOwnerParticipant(
|
||||
session: TerminalSession,
|
||||
): SessionParticipant | null {
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.isOwner) return participant;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getOwnerEntry(
|
||||
session: TerminalSession,
|
||||
): [string, SessionParticipant] | null {
|
||||
for (const entry of session.participants.entries()) {
|
||||
if (entry[1].isOwner) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
attachWs(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
ws: WebSocket,
|
||||
tabInstanceId?: string,
|
||||
): TerminalSession | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
sshLogger.warn("Session not found for attachment", {
|
||||
operation: "session_attach_not_found",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (session.userId !== userId) {
|
||||
sshLogger.warn("Session userId mismatch", {
|
||||
operation: "session_attach_user_mismatch",
|
||||
sessionId,
|
||||
expectedUserId: session.userId,
|
||||
providedUserId: userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!session.isConnected) {
|
||||
sshLogger.warn("Session not connected", {
|
||||
operation: "session_attach_not_connected",
|
||||
sessionId,
|
||||
userId,
|
||||
createdAt: session.createdAt,
|
||||
elapsed: Date.now() - session.createdAt,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const ownerParticipant = this.getOwnerParticipant(session);
|
||||
const isDetached =
|
||||
!ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN;
|
||||
const isOriginalTab =
|
||||
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
||||
tabInstanceId;
|
||||
|
||||
if (
|
||||
!isDetached &&
|
||||
!isOriginalTab &&
|
||||
session.tabInstanceId &&
|
||||
tabInstanceId
|
||||
) {
|
||||
sshLogger.warn("Session actively attached to different tab instance", {
|
||||
operation: "session_attach_instance_conflict",
|
||||
sessionId,
|
||||
sessionInstanceId: session.tabInstanceId,
|
||||
providedInstanceId: tabInstanceId,
|
||||
});
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionExpired",
|
||||
sessionId,
|
||||
message: "Session belongs to a different tab instance",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
session.tabInstanceId &&
|
||||
tabInstanceId &&
|
||||
session.tabInstanceId !== tabInstanceId
|
||||
) {
|
||||
sshLogger.info(
|
||||
"Session attached to different tab instance (split-screen)",
|
||||
{
|
||||
operation: "session_attach_split_screen",
|
||||
originalInstanceId: session.tabInstanceId,
|
||||
newInstanceId: tabInstanceId,
|
||||
sessionId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const ownerEntry = this.getOwnerEntry(session);
|
||||
if (ownerEntry && ownerEntry[1].ws !== ws) {
|
||||
try {
|
||||
ownerEntry[1].ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionTakenOver",
|
||||
sessionId,
|
||||
message: "Session was attached from another tab",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.participants.delete(ownerEntry[0]);
|
||||
}
|
||||
|
||||
if (session.detachTimeout) {
|
||||
clearTimeout(session.detachTimeout);
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
const participantId = crypto.randomUUID();
|
||||
session.participants.set(participantId, {
|
||||
ws,
|
||||
userId,
|
||||
permissionLevel: "read-write",
|
||||
isOwner: true,
|
||||
tabInstanceId,
|
||||
});
|
||||
session.attachedTabInstanceId = tabInstanceId;
|
||||
session.lastDetachedAt = null;
|
||||
this.broadcastParticipants(sessionId);
|
||||
|
||||
sshLogger.info("WebSocket attached to session", {
|
||||
operation: "session_attach",
|
||||
sessionId,
|
||||
userId,
|
||||
tabInstanceId,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a non-owner participant (in-app share join or anonymous link guest).
|
||||
* Purely additive - never evicts the owner or any other participant.
|
||||
*/
|
||||
joinAsParticipant(
|
||||
sessionId: string,
|
||||
ws: WebSocket,
|
||||
opts: {
|
||||
userId: string | null;
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
displayName?: string;
|
||||
guestLabel?: string;
|
||||
tabInstanceId?: string;
|
||||
shareId?: string;
|
||||
},
|
||||
): TerminalSession | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || !session.isConnected) return null;
|
||||
|
||||
const participantId = crypto.randomUUID();
|
||||
session.participants.set(participantId, {
|
||||
ws,
|
||||
userId: opts.userId,
|
||||
permissionLevel: opts.permissionLevel,
|
||||
isOwner: false,
|
||||
displayName: opts.displayName,
|
||||
guestLabel: opts.guestLabel,
|
||||
tabInstanceId: opts.tabInstanceId,
|
||||
joinedViaShareId: opts.shareId,
|
||||
});
|
||||
this.broadcastParticipants(sessionId);
|
||||
|
||||
sshLogger.info("Participant joined shared session", {
|
||||
operation: "session_join_participant",
|
||||
sessionId,
|
||||
userId: opts.userId,
|
||||
permissionLevel: opts.permissionLevel,
|
||||
shareId: opts.shareId,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells everyone in a shared session who is present. Sent on join and
|
||||
* leave, and only while someone besides the owner is (or just was) in the
|
||||
* room - a solo owner never receives presence traffic.
|
||||
*/
|
||||
private broadcastParticipants(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
const participants = Array.from(session.participants.values()).map(
|
||||
(participant) => ({
|
||||
isOwner: participant.isOwner,
|
||||
permissionLevel: participant.permissionLevel,
|
||||
label: participant.displayName ?? participant.guestLabel ?? null,
|
||||
}),
|
||||
);
|
||||
if (participants.every((participant) => participant.isOwner)) return;
|
||||
this.broadcast(sessionId, { type: "participants", participants });
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants stage control: participants joined via this share become
|
||||
* read-write only while they are the controller. The owner is untouched.
|
||||
*/
|
||||
setRoomShareControl(
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.isOwner || participant.joinedViaShareId !== shareId)
|
||||
continue;
|
||||
participant.permissionLevel =
|
||||
controllerUserId && participant.userId === controllerUserId
|
||||
? "read-write"
|
||||
: "read-only";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects non-owner participants that joined through one share.
|
||||
* Supplying userId narrows the kick to that authenticated user; null targets
|
||||
* anonymous guests. Omitting it revokes the share for every participant.
|
||||
*/
|
||||
disconnectShareParticipants(
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { userId?: string | null; reason: string },
|
||||
): number {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return 0;
|
||||
const filterByUser = Object.hasOwn(options, "userId");
|
||||
let disconnected = 0;
|
||||
for (const [id, participant] of session.participants.entries()) {
|
||||
if (
|
||||
participant.isOwner ||
|
||||
participant.joinedViaShareId !== shareId ||
|
||||
(filterByUser && participant.userId !== options.userId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
session.participants.delete(id);
|
||||
disconnected += 1;
|
||||
if (participant.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
participant.ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionExpired",
|
||||
sessionId,
|
||||
message: options.reason,
|
||||
}),
|
||||
);
|
||||
participant.ws.close(1008, options.reason);
|
||||
} catch {
|
||||
participant.ws.terminate();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (disconnected > 0) this.broadcastParticipants(sessionId);
|
||||
return disconnected;
|
||||
}
|
||||
|
||||
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
||||
broadcast(sessionId: string, message: object): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
const payload = JSON.stringify(message);
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||
try {
|
||||
participant.ws.send(payload);
|
||||
} catch {
|
||||
/* ignore individual send failures, keep broadcasting to the rest */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Finds the participant entry (owner or not) for a given socket. */
|
||||
getParticipantForWs(
|
||||
session: TerminalSession,
|
||||
ws: WebSocket,
|
||||
): SessionParticipant | null {
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.ws === ws) return participant;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a non-owner participant's socket. No detach timeout or session
|
||||
* destruction side effects - a guest leaving must never end the session.
|
||||
*/
|
||||
removeParticipant(sessionId: string, ws: WebSocket): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
for (const [id, participant] of session.participants.entries()) {
|
||||
if (participant.ws === ws && !participant.isOwner) {
|
||||
session.participants.delete(id);
|
||||
this.broadcastParticipants(sessionId);
|
||||
sshLogger.info("Participant left shared session", {
|
||||
operation: "session_leave_participant",
|
||||
sessionId,
|
||||
userId: participant.userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Broadcasts termination to all guests, then destroys the session. */
|
||||
ownerEndSession(sessionId: string, reason: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
this.broadcast(sessionId, { type: "sessionTerminatedByOwner", reason });
|
||||
session.terminatedByOwner = true;
|
||||
session.terminationReason = reason;
|
||||
|
||||
sshLogger.info("Owner ended shared session", {
|
||||
operation: "session_owner_end",
|
||||
sessionId,
|
||||
reason,
|
||||
});
|
||||
|
||||
this.destroySession(sessionId);
|
||||
}
|
||||
|
||||
detachWs(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (session.detachTimeout) {
|
||||
clearTimeout(session.detachTimeout);
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
const ownerEntry = this.getOwnerEntry(session);
|
||||
if (ownerEntry) {
|
||||
session.participants.delete(ownerEntry[0]);
|
||||
}
|
||||
session.lastDetachedAt = Date.now();
|
||||
|
||||
// Persist log immediately when the user detaches so it appears right away,
|
||||
// regardless of whether the session is later reattached or times out.
|
||||
this.maybePersistLog(session);
|
||||
|
||||
const timeoutMs = this.getTimeoutMs();
|
||||
|
||||
session.detachTimeout = setTimeout(() => {
|
||||
sshLogger.info("Session idle timeout expired", {
|
||||
operation: "session_idle_timeout",
|
||||
sessionId,
|
||||
userId: session.userId,
|
||||
});
|
||||
this.destroySession(sessionId);
|
||||
}, timeoutMs);
|
||||
|
||||
sshLogger.info("WebSocket detached from session", {
|
||||
operation: "session_detach",
|
||||
sessionId,
|
||||
userId: session.userId,
|
||||
timeoutMinutes: timeoutMs / 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
destroySession(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (session.detachTimeout) {
|
||||
clearTimeout(session.detachTimeout);
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
this.maybePersistLog(session, true);
|
||||
if (session.recordingPath && session.recordingBytes === 0) {
|
||||
fs.promises.unlink(session.recordingPath).catch(() => {});
|
||||
}
|
||||
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.isOwner) continue;
|
||||
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||
try {
|
||||
participant.ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionExpired",
|
||||
sessionId,
|
||||
message: "Session has ended",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
session.participants.clear();
|
||||
|
||||
if (session.sshStream) {
|
||||
try {
|
||||
session.sshStream.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.sshStream = null;
|
||||
}
|
||||
|
||||
if (session.sshConn) {
|
||||
try {
|
||||
session.sshConn.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.sshConn = null;
|
||||
}
|
||||
|
||||
if (session.jumpClient) {
|
||||
try {
|
||||
session.jumpClient.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.jumpClient = null;
|
||||
}
|
||||
|
||||
session.isConnected = false;
|
||||
session.outputBuffer = [];
|
||||
session.outputBufferBytes = 0;
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
sshLogger.info("Terminal session destroyed", {
|
||||
operation: "session_destroyed",
|
||||
sessionId,
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
}
|
||||
|
||||
private maybePersistLog(session: TerminalSession, force = false): void {
|
||||
if (!session.sessionLoggingEnabled) return;
|
||||
if (session.recordingFlushTimer) {
|
||||
clearTimeout(session.recordingFlushTimer);
|
||||
session.recordingFlushTimer = null;
|
||||
this.flushRecording(session);
|
||||
}
|
||||
if (session.recordingBytes === 0) return;
|
||||
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
|
||||
session.lastPersistedBytes = session.recordingBytes;
|
||||
session.recordingPersistChain = session.recordingPersistChain
|
||||
.then(() => this.persistSessionLog(session))
|
||||
.catch((err) => {
|
||||
sshLogger.warn("Failed to persist session log", {
|
||||
operation: "session_log_persist_error",
|
||||
sessionId: session.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async persistSessionLog(session: TerminalSession): Promise<void> {
|
||||
if (!session.recordingPath) return;
|
||||
await session.recordingWriteChain;
|
||||
const endedAt = Date.now();
|
||||
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
|
||||
|
||||
try {
|
||||
const repo = createCurrentSessionRecordingRepository();
|
||||
if (session.recordingId == null) {
|
||||
const created = await repo.create({
|
||||
hostId: session.hostId,
|
||||
userId: session.userId,
|
||||
startedAt: new Date(session.sessionStartedAt).toISOString(),
|
||||
endedAt: new Date(endedAt).toISOString(),
|
||||
duration,
|
||||
recordingPath: session.recordingPath,
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
terminatedByOwner: session.terminatedByOwner || undefined,
|
||||
terminationReason: session.terminationReason ?? undefined,
|
||||
});
|
||||
session.recordingId = created.id;
|
||||
} else {
|
||||
await repo.updateEnded(session.recordingId, {
|
||||
endedAt: new Date(endedAt).toISOString(),
|
||||
duration,
|
||||
terminatedByOwner: session.terminatedByOwner || undefined,
|
||||
terminationReason: session.terminationReason ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to insert session recording row", {
|
||||
operation: "session_recording_insert_error",
|
||||
sessionId: session.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
sshLogger.info("Session log persisted", {
|
||||
operation: "session_log_persisted",
|
||||
sessionId: session.id,
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
duration,
|
||||
bytes: session.recordingBytes,
|
||||
});
|
||||
}
|
||||
|
||||
getUserSessions(userId: string): TerminalSession[] {
|
||||
const result: TerminalSession[] = [];
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.userId === userId) {
|
||||
result.push(session);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bufferOutput(sessionId: string, data: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.outputBuffer.push(data);
|
||||
session.outputBufferBytes += data.length;
|
||||
|
||||
while (
|
||||
session.outputBufferBytes > MAX_BUFFER_BYTES &&
|
||||
session.outputBuffer.length > 0
|
||||
) {
|
||||
const removed = session.outputBuffer.shift();
|
||||
if (removed) session.outputBufferBytes -= removed.length;
|
||||
}
|
||||
|
||||
this.recordSessionEvent(session, "o", data);
|
||||
}
|
||||
|
||||
bufferInput(sessionId: string, data: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.recordSessionEvent(session, "i", data);
|
||||
}
|
||||
|
||||
bufferResize(sessionId: string, cols: number, rows: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.recordSessionEvent(session, "r", `${cols}x${rows}`);
|
||||
}
|
||||
|
||||
private recordSessionEvent(
|
||||
session: TerminalSession,
|
||||
type: "i" | "o" | "r",
|
||||
data: string,
|
||||
): void {
|
||||
if (!session.sessionLoggingEnabled || !session.recordingPath || !data)
|
||||
return;
|
||||
const elapsed = (Date.now() - session.sessionStartedAt) / 1000;
|
||||
const line = `${JSON.stringify([elapsed, type, data])}\n`;
|
||||
session.recordingBytes += Buffer.byteLength(line);
|
||||
session.pendingRecordingData += line;
|
||||
|
||||
if (!session.recordingFlushTimer) {
|
||||
session.recordingFlushTimer = setTimeout(() => {
|
||||
session.recordingFlushTimer = null;
|
||||
this.flushRecording(session);
|
||||
}, RECORDING_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Coalesces buffered recording lines into a single disk write. */
|
||||
private flushRecording(session: TerminalSession): void {
|
||||
if (!session.recordingPath || !session.pendingRecordingData) return;
|
||||
const chunk = session.pendingRecordingData;
|
||||
session.pendingRecordingData = "";
|
||||
const firstWrite = session.recordingBytes === Buffer.byteLength(chunk);
|
||||
|
||||
session.recordingWriteChain = session.recordingWriteChain.then(async () => {
|
||||
if (firstWrite) {
|
||||
await fs.promises.mkdir(path.dirname(session.recordingPath!), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.promises.writeFile(
|
||||
session.recordingPath!,
|
||||
`${session.recordingHeader}${chunk}`,
|
||||
"utf8",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await fs.promises.appendFile(session.recordingPath!, chunk, "utf8");
|
||||
});
|
||||
}
|
||||
|
||||
flushBuffer(session: TerminalSession): string | null {
|
||||
if (session.outputBuffer.length === 0) return null;
|
||||
const data = session.outputBuffer.join("");
|
||||
session.outputBuffer = [];
|
||||
session.outputBufferBytes = 0;
|
||||
return data;
|
||||
}
|
||||
|
||||
getBuffer(session: TerminalSession): string | null {
|
||||
if (session.outputBuffer.length === 0) return null;
|
||||
return session.outputBuffer.join("");
|
||||
}
|
||||
|
||||
private getTimeoutMs(): number {
|
||||
try {
|
||||
const value = getCurrentSettingValue("terminal_session_timeout_minutes");
|
||||
if (value) {
|
||||
const minutes = parseInt(value, 10);
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
return minutes * 60_000;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DB not available, use default
|
||||
}
|
||||
return DEFAULT_TIMEOUT_MINUTES * 60_000;
|
||||
}
|
||||
|
||||
private healthCheck(): void {
|
||||
const toDestroy: string[] = [];
|
||||
const now = Date.now();
|
||||
const GRACE_PERIOD_MS = 10_000;
|
||||
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (!session.isConnected) continue;
|
||||
|
||||
const hasOpenParticipant = Array.from(session.participants.values()).some(
|
||||
(p) => p.ws.readyState === WebSocket.OPEN,
|
||||
);
|
||||
if (hasOpenParticipant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (session.sshStream?.destroyed) {
|
||||
const detachedDuration = session.lastDetachedAt
|
||||
? now - session.lastDetachedAt
|
||||
: 0;
|
||||
|
||||
if (detachedDuration > GRACE_PERIOD_MS) {
|
||||
sshLogger.info(
|
||||
"SSH stream destroyed during detach window, cleaning up",
|
||||
{
|
||||
operation: "session_health_check_stream_destroyed",
|
||||
sessionId: id,
|
||||
userId: session.userId,
|
||||
detachedFor: detachedDuration,
|
||||
},
|
||||
);
|
||||
toDestroy.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!session.sshConn) {
|
||||
toDestroy.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of toDestroy) {
|
||||
this.destroySession(id);
|
||||
}
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
for (const id of [...this.sessions.keys()]) {
|
||||
this.destroySession(id);
|
||||
}
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionManager = TerminalSessionManager.getInstance();
|
||||
Reference in new issue
Block a user