Files
stack/apps/api/src/terminal/terminal.gateway.ts
Jason Woltje c23c33b0c5
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
fix(api): use TRUSTED_ORIGINS for socket.io gateway CORS (#628)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 20:13:13 +00:00

426 lines
14 KiB
TypeScript

/**
* TerminalGateway
*
* WebSocket gateway for real-time PTY terminal sessions.
* Uses the `/terminal` namespace to keep terminal traffic separate
* from the main WebSocket gateway.
*
* Protocol:
* 1. Client connects with auth token in handshake
* 2. Client emits `terminal:create` to spawn a new PTY session
* 3. Server emits `terminal:created` with { sessionId }
* 4. Client emits `terminal:input` with { sessionId, data } to send keystrokes
* 5. Server emits `terminal:output` with { sessionId, data } for stdout/stderr
* 6. Client emits `terminal:resize` with { sessionId, cols, rows } on window resize
* 7. Client emits `terminal:close` with { sessionId } to terminate the PTY
* 8. Server emits `terminal:exit` with { sessionId, exitCode, signal } on PTY exit
*
* Authentication:
* - Same pattern as websocket.gateway.ts and speech.gateway.ts
* - Token extracted from handshake.auth.token / query.token / Authorization header
*
* Workspace isolation:
* - Clients join room `terminal:{workspaceId}` on connect
* - Sessions are scoped to workspace; cross-workspace access is denied
*/
import {
WebSocketGateway as WSGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
} from "@nestjs/websockets";
import { Logger } from "@nestjs/common";
import { Server, Socket } from "socket.io";
import { AuthService } from "../auth/auth.service";
import { PrismaService } from "../prisma/prisma.service";
import { TerminalService } from "./terminal.service";
import {
CreateTerminalDto,
TerminalInputDto,
TerminalResizeDto,
CloseTerminalDto,
} from "./terminal.dto";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
// ==========================================
// Types
// ==========================================
interface AuthenticatedSocket extends Socket {
data: {
userId?: string;
workspaceId?: string;
};
}
// ==========================================
// Gateway
// ==========================================
@WSGateway({
namespace: "/terminal",
cors: {
origin: (process.env.TRUSTED_ORIGINS ?? process.env.WEB_URL ?? "http://localhost:3000")
.split(",")
.map((s) => s.trim()),
credentials: true,
},
})
export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(TerminalGateway.name);
private readonly CONNECTION_TIMEOUT_MS = 5000;
constructor(
private readonly authService: AuthService,
private readonly prisma: PrismaService,
private readonly terminalService: TerminalService
) {}
// ==========================================
// Connection lifecycle
// ==========================================
/**
* Authenticate client on connection using handshake token.
* Validates workspace membership and joins the workspace-scoped room.
*/
async handleConnection(client: Socket): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const timeoutId = setTimeout(() => {
if (!authenticatedClient.data.userId) {
this.logger.warn(
`Terminal client ${authenticatedClient.id} timed out during authentication`
);
authenticatedClient.emit("terminal:error", {
message: "Authentication timed out.",
});
authenticatedClient.disconnect();
}
}, this.CONNECTION_TIMEOUT_MS);
try {
const token = this.extractTokenFromHandshake(authenticatedClient);
if (!token) {
this.logger.warn(`Terminal client ${authenticatedClient.id} connected without token`);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: no token provided.",
});
authenticatedClient.disconnect();
clearTimeout(timeoutId);
return;
}
const sessionData = await this.authService.verifySession(token);
if (!sessionData) {
this.logger.warn(`Terminal client ${authenticatedClient.id} has invalid token`);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: invalid or expired token.",
});
authenticatedClient.disconnect();
clearTimeout(timeoutId);
return;
}
const user = sessionData.user as { id: string };
const userId = user.id;
const workspaceMembership = await this.prisma.workspaceMember.findFirst({
where: { userId },
select: { workspaceId: true, userId: true, role: true },
});
if (!workspaceMembership) {
this.logger.warn(`Terminal user ${userId} has no workspace access`);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: no workspace access.",
});
authenticatedClient.disconnect();
clearTimeout(timeoutId);
return;
}
authenticatedClient.data.userId = userId;
authenticatedClient.data.workspaceId = workspaceMembership.workspaceId;
// Join workspace-scoped terminal room
const room = this.getWorkspaceRoom(workspaceMembership.workspaceId);
await authenticatedClient.join(room);
clearTimeout(timeoutId);
this.logger.log(
`Terminal client ${authenticatedClient.id} connected (user: ${userId}, workspace: ${workspaceMembership.workspaceId})`
);
} catch (error) {
clearTimeout(timeoutId);
this.logger.error(
`Authentication failed for terminal client ${authenticatedClient.id}:`,
error instanceof Error ? error.message : "Unknown error"
);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: an unexpected error occurred.",
});
authenticatedClient.disconnect();
}
}
/**
* Clean up all PTY sessions for this client's workspace on disconnect.
*/
handleDisconnect(client: Socket): void {
const authenticatedClient = client as AuthenticatedSocket;
const { workspaceId, userId } = authenticatedClient.data;
if (workspaceId) {
this.terminalService.closeWorkspaceSessions(workspaceId);
const room = this.getWorkspaceRoom(workspaceId);
void authenticatedClient.leave(room);
this.logger.log(
`Terminal client ${authenticatedClient.id} disconnected (user: ${userId ?? "unknown"}, workspace: ${workspaceId})`
);
} else {
this.logger.debug(`Terminal client ${authenticatedClient.id} disconnected (unauthenticated)`);
}
}
// ==========================================
// Terminal events
// ==========================================
/**
* Spawn a new PTY session for the connected client.
*
* Emits `terminal:created` with { sessionId, name, cols, rows } on success.
* Emits `terminal:error` on failure.
*/
@SubscribeMessage("terminal:create")
async handleCreate(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
// Validate DTO
const dto = plainToInstance(CreateTerminalDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
try {
const result = this.terminalService.createSession(authenticatedClient, {
workspaceId,
socketId: authenticatedClient.id,
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.cols !== undefined ? { cols: dto.cols } : {}),
...(dto.rows !== undefined ? { rows: dto.rows } : {}),
...(dto.cwd !== undefined ? { cwd: dto.cwd } : {}),
});
authenticatedClient.emit("terminal:created", {
sessionId: result.sessionId,
name: result.name,
cols: result.cols,
rows: result.rows,
});
this.logger.log(
`Terminal session ${result.sessionId} created for client ${authenticatedClient.id} (workspace: ${workspaceId})`
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(
`Failed to create terminal session for client ${authenticatedClient.id}: ${message}`
);
authenticatedClient.emit("terminal:error", { message });
}
}
/**
* Write input data to an existing PTY session.
*
* Emits `terminal:error` if the session is not found or unauthorized.
*/
@SubscribeMessage("terminal:input")
async handleInput(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
const dto = plainToInstance(TerminalInputDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
});
return;
}
try {
this.terminalService.writeToSession(dto.sessionId, dto.data);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to write to terminal session ${dto.sessionId}: ${message}`);
authenticatedClient.emit("terminal:error", { message });
}
}
/**
* Resize an existing PTY session.
*
* Emits `terminal:error` if the session is not found or unauthorized.
*/
@SubscribeMessage("terminal:resize")
async handleResize(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
const dto = plainToInstance(TerminalResizeDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
});
return;
}
try {
this.terminalService.resizeSession(dto.sessionId, dto.cols, dto.rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to resize terminal session ${dto.sessionId}: ${message}`);
authenticatedClient.emit("terminal:error", { message });
}
}
/**
* Kill and close an existing PTY session.
*
* Emits `terminal:error` if the session is not found or unauthorized.
*/
@SubscribeMessage("terminal:close")
async handleClose(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
const dto = plainToInstance(CloseTerminalDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
});
return;
}
const closed = this.terminalService.closeSession(dto.sessionId);
if (!closed) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found.`,
});
return;
}
this.logger.log(`Terminal session ${dto.sessionId} closed by client ${authenticatedClient.id}`);
}
// ==========================================
// Private helpers
// ==========================================
/**
* Extract authentication token from Socket.IO handshake.
* Checks auth.token, query.token, and Authorization header (in that order).
*/
private extractTokenFromHandshake(client: Socket): string | undefined {
const authToken = client.handshake.auth.token as unknown;
if (typeof authToken === "string" && authToken.length > 0) {
return authToken;
}
const queryToken = client.handshake.query.token as unknown;
if (typeof queryToken === "string" && queryToken.length > 0) {
return queryToken;
}
const authHeader = client.handshake.headers.authorization as unknown;
if (typeof authHeader === "string") {
const parts = authHeader.split(" ");
const [type, token] = parts;
if (type === "Bearer" && token) {
return token;
}
}
return undefined;
}
/**
* Get the workspace-scoped room name for the terminal namespace.
*/
private getWorkspaceRoom(workspaceId: string): string {
return `terminal:${workspaceId}`;
}
}