mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -108,6 +108,16 @@ function issuedCertificateProvider(
|
||||
request.requestOrigin,
|
||||
);
|
||||
},
|
||||
cancelInteraction: async (request) => {
|
||||
if (!request.requestId) return;
|
||||
if (type === "stepca") {
|
||||
const { cancelStepCaAuth } = await import("../hosts/step-ca-auth.js");
|
||||
cancelStepCaAuth(request.requestId);
|
||||
return;
|
||||
}
|
||||
const { cancelAuthSession } = await import("../hosts/opkssh-auth.js");
|
||||
cancelAuthSession(request.requestId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,6 +212,12 @@ const vaultProvider: SshAuthProvider = {
|
||||
request.requestOrigin,
|
||||
);
|
||||
},
|
||||
cancelInteraction: async (request) => {
|
||||
if (request.hostId === undefined) return;
|
||||
const { cancelVaultAuthByHost } =
|
||||
await import("../hosts/vault-oidc-auth.js");
|
||||
cancelVaultAuthByHost(request.userId, request.hostId);
|
||||
},
|
||||
};
|
||||
|
||||
const WARPGATE_PATTERN = /warpgate\s+authentication/i;
|
||||
|
||||
@@ -10,7 +10,6 @@ import hostRoutes from "./routes/host.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||
import collabRoutes from "../hosts/collab/routes.js";
|
||||
@@ -1808,7 +1807,6 @@ app.use("/host", hostRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/ssh-auth", sshAuthRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/session_logs", sessionLogRoutes);
|
||||
app.use("/session-sharing", sessionSharingRoutes);
|
||||
app.use("/collab", collabRoutes);
|
||||
|
||||
@@ -419,15 +419,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS command_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
-- command_history is owned by the ssh-terminal plugin, which adopts it
|
||||
-- as p_ssh_terminal_command_history in its first migration.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -858,35 +851,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
|
||||
`);
|
||||
|
||||
try {
|
||||
const timeoutRow = sqlite
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
const timeoutMinutes = timeoutRow
|
||||
? parseInt(timeoutRow.value, 10)
|
||||
: 30;
|
||||
const ttlMs =
|
||||
!isNaN(timeoutMinutes) && timeoutMinutes > 0
|
||||
? timeoutMinutes * 60_000
|
||||
: 30 * 60_000;
|
||||
const cutoff = new Date(Date.now() - ttlMs).toISOString();
|
||||
const result = sqlite
|
||||
.prepare("DELETE FROM user_open_tabs WHERE updated_at <= ?")
|
||||
.run(cutoff);
|
||||
if (result.changes > 0) {
|
||||
databaseLogger.info("Expired open tabs cleared on startup", {
|
||||
operation: "db_init_open_tabs_cleanup",
|
||||
count: result.changes,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not clear expired open tabs on startup", {
|
||||
operation: "db_init_open_tabs_cleanup_failed",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
// Expired open tabs are dropped when the tab list is read, against the
|
||||
// terminal plugin's own session timeout.
|
||||
|
||||
try {
|
||||
const result = sqlite
|
||||
|
||||
@@ -105,14 +105,9 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
table: "recent_activity",
|
||||
columns: "user_id, timestamp",
|
||||
},
|
||||
{
|
||||
name: "idx_command_history_user_host",
|
||||
table: "command_history",
|
||||
columns: "user_id, host_id",
|
||||
},
|
||||
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
|
||||
// transfer_recent are owned by the file-manager plugin now; its adopted
|
||||
// table definitions carry these same indexes across the rename.
|
||||
// file_manager_recent, file_manager_pinned, file_manager_shortcuts,
|
||||
// transfer_recent and command_history are owned by plugins now; their
|
||||
// adopted table definitions carry these same indexes across the rename.
|
||||
{
|
||||
name: "idx_user_open_tabs_user_id",
|
||||
table: "user_open_tabs",
|
||||
|
||||
@@ -515,26 +515,6 @@ export const recentActivity = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const commandHistory = mysqlTable(
|
||||
"command_history",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: int("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
command: text("command").notNull(),
|
||||
executedAt: text("executed_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_command_history_user_host").on(table.userId, table.hostId),
|
||||
],
|
||||
);
|
||||
|
||||
export const hostAccess = mysqlTable(
|
||||
"host_access",
|
||||
{
|
||||
|
||||
@@ -516,26 +516,6 @@ export const recentActivity = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const commandHistory = pgTable(
|
||||
"command_history",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
command: text("command").notNull(),
|
||||
executedAt: text("executed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_command_history_user_host").on(table.userId, table.hostId),
|
||||
],
|
||||
);
|
||||
|
||||
export const hostAccess = pgTable(
|
||||
"host_access",
|
||||
{
|
||||
|
||||
@@ -508,26 +508,6 @@ export const recentActivity = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const commandHistory = sqliteTable(
|
||||
"command_history",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
command: text("command").notNull(),
|
||||
executedAt: text("executed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_command_history_user_host").on(table.userId, table.hostId),
|
||||
],
|
||||
);
|
||||
|
||||
export const hostAccess = sqliteTable(
|
||||
"host_access",
|
||||
{
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { commandHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
|
||||
export type CommandHistoryRecord = typeof commandHistory.$inferSelect;
|
||||
|
||||
export class CommandHistoryRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
command: string,
|
||||
executedAt = new Date().toISOString(),
|
||||
): Promise<CommandHistoryRecord> {
|
||||
const [created] = await insertReturning(this.context, commandHistory, {
|
||||
userId,
|
||||
hostId,
|
||||
command,
|
||||
executedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async listUniqueCommandsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 500,
|
||||
): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
command: commandHistory.command,
|
||||
maxExecutedAt: sql<number>`MAX(${commandHistory.executedAt})`,
|
||||
})
|
||||
.from(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.groupBy(commandHistory.command)
|
||||
.orderBy(desc(sql`MAX(${commandHistory.executedAt})`))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => row.command);
|
||||
}
|
||||
|
||||
async listCommandsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 200,
|
||||
): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: commandHistory.id,
|
||||
command: commandHistory.command,
|
||||
})
|
||||
.from(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(commandHistory.executedAt))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => row.command);
|
||||
}
|
||||
|
||||
async deleteCommandForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
eq(commandHistory.command, command),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.hostId, hostId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(inArray(commandHistory.hostId, hostIds));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { NotificationChannelRepository } from "./notification-channel-repository
|
||||
import { AutomationRepository } from "./automation-repository.js";
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
import { CommandHistoryRepository } from "./command-history-repository.js";
|
||||
import { CredentialRepository } from "./credential-repository.js";
|
||||
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
|
||||
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
|
||||
@@ -184,13 +183,6 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentCommandHistoryRepository(): CommandHistoryRepository {
|
||||
return new CommandHistoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("command_history_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentCredentialRepository(): CredentialRepository {
|
||||
return new CredentialRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentApiKeyRepository,
|
||||
createCurrentAuditLogRepository,
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDashboardServiceLinkRepository,
|
||||
createCurrentDismissedAlertRepository,
|
||||
@@ -94,8 +93,6 @@ export async function deleteUserAndRelatedData(
|
||||
|
||||
await createCurrentHostFolderRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentCommandHistoryRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentHostHealthRepository().deleteByUserId(userId);
|
||||
await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId);
|
||||
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { createCurrentCommandHistoryRepository } from "../repositories/factory.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
|
||||
export function registerHostCommandHistoryRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
requireViewPermission: RequestHandler,
|
||||
requireDataAccess: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/command-history/{hostId}:
|
||||
* get:
|
||||
* summary: Get command history
|
||||
* description: Retrieves the command history for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of commands.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch command history.
|
||||
*/
|
||||
router.get(
|
||||
"/command-history/:hostId",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostIdParam = Array.isArray(req.params.hostId)
|
||||
? req.params.hostId[0]
|
||||
: req.params.hostId;
|
||||
const hostId = parseInt(hostIdParam, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId) {
|
||||
sshLogger.warn("Invalid userId or hostId for command history fetch", {
|
||||
operation: "command_history_fetch",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const history =
|
||||
await createCurrentCommandHistoryRepository().listCommandsForHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
|
||||
res.json(history);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch command history from database", err, {
|
||||
operation: "command_history_fetch",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch command history" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/command-history:
|
||||
* delete:
|
||||
* summary: Delete command from history
|
||||
* description: Deletes a specific command from the history of a host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* command:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Command deleted from history.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to delete command.
|
||||
*/
|
||||
router.delete(
|
||||
"/command-history",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, command } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !command) {
|
||||
sshLogger.warn("Invalid data for command history deletion", {
|
||||
operation: "command_history_delete",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await createCurrentCommandHistoryRepository().deleteCommandForHost(
|
||||
userId,
|
||||
hostId,
|
||||
command,
|
||||
);
|
||||
|
||||
res.json({ message: "Command deleted from history" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to delete command from history", err, {
|
||||
operation: "command_history_delete",
|
||||
hostId,
|
||||
userId,
|
||||
command,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete command" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
import {
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostFolderRepository,
|
||||
createCurrentFolderAccessRepository,
|
||||
@@ -398,13 +397,9 @@ export function registerHostFolderRoutes(
|
||||
const hostIds = hostsToDelete.map((host) => host.id);
|
||||
|
||||
if (hostIds.length > 0) {
|
||||
// file manager recent/pinned/shortcuts and transfer_recent cascade
|
||||
// on the host's refHost() foreign key, as the file-manager
|
||||
// plugin's adopted tables.
|
||||
|
||||
await createCurrentCommandHistoryRepository().deleteByHostIds(
|
||||
hostIds,
|
||||
);
|
||||
// file manager recent/pinned/shortcuts, transfer_recent and command
|
||||
// history cascade on the host's refHost() foreign key, as the
|
||||
// file-manager and ssh-terminal plugins' adopted tables.
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
|
||||
hostIds,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "../../hosts/credential-username.js";
|
||||
import { notifyAutomationInternalEvent } from "../../hosts/automation-events.js";
|
||||
import {
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentOpksshTokenRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
@@ -53,7 +52,6 @@ import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
|
||||
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
|
||||
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
|
||||
import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
|
||||
import { registerHostInternalRoutes } from "./host-internal-routes.js";
|
||||
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
||||
@@ -2530,13 +2528,9 @@ router.delete(
|
||||
|
||||
const numericHostId = Number(hostId);
|
||||
|
||||
// file manager recent/pinned/shortcuts and transfer_recent cascade on
|
||||
// the host's refHost() foreign key, as the file-manager plugin's
|
||||
// adopted tables.
|
||||
|
||||
await createCurrentCommandHistoryRepository().deleteByHostId(
|
||||
numericHostId,
|
||||
);
|
||||
// file manager recent/pinned/shortcuts, transfer_recent and command
|
||||
// history cascade on the host's refHost() foreign key, as the
|
||||
// file-manager and ssh-terminal plugins' adopted tables.
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByHostId(
|
||||
numericHostId,
|
||||
@@ -2612,13 +2606,8 @@ router.delete(
|
||||
);
|
||||
|
||||
// File manager recent/pinned/shortcuts and transfer/recent routes moved to
|
||||
// the file-manager plugin, under /plugin-api/file-manager/.
|
||||
registerHostCommandHistoryRoutes(
|
||||
router,
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("hosts.view"),
|
||||
requireDataAccess,
|
||||
);
|
||||
// the file-manager plugin, under /plugin-api/file-manager/, and command
|
||||
// history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/.
|
||||
|
||||
async function resolveHostCredentials(
|
||||
host: Record<string, unknown>,
|
||||
|
||||
@@ -2,9 +2,8 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { sessionManager } from "../../hosts/terminal/session-manager.js";
|
||||
import { liveTerminalSessions } from "../../hosts/live-terminal-sessions.js";
|
||||
import {
|
||||
getCurrentSettingValue,
|
||||
createCurrentOpenTabRepository,
|
||||
createCurrentSessionShareRepository,
|
||||
} from "../repositories/factory.js";
|
||||
@@ -26,17 +25,10 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
||||
*/
|
||||
const DEFAULT_TAB_TTL_MINUTES = 30;
|
||||
|
||||
// Tabs live as long as the terminal keeps a detached session.
|
||||
function getTabTtlMs(): 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_TAB_TTL_MINUTES * 60_000;
|
||||
const minutes = liveTerminalSessions.idleTimeoutMinutes();
|
||||
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
|
||||
}
|
||||
|
||||
// Legacy tab types that were renamed. Normalize on read so previously saved
|
||||
@@ -273,6 +265,33 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /open-tabs/session-timeout:
|
||||
* get:
|
||||
* summary: Get how long a detached session is kept
|
||||
* description: The terminal's idle timeout for detached sessions, which is also how long a saved open tab lives. 30 while the terminal is off.
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The timeout in minutes.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* minutes:
|
||||
* type: integer
|
||||
*/
|
||||
router.get(
|
||||
"/session-timeout",
|
||||
authenticateJWT,
|
||||
(_req: Request, res: Response) => {
|
||||
res.json({ minutes: getTabTtlMs() / 60_000 });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /open-tabs/active-sessions:
|
||||
@@ -324,12 +343,12 @@ router.get(
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const ownSessions = sessionManager.getUserSessions(userId);
|
||||
const ownSessions = liveTerminalSessions.listForUser(userId);
|
||||
const result = ownSessions.map((s) => ({
|
||||
sessionId: s.id,
|
||||
hostId: s.hostId,
|
||||
hostName: s.hostName,
|
||||
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
|
||||
tabInstanceId: s.tabInstanceId,
|
||||
isConnected: s.isConnected,
|
||||
createdAt: s.createdAt,
|
||||
isOwnSession: true,
|
||||
@@ -344,16 +363,13 @@ router.get(
|
||||
);
|
||||
for (const share of sharedWithMe) {
|
||||
if (share.protocol !== "ssh") continue;
|
||||
const sharedSession = sessionManager.getSession(share.sessionId);
|
||||
const sharedSession = liveTerminalSessions.getSession(share.sessionId);
|
||||
if (!sharedSession || !sharedSession.isConnected) continue;
|
||||
result.push({
|
||||
sessionId: sharedSession.id,
|
||||
hostId: sharedSession.hostId,
|
||||
hostName: sharedSession.hostName,
|
||||
tabInstanceId:
|
||||
sharedSession.attachedTabInstanceId ??
|
||||
sharedSession.tabInstanceId ??
|
||||
null,
|
||||
tabInstanceId: sharedSession.tabInstanceId,
|
||||
isConnected: sharedSession.isConnected,
|
||||
createdAt: sharedSession.createdAt,
|
||||
isOwnSession: false,
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import path from "path";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
/**
|
||||
* Terminal image storage modes.
|
||||
*
|
||||
* - `local`: always write to the backend's mapped local storage. Deterministic:
|
||||
* never falls back to the remote SFTP path.
|
||||
* - `remote-sftp`: always write to the connected terminal's SSH host over SFTP.
|
||||
* Deterministic: never falls back to local storage.
|
||||
* - `auto`: pick by capability only — remote SFTP when a connected terminal
|
||||
* session exists, local storage otherwise. Configuration never influences
|
||||
* this choice.
|
||||
*/
|
||||
export const TERMINAL_IMAGE_STORAGE_MODES = [
|
||||
"auto",
|
||||
"local",
|
||||
"remote-sftp",
|
||||
] as const;
|
||||
|
||||
export type TerminalImageStorageMode =
|
||||
(typeof TERMINAL_IMAGE_STORAGE_MODES)[number];
|
||||
|
||||
export interface TerminalImageStorageSettings {
|
||||
mode: TerminalImageStorageMode;
|
||||
/** Absolute path on the Termix backend where local-mode files are written. */
|
||||
localDir: string;
|
||||
/**
|
||||
* Absolute path handed to the terminal agent in local mode. This is the
|
||||
* host-side view of `localDir` (e.g. /tmp mapped into the container); it is
|
||||
* the only path ever exposed to callers.
|
||||
*/
|
||||
hostPath: string;
|
||||
ttlMs: number;
|
||||
maxCount: number;
|
||||
maxBytes: number;
|
||||
/** Both localDir and hostPath were explicitly configured and must be probed. */
|
||||
localMappingConfigured: boolean;
|
||||
}
|
||||
|
||||
/** Settings-table keys. Persisted values always win over environment. */
|
||||
export const TERMINAL_IMAGE_STORAGE_KEYS = {
|
||||
mode: "terminal_image_storage_mode",
|
||||
localDir: "terminal_image_local_dir",
|
||||
hostPath: "terminal_image_host_path",
|
||||
ttlMs: "terminal_image_ttl_ms",
|
||||
maxCount: "terminal_image_max_count",
|
||||
maxBytes: "terminal_image_max_storage_bytes",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Legacy environment variables from the original env-only configuration. They
|
||||
* seed defaults only when no database value exists for the same field.
|
||||
*/
|
||||
export const TERMINAL_IMAGE_STORAGE_ENV = {
|
||||
mode: "TERMIX_IMAGE_STORAGE_MODE",
|
||||
localDir: "TERMIX_IMAGE_DIR",
|
||||
hostPath: "TERMIX_IMAGE_HOST_PATH",
|
||||
ttlMs: "TERMIX_IMAGE_TTL_MS",
|
||||
maxCount: "TERMIX_MAX_IMAGE_COUNT",
|
||||
maxBytes: "TERMIX_MAX_IMAGE_STORAGE_BYTES",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_IMAGE_TTL_MS = 3_600_000;
|
||||
export const DEFAULT_IMAGE_MAX_COUNT = 100;
|
||||
export const DEFAULT_IMAGE_MAX_BYTES = 5_368_709_120;
|
||||
export const DEFAULT_IMAGE_HOST_PATH = "/tmp/termix-image-v0";
|
||||
export const MIN_IMAGE_MAX_BYTES = 1_048_576;
|
||||
|
||||
export function defaultImageLocalDir(env: NodeJS.ProcessEnv): string {
|
||||
return path.resolve(
|
||||
path.join(env.DATA_DIR || "./db/data", "termix-image-v0"),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseTerminalImageStorageMode(
|
||||
value: unknown,
|
||||
): TerminalImageStorageMode | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return (TERMINAL_IMAGE_STORAGE_MODES as readonly string[]).includes(
|
||||
normalized,
|
||||
)
|
||||
? (normalized as TerminalImageStorageMode)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local write directory must be an absolute path without NUL bytes. Relative
|
||||
* values are rejected rather than resolved: a relative entry silently depends
|
||||
* on the process cwd, which differs between Docker, systemd and dev runs.
|
||||
*/
|
||||
export function parseImageLocalDir(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
|
||||
if (!path.isAbsolute(trimmed)) return null;
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
/** Agent-visible path. Always POSIX-style and absolute. */
|
||||
export function parseImageHostPath(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
|
||||
if (!path.posix.isAbsolute(trimmed)) return null;
|
||||
return path.posix.normalize(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric fields: unparseable values are rejected (caller falls through to the
|
||||
* next source); parseable but out-of-range values are clamped, matching the
|
||||
* original env-only behavior.
|
||||
*/
|
||||
function parseClampedInt(value: unknown, min: number): number | null {
|
||||
if (typeof value !== "string" && typeof value !== "number") return null;
|
||||
const parsed =
|
||||
typeof value === "number" ? value : Number.parseInt(value.trim(), 10);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return Math.max(min, Math.trunc(parsed));
|
||||
}
|
||||
|
||||
interface SettingSource {
|
||||
get(key: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
function warnInvalid(key: string, source: string): void {
|
||||
databaseLogger.warn("Ignoring invalid terminal image storage setting", {
|
||||
operation: "terminal_image_storage_settings_invalid",
|
||||
key,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
function hasUnsafePathSyntax(value: string): boolean {
|
||||
return (
|
||||
/[\u0000-\u001f\u007f]/.test(value) ||
|
||||
value.split(/[\\/]+/).some((segment) => segment === "..") ||
|
||||
value.includes("//") ||
|
||||
value.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective image storage settings.
|
||||
*
|
||||
* Per-field precedence: a valid persisted database value wins; a legacy
|
||||
* TERMIX_IMAGE_* variable seeds the default only when no database value
|
||||
* exists; otherwise the built-in default applies. Invalid values are skipped
|
||||
* with a warning and resolution falls through to the next source.
|
||||
*
|
||||
* Mode compatibility: with no mode in the database or environment, an explicit
|
||||
* legacy local mapping (TERMIX_IMAGE_DIR) keeps old deployments on `local`;
|
||||
* everything else defaults to `auto`.
|
||||
*/
|
||||
export async function resolveTerminalImageStorageSettings(
|
||||
settings: SettingSource,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<TerminalImageStorageSettings> {
|
||||
async function pick<T>(
|
||||
key: string,
|
||||
envName: string,
|
||||
parse: (value: unknown) => T | null,
|
||||
fallback: T,
|
||||
): Promise<T> {
|
||||
const stored = await settings.get(key);
|
||||
if (stored !== null) {
|
||||
const parsed = parse(stored);
|
||||
if (parsed !== null) return parsed;
|
||||
warnInvalid(key, "database");
|
||||
}
|
||||
const fromEnv = env[envName];
|
||||
if (fromEnv !== undefined) {
|
||||
const parsed = parse(fromEnv);
|
||||
if (parsed !== null) return parsed;
|
||||
warnInvalid(key, "environment");
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const dbModeRaw = await settings.get(TERMINAL_IMAGE_STORAGE_KEYS.mode);
|
||||
let mode: TerminalImageStorageMode | null = null;
|
||||
if (dbModeRaw !== null) {
|
||||
mode = parseTerminalImageStorageMode(dbModeRaw);
|
||||
if (mode === null)
|
||||
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "database");
|
||||
}
|
||||
if (mode === null) {
|
||||
const envModeRaw = env[TERMINAL_IMAGE_STORAGE_ENV.mode];
|
||||
if (envModeRaw !== undefined) {
|
||||
mode = parseTerminalImageStorageMode(envModeRaw);
|
||||
if (mode === null)
|
||||
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "environment");
|
||||
}
|
||||
}
|
||||
if (mode === null) {
|
||||
// Legacy deployments configured local storage purely through
|
||||
// TERMIX_IMAGE_DIR; keep them on local mode unless a database value says
|
||||
// otherwise.
|
||||
mode =
|
||||
env[TERMINAL_IMAGE_STORAGE_ENV.localDir] !== undefined ? "local" : "auto";
|
||||
}
|
||||
|
||||
const legacyLocalDir = parseImageLocalDir(
|
||||
env[TERMINAL_IMAGE_STORAGE_ENV.localDir],
|
||||
);
|
||||
const [localDir, hostPath, ttlMs, maxCount, maxBytes] = await Promise.all([
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.localDir,
|
||||
parseImageLocalDir,
|
||||
defaultImageLocalDir(env),
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.hostPath,
|
||||
parseImageHostPath,
|
||||
legacyLocalDir ?? DEFAULT_IMAGE_HOST_PATH,
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.ttlMs,
|
||||
(value) => parseClampedInt(value, 0),
|
||||
DEFAULT_IMAGE_TTL_MS,
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.maxCount,
|
||||
(value) => parseClampedInt(value, 1),
|
||||
DEFAULT_IMAGE_MAX_COUNT,
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.maxBytes,
|
||||
(value) => parseClampedInt(value, MIN_IMAGE_MAX_BYTES),
|
||||
DEFAULT_IMAGE_MAX_BYTES,
|
||||
),
|
||||
]);
|
||||
|
||||
const persistedLocalDir = await settings.get(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
|
||||
);
|
||||
const persistedHostPath = await settings.get(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
|
||||
);
|
||||
const localMappingConfigured =
|
||||
((persistedLocalDir !== null &&
|
||||
parseImageLocalDir(persistedLocalDir) !== null) ||
|
||||
parseImageLocalDir(env[TERMINAL_IMAGE_STORAGE_ENV.localDir]) !== null) &&
|
||||
((persistedHostPath !== null &&
|
||||
parseImageHostPath(persistedHostPath) !== null) ||
|
||||
parseImageHostPath(env[TERMINAL_IMAGE_STORAGE_ENV.hostPath]) !== null ||
|
||||
legacyLocalDir !== null);
|
||||
|
||||
return {
|
||||
mode,
|
||||
localDir,
|
||||
hostPath,
|
||||
ttlMs,
|
||||
maxCount,
|
||||
maxBytes,
|
||||
localMappingConfigured,
|
||||
};
|
||||
}
|
||||
@@ -1,675 +0,0 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
exceedsImageStorageLimit,
|
||||
isExpiredImage,
|
||||
isImageFilename,
|
||||
} from "./terminal-image-utils.js";
|
||||
import type { TerminalImageStorageSettings } from "./terminal-image-storage-settings.js";
|
||||
|
||||
/**
|
||||
* Stable error codes for image storage failures. These are part of the upload
|
||||
* route's contract; `IMAGE_REMOTE_WRITE_FAILED` predates this module and must
|
||||
* not change.
|
||||
*/
|
||||
export type TerminalImageStorageErrorCode =
|
||||
| "IMAGE_STORAGE_LIMIT_REACHED"
|
||||
| "IMAGE_LOCAL_WRITE_FAILED"
|
||||
| "IMAGE_LOCAL_INSPECTION_FAILED"
|
||||
| "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
|
||||
| "IMAGE_REMOTE_WRITE_FAILED";
|
||||
|
||||
export class TerminalImageStorageError extends Error {
|
||||
constructor(
|
||||
readonly code: TerminalImageStorageErrorCode,
|
||||
message: string,
|
||||
readonly cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "TerminalImageStorageError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredTerminalImage {
|
||||
id: string;
|
||||
filename: string;
|
||||
/** Agent-visible path; never a backend-internal path. */
|
||||
shellPath: string;
|
||||
storage: "local" | "remote-sftp";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode selection for one upload. Explicit modes are deterministic — they are
|
||||
* returned regardless of capability and the route reports the failure; only
|
||||
* `auto` falls back, and only on capability (a connected terminal session
|
||||
* with SFTP), never on configuration.
|
||||
*/
|
||||
export function selectImageStorageMode(
|
||||
settings: Pick<
|
||||
TerminalImageStorageSettings,
|
||||
"mode" | "localMappingConfigured"
|
||||
>,
|
||||
capability: { remoteSftpAvailable: boolean; localHostVisible?: boolean },
|
||||
): "local" | "remote-sftp" | "unavailable" {
|
||||
if (settings.mode === "local") return "local";
|
||||
if (settings.mode === "remote-sftp") return "remote-sftp";
|
||||
if (settings.localMappingConfigured && capability.localHostVisible === true) {
|
||||
return "local";
|
||||
}
|
||||
return capability.remoteSftpAvailable ? "remote-sftp" : "unavailable";
|
||||
}
|
||||
|
||||
// Capacity checks and writes are serialized so concurrent uploads cannot
|
||||
// bypass the count or byte limits.
|
||||
let imageStorageQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function withImageStorageLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = imageStorageQueue.then(operation, operation);
|
||||
imageStorageQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function cleanupExpiredImages(
|
||||
localDir: string,
|
||||
ttlMs: number,
|
||||
): Promise<void> {
|
||||
const entries = await fs.readdir(localDir, { withFileTypes: true });
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
|
||||
.map(async (entry) => {
|
||||
const filePath = path.join(localDir, entry.name);
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat && isExpiredImage(stat.mtimeMs, now, ttlMs)) {
|
||||
await fs.unlink(filePath).catch(() => undefined);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function getActiveImageStorageUsage(
|
||||
localDir: string,
|
||||
ttlMs: number,
|
||||
): Promise<{ fileCount: number; totalBytes: number }> {
|
||||
const entries = await fs.readdir(localDir, { withFileTypes: true });
|
||||
const now = Date.now();
|
||||
const stats = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
|
||||
.map(async (entry) => {
|
||||
const stat = await fs.stat(path.join(localDir, entry.name));
|
||||
return !isExpiredImage(stat.mtimeMs, now, ttlMs) ? stat : null;
|
||||
}),
|
||||
);
|
||||
|
||||
return stats.reduce(
|
||||
(usage, stat) => {
|
||||
if (stat) {
|
||||
usage.fileCount += 1;
|
||||
usage.totalBytes += stat.size;
|
||||
}
|
||||
return usage;
|
||||
},
|
||||
{ fileCount: 0, totalBytes: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local mapped-storage adapter. Enforces the TTL/count/byte policy and raises
|
||||
* `IMAGE_STORAGE_LIMIT_REACHED` (HTTP 507 at the route) when the caps are hit.
|
||||
* The returned shellPath is built from the agent-visible hostPath — the
|
||||
* backend's own localDir is never exposed.
|
||||
*/
|
||||
export async function storeImageLocally(
|
||||
image: Buffer,
|
||||
settings: TerminalImageStorageSettings,
|
||||
): Promise<StoredTerminalImage> {
|
||||
return withImageStorageLock(async () => {
|
||||
let usage: { fileCount: number; totalBytes: number };
|
||||
try {
|
||||
await fs.mkdir(settings.localDir, { recursive: true });
|
||||
await cleanupExpiredImages(settings.localDir, settings.ttlMs);
|
||||
usage = await getActiveImageStorageUsage(
|
||||
settings.localDir,
|
||||
settings.ttlMs,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_LOCAL_INSPECTION_FAILED",
|
||||
"Unable to inspect local image storage",
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
exceedsImageStorageLimit(
|
||||
usage.fileCount,
|
||||
usage.totalBytes,
|
||||
image.length,
|
||||
settings.maxCount,
|
||||
settings.maxBytes,
|
||||
)
|
||||
) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
"Image storage limit reached",
|
||||
);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const filename = `${id}.png`;
|
||||
try {
|
||||
await fs.writeFile(path.join(settings.localDir, filename), image);
|
||||
} catch (error) {
|
||||
await fs
|
||||
.rm(path.join(settings.localDir, filename), { force: true })
|
||||
.catch(() => undefined);
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_LOCAL_WRITE_FAILED",
|
||||
"Failed to write image to local storage",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
filename,
|
||||
shellPath: path.posix.join(settings.hostPath, filename),
|
||||
storage: "local",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Remote directory (on the SSH host the terminal is connected to) that
|
||||
// uploaded/pasted images are written into. Always POSIX-style: this is a
|
||||
// path on the remote shell, not on the Termix backend's own filesystem.
|
||||
export const REMOTE_IMAGE_DIR = "/tmp/termix-images";
|
||||
|
||||
/** Minimal SFTP surface the remote adapter needs (satisfied by ssh2). */
|
||||
export interface ImageSftpClient {
|
||||
mkdir(
|
||||
dir: string,
|
||||
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
|
||||
callback?: (err?: Error) => void,
|
||||
): void;
|
||||
createWriteStream(
|
||||
remotePath: string,
|
||||
options?: { mode?: number },
|
||||
): NodeJS.WritableStream;
|
||||
stat?: (
|
||||
dir: string,
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
attrs?: { mode?: number; mtime?: number },
|
||||
) => void,
|
||||
) => void;
|
||||
lstat?: (
|
||||
dir: string,
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
attrs?: { mode?: number; mtime?: number },
|
||||
) => void,
|
||||
) => void;
|
||||
chmod?: (
|
||||
dir: string,
|
||||
mode: number,
|
||||
callback: (error?: Error) => void,
|
||||
) => void;
|
||||
readdir?: (
|
||||
dir: string,
|
||||
callback: (error: Error | undefined, entries: ImageSftpEntry[]) => void,
|
||||
) => void;
|
||||
unlink?: (remotePath: string, callback: (error?: Error) => void) => void;
|
||||
rmdir?: (dir: string, callback: (error?: Error) => void) => void;
|
||||
end?: () => void;
|
||||
}
|
||||
|
||||
interface ImageSftpEntry {
|
||||
filename: string;
|
||||
attrs?: { mtime?: number; size?: number };
|
||||
}
|
||||
|
||||
export interface ImageSshExecClient {
|
||||
exec(
|
||||
command: string,
|
||||
callback: (error: Error | undefined, stream?: ImageExecStream) => void,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface ImageExecStream {
|
||||
on(event: "close", listener: (code: number | null) => void): this;
|
||||
on(event: "error", listener: (error: Error) => void): this;
|
||||
resume(): void;
|
||||
}
|
||||
|
||||
function quoteRemotePath(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function execBounded(
|
||||
sshConn: ImageSshExecClient,
|
||||
command: string,
|
||||
timeoutMs = 3_000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), timeoutMs);
|
||||
sshConn.exec(command, (error, stream) => {
|
||||
if (error || !stream) {
|
||||
clearTimeout(timer);
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
finish(code === 0);
|
||||
});
|
||||
stream.on("error", () => {
|
||||
clearTimeout(timer);
|
||||
finish(false);
|
||||
});
|
||||
stream.resume();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function withTimeout<T>(
|
||||
operation: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
operation.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Verify a configured local mapping from the currently connected SSH session. */
|
||||
export async function probeLocalImageVisibility(
|
||||
sshConn: ImageSshExecClient,
|
||||
settings: Pick<TerminalImageStorageSettings, "localDir" | "hostPath">,
|
||||
): Promise<boolean> {
|
||||
const filename = `.termix-image-probe-${randomUUID()}`;
|
||||
const localProbe = path.join(settings.localDir, filename);
|
||||
const remoteProbe = path.posix.join(settings.hostPath, filename);
|
||||
await fs.mkdir(settings.localDir, { recursive: true });
|
||||
await fs.writeFile(localProbe, "termix-image-probe", { flag: "wx" });
|
||||
try {
|
||||
return await execBounded(
|
||||
sshConn,
|
||||
`test -f -- ${quoteRemotePath(remoteProbe)}`,
|
||||
);
|
||||
} finally {
|
||||
await fs.unlink(localProbe).catch(() => undefined);
|
||||
await execBounded(sshConn, `rm -f -- ${quoteRemotePath(remoteProbe)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sftpMkdir(sftp: ImageSftpClient, dir: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.mkdir(dir, { mode: 0o700 }, (err) => {
|
||||
if (!err) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
|
||||
if (!inspect) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
inspect(dir, (inspectError, attrs) => {
|
||||
if (inspectError || !attrs) {
|
||||
reject(inspectError ?? err);
|
||||
return;
|
||||
}
|
||||
if (attrs.mode !== undefined && (attrs.mode & 0o170000) !== 0o040000) {
|
||||
reject(new Error("Remote image path is not a directory"));
|
||||
return;
|
||||
}
|
||||
if (!sftp.chmod) {
|
||||
reject(
|
||||
new Error("Remote image directory permissions cannot be verified"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
sftp.chmod(dir, 0o700, (chmodError) => {
|
||||
if (chmodError) reject(chmodError);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const REMOTE_IMAGE_LOCK_DIR = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
|
||||
const REMOTE_IMAGE_LOCK_LEASE_MS = 60_000;
|
||||
|
||||
function waitForRemoteImageLock(
|
||||
sftp: ImageSftpClient,
|
||||
attempts = 60,
|
||||
): Promise<() => Promise<void>> {
|
||||
if (!sftp.rmdir) {
|
||||
return Promise.reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage lock cannot be verified",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let remaining = attempts;
|
||||
const tryAcquire = () => {
|
||||
sftp.mkdir(REMOTE_IMAGE_LOCK_DIR, { mode: 0o700 }, (error) => {
|
||||
if (!error) {
|
||||
resolve(
|
||||
() =>
|
||||
new Promise<void>((releaseResolve, releaseReject) => {
|
||||
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (releaseError) =>
|
||||
releaseError ? releaseReject(releaseError) : releaseResolve(),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
|
||||
if (!inspect) {
|
||||
reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage lock cannot be verified",
|
||||
error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
inspect(REMOTE_IMAGE_LOCK_DIR, (inspectError, attrs) => {
|
||||
const stale =
|
||||
!inspectError &&
|
||||
typeof attrs?.mtime === "number" &&
|
||||
Date.now() - attrs.mtime * 1000 > REMOTE_IMAGE_LOCK_LEASE_MS;
|
||||
if (stale) {
|
||||
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (removeError) => {
|
||||
if (removeError) {
|
||||
if (--remaining <= 0) {
|
||||
reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Stale remote image storage lock cannot be removed",
|
||||
removeError,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(tryAcquire, 50);
|
||||
return;
|
||||
}
|
||||
tryAcquire();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (--remaining <= 0) {
|
||||
reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage lock is unavailable",
|
||||
error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(tryAcquire, 50);
|
||||
});
|
||||
});
|
||||
};
|
||||
tryAcquire();
|
||||
});
|
||||
}
|
||||
|
||||
function sftpWriteFile(
|
||||
sftp: ImageSftpClient,
|
||||
remotePath: string,
|
||||
data: Buffer,
|
||||
timeoutMs = 10_000,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = sftp.createWriteStream(remotePath, {
|
||||
mode: 0o600,
|
||||
}) as NodeJS.WritableStream & {
|
||||
destroy?: () => void;
|
||||
};
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
stream.destroy?.();
|
||||
reject(new Error("SFTP image write timed out"));
|
||||
}, timeoutMs);
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
stream.on("error", (error: Error) => finish(error));
|
||||
stream.on("close", () => finish());
|
||||
stream.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupExpiredRemoteImages(
|
||||
sftp: ImageSftpClient,
|
||||
ttlMs: number | undefined,
|
||||
nowMs = Date.now(),
|
||||
): Promise<void> {
|
||||
if (!ttlMs || ttlMs <= 0 || !sftp.readdir || !sftp.unlink) return;
|
||||
const entries = await new Promise<ImageSftpEntry[]>((resolve) => {
|
||||
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
|
||||
resolve(error ? [] : result);
|
||||
});
|
||||
});
|
||||
const cutoffSeconds = (nowMs - ttlMs) / 1000;
|
||||
const uuidPng =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
uuidPng.test(entry.filename) &&
|
||||
typeof entry.attrs?.mtime === "number" &&
|
||||
entry.attrs.mtime < cutoffSeconds,
|
||||
)
|
||||
.map((entry) =>
|
||||
withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
sftp.unlink!(`${REMOTE_IMAGE_DIR}/${entry.filename}`, () =>
|
||||
resolve(),
|
||||
);
|
||||
}),
|
||||
3_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const REMOTE_UUID_PNG_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
|
||||
|
||||
async function enforceRemoteImageLimits(
|
||||
sftp: ImageSftpClient,
|
||||
imageBytes: number,
|
||||
maxCount: number,
|
||||
maxBytes: number,
|
||||
): Promise<void> {
|
||||
if (!sftp.readdir) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage limits cannot be verified",
|
||||
);
|
||||
}
|
||||
let entries: ImageSftpEntry[];
|
||||
try {
|
||||
entries = await new Promise<ImageSftpEntry[]>((resolve, reject) => {
|
||||
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage limits cannot be verified",
|
||||
error,
|
||||
);
|
||||
}
|
||||
const images = entries.filter((entry) =>
|
||||
REMOTE_UUID_PNG_PATTERN.test(entry.filename),
|
||||
);
|
||||
const totalBytes = images.reduce((sum, entry) => {
|
||||
if (typeof entry.attrs?.size !== "number") {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage limits cannot be verified",
|
||||
);
|
||||
}
|
||||
return sum + entry.attrs.size;
|
||||
}, 0);
|
||||
if (images.length >= maxCount || totalBytes + imageBytes > maxBytes) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
"Image storage limit reached",
|
||||
);
|
||||
}
|
||||
}
|
||||
async function storeImageViaSftpUnlocked(
|
||||
sftp: ImageSftpClient,
|
||||
image: Buffer,
|
||||
options: {
|
||||
writeTimeoutMs?: number;
|
||||
ttlMs?: number;
|
||||
maxCount?: number;
|
||||
maxBytes?: number;
|
||||
nowMs?: number;
|
||||
} = {},
|
||||
): Promise<StoredTerminalImage> {
|
||||
const id = randomUUID();
|
||||
const filename = `${id}.png`;
|
||||
const remotePath = `${REMOTE_IMAGE_DIR}/${filename}`;
|
||||
|
||||
let releaseRemoteLock: (() => Promise<void>) | undefined;
|
||||
let operationError: TerminalImageStorageError | undefined;
|
||||
try {
|
||||
await withTimeout(
|
||||
sftpMkdir(sftp, REMOTE_IMAGE_DIR),
|
||||
3_000,
|
||||
"SFTP directory operation timed out",
|
||||
);
|
||||
releaseRemoteLock = await withTimeout(
|
||||
waitForRemoteImageLock(sftp),
|
||||
10_000,
|
||||
"SFTP lock operation timed out",
|
||||
);
|
||||
await withTimeout(
|
||||
cleanupExpiredRemoteImages(sftp, options.ttlMs, options.nowMs),
|
||||
5_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
);
|
||||
if (options.maxCount !== undefined || options.maxBytes !== undefined) {
|
||||
await withTimeout(
|
||||
enforceRemoteImageLimits(
|
||||
sftp,
|
||||
image.length,
|
||||
options.maxCount ?? 100,
|
||||
options.maxBytes ?? 5_368_709_120,
|
||||
),
|
||||
5_000,
|
||||
"SFTP quota operation timed out",
|
||||
);
|
||||
}
|
||||
await sftpWriteFile(sftp, remotePath, image, options.writeTimeoutMs);
|
||||
} catch (error) {
|
||||
if (sftp.unlink) {
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
sftp.unlink!(remotePath, () => resolve());
|
||||
}),
|
||||
3_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
).catch(() => undefined);
|
||||
}
|
||||
operationError =
|
||||
error instanceof TerminalImageStorageError
|
||||
? error
|
||||
: new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
"Failed to write image to the remote host",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (releaseRemoteLock) {
|
||||
try {
|
||||
await withTimeout(
|
||||
releaseRemoteLock(),
|
||||
3_000,
|
||||
"SFTP lock release timed out",
|
||||
);
|
||||
} catch (releaseError) {
|
||||
if (!operationError) {
|
||||
if (sftp.unlink) {
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
sftp.unlink!(remotePath, () => resolve());
|
||||
}),
|
||||
3_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
).catch(() => undefined);
|
||||
}
|
||||
operationError = new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
"Failed to release remote image storage lock",
|
||||
releaseError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operationError) throw operationError;
|
||||
|
||||
return { id, filename, shellPath: remotePath, storage: "remote-sftp" };
|
||||
}
|
||||
|
||||
export function storeImageViaSftp(
|
||||
sftp: ImageSftpClient,
|
||||
image: Buffer,
|
||||
options: Parameters<typeof storeImageViaSftpUnlocked>[2] = {},
|
||||
): Promise<StoredTerminalImage> {
|
||||
return withImageStorageLock(() =>
|
||||
withTimeout(
|
||||
storeImageViaSftpUnlocked(sftp, image, options),
|
||||
20_000,
|
||||
"SFTP image operation timed out",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// Accepted decoded input formats; uploads are normalized to PNG by the route.
|
||||
export const IMAGE_FORMAT_EXTENSIONS: Record<string, string> = {
|
||||
avif: "avif",
|
||||
gif: "gif",
|
||||
heif: "heif",
|
||||
jpeg: "jpg",
|
||||
jp2: "jp2",
|
||||
jxl: "jxl",
|
||||
png: "png",
|
||||
tiff: "tiff",
|
||||
webp: "webp",
|
||||
};
|
||||
|
||||
export function imageExtensionForFormat(
|
||||
format: string | undefined,
|
||||
): string | undefined {
|
||||
return format ? IMAGE_FORMAT_EXTENSIONS[format] : undefined;
|
||||
}
|
||||
|
||||
export const MAX_NORMALIZED_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function exceedsNormalizedImageSize(
|
||||
byteLength: number,
|
||||
maxBytes = MAX_NORMALIZED_IMAGE_BYTES,
|
||||
): boolean {
|
||||
return byteLength > maxBytes;
|
||||
}
|
||||
|
||||
export function createConcurrencyLimiter(
|
||||
limit: number,
|
||||
maxQueued = Number.POSITIVE_INFINITY,
|
||||
): {
|
||||
acquire: () => Promise<() => void>;
|
||||
readonly active: number;
|
||||
readonly queued: number;
|
||||
} {
|
||||
const max = Math.max(1, Math.floor(limit));
|
||||
const queueLimit = Math.max(0, Math.floor(maxQueued));
|
||||
let active = 0;
|
||||
const waiters: Array<() => void> = [];
|
||||
|
||||
const startNext = () => {
|
||||
if (active >= max || waiters.length === 0) return;
|
||||
active += 1;
|
||||
waiters.shift()!();
|
||||
};
|
||||
|
||||
return {
|
||||
acquire: () =>
|
||||
new Promise<() => void>((resolve, reject) => {
|
||||
if (active >= max && waiters.length >= queueLimit) {
|
||||
reject(new Error("Concurrency admission queue is full"));
|
||||
return;
|
||||
}
|
||||
waiters.push(() => {
|
||||
let released = false;
|
||||
resolve(() => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
active -= 1;
|
||||
startNext();
|
||||
});
|
||||
});
|
||||
startNext();
|
||||
}),
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get queued() {
|
||||
return waiters.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const IMAGE_FILENAME_PATTERN = /^[0-9a-f-]{36}\.[a-z0-9]+$/i;
|
||||
|
||||
export function isImageFilename(filename: string): boolean {
|
||||
return IMAGE_FILENAME_PATTERN.test(filename);
|
||||
}
|
||||
|
||||
export function isExpiredImage(
|
||||
modifiedAtMs: number,
|
||||
nowMs: number,
|
||||
ttlMs: number,
|
||||
): boolean {
|
||||
if (ttlMs <= 0) return false;
|
||||
return nowMs - modifiedAtMs > ttlMs;
|
||||
}
|
||||
|
||||
export function exceedsImageStorageLimit(
|
||||
fileCount: number,
|
||||
totalBytes: number,
|
||||
incomingBytes: number,
|
||||
maxFileCount: number,
|
||||
maxStorageBytes: number,
|
||||
): boolean {
|
||||
return (
|
||||
fileCount >= maxFileCount || totalBytes + incomingBytes > maxStorageBytes
|
||||
);
|
||||
}
|
||||
@@ -1,778 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import multer from "multer";
|
||||
import {
|
||||
createConcurrencyLimiter,
|
||||
exceedsNormalizedImageSize,
|
||||
imageExtensionForFormat,
|
||||
} from "./terminal-image-utils.js";
|
||||
import { resolveTerminalImageStorageSettings } from "./terminal-image-storage-settings.js";
|
||||
import {
|
||||
selectImageStorageMode,
|
||||
probeLocalImageVisibility,
|
||||
storeImageLocally,
|
||||
storeImageViaSftp,
|
||||
TerminalImageStorageError,
|
||||
type ImageSftpClient,
|
||||
} from "./terminal-image-storage.js";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { sessionManager } from "../../hosts/terminal/session-manager.js";
|
||||
import {
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Browser image handoff for local terminal-agent workflows.
|
||||
const imageUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024,
|
||||
fields: 4,
|
||||
fieldSize: 64 * 1024,
|
||||
files: 1,
|
||||
parts: 5,
|
||||
headerPairs: 200,
|
||||
},
|
||||
});
|
||||
const imageUploadMiddleware = imageUpload.single("image");
|
||||
|
||||
type SharpFactory = typeof import("sharp").default;
|
||||
|
||||
let sharpFactory: SharpFactory | null = null;
|
||||
let sharpLoadFailure: string | null = null;
|
||||
|
||||
// A static import of sharp takes backend startup down with it when the native
|
||||
// binary for the running architecture is missing. Resolve on first use so only
|
||||
// image upload is lost.
|
||||
async function loadSharp(): Promise<SharpFactory> {
|
||||
if (sharpFactory) return sharpFactory;
|
||||
if (sharpLoadFailure !== null) throw new Error(sharpLoadFailure);
|
||||
try {
|
||||
sharpFactory = (await import("sharp")).default;
|
||||
return sharpFactory;
|
||||
} catch (error) {
|
||||
sharpLoadFailure = getErrorMessage(error, "unknown");
|
||||
throw new Error(sharpLoadFailure);
|
||||
}
|
||||
}
|
||||
|
||||
const imageProcessingLimiter = createConcurrencyLimiter(4, 4);
|
||||
const imageMultipartAdmissionLimiter = createConcurrencyLimiter(4, 4);
|
||||
let imageUploadSequence = 0;
|
||||
|
||||
async function handleImageUploadMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
let releaseAdmission: (() => void) | undefined;
|
||||
try {
|
||||
releaseAdmission = await imageMultipartAdmissionLimiter.acquire();
|
||||
} catch {
|
||||
res.status(503).json({
|
||||
error: "Image upload capacity is temporarily unavailable",
|
||||
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
imageUploadMiddleware(req, res, (error: unknown) => {
|
||||
try {
|
||||
if (!error) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (error instanceof multer.MulterError) {
|
||||
databaseLogger.warn("Image upload multipart request rejected", {
|
||||
operation: "terminal_image_upload_multipart_rejected",
|
||||
code: error.code,
|
||||
field: error.field,
|
||||
contentType: req.headers["content-type"]?.split(";", 1)[0],
|
||||
});
|
||||
res.status(400).json({
|
||||
error: "Image upload request rejected",
|
||||
code: error.code,
|
||||
field: error.field,
|
||||
});
|
||||
return;
|
||||
}
|
||||
databaseLogger.warn("Image upload multipart request malformed", {
|
||||
operation: "terminal_image_upload_multipart_invalid",
|
||||
contentType: req.headers["content-type"]?.split(";", 1)[0],
|
||||
});
|
||||
res.status(400).json({
|
||||
error: "Malformed image upload request",
|
||||
code: "IMAGE_MULTIPART_INVALID",
|
||||
});
|
||||
} finally {
|
||||
releaseAdmission?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
function findTerminalSession(userId: string, instanceId: string) {
|
||||
return sessionManager
|
||||
.getUserSessions(userId)
|
||||
.find(
|
||||
(session) =>
|
||||
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
||||
instanceId && session.isConnected,
|
||||
);
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/image-upload",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
handleImageUploadMiddleware,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const instanceId = req.body?.instanceId;
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: "Image required",
|
||||
code: "IMAGE_FILE_MISSING",
|
||||
});
|
||||
}
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({
|
||||
error: "Missing terminal session",
|
||||
code: "IMAGE_SESSION_MISSING",
|
||||
});
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const sequence = ++imageUploadSequence;
|
||||
const source =
|
||||
req.body?.source === "file" || req.body?.source === "clipboard"
|
||||
? req.body.source
|
||||
: undefined;
|
||||
const clientUploadTimestamp =
|
||||
typeof req.body?.clientUploadTimestamp === "string" &&
|
||||
!Number.isNaN(Date.parse(req.body.clientUploadTimestamp))
|
||||
? req.body.clientUploadTimestamp
|
||||
: undefined;
|
||||
const serverReceivedAt = new Date().toISOString();
|
||||
databaseLogger.info("Terminal image upload received", {
|
||||
operation: "terminal_image_upload_received",
|
||||
requestId,
|
||||
sequence,
|
||||
source,
|
||||
clientUploadTimestamp,
|
||||
serverReceivedAt,
|
||||
bytes: req.file.size,
|
||||
});
|
||||
|
||||
const storageSettings = await resolveTerminalImageStorageSettings(
|
||||
createCurrentSettingsRepository(),
|
||||
);
|
||||
const session = isNonEmptyString(instanceId)
|
||||
? findTerminalSession(userId, instanceId)
|
||||
: undefined;
|
||||
let localHostVisible = false;
|
||||
if (storageSettings.localMappingConfigured && session?.sshConn) {
|
||||
localHostVisible = await probeLocalImageVisibility(
|
||||
session.sshConn,
|
||||
storageSettings,
|
||||
).catch(() => false);
|
||||
}
|
||||
const storageMode = selectImageStorageMode(storageSettings, {
|
||||
remoteSftpAvailable: !!session?.sshConn,
|
||||
localHostVisible,
|
||||
});
|
||||
|
||||
if (storageMode === "unavailable") {
|
||||
return res.status(503).json({
|
||||
error: "Image storage is unavailable",
|
||||
code: "IMAGE_STORAGE_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
if (storageMode === "local" && !storageSettings.localMappingConfigured) {
|
||||
return res.status(503).json({
|
||||
error: "Local image storage is not configured",
|
||||
code: "IMAGE_LOCAL_STORAGE_NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
|
||||
if (storageMode === "remote-sftp") {
|
||||
if (!isNonEmptyString(instanceId)) {
|
||||
return res.status(400).json({
|
||||
error: "Missing terminal session",
|
||||
code: "IMAGE_SESSION_MISSING",
|
||||
});
|
||||
}
|
||||
if (!session || !session.sshConn) {
|
||||
return res.status(409).json({
|
||||
error: "Terminal is not connected",
|
||||
code: "IMAGE_TERMINAL_NOT_CONNECTED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let sharp: SharpFactory;
|
||||
try {
|
||||
sharp = await loadSharp();
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Image processing unavailable: sharp failed to load",
|
||||
error,
|
||||
{
|
||||
operation: "terminal_image_upload_sharp_unavailable",
|
||||
reason: getErrorMessage(error, "unknown"),
|
||||
},
|
||||
);
|
||||
return res.status(503).json({
|
||||
error: "Image processing is unavailable on this installation",
|
||||
code: "IMAGE_PROCESSING_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
let normalizedImage: Buffer;
|
||||
let releaseImageProcessingSlot: (() => void) | undefined;
|
||||
try {
|
||||
releaseImageProcessingSlot = await imageProcessingLimiter.acquire();
|
||||
} catch {
|
||||
return res.status(503).json({
|
||||
error: "Image upload capacity is temporarily exhausted",
|
||||
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const source = sharp(req.file.buffer, {
|
||||
failOn: "error",
|
||||
limitInputPixels: 40_000_000,
|
||||
});
|
||||
const { format } = await source.metadata();
|
||||
if (!imageExtensionForFormat(format)) {
|
||||
return res.status(400).json({
|
||||
error: "Unsupported image format",
|
||||
code: "IMAGE_FORMAT_UNSUPPORTED",
|
||||
});
|
||||
}
|
||||
normalizedImage = await source.rotate().png().toBuffer();
|
||||
if (exceedsNormalizedImageSize(normalizedImage.length)) {
|
||||
return res.status(413).json({
|
||||
error: "Normalized image is too large",
|
||||
code: "IMAGE_NORMALIZED_SIZE_LIMIT",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Image upload failed image decoding", {
|
||||
operation: "terminal_image_upload_decode",
|
||||
mimeType: req.file.mimetype,
|
||||
bytes: req.file.size,
|
||||
reason: getErrorMessage(error, "unknown"),
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Invalid image data",
|
||||
code: "IMAGE_DECODE_FAILED",
|
||||
});
|
||||
} finally {
|
||||
releaseImageProcessingSlot?.();
|
||||
}
|
||||
|
||||
let remoteSftp: ImageSftpClient | undefined;
|
||||
try {
|
||||
const stored =
|
||||
storageMode === "remote-sftp"
|
||||
? await (async () => {
|
||||
remoteSftp = await new Promise<ImageSftpClient>(
|
||||
(resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
settled = true;
|
||||
reject(new Error("SFTP channel acquisition timed out"));
|
||||
}, 3_000);
|
||||
session!.sshConn!.sftp((err, sftp) => {
|
||||
if (settled) {
|
||||
sftp?.end?.();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) return reject(err);
|
||||
resolve(sftp);
|
||||
});
|
||||
},
|
||||
);
|
||||
return storeImageViaSftp(remoteSftp, normalizedImage, {
|
||||
ttlMs: storageSettings.ttlMs,
|
||||
maxCount: storageSettings.maxCount,
|
||||
maxBytes: storageSettings.maxBytes,
|
||||
});
|
||||
})()
|
||||
: await storeImageLocally(normalizedImage, storageSettings);
|
||||
|
||||
res.json(stored);
|
||||
} catch (error) {
|
||||
if (error instanceof TerminalImageStorageError) {
|
||||
const status =
|
||||
error.code === "IMAGE_STORAGE_LIMIT_REACHED"
|
||||
? 507
|
||||
: error.code === "IMAGE_REMOTE_WRITE_FAILED"
|
||||
? 502
|
||||
: error.code === "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
|
||||
? 503
|
||||
: error.code === "IMAGE_LOCAL_INSPECTION_FAILED"
|
||||
? 503
|
||||
: 500;
|
||||
databaseLogger.warn("Image upload storage write failed", {
|
||||
operation:
|
||||
error.code === "IMAGE_REMOTE_WRITE_FAILED"
|
||||
? "terminal_image_upload_sftp_failed"
|
||||
: "terminal_image_upload_local_failed",
|
||||
code: error.code,
|
||||
userId,
|
||||
instanceId,
|
||||
reason: getErrorMessage(error.cause ?? error, "unknown"),
|
||||
});
|
||||
return res.status(status).json({
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
databaseLogger.warn("Image upload failed to acquire remote channel", {
|
||||
operation: "terminal_image_upload_sftp_failed",
|
||||
code: "IMAGE_REMOTE_WRITE_FAILED",
|
||||
userId,
|
||||
instanceId,
|
||||
reason: getErrorMessage(error, "unknown"),
|
||||
});
|
||||
return res.status(502).json({
|
||||
error: "Failed to write image to the remote host",
|
||||
code: "IMAGE_REMOTE_WRITE_FAILED",
|
||||
});
|
||||
} finally {
|
||||
remoteSftp?.end?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/command_history:
|
||||
* post:
|
||||
* summary: Save command to history
|
||||
* description: Saves a command to the command history for a specific host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* command:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Command saved successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters.
|
||||
* 500:
|
||||
* description: Failed to save command.
|
||||
*/
|
||||
router.post(
|
||||
"/command_history",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, command } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
|
||||
authLogger.warn("Invalid command history save request", {
|
||||
operation: "command_history_save",
|
||||
userId,
|
||||
hasHostId: !!hostId,
|
||||
hasCommand: !!command,
|
||||
});
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
const sensitivePatterns = [
|
||||
/passw(or)?d/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bapi.?key\b/i,
|
||||
/PASS(WORD)?=/i,
|
||||
/AWS_SECRET/i,
|
||||
/mysql\b.*-p/i,
|
||||
/sudo\s+-S\b/,
|
||||
/htpasswd/i,
|
||||
/sshpass/i,
|
||||
/curl\b.*-u\s/i,
|
||||
/export\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
|
||||
];
|
||||
|
||||
const trimmedCommand = command.trim();
|
||||
if (sensitivePatterns.some((p: RegExp) => p.test(trimmedCommand))) {
|
||||
return res.status(201).json({
|
||||
id: 0,
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: trimmedCommand,
|
||||
executedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
|
||||
"command_history_enabled",
|
||||
true,
|
||||
);
|
||||
if (!globalEnabled) {
|
||||
return res.status(201).json({
|
||||
id: 0,
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: trimmedCommand,
|
||||
executedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const hostRecord =
|
||||
await createCurrentHostResolutionRepository().findHostById(
|
||||
parseInt(hostId, 10),
|
||||
userId,
|
||||
);
|
||||
if (hostRecord?.enableCommandHistory === false) {
|
||||
return res.status(201).json({
|
||||
id: 0,
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: trimmedCommand,
|
||||
executedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createCurrentCommandHistoryRepository().create(
|
||||
userId,
|
||||
parseInt(hostId, 10),
|
||||
trimmedCommand,
|
||||
);
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to save command to history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to save command"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/command_history/{hostId}:
|
||||
* get:
|
||||
* summary: Get command history
|
||||
* description: Retrieves the command history for a specific host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of commands.
|
||||
* 400:
|
||||
* description: Invalid request parameters.
|
||||
* 500:
|
||||
* description: Failed to fetch history.
|
||||
*/
|
||||
router.get(
|
||||
"/command_history/:hostId",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = Array.isArray(req.params.hostId)
|
||||
? req.params.hostId[0]
|
||||
: req.params.hostId;
|
||||
const hostIdNum = parseInt(hostId, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||
authLogger.warn("Invalid command history fetch request", {
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid request parameters" });
|
||||
}
|
||||
|
||||
try {
|
||||
const uniqueCommands =
|
||||
await createCurrentCommandHistoryRepository().listUniqueCommandsForHost(
|
||||
userId,
|
||||
hostIdNum,
|
||||
);
|
||||
|
||||
res.json(uniqueCommands);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch command history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to fetch history"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/command_history/delete:
|
||||
* post:
|
||||
* summary: Delete a specific command from history
|
||||
* description: Deletes a specific command from the history of a host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* command:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Command deleted successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters.
|
||||
* 500:
|
||||
* description: Failed to delete command.
|
||||
*/
|
||||
router.post(
|
||||
"/command_history/delete",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, command } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
|
||||
authLogger.warn("Invalid command delete request", {
|
||||
operation: "command_history_delete",
|
||||
userId,
|
||||
hasHostId: !!hostId,
|
||||
hasCommand: !!command,
|
||||
});
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
try {
|
||||
const hostIdNum = parseInt(hostId, 10);
|
||||
|
||||
await createCurrentCommandHistoryRepository().deleteCommandForHost(
|
||||
userId,
|
||||
hostIdNum,
|
||||
command.trim(),
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete command from history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to delete command"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/command_history/{hostId}:
|
||||
* delete:
|
||||
* summary: Clear command history
|
||||
* description: Clears the entire command history for a specific host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Command history cleared successfully.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 500:
|
||||
* description: Failed to clear history.
|
||||
*/
|
||||
router.delete(
|
||||
"/command_history/:hostId",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = Array.isArray(req.params.hostId)
|
||||
? req.params.hostId[0]
|
||||
: req.params.hostId;
|
||||
const hostIdNum = parseInt(hostId, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||
authLogger.warn("Invalid command history clear request");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
await createCurrentCommandHistoryRepository().deleteByUserAndHost(
|
||||
userId,
|
||||
hostIdNum,
|
||||
);
|
||||
databaseLogger.info("Terminal history cleared", {
|
||||
operation: "terminal_history_clear",
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to clear command history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to clear history"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/session_settings:
|
||||
* get:
|
||||
* summary: Get session persistence settings
|
||||
* description: Returns the session timeout and persistence enabled flag.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session settings.
|
||||
* 500:
|
||||
* description: Failed to fetch settings.
|
||||
*/
|
||||
router.get(
|
||||
"/session_settings",
|
||||
authenticateJWT,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const timeoutValue = await settings.get(
|
||||
"terminal_session_timeout_minutes",
|
||||
);
|
||||
const enabled = await settings.getBoolean(
|
||||
"terminal_session_persistence_enabled",
|
||||
true,
|
||||
);
|
||||
|
||||
res.json({
|
||||
timeoutMinutes: timeoutValue ? parseInt(timeoutValue, 10) : 30,
|
||||
enabled,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch session settings", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to fetch settings"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/session_settings:
|
||||
* post:
|
||||
* summary: Update session persistence settings
|
||||
* description: Saves session timeout and persistence enabled flag.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* timeoutMinutes:
|
||||
* type: integer
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Settings saved successfully.
|
||||
* 400:
|
||||
* description: Invalid parameters.
|
||||
* 500:
|
||||
* description: Failed to save settings.
|
||||
*/
|
||||
router.post(
|
||||
"/session_settings",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const { timeoutMinutes, enabled } = req.body;
|
||||
|
||||
if (
|
||||
timeoutMinutes !== undefined &&
|
||||
(typeof timeoutMinutes !== "number" ||
|
||||
timeoutMinutes < 1 ||
|
||||
timeoutMinutes > 1440)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "timeoutMinutes must be between 1 and 1440" });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
if (timeoutMinutes !== undefined) {
|
||||
await settings.set(
|
||||
"terminal_session_timeout_minutes",
|
||||
String(timeoutMinutes),
|
||||
);
|
||||
}
|
||||
|
||||
if (enabled !== undefined) {
|
||||
await settings.set(
|
||||
"terminal_session_persistence_enabled",
|
||||
String(enabled),
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to save session settings", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import {
|
||||
normalizeTouchInputSettings,
|
||||
TOUCH_INPUT_SETTING_KEY,
|
||||
validateTouchInputSettingsUpdate,
|
||||
} from "../../../types/touch-input-settings.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
async function readSettings() {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
TOUCH_INPUT_SETTING_KEY,
|
||||
);
|
||||
if (!raw) return normalizeTouchInputSettings(null);
|
||||
try {
|
||||
return normalizeTouchInputSettings(JSON.parse(raw));
|
||||
} catch {
|
||||
return normalizeTouchInputSettings(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTouchInputSettingsRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
router.get("/touch-input-settings", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
res.json(await readSettings());
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get touch input settings", error);
|
||||
res.status(500).json({ error: "Failed to get touch input settings" });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/touch-input-settings", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = userId
|
||||
? await createCurrentUserRepository().findById(userId)
|
||||
: null;
|
||||
if (!user?.isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const validationError = validateTouchInputSettingsUpdate(req.body);
|
||||
if (validationError) {
|
||||
return res.status(400).json({ error: validationError });
|
||||
}
|
||||
const current = await readSettings();
|
||||
const merged = { ...current, ...req.body };
|
||||
const mergedValidationError = validateTouchInputSettingsUpdate(merged);
|
||||
if (mergedValidationError) {
|
||||
return res.status(400).json({ error: mergedValidationError });
|
||||
}
|
||||
const next = normalizeTouchInputSettings(merged);
|
||||
await createCurrentSettingsRepository().set(
|
||||
TOUCH_INPUT_SETTING_KEY,
|
||||
JSON.stringify(next),
|
||||
);
|
||||
res.json(next);
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to update touch input settings", error);
|
||||
res.status(500).json({ error: "Failed to update touch input settings" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { sessionManager } from "../../hosts/terminal/session-manager.js";
|
||||
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
MIN_IMAGE_MAX_BYTES,
|
||||
TERMINAL_IMAGE_STORAGE_KEYS,
|
||||
parseImageHostPath,
|
||||
parseImageLocalDir,
|
||||
parseTerminalImageStorageMode,
|
||||
resolveTerminalImageStorageSettings,
|
||||
type TerminalImageStorageSettings,
|
||||
} from "./terminal-image-storage-settings.js";
|
||||
import {
|
||||
probeLocalImageVisibility,
|
||||
selectImageStorageMode,
|
||||
} from "./terminal-image-storage.js";
|
||||
|
||||
/**
|
||||
* Admin-only terminal image storage settings.
|
||||
*
|
||||
* The public shape deliberately omits `localDir`: it is a backend-internal
|
||||
* path and only the agent-visible `hostPath` may leave the server. No
|
||||
* credentials or connection details are ever returned here.
|
||||
*/
|
||||
interface PublicImageStorageSettings {
|
||||
mode: TerminalImageStorageSettings["mode"];
|
||||
hostPath: string;
|
||||
ttlMs: number;
|
||||
maxCount: number;
|
||||
maxBytes: number;
|
||||
localMappingConfigured: boolean;
|
||||
}
|
||||
|
||||
function toPublicSettings(
|
||||
settings: TerminalImageStorageSettings,
|
||||
): PublicImageStorageSettings {
|
||||
return {
|
||||
mode: settings.mode,
|
||||
hostPath: settings.hostPath,
|
||||
ttlMs: settings.ttlMs,
|
||||
maxCount: settings.maxCount,
|
||||
maxBytes: settings.maxBytes,
|
||||
localMappingConfigured: settings.localMappingConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
const PATCHABLE_FIELDS = [
|
||||
"mode",
|
||||
"localDir",
|
||||
"hostPath",
|
||||
"ttlMs",
|
||||
"maxCount",
|
||||
"maxBytes",
|
||||
] as const;
|
||||
|
||||
type PatchableField = (typeof PATCHABLE_FIELDS)[number];
|
||||
|
||||
function invalidField(res: Response, field: string): void {
|
||||
// Safe by construction: the field name is fixed, the rejected value and
|
||||
// any backend path are never echoed back.
|
||||
res.status(400).json({
|
||||
error: `Invalid value for ${field}`,
|
||||
code: "IMAGE_STORAGE_SETTINGS_INVALID",
|
||||
field,
|
||||
});
|
||||
}
|
||||
|
||||
function parseIntegerField(value: unknown, min: number): number | null {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) return null;
|
||||
if (value < min) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a PATCH body and returns the settings-table writes it implies, or
|
||||
* null after the response has already been failed with a 400.
|
||||
*/
|
||||
function buildImageStorageWrites(
|
||||
body: unknown,
|
||||
res: Response,
|
||||
): Array<{ key: string; value: string }> | null {
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
res.status(400).json({
|
||||
error: "Invalid request body",
|
||||
code: "IMAGE_STORAGE_SETTINGS_INVALID",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const field of Object.keys(body)) {
|
||||
if (!(PATCHABLE_FIELDS as readonly string[]).includes(field)) {
|
||||
res.status(400).json({
|
||||
error: `Unknown setting field: ${field}`,
|
||||
code: "IMAGE_STORAGE_SETTINGS_UNKNOWN_FIELD",
|
||||
field,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const input = body as Partial<Record<PatchableField, unknown>>;
|
||||
const writes: Array<{ key: string; value: string }> = [];
|
||||
|
||||
if (input.mode !== undefined) {
|
||||
const mode = parseTerminalImageStorageMode(input.mode);
|
||||
if (mode === null) {
|
||||
invalidField(res, "mode");
|
||||
return null;
|
||||
}
|
||||
writes.push({ key: TERMINAL_IMAGE_STORAGE_KEYS.mode, value: mode });
|
||||
}
|
||||
|
||||
if (input.localDir !== undefined) {
|
||||
const localDir = parseImageLocalDir(input.localDir);
|
||||
if (localDir === null) {
|
||||
invalidField(res, "localDir");
|
||||
return null;
|
||||
}
|
||||
writes.push({ key: TERMINAL_IMAGE_STORAGE_KEYS.localDir, value: localDir });
|
||||
}
|
||||
|
||||
if (input.hostPath !== undefined) {
|
||||
const hostPath = parseImageHostPath(input.hostPath);
|
||||
if (hostPath === null) {
|
||||
invalidField(res, "hostPath");
|
||||
return null;
|
||||
}
|
||||
writes.push({
|
||||
key: TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
|
||||
value: hostPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.ttlMs !== undefined) {
|
||||
const ttlMs = parseIntegerField(input.ttlMs, 0);
|
||||
if (ttlMs === null) {
|
||||
invalidField(res, "ttlMs");
|
||||
return null;
|
||||
}
|
||||
writes.push({
|
||||
key: TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
|
||||
value: String(ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
if (input.maxCount !== undefined) {
|
||||
const maxCount = parseIntegerField(input.maxCount, 1);
|
||||
if (maxCount === null) {
|
||||
invalidField(res, "maxCount");
|
||||
return null;
|
||||
}
|
||||
writes.push({
|
||||
key: TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
|
||||
value: String(maxCount),
|
||||
});
|
||||
}
|
||||
|
||||
if (input.maxBytes !== undefined) {
|
||||
const maxBytes = parseIntegerField(input.maxBytes, MIN_IMAGE_MAX_BYTES);
|
||||
if (maxBytes === null) {
|
||||
invalidField(res, "maxBytes");
|
||||
return null;
|
||||
}
|
||||
writes.push({
|
||||
key: TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
|
||||
value: String(maxBytes),
|
||||
});
|
||||
}
|
||||
|
||||
return writes;
|
||||
}
|
||||
|
||||
export function registerUserImageStorageRoutes(
|
||||
router: Router,
|
||||
requireAdmin: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/terminal-image-storage-settings:
|
||||
* get:
|
||||
* summary: Get terminal image storage settings (admin only)
|
||||
* description: Returns the effective terminal image storage settings. The backend-internal localDir is never exposed; only the agent-visible hostPath is returned.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Effective image storage settings.
|
||||
* 401:
|
||||
* description: Not authenticated.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 500:
|
||||
* description: Failed to load settings.
|
||||
*/
|
||||
router.get(
|
||||
"/terminal-image-storage-settings",
|
||||
requireAdmin,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = await resolveTerminalImageStorageSettings(
|
||||
createCurrentSettingsRepository(),
|
||||
);
|
||||
res.json(toPublicSettings(settings));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to load image storage settings", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to load image storage settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/terminal-image-storage-settings:
|
||||
* patch:
|
||||
* summary: Update terminal image storage settings (admin only)
|
||||
* description: Persists a partial update. Accepts only mode (auto, local, remote-sftp), localDir, hostPath, ttlMs, maxCount and maxBytes; invalid values are rejected with a 400.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Updated effective settings.
|
||||
* 400:
|
||||
* description: Invalid or unknown setting field.
|
||||
* 401:
|
||||
* description: Not authenticated.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 500:
|
||||
* description: Failed to save settings.
|
||||
*/
|
||||
router.patch(
|
||||
"/terminal-image-storage-settings",
|
||||
requireAdmin,
|
||||
async (req: Request, res: Response) => {
|
||||
const writes = buildImageStorageWrites(req.body, res);
|
||||
if (writes === null) return;
|
||||
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
if (typeof settings.setMany !== "function") {
|
||||
throw new Error("Atomic settings persistence is unavailable");
|
||||
}
|
||||
await settings.setMany(writes);
|
||||
const resolved = await resolveTerminalImageStorageSettings(settings);
|
||||
res.json(toPublicSettings(resolved));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to save image storage settings", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to save image storage settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/terminal-image-storage-settings/test:
|
||||
* post:
|
||||
* summary: Test image storage visibility (admin only)
|
||||
* description: Reports which storage mode an upload would take for one of the caller's already-connected terminal sessions. Uses the bounded local-mapping probe only; it never opens new connections.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Visibility test result.
|
||||
* 400:
|
||||
* description: Missing terminal session instanceId.
|
||||
* 401:
|
||||
* description: Not authenticated.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 500:
|
||||
* description: Failed to run the test.
|
||||
*/
|
||||
router.post(
|
||||
"/terminal-image-storage-settings/test",
|
||||
requireAdmin,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const instanceId = req.body?.instanceId;
|
||||
if (typeof instanceId !== "string" || instanceId.trim().length === 0) {
|
||||
return res.status(400).json({
|
||||
error: "Missing terminal session",
|
||||
code: "IMAGE_SESSION_MISSING",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await resolveTerminalImageStorageSettings(
|
||||
createCurrentSettingsRepository(),
|
||||
);
|
||||
const session = sessionManager
|
||||
.getUserSessions(userId)
|
||||
.find(
|
||||
(candidate) =>
|
||||
(candidate.attachedTabInstanceId ?? candidate.tabInstanceId) ===
|
||||
instanceId && candidate.isConnected,
|
||||
);
|
||||
const remoteSftpAvailable = !!session?.sshConn;
|
||||
|
||||
let localHostVisible: boolean | null = null;
|
||||
if (settings.localMappingConfigured && session?.sshConn) {
|
||||
localHostVisible = await probeLocalImageVisibility(
|
||||
session.sshConn,
|
||||
settings,
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
const selectedMode = selectImageStorageMode(settings, {
|
||||
remoteSftpAvailable,
|
||||
...(localHostVisible !== null ? { localHostVisible } : {}),
|
||||
});
|
||||
|
||||
res.json({
|
||||
mode: settings.mode,
|
||||
connected: !!session,
|
||||
remoteSftpAvailable,
|
||||
localHostVisible,
|
||||
selectedMode,
|
||||
localMappingConfigured: settings.localMappingConfigured,
|
||||
});
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to test image storage visibility", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to test image storage visibility" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -338,110 +338,6 @@ export function registerUserSettingsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/command-history-enabled:
|
||||
* get:
|
||||
* summary: Get command history enabled setting
|
||||
* description: Returns whether command history recording is globally enabled.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Command history enabled status.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
*/
|
||||
router.get("/command-history-enabled", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"command_history_enabled",
|
||||
true,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get command history enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get command history enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/command-history-enabled:
|
||||
* patch:
|
||||
* summary: Update command history enabled setting (admin only)
|
||||
* description: Globally enables or disables command history recording for all hosts.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
router.patch(
|
||||
"/command-history-enabled",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"command_history_enabled",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_command_history_enabled",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error(
|
||||
"Failed to update command history enabled setting",
|
||||
err,
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update command history enabled setting" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/analytics-enabled:
|
||||
|
||||
@@ -24,10 +24,8 @@ import {
|
||||
validateLogoutToken,
|
||||
} from "./user-oidc-utils.js";
|
||||
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||
import { registerUserImageStorageRoutes } from "./user-image-storage-routes.js";
|
||||
import { registerBrandingRoutes } from "./branding-routes.js";
|
||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
import { registerTouchInputSettingsRoutes } from "./touch-input-settings-routes.js";
|
||||
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
|
||||
import { registerUserTotpRoutes } from "./user-totp-routes.js";
|
||||
import { registerUserWebAuthnRoutes } from "./user-webauthn-routes.js";
|
||||
@@ -2126,11 +2124,9 @@ registerUserOidcAccountRoutes(router, {
|
||||
});
|
||||
|
||||
registerUserSettingsRoutes(router, authenticateJWT);
|
||||
registerTouchInputSettingsRoutes(router, authenticateJWT);
|
||||
registerAcmeSSLRoutes(router, authenticateJWT);
|
||||
|
||||
registerUserApiKeyRoutes(router, requireAdmin);
|
||||
registerUserImageStorageRoutes(router, requireAdmin);
|
||||
registerBrandingRoutes(router, requireAdmin);
|
||||
|
||||
registerSSOProviderRoutes(router);
|
||||
|
||||
@@ -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();
|
||||
@@ -21,7 +21,9 @@ import type {
|
||||
PluginShareableRole,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { hostSessionStatus } from "../hosts/host-session-status.js";
|
||||
import { getActor } from "./actor.js";
|
||||
|
||||
type AuditFn = (
|
||||
@@ -392,5 +394,22 @@ export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
|
||||
.filter((r) => !r.isSystem)
|
||||
.map((r) => ({ id: r.id, name: r.name, displayName: r.displayName }));
|
||||
},
|
||||
|
||||
// Synchronous so a transport can call it from an ssh2 "ready" handler, so
|
||||
// only the declaration is checked here, like ctx.http.router.
|
||||
trackSession: (hostId: number) => {
|
||||
if (!declared.includes("hosts:read")) {
|
||||
throw new PluginCapabilityError(pluginId, "hosts:read");
|
||||
}
|
||||
return hostSessionStatus.register(hostId);
|
||||
},
|
||||
|
||||
recordActivity: async (hostId, type, hostName) => {
|
||||
await requireRead();
|
||||
const userId = actingUser();
|
||||
const { recordRecentActivity } =
|
||||
await import("../services/recent-activity.js");
|
||||
await recordRecentActivity(userId, { type, hostId, hostName });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,12 +13,16 @@ import type {
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
PluginCapabilityError,
|
||||
PluginSshInteractionError,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import {
|
||||
getSshAuthProvider,
|
||||
listSshAuthProviders,
|
||||
registerKeyboardInteractiveInterceptor,
|
||||
registerSshAuthProvider,
|
||||
} from "../hosts/connect/auth-provider-registry.js";
|
||||
@@ -55,6 +59,7 @@ const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
|
||||
"tunnel",
|
||||
"file-manager",
|
||||
"file-transfer",
|
||||
"terminal",
|
||||
]);
|
||||
|
||||
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
|
||||
@@ -222,6 +227,21 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
|
||||
poolKey,
|
||||
|
||||
resolveHost: async (hostId, options) => {
|
||||
await checkSsh(true);
|
||||
const userId = actingUser(hostId);
|
||||
const { resolveHostById, resolveHostBySyncId } =
|
||||
await import("../hosts/host-resolver.js");
|
||||
const resolved = options?.syncId
|
||||
? await resolveHostBySyncId(options.syncId, userId)
|
||||
: await resolveHostById(hostId, userId);
|
||||
await audit("ssh_resolve_host", describeHost(hostId), {
|
||||
success: !!resolved,
|
||||
errorMessage: resolved ? undefined : "Host not found",
|
||||
});
|
||||
return (resolved as unknown as PluginSshHost | null) ?? null;
|
||||
},
|
||||
|
||||
prepare: async (host, options) => {
|
||||
await checkSsh(true);
|
||||
const { buildConnectConfig } =
|
||||
@@ -232,22 +252,38 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
client: options.client as never,
|
||||
serverHostId: options.serverHostId,
|
||||
log: options.log,
|
||||
interactive: options.interactive,
|
||||
hostKeySocket: (options.hostKeySocket ?? null) as never,
|
||||
});
|
||||
await audit("ssh_prepare", describeHost(host), {
|
||||
success: built.outcome.status === "ready",
|
||||
errorMessage:
|
||||
built.outcome.status === "ready" ? undefined : built.outcome.message,
|
||||
});
|
||||
return { config: built.config, outcome: built.outcome };
|
||||
const provider = built.provider;
|
||||
const target = host as SshConnectHost;
|
||||
return {
|
||||
config: built.config,
|
||||
outcome: built.outcome,
|
||||
authType: provider?.type ?? null,
|
||||
onBanner: provider?.onBanner
|
||||
? (banner: string) =>
|
||||
provider.onBanner!(banner, target, built.env) as never
|
||||
: undefined,
|
||||
onAuthFailed: provider?.onAuthFailed
|
||||
? (context) => provider.onAuthFailed!(target, built.env, context)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
openTransport: async (host, config) => {
|
||||
openTransport: async (host, config, transportOptions) => {
|
||||
await checkSsh(false);
|
||||
const { openSshTransport } =
|
||||
await import("../hosts/connect/transport.js");
|
||||
const opened = await openSshTransport(
|
||||
host as SshConnectHost,
|
||||
config as MutableConnectConfig,
|
||||
transportOptions,
|
||||
);
|
||||
if (opened.jumpClient) {
|
||||
const jumpClient = opened.jumpClient;
|
||||
@@ -261,6 +297,60 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
return opened;
|
||||
},
|
||||
|
||||
startInteraction: async (interaction, request) => {
|
||||
await checkSsh(true);
|
||||
const userId = actingUser(request.hostId);
|
||||
ensureCoreSshAuthProviders();
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||
request.hostId,
|
||||
userId,
|
||||
);
|
||||
if (!host) throw new PluginSshInteractionError("Host not found");
|
||||
|
||||
const own = getSshAuthProvider(host.authType as string);
|
||||
const provider =
|
||||
own?.interaction === interaction && own.startInteraction
|
||||
? own
|
||||
: listSshAuthProviders().find(
|
||||
(candidate) =>
|
||||
candidate.interaction === interaction &&
|
||||
candidate.startInteraction,
|
||||
);
|
||||
if (!provider?.startInteraction) {
|
||||
throw new PluginSshInteractionError(
|
||||
`No enabled provider handles ${interaction} sign-in`,
|
||||
);
|
||||
}
|
||||
await audit("ssh_start_interaction", describeHost(request.hostId), {
|
||||
success: true,
|
||||
});
|
||||
await provider.startInteraction({
|
||||
userId,
|
||||
hostId: request.hostId,
|
||||
host: {
|
||||
name: host.name as string | null,
|
||||
ip: host.ip as string,
|
||||
username: host.username as string,
|
||||
},
|
||||
socket: request.socket as never,
|
||||
requestOrigin: request.requestOrigin,
|
||||
payload: request.payload ?? {},
|
||||
});
|
||||
},
|
||||
|
||||
cancelInteraction: async (interaction, request) => {
|
||||
await checkSsh(false);
|
||||
const userId = actingUser(request.hostId ?? 0);
|
||||
ensureCoreSshAuthProviders();
|
||||
for (const provider of listSshAuthProviders()) {
|
||||
if (provider.interaction !== interaction) continue;
|
||||
if (!provider.cancelInteraction) continue;
|
||||
await provider.cancelInteraction({ userId, ...request });
|
||||
}
|
||||
},
|
||||
|
||||
classifyKeyboardInteractive: (round, host) => {
|
||||
ensureCoreSshAuthProviders();
|
||||
return classifyKeyboardInteractive(round, host as SshConnectHost);
|
||||
|
||||
@@ -640,6 +640,29 @@ export function createPluginContext(
|
||||
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
||||
},
|
||||
|
||||
audit: {
|
||||
// Attribution comes from the runtime: the actor, never a plugin value.
|
||||
record: async (entry) => {
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
const actor = getActor() ?? "system";
|
||||
await logAudit({
|
||||
userId: actor,
|
||||
username: actor,
|
||||
action: entry.action,
|
||||
resourceType: entry.resourceType ?? "plugin",
|
||||
resourceId: entry.resourceId ?? pluginId,
|
||||
resourceName: entry.resourceName ?? manifest.name,
|
||||
details: entry.details ?? `via plugin ${pluginId}`,
|
||||
success: entry.success,
|
||||
errorMessage: entry.errorMessage,
|
||||
});
|
||||
} catch {
|
||||
// Auditing must never break the caller.
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Background work acts as a named user. Always audited, because "this ran
|
||||
* as someone" is exactly the thing an operator needs to be able to see.
|
||||
|
||||
@@ -166,6 +166,25 @@ export function getRegistration(
|
||||
return services.get(service);
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin service as core itself calls it, or undefined while no plugin
|
||||
* provides a compatible version.
|
||||
*
|
||||
* Core is not a plugin and already authorized the request it is serving, so
|
||||
* this skips the per-call permission check a plugin's handle runs. It is the
|
||||
* same object plugins get. Session sharing reaching the terminal's live
|
||||
* sessions is the first caller.
|
||||
*/
|
||||
export function getServiceImplementation<T extends object>(
|
||||
service: string,
|
||||
versionRange: string,
|
||||
): T | undefined {
|
||||
const registration = services.get(service);
|
||||
if (!registration) return undefined;
|
||||
if (!semver.satisfies(registration.version, versionRange)) return undefined;
|
||||
return registration.implementation as T;
|
||||
}
|
||||
|
||||
export function listServices(): ServiceRegistration[] {
|
||||
return [...services.values()];
|
||||
}
|
||||
|
||||
@@ -159,6 +159,8 @@ export async function handlePluginUpgrade(
|
||||
return true;
|
||||
}
|
||||
userId = resolved;
|
||||
} else if (route.options.optionalAuth) {
|
||||
userId = (await verifyToken(request)) ?? "";
|
||||
}
|
||||
|
||||
if (route.rawHandler) {
|
||||
@@ -175,6 +177,17 @@ export async function handlePluginUpgrade(
|
||||
return true;
|
||||
}
|
||||
|
||||
const { getClientIp, getRequestOrigin } =
|
||||
await import("../utils/request-origin.js");
|
||||
const { DataCrypto } = await import("../utils/data-crypto.js");
|
||||
const connection = {
|
||||
userId,
|
||||
request,
|
||||
clientIp: getClientIp(request as never),
|
||||
requestOrigin: getRequestOrigin(request as never),
|
||||
isDataUnlocked: () => !!userId && !!DataCrypto.getUserDataKey(userId),
|
||||
};
|
||||
|
||||
server.handleUpgrade(request, socket, head, (ws: WebSocket) => {
|
||||
const handler = route.handler;
|
||||
if (!handler) {
|
||||
@@ -182,7 +195,7 @@ export async function handlePluginUpgrade(
|
||||
return;
|
||||
}
|
||||
runAsActor(userId || "anonymous", "request", () => {
|
||||
void Promise.resolve(handler({ userId, request, socket: ws })).catch(
|
||||
void Promise.resolve(handler({ ...connection, socket: ws })).catch(
|
||||
(error) => {
|
||||
pluginLogger.error(
|
||||
`Plugin ${route.pluginId} socket ${route.path} failed`,
|
||||
|
||||
@@ -6,13 +6,9 @@ import { dashboardLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { dashboardServiceLinksRouter } from "../database/routes/dashboard-service-links-routes.js";
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentRoleRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { createCurrentRecentActivityRepository } from "../database/repositories/factory.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { recordRecentActivity } from "./recent-activity.js";
|
||||
import { listenOnServicePort } from "../utils/service-listen.js";
|
||||
|
||||
const app = express();
|
||||
@@ -21,9 +17,6 @@ const authManager = AuthManager.getInstance();
|
||||
|
||||
const serverStartTime = Date.now();
|
||||
|
||||
const activityRateLimiter = new Map<string, number>();
|
||||
const RATE_LIMIT_MS = 1000;
|
||||
|
||||
function isUserDataUnlocked(userId: string): boolean {
|
||||
return DataCrypto.getUserDataKey(userId) !== null;
|
||||
}
|
||||
@@ -181,91 +174,24 @@ app.post("/activity/log", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
![
|
||||
"terminal",
|
||||
"file_manager",
|
||||
"server_stats",
|
||||
"tunnel",
|
||||
"docker",
|
||||
"telnet",
|
||||
"vnc",
|
||||
"rdp",
|
||||
].includes(type)
|
||||
) {
|
||||
const result = await recordRecentActivity(userId, {
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
});
|
||||
if (result.status === "invalid_type") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitKey = `${userId}:${hostId}:${type}`;
|
||||
const now = Date.now();
|
||||
const lastLogged = activityRateLimiter.get(rateLimitKey);
|
||||
|
||||
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
|
||||
if (result.status === "rate_limited") {
|
||||
return res.json({
|
||||
message: "Activity already logged recently (rate limited)",
|
||||
});
|
||||
}
|
||||
|
||||
activityRateLimiter.set(rateLimitKey, now);
|
||||
|
||||
if (activityRateLimiter.size > 10000) {
|
||||
const entriesToDelete: string[] = [];
|
||||
for (const [key, timestamp] of activityRateLimiter.entries()) {
|
||||
if (now - timestamp > RATE_LIMIT_MS * 2) {
|
||||
entriesToDelete.push(key);
|
||||
}
|
||||
}
|
||||
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
||||
}
|
||||
|
||||
const isOwnedHost =
|
||||
await createCurrentHostResolutionRepository().isHostOwnedByUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!isOwnedHost) {
|
||||
const roleIds =
|
||||
await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||
const sharedHosts =
|
||||
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
||||
userId,
|
||||
roleIds,
|
||||
);
|
||||
const hasSharedAccess = sharedHosts.some(
|
||||
(access) => access.hostId === hostId,
|
||||
);
|
||||
|
||||
if (!hasSharedAccess) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Host not found or access denied" });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createCurrentRecentActivityRepository().create({
|
||||
userId,
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
});
|
||||
|
||||
// Best-effort trim of old activity entries; failures here should not
|
||||
// cause the primary /activity/log request to 500.
|
||||
try {
|
||||
await createCurrentRecentActivityRepository().trimUserActivity(
|
||||
userId,
|
||||
100,
|
||||
);
|
||||
} catch (trimErr) {
|
||||
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
|
||||
operation: "trim_recent_activity",
|
||||
userId,
|
||||
error: trimErr instanceof Error ? trimErr.message : String(trimErr),
|
||||
});
|
||||
if (result.status === "denied") {
|
||||
return res.status(404).json({ error: "Host not found or access denied" });
|
||||
}
|
||||
|
||||
res.json({ message: "Activity logged", id: result.id });
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentRoleRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { dashboardLogger } from "../utils/logger.js";
|
||||
|
||||
const RATE_LIMIT_MS = 1000;
|
||||
const activityRateLimiter = new Map<string, number>();
|
||||
|
||||
export const RECENT_ACTIVITY_TYPES = [
|
||||
"terminal",
|
||||
"file_manager",
|
||||
"server_stats",
|
||||
"tunnel",
|
||||
"docker",
|
||||
"telnet",
|
||||
"vnc",
|
||||
"rdp",
|
||||
] as const;
|
||||
|
||||
export type RecordActivityResult =
|
||||
| { status: "logged"; id: number }
|
||||
| { status: "rate_limited" }
|
||||
| { status: "invalid_type" }
|
||||
| { status: "denied" };
|
||||
|
||||
/**
|
||||
* Adds one recent activity entry: the dashboard's /activity/log route and
|
||||
* ctx.hosts.recordActivity both come through here, so the rate limit and the
|
||||
* access rule are the same for both.
|
||||
*/
|
||||
export async function recordRecentActivity(
|
||||
userId: string,
|
||||
entry: { type: string; hostId: number; hostName: string },
|
||||
): Promise<RecordActivityResult> {
|
||||
const { type, hostId, hostName } = entry;
|
||||
if (!(RECENT_ACTIVITY_TYPES as readonly string[]).includes(type)) {
|
||||
return { status: "invalid_type" };
|
||||
}
|
||||
|
||||
const rateLimitKey = `${userId}:${hostId}:${type}`;
|
||||
const now = Date.now();
|
||||
const lastLogged = activityRateLimiter.get(rateLimitKey);
|
||||
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
|
||||
return { status: "rate_limited" };
|
||||
}
|
||||
activityRateLimiter.set(rateLimitKey, now);
|
||||
|
||||
if (activityRateLimiter.size > 10000) {
|
||||
for (const [key, timestamp] of activityRateLimiter.entries()) {
|
||||
if (now - timestamp > RATE_LIMIT_MS * 2) activityRateLimiter.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const isOwnedHost =
|
||||
await createCurrentHostResolutionRepository().isHostOwnedByUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (!isOwnedHost) {
|
||||
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||
const sharedHosts =
|
||||
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
||||
userId,
|
||||
roleIds,
|
||||
);
|
||||
if (!sharedHosts.some((access) => access.hostId === hostId)) {
|
||||
return { status: "denied" };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createCurrentRecentActivityRepository().create({
|
||||
userId,
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
});
|
||||
|
||||
// Best-effort trim; a failure here must not fail the log itself.
|
||||
try {
|
||||
await createCurrentRecentActivityRepository().trimUserActivity(userId, 100);
|
||||
} catch (trimErr) {
|
||||
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
|
||||
operation: "trim_recent_activity",
|
||||
userId,
|
||||
error: trimErr instanceof Error ? trimErr.message : String(trimErr),
|
||||
});
|
||||
}
|
||||
|
||||
return { status: "logged", id: result.id };
|
||||
}
|
||||
@@ -361,6 +361,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { runWebEndpointSettingsMigration } =
|
||||
await import("./utils/crypto-migration/web-endpoint-settings-migration.js");
|
||||
await runWebEndpointSettingsMigration();
|
||||
|
||||
const { runSshTerminalSettingsMigration } =
|
||||
await import("./utils/crypto-migration/ssh-terminal-settings-migration.js");
|
||||
await runSshTerminalSettingsMigration();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { CommandHistoryRepository } from "../../../database/repositories/command-history-repository.js";
|
||||
|
||||
describe("CommandHistoryRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<CommandHistoryRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new CommandHistoryRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists unique commands by latest execution", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create("user-1", 1, "ls", "2026-06-27T00:00:00.000Z");
|
||||
await repo.create("user-1", 1, "pwd", "2026-06-27T01:00:00.000Z");
|
||||
await repo.create("user-1", 1, "ls", "2026-06-27T02:00:00.000Z");
|
||||
await repo.create("user-2", 3, "whoami", "2026-06-27T03:00:00.000Z");
|
||||
|
||||
expect(await repo.listUniqueCommandsForHost("user-1", 1)).toEqual([
|
||||
"ls",
|
||||
"pwd",
|
||||
]);
|
||||
expect(await repo.listCommandsForHost("user-1", 1)).toEqual([
|
||||
"ls",
|
||||
"pwd",
|
||||
"ls",
|
||||
]);
|
||||
});
|
||||
|
||||
it("deletes commands by command, host, host list, and user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create("user-1", 1, "ls");
|
||||
await repo.create("user-1", 1, "ls");
|
||||
await repo.create("user-1", 2, "pwd");
|
||||
await repo.create("user-2", 3, "whoami");
|
||||
expect(writeCount).toBe(4);
|
||||
|
||||
expect(await repo.deleteCommandForHost("user-1", 1, "missing")).toBe(0);
|
||||
expect(writeCount).toBe(4);
|
||||
|
||||
expect(await repo.deleteCommandForHost("user-1", 1, "ls")).toBe(2);
|
||||
expect(writeCount).toBe(5);
|
||||
|
||||
expect(await repo.deleteByUserAndHost("user-1", 2)).toBe(1);
|
||||
expect(await repo.deleteByHostIds([])).toBe(0);
|
||||
expect(await repo.deleteByHostIds([3])).toBe(1);
|
||||
expect(writeCount).toBe(7);
|
||||
|
||||
await repo.create("user-1", 1, "date");
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(1);
|
||||
expect(writeCount).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -1,304 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import express, {
|
||||
type Request,
|
||||
type RequestHandler,
|
||||
type Response,
|
||||
} from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "admin-1",
|
||||
settings: {} as Record<string, string>,
|
||||
sessions: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
getUserSessions: () => state.sessions,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings[key] ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings[key] = value;
|
||||
},
|
||||
setMany: async (writes: Array<{ key: string; value: string }>) => {
|
||||
for (const write of writes) state.settings[write.key] = write.value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { registerUserImageStorageRoutes } =
|
||||
await import("../../../database/routes/user-image-storage-routes.js");
|
||||
|
||||
const requireAdmin: RequestHandler = (_req, _res, next) => next();
|
||||
const router = express.Router();
|
||||
registerUserImageStorageRoutes(router, requireAdmin);
|
||||
|
||||
interface RouteLayer {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: RequestHandler }>;
|
||||
};
|
||||
}
|
||||
|
||||
function handlerFor(pathName: string, method: string): RequestHandler {
|
||||
const layer = (router as unknown as { stack: RouteLayer[] }).stack.find(
|
||||
(candidate) =>
|
||||
candidate.route?.path === pathName && candidate.route.methods[method],
|
||||
);
|
||||
if (!layer) throw new Error(`Route not registered: ${method} ${pathName}`);
|
||||
return layer.route!.stack[layer.route!.stack.length - 1]!.handle;
|
||||
}
|
||||
|
||||
const getHandler = handlerFor("/terminal-image-storage-settings", "get");
|
||||
const patchHandler = handlerFor("/terminal-image-storage-settings", "patch");
|
||||
const testHandler = handlerFor("/terminal-image-storage-settings/test", "post");
|
||||
|
||||
async function invoke(handler: RequestHandler, body?: unknown) {
|
||||
const req = {
|
||||
userId: state.userId,
|
||||
body: body ?? {},
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
const result = { statusCode: 200, body: null as unknown };
|
||||
const res = {
|
||||
status(code: number) {
|
||||
result.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
result.body = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response;
|
||||
await handler(req, res, () => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Fake ssh2 exec channel: every command exits 0. */
|
||||
function fakeSshConn() {
|
||||
return {
|
||||
exec(
|
||||
_command: string,
|
||||
callback: (error: Error | undefined, stream?: unknown) => void,
|
||||
) {
|
||||
const stream = new EventEmitter() as EventEmitter & {
|
||||
resume: () => void;
|
||||
};
|
||||
stream.resume = () => queueMicrotask(() => stream.emit("close", 0));
|
||||
callback(undefined, stream);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const LEGACY_ENV_NAMES = [
|
||||
"TERMIX_IMAGE_STORAGE_MODE",
|
||||
"TERMIX_IMAGE_DIR",
|
||||
"TERMIX_IMAGE_HOST_PATH",
|
||||
"TERMIX_IMAGE_TTL_MS",
|
||||
"TERMIX_MAX_IMAGE_COUNT",
|
||||
"TERMIX_MAX_IMAGE_STORAGE_BYTES",
|
||||
"DATA_DIR",
|
||||
];
|
||||
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
let localDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
state.settings = {};
|
||||
state.sessions = [];
|
||||
savedEnv = Object.fromEntries(
|
||||
LEGACY_ENV_NAMES.map((name) => [name, process.env[name]]),
|
||||
);
|
||||
for (const name of LEGACY_ENV_NAMES) delete process.env[name];
|
||||
// The settings validator rejects backslashes, so use a POSIX-style form of
|
||||
// the real temp dir. Windows still resolves it, so file checks keep working.
|
||||
localDir = (
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "termix-image-test-"))
|
||||
).replace(/\\/g, "/");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const name of LEGACY_ENV_NAMES) {
|
||||
if (savedEnv[name] === undefined) delete process.env[name];
|
||||
else process.env[name] = savedEnv[name];
|
||||
}
|
||||
await fs.rm(localDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("GET /users/terminal-image-storage-settings", () => {
|
||||
it("returns the public settings shape without the backend localDir", async () => {
|
||||
state.settings["terminal_image_storage_mode"] = "local";
|
||||
state.settings["terminal_image_local_dir"] = localDir;
|
||||
state.settings["terminal_image_host_path"] = "/mnt/images";
|
||||
|
||||
const response = await invoke(getHandler);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
mode: "local",
|
||||
hostPath: "/mnt/images",
|
||||
ttlMs: 3_600_000,
|
||||
maxCount: 100,
|
||||
maxBytes: 5_368_709_120,
|
||||
localMappingConfigured: true,
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain(localDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /users/terminal-image-storage-settings", () => {
|
||||
it("rejects an invalid mode with a safe 400", async () => {
|
||||
const response = await invoke(patchHandler, { mode: "nfs" });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
error: "Invalid value for mode",
|
||||
code: "IMAGE_STORAGE_SETTINGS_INVALID",
|
||||
field: "mode",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a relative localDir", async () => {
|
||||
const response = await invoke(patchHandler, { localDir: "images/tmp" });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_STORAGE_SETTINGS_INVALID",
|
||||
field: "localDir",
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain("images/tmp");
|
||||
});
|
||||
|
||||
it("rejects out-of-range numeric limits", async () => {
|
||||
const response = await invoke(patchHandler, { maxBytes: 1024 });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_STORAGE_SETTINGS_INVALID",
|
||||
field: "maxBytes",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown fields without writing anything", async () => {
|
||||
const response = await invoke(patchHandler, { shellPath: "/tmp/x" });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_STORAGE_SETTINGS_UNKNOWN_FIELD",
|
||||
});
|
||||
expect(state.settings).toEqual({});
|
||||
});
|
||||
|
||||
// On Windows path.resolve turns the stored dir back into a backslash path,
|
||||
// which the validator rejects on read, so the round trip only holds on POSIX.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"persists a valid partial update and returns the public shape",
|
||||
async () => {
|
||||
const response = await invoke(patchHandler, {
|
||||
mode: "local",
|
||||
localDir,
|
||||
hostPath: "/host/images",
|
||||
ttlMs: 60_000,
|
||||
maxCount: 5,
|
||||
maxBytes: 10_485_760,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(state.settings).toEqual({
|
||||
terminal_image_storage_mode: "local",
|
||||
terminal_image_local_dir: path.resolve(localDir),
|
||||
terminal_image_host_path: "/host/images",
|
||||
terminal_image_ttl_ms: "60000",
|
||||
terminal_image_max_count: "5",
|
||||
terminal_image_max_storage_bytes: "10485760",
|
||||
});
|
||||
expect(response.body).toMatchObject({
|
||||
mode: "local",
|
||||
hostPath: "/host/images",
|
||||
ttlMs: 60_000,
|
||||
maxCount: 5,
|
||||
maxBytes: 10_485_760,
|
||||
localMappingConfigured: true,
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain(localDir);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("POST /users/terminal-image-storage-settings/test", () => {
|
||||
it("requires an instanceId", async () => {
|
||||
const response = await invoke(testHandler, {});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({ code: "IMAGE_SESSION_MISSING" });
|
||||
});
|
||||
|
||||
it("reports unavailable storage when no session is connected", async () => {
|
||||
const response = await invoke(testHandler, { instanceId: "tab-1" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
mode: "auto",
|
||||
connected: false,
|
||||
remoteSftpAvailable: false,
|
||||
localHostVisible: null,
|
||||
selectedMode: "unavailable",
|
||||
localMappingConfigured: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("probes local visibility through the connected session only", async () => {
|
||||
state.settings["terminal_image_local_dir"] = localDir;
|
||||
state.settings["terminal_image_host_path"] = "/host/images";
|
||||
state.sessions = [
|
||||
{
|
||||
tabInstanceId: "tab-1",
|
||||
isConnected: true,
|
||||
sshConn: fakeSshConn(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await invoke(testHandler, { instanceId: "tab-1" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
mode: "auto",
|
||||
connected: true,
|
||||
remoteSftpAvailable: true,
|
||||
localHostVisible: true,
|
||||
selectedMode: "local",
|
||||
localMappingConfigured: true,
|
||||
});
|
||||
// The bounded probe cleans up after itself.
|
||||
expect(
|
||||
(await fs.readdir(localDir)).filter((f) => f.includes("probe")),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not probe sessions owned by other instance IDs", async () => {
|
||||
state.sessions = [
|
||||
{
|
||||
tabInstanceId: "tab-2",
|
||||
isConnected: true,
|
||||
sshConn: fakeSshConn(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await invoke(testHandler, { instanceId: "tab-1" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: false,
|
||||
remoteSftpAvailable: false,
|
||||
localHostVisible: null,
|
||||
selectedMode: "unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_IMAGE_HOST_PATH,
|
||||
DEFAULT_IMAGE_MAX_BYTES,
|
||||
DEFAULT_IMAGE_MAX_COUNT,
|
||||
DEFAULT_IMAGE_TTL_MS,
|
||||
defaultImageLocalDir,
|
||||
parseImageHostPath,
|
||||
parseImageLocalDir,
|
||||
parseTerminalImageStorageMode,
|
||||
resolveTerminalImageStorageSettings,
|
||||
TERMINAL_IMAGE_STORAGE_KEYS,
|
||||
} from "../../../database/routes/terminal-image-storage-settings.js";
|
||||
import { SettingsRepository } from "../../../database/repositories/settings-repository.js";
|
||||
import { TestSqliteDatabase } from "../repositories/test-support.js";
|
||||
|
||||
// parseImageLocalDir resolves against the host platform, so a POSIX literal
|
||||
// becomes a drive-rooted path on Windows. Compare against the same resolution.
|
||||
function localDir(posixPath: string): string {
|
||||
return path.resolve(posixPath);
|
||||
}
|
||||
|
||||
function stubSettings(values: Record<string, string> = {}) {
|
||||
return {
|
||||
get: async (key: string) => values[key] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_ENV: NodeJS.ProcessEnv = {};
|
||||
|
||||
describe("terminal image storage settings", () => {
|
||||
describe("mode parsing", () => {
|
||||
it("accepts the three documented modes case-insensitively", () => {
|
||||
expect(parseTerminalImageStorageMode("auto")).toBe("auto");
|
||||
expect(parseTerminalImageStorageMode("LOCAL")).toBe("local");
|
||||
expect(parseTerminalImageStorageMode(" remote-sftp ")).toBe(
|
||||
"remote-sftp",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown modes and non-strings", () => {
|
||||
expect(parseTerminalImageStorageMode("s3")).toBeNull();
|
||||
expect(parseTerminalImageStorageMode("")).toBeNull();
|
||||
expect(parseTerminalImageStorageMode(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("path validation", () => {
|
||||
it("accepts absolute local directories and normalizes them", () => {
|
||||
expect(parseImageLocalDir("/var/lib/termix/images")).toBe(
|
||||
localDir("/var/lib/termix/images"),
|
||||
);
|
||||
expect(parseImageLocalDir("/var/lib/termix/../termix/images")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects relative, empty and NUL-containing local directories", () => {
|
||||
expect(parseImageLocalDir("images")).toBeNull();
|
||||
expect(parseImageLocalDir("./db/data/images")).toBeNull();
|
||||
expect(parseImageLocalDir("")).toBeNull();
|
||||
expect(parseImageLocalDir("/tmp/a\0b")).toBeNull();
|
||||
});
|
||||
|
||||
it("requires the agent-visible host path to be POSIX-absolute", () => {
|
||||
expect(parseImageHostPath("/tmp/termix-image-v0")).toBe(
|
||||
"/tmp/termix-image-v0",
|
||||
);
|
||||
expect(parseImageHostPath("tmp/termix-image-v0")).toBeNull();
|
||||
expect(parseImageHostPath("C:\\images")).toBeNull();
|
||||
expect(parseImageHostPath("/tmp/a\0b")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolution precedence", () => {
|
||||
it("uses built-in defaults when neither the database nor env has values", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings(),
|
||||
EMPTY_ENV,
|
||||
);
|
||||
expect(resolved.mode).toBe("auto");
|
||||
expect(resolved.localDir).toBe(defaultImageLocalDir(EMPTY_ENV));
|
||||
expect(resolved.hostPath).toBe(DEFAULT_IMAGE_HOST_PATH);
|
||||
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
|
||||
expect(resolved.maxCount).toBe(DEFAULT_IMAGE_MAX_COUNT);
|
||||
expect(resolved.maxBytes).toBe(DEFAULT_IMAGE_MAX_BYTES);
|
||||
});
|
||||
|
||||
it("seeds defaults from legacy TERMIX_IMAGE_* env when no DB value exists", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings(),
|
||||
{
|
||||
TERMIX_IMAGE_DIR: "/host-tmp/images",
|
||||
TERMIX_IMAGE_HOST_PATH: "/tmp/images",
|
||||
TERMIX_IMAGE_TTL_MS: "60000",
|
||||
TERMIX_MAX_IMAGE_COUNT: "5",
|
||||
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10485760",
|
||||
},
|
||||
);
|
||||
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
|
||||
expect(resolved.hostPath).toBe("/tmp/images");
|
||||
expect(resolved.ttlMs).toBe(60_000);
|
||||
expect(resolved.maxCount).toBe(5);
|
||||
expect(resolved.maxBytes).toBe(10_485_760);
|
||||
});
|
||||
|
||||
it("lets persisted DB values win over legacy env values", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings({
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "/db/images",
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "1000",
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.maxCount]: "7",
|
||||
}),
|
||||
{
|
||||
TERMIX_IMAGE_DIR: "/host-tmp/images",
|
||||
TERMIX_IMAGE_TTL_MS: "60000",
|
||||
TERMIX_MAX_IMAGE_COUNT: "5",
|
||||
},
|
||||
);
|
||||
expect(resolved.localDir).toBe(localDir("/db/images"));
|
||||
expect(resolved.ttlMs).toBe(1_000);
|
||||
expect(resolved.maxCount).toBe(7);
|
||||
});
|
||||
|
||||
it("falls through to env and defaults when the DB value is invalid", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings({
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "relative/path",
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "not-a-number",
|
||||
}),
|
||||
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
|
||||
);
|
||||
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
|
||||
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
|
||||
});
|
||||
|
||||
it("keeps legacy explicit local mappings on local mode", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings(),
|
||||
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
|
||||
);
|
||||
expect(resolved.mode).toBe("local");
|
||||
// hostPath falls back to the legacy local dir, which resolves natively.
|
||||
expect(resolved.hostPath).toBe(localDir("/host-tmp/images"));
|
||||
expect(resolved.localMappingConfigured).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a persisted DB mode override the legacy local mapping", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings({
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: "remote-sftp",
|
||||
}),
|
||||
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
|
||||
);
|
||||
expect(resolved.mode).toBe("remote-sftp");
|
||||
});
|
||||
|
||||
it("clamps out-of-range numeric values like the legacy env parsing did", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings(),
|
||||
{
|
||||
TERMIX_IMAGE_TTL_MS: "-5",
|
||||
TERMIX_MAX_IMAGE_COUNT: "0",
|
||||
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10",
|
||||
},
|
||||
);
|
||||
expect(resolved.ttlMs).toBe(0);
|
||||
expect(resolved.maxCount).toBe(1);
|
||||
expect(resolved.maxBytes).toBe(1_048_576);
|
||||
});
|
||||
|
||||
it("reads persisted values through the real settings repository", async () => {
|
||||
const adapter = new TestSqliteDatabase();
|
||||
try {
|
||||
const context = await adapter.connect();
|
||||
const repository = new SettingsRepository(context);
|
||||
await repository.set(TERMINAL_IMAGE_STORAGE_KEYS.mode, "local");
|
||||
await repository.set(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
|
||||
"/persisted/images",
|
||||
);
|
||||
|
||||
const resolved = await resolveTerminalImageStorageSettings(repository, {
|
||||
TERMIX_IMAGE_DIR: "/host-tmp/images",
|
||||
});
|
||||
expect(resolved.mode).toBe("local");
|
||||
expect(resolved.localDir).toBe(localDir("/persisted/images"));
|
||||
} finally {
|
||||
await adapter.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,571 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
REMOTE_IMAGE_DIR,
|
||||
selectImageStorageMode,
|
||||
storeImageLocally,
|
||||
storeImageViaSftp,
|
||||
TerminalImageStorageError,
|
||||
type ImageSftpClient,
|
||||
} from "../../../database/routes/terminal-image-storage.js";
|
||||
import type { TerminalImageStorageSettings } from "../../../database/routes/terminal-image-storage-settings.js";
|
||||
|
||||
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
function settings(
|
||||
overrides: Partial<TerminalImageStorageSettings>,
|
||||
): TerminalImageStorageSettings {
|
||||
return {
|
||||
mode: "local",
|
||||
localDir: "/nonexistent",
|
||||
hostPath: "/tmp/termix-image-v0",
|
||||
ttlMs: 3_600_000,
|
||||
maxCount: 100,
|
||||
maxBytes: 5_368_709_120,
|
||||
localMappingConfigured: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectImageStorageMode", () => {
|
||||
it("keeps explicit modes deterministic regardless of capability", () => {
|
||||
expect(
|
||||
selectImageStorageMode(settings({ mode: "local" }), {
|
||||
remoteSftpAvailable: true,
|
||||
}),
|
||||
).toBe("local");
|
||||
expect(
|
||||
selectImageStorageMode(settings({ mode: "remote-sftp" }), {
|
||||
remoteSftpAvailable: false,
|
||||
}),
|
||||
).toBe("remote-sftp");
|
||||
});
|
||||
|
||||
it("falls back on capability only in auto mode", () => {
|
||||
expect(
|
||||
selectImageStorageMode(settings({ mode: "auto" }), {
|
||||
remoteSftpAvailable: true,
|
||||
}),
|
||||
).toBe("remote-sftp");
|
||||
expect(
|
||||
selectImageStorageMode(settings({ mode: "auto" }), {
|
||||
remoteSftpAvailable: false,
|
||||
}),
|
||||
).toBe("unavailable");
|
||||
expect(
|
||||
selectImageStorageMode(
|
||||
settings({ mode: "auto", localMappingConfigured: true }),
|
||||
{ remoteSftpAvailable: false, localHostVisible: true },
|
||||
),
|
||||
).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeImageLocally", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-images-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedFile(bytes: number, mtimeMs?: number): Promise<string> {
|
||||
const name = `${randomUUID()}.png`;
|
||||
const filePath = path.join(dir, name);
|
||||
await fs.writeFile(filePath, Buffer.alloc(bytes));
|
||||
if (mtimeMs !== undefined) {
|
||||
const date = new Date(mtimeMs);
|
||||
await fs.utimes(filePath, date, date);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
it("writes a UUID-named PNG and returns the agent-visible host path", async () => {
|
||||
const stored = await storeImageLocally(
|
||||
PNG_BYTES,
|
||||
settings({ localDir: dir, hostPath: "/host-view/images" }),
|
||||
);
|
||||
|
||||
expect(stored.storage).toBe("local");
|
||||
expect(stored.filename).toBe(`${stored.id}.png`);
|
||||
expect(stored.shellPath).toBe(
|
||||
path.posix.join("/host-view/images", stored.filename),
|
||||
);
|
||||
expect(stored.shellPath).not.toContain(dir);
|
||||
await expect(fs.readFile(path.join(dir, stored.filename))).resolves.toEqual(
|
||||
PNG_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the count cap is full", async () => {
|
||||
await seedFile(10);
|
||||
const error = await storeImageLocally(
|
||||
PNG_BYTES,
|
||||
settings({ localDir: dir, maxCount: 1 }),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the byte cap is full", async () => {
|
||||
await seedFile(900);
|
||||
const error = await storeImageLocally(
|
||||
Buffer.alloc(200),
|
||||
settings({ localDir: dir, maxBytes: 1_000 }),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans expired files during upload so they no longer count", async () => {
|
||||
await seedFile(10, Date.now() - 2 * 3_600_000);
|
||||
const stored = await storeImageLocally(
|
||||
PNG_BYTES,
|
||||
settings({ localDir: dir, maxCount: 1, ttlMs: 3_600_000 }),
|
||||
);
|
||||
|
||||
expect(stored.storage).toBe("local");
|
||||
const remaining = await fs.readdir(dir);
|
||||
expect(remaining).toEqual([stored.filename]);
|
||||
});
|
||||
|
||||
it("fails closed when local storage inspection is unavailable", async () => {
|
||||
const blocked = path.join(dir, "blocked-file");
|
||||
await fs.writeFile(blocked, "not a directory");
|
||||
|
||||
const error = await storeImageLocally(
|
||||
PNG_BYTES,
|
||||
settings({ localDir: blocked }),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_LOCAL_INSPECTION_FAILED",
|
||||
);
|
||||
});
|
||||
it("reports inspection failures before attempting a write", async () => {
|
||||
const blocked = path.join(dir, "blocked");
|
||||
await fs.writeFile(blocked, "not a directory");
|
||||
|
||||
const error = await storeImageLocally(
|
||||
PNG_BYTES,
|
||||
settings({ localDir: path.join(blocked, "images") }),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_LOCAL_INSPECTION_FAILED",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeImageViaSftp", () => {
|
||||
function fakeSftp(behavior: {
|
||||
mkdirError?: Error;
|
||||
writeError?: Error;
|
||||
stallWrite?: boolean;
|
||||
stallLock?: boolean;
|
||||
stallReaddir?: boolean;
|
||||
stallRmdir?: boolean;
|
||||
stallUnlink?: boolean;
|
||||
readdirEntries?: Array<{ filename: string; mtime?: number; size?: number }>;
|
||||
readdirError?: Error;
|
||||
statMode?: number;
|
||||
}): {
|
||||
sftp: ImageSftpClient;
|
||||
written: Map<string, Buffer>;
|
||||
calls: {
|
||||
mkdir: Array<{ dir: string; mode?: number }>;
|
||||
createWriteStream: Array<{ path: string; mode?: number }>;
|
||||
};
|
||||
streams: Array<
|
||||
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
|
||||
>;
|
||||
} {
|
||||
const written = new Map<string, Buffer>();
|
||||
const streams: Array<
|
||||
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
|
||||
> = [];
|
||||
const calls = { mkdir: [], createWriteStream: [] } as {
|
||||
mkdir: Array<{ dir: string; mode?: number }>;
|
||||
createWriteStream: Array<{ path: string; mode?: number }>;
|
||||
};
|
||||
const sftp = {
|
||||
mkdir: (
|
||||
dir: string,
|
||||
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
|
||||
maybeCallback?: (err?: Error) => void,
|
||||
) => {
|
||||
const callback =
|
||||
typeof attrsOrCallback === "function"
|
||||
? attrsOrCallback
|
||||
: maybeCallback!;
|
||||
calls.mkdir.push({
|
||||
dir,
|
||||
mode:
|
||||
typeof attrsOrCallback === "function"
|
||||
? undefined
|
||||
: attrsOrCallback.mode,
|
||||
});
|
||||
if (
|
||||
behavior.stallLock &&
|
||||
dir === `${REMOTE_IMAGE_DIR}/.termix-write-lock`
|
||||
) {
|
||||
return;
|
||||
}
|
||||
callback(dir === REMOTE_IMAGE_DIR ? behavior.mkdirError : undefined);
|
||||
},
|
||||
stat: (
|
||||
_dir: string,
|
||||
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
|
||||
) => callback(undefined, { mode: behavior.statMode ?? 0o40700 }),
|
||||
chmod: (_dir: string, _mode: number, callback: (error?: Error) => void) =>
|
||||
callback(),
|
||||
readdir: (
|
||||
_dir: string,
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
entries: Array<{
|
||||
filename: string;
|
||||
attrs?: { mtime?: number; size?: number };
|
||||
}>,
|
||||
) => void,
|
||||
) => {
|
||||
if (behavior.stallReaddir) return;
|
||||
callback(
|
||||
behavior.readdirError,
|
||||
behavior.readdirEntries?.map((entry) => ({
|
||||
filename: entry.filename,
|
||||
attrs: entry,
|
||||
})) ?? [],
|
||||
);
|
||||
},
|
||||
createWriteStream: (remotePath: string, options?: { mode?: number }) => {
|
||||
calls.createWriteStream.push({ path: remotePath, mode: options?.mode });
|
||||
const stream = new EventEmitter() as NodeJS.WritableStream & {
|
||||
end: (data: Buffer) => void;
|
||||
destroy: () => void;
|
||||
destroyed: boolean;
|
||||
};
|
||||
stream.destroyed = false;
|
||||
stream.destroy = () => {
|
||||
stream.destroyed = true;
|
||||
};
|
||||
streams.push(stream);
|
||||
stream.end = (data: Buffer) => {
|
||||
if (behavior.stallWrite) return;
|
||||
queueMicrotask(() => {
|
||||
if (behavior.writeError) {
|
||||
stream.emit("error", behavior.writeError);
|
||||
return;
|
||||
}
|
||||
written.set(remotePath, data);
|
||||
stream.emit("close");
|
||||
});
|
||||
};
|
||||
return stream;
|
||||
},
|
||||
unlink: (_remotePath: string, callback: (error?: Error) => void) => {
|
||||
if (behavior.stallUnlink) return;
|
||||
callback();
|
||||
},
|
||||
rmdir: (_dir: string, callback: (error?: Error) => void) => {
|
||||
if (behavior.stallRmdir) return;
|
||||
callback();
|
||||
},
|
||||
} as unknown as ImageSftpClient;
|
||||
return { sftp, written, calls, streams };
|
||||
}
|
||||
|
||||
it("writes into the remote image directory and returns its POSIX path", async () => {
|
||||
const { sftp, written } = fakeSftp({});
|
||||
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
|
||||
|
||||
expect(stored.storage).toBe("remote-sftp");
|
||||
expect(stored.shellPath).toBe(`${REMOTE_IMAGE_DIR}/${stored.id}.png`);
|
||||
expect(written.get(stored.shellPath)).toEqual(PNG_BYTES);
|
||||
});
|
||||
|
||||
it("requests restrictive modes for the remote directory and file", async () => {
|
||||
const { sftp, calls } = fakeSftp({});
|
||||
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
|
||||
|
||||
expect(stored.storage).toBe("remote-sftp");
|
||||
expect(calls.mkdir).toEqual([
|
||||
{ dir: REMOTE_IMAGE_DIR, mode: 0o700 },
|
||||
{ dir: `${REMOTE_IMAGE_DIR}/.termix-write-lock`, mode: 0o700 },
|
||||
]);
|
||||
expect(calls.createWriteStream).toEqual([
|
||||
{ path: stored.shellPath, mode: 0o600 },
|
||||
]);
|
||||
});
|
||||
it("removes expired UUID PNGs with best-effort remote retention", async () => {
|
||||
const nowMs = 10_000_000;
|
||||
const expiredName = `${randomUUID()}.png`;
|
||||
const freshName = `${randomUUID()}.png`;
|
||||
const unlinked: string[] = [];
|
||||
const base = fakeSftp({});
|
||||
const sftp = base.sftp as ImageSftpClient & {
|
||||
readdir: (
|
||||
dir: string,
|
||||
callback: (
|
||||
err: Error | undefined,
|
||||
entries: Array<{ filename: string; attrs?: { mtime?: number } }>,
|
||||
) => void,
|
||||
) => void;
|
||||
unlink: (remotePath: string, callback: (err?: Error) => void) => void;
|
||||
};
|
||||
sftp.readdir = (_dir, callback) =>
|
||||
callback(undefined, [
|
||||
{ filename: expiredName, attrs: { mtime: (nowMs - 7_200_000) / 1000 } },
|
||||
{ filename: freshName, attrs: { mtime: nowMs / 1000 } },
|
||||
{ filename: "other.txt", attrs: { mtime: 0 } },
|
||||
]);
|
||||
sftp.unlink = (remotePath, callback) => {
|
||||
unlinked.push(remotePath);
|
||||
callback();
|
||||
};
|
||||
|
||||
await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
ttlMs: 3_600_000,
|
||||
nowMs,
|
||||
});
|
||||
|
||||
expect(unlinked).toEqual([`${REMOTE_IMAGE_DIR}/${expiredName}`]);
|
||||
});
|
||||
it("fails closed when remote quota inspection fails", async () => {
|
||||
const { sftp } = fakeSftp({
|
||||
readdirError: new Error("remote listing failed"),
|
||||
});
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
maxCount: 10,
|
||||
maxBytes: 1024,
|
||||
}).catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects remote writes at the configured count limit", async () => {
|
||||
const { sftp } = fakeSftp({
|
||||
readdirEntries: [{ filename: `${randomUUID()}.png`, size: 12 }],
|
||||
});
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
maxCount: 1,
|
||||
maxBytes: 1024,
|
||||
}).catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
);
|
||||
});
|
||||
|
||||
it("destroys a stalled SFTP write after its timeout", async () => {
|
||||
const { sftp, streams } = fakeSftp({ stallWrite: true });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
writeTimeoutMs: 25,
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
expect(streams[0]!.destroyed).toBe(true);
|
||||
});
|
||||
it("rejects an existing remote path that is not a directory", async () => {
|
||||
const { sftp } = fakeSftp({
|
||||
mkdirError: new Error("Failure: file already exists"),
|
||||
statMode: 0o100644,
|
||||
});
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves SFTP client context for directory inspection", async () => {
|
||||
const base = fakeSftp({ mkdirError: new Error("already exists") });
|
||||
const sftp = base.sftp;
|
||||
sftp.lstat = function (
|
||||
this: ImageSftpClient,
|
||||
_dir: string,
|
||||
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
|
||||
) {
|
||||
if (this !== sftp) {
|
||||
callback(new Error("SFTP context lost"));
|
||||
return;
|
||||
}
|
||||
callback(undefined, { mode: 0o40700 });
|
||||
};
|
||||
const result = await storeImageViaSftp(sftp, PNG_BYTES);
|
||||
expect(result.storage).toBe("remote-sftp");
|
||||
});
|
||||
|
||||
it("tolerates mkdir failures for an already-existing directory", async () => {
|
||||
const { sftp } = fakeSftp({
|
||||
mkdirError: new Error("Failure: file already exists"),
|
||||
});
|
||||
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
|
||||
storage: "remote-sftp",
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans up a partially created remote file after a write failure", async () => {
|
||||
const { sftp } = fakeSftp({ writeError: new Error("write failed") });
|
||||
const unlinked: string[] = [];
|
||||
(sftp as ImageSftpClient).unlink = (remotePath, callback) => {
|
||||
unlinked.push(remotePath);
|
||||
callback();
|
||||
};
|
||||
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect(unlinked).toHaveLength(1);
|
||||
expect(unlinked[0]).toMatch(
|
||||
new RegExp(`${REMOTE_IMAGE_DIR}/[0-9a-f-]+\\.png`),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the write error when partial-file cleanup stalls", async () => {
|
||||
const { sftp } = fakeSftp({
|
||||
writeError: new Error("write failed"),
|
||||
stallUnlink: true,
|
||||
});
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
}, 5_000);
|
||||
|
||||
it("recovers a stale remote lock before writing", async () => {
|
||||
const base = fakeSftp({});
|
||||
const sftp = base.sftp as ImageSftpClient;
|
||||
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
|
||||
let lockAttempt = 0;
|
||||
const removed: string[] = [];
|
||||
const originalMkdir = sftp.mkdir.bind(sftp);
|
||||
sftp.mkdir = (dir, attrs, callback) => {
|
||||
if (dir === lockPath && lockAttempt++ === 0) {
|
||||
(typeof attrs === "function" ? attrs : callback!)(new Error("exists"));
|
||||
return;
|
||||
}
|
||||
originalMkdir(dir, attrs, callback);
|
||||
};
|
||||
sftp.lstat = (_dir, callback) =>
|
||||
callback(undefined, {
|
||||
mode: 0o40700,
|
||||
mtime: (Date.now() - 60_000) / 1000,
|
||||
});
|
||||
sftp.rmdir = (dir, callback) => {
|
||||
removed.push(dir);
|
||||
callback();
|
||||
};
|
||||
|
||||
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
|
||||
storage: "remote-sftp",
|
||||
});
|
||||
expect(removed).toContain(lockPath);
|
||||
});
|
||||
|
||||
it("does not replace a successful write with an unlock failure", async () => {
|
||||
const base = fakeSftp({});
|
||||
const sftp = base.sftp as ImageSftpClient;
|
||||
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
|
||||
const originalMkdir = sftp.mkdir.bind(sftp);
|
||||
sftp.mkdir = (dir, attrs, callback) => originalMkdir(dir, attrs, callback);
|
||||
sftp.rmdir = (dir, callback) => {
|
||||
if (dir === lockPath) callback(new Error("unlock failed"));
|
||||
else callback();
|
||||
};
|
||||
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when remote lock acquisition stalls", async () => {
|
||||
const { sftp } = fakeSftp({ stallLock: true });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
}, 12_000);
|
||||
|
||||
it("fails closed when remote quota inspection stalls", async () => {
|
||||
const { sftp } = fakeSftp({ stallReaddir: true });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
maxCount: 10,
|
||||
maxBytes: 1024,
|
||||
}).catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
}, 7_000);
|
||||
|
||||
it("fails closed when remote expiry cleanup stalls", async () => {
|
||||
const { sftp } = fakeSftp({ stallReaddir: true });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
ttlMs: 1_000,
|
||||
}).catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
}, 7_000);
|
||||
|
||||
it("fails closed when remote lock release stalls", async () => {
|
||||
const { sftp } = fakeSftp({ stallRmdir: true });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
}, 5_000);
|
||||
|
||||
it("maps SFTP failures to IMAGE_REMOTE_WRITE_FAILED", async () => {
|
||||
const { sftp } = fakeSftp({ writeError: new Error("Permission denied") });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
expect((error as TerminalImageStorageError).message).toBe(
|
||||
"Failed to write image to the remote host",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,541 +0,0 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import sharp from "sharp";
|
||||
import type { Request, RequestHandler, Response } from "express";
|
||||
import type { ImageSftpClient } from "../../../database/routes/terminal-image-storage.js";
|
||||
import { databaseLogger } from "../../../utils/logger.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "user-1",
|
||||
settings: {} as Record<string, string>,
|
||||
sessions: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
getUserSessions: () => state.sessions,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings[key] ?? null,
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({}),
|
||||
createCurrentCommandHistoryRepository: () => ({}),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/terminal.js");
|
||||
|
||||
interface RouteLayer {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: RequestHandler }>;
|
||||
};
|
||||
}
|
||||
|
||||
const imageUploadLayer = (
|
||||
router as unknown as { stack: RouteLayer[] }
|
||||
).stack.find(
|
||||
(layer) => layer.route?.path === "/image-upload" && layer.route.methods.post,
|
||||
);
|
||||
const imageUploadHandler =
|
||||
imageUploadLayer!.route!.stack[imageUploadLayer!.route!.stack.length - 1]!
|
||||
.handle;
|
||||
|
||||
function fakeSftp(behavior: { writeError?: Error } = {}): {
|
||||
sftp: ImageSftpClient;
|
||||
written: Map<string, Buffer>;
|
||||
end: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const written = new Map<string, Buffer>();
|
||||
const end = vi.fn();
|
||||
const sftp = {
|
||||
mkdir: (
|
||||
_dir: string,
|
||||
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
|
||||
maybeCallback?: (err?: Error) => void,
|
||||
) => {
|
||||
const callback =
|
||||
typeof attrsOrCallback === "function"
|
||||
? attrsOrCallback
|
||||
: maybeCallback!;
|
||||
callback();
|
||||
},
|
||||
createWriteStream: (remotePath: string, _options?: { mode?: number }) => {
|
||||
const stream = new EventEmitter() as NodeJS.WritableStream & {
|
||||
end: (data: Buffer) => void;
|
||||
};
|
||||
stream.end = (data: Buffer) => {
|
||||
queueMicrotask(() => {
|
||||
if (behavior.writeError) {
|
||||
stream.emit("error", behavior.writeError);
|
||||
return;
|
||||
}
|
||||
written.set(remotePath, data);
|
||||
stream.emit("close");
|
||||
});
|
||||
};
|
||||
return stream;
|
||||
},
|
||||
readdir: (
|
||||
_dir: string,
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
entries: Array<{
|
||||
filename: string;
|
||||
attrs?: { size?: number; mtime?: number };
|
||||
}>,
|
||||
) => void,
|
||||
) => callback(undefined, []),
|
||||
unlink: (_path: string, callback: (error?: Error) => void) => callback(),
|
||||
rmdir: (_dir: string, callback: (error?: Error) => void) => callback(),
|
||||
end,
|
||||
} as unknown as ImageSftpClient & { end: ReturnType<typeof vi.fn> };
|
||||
return { sftp, written, end };
|
||||
}
|
||||
|
||||
function connectedSession(instanceId: string, sftp: ImageSftpClient) {
|
||||
return {
|
||||
tabInstanceId: instanceId,
|
||||
isConnected: true,
|
||||
sshConn: {
|
||||
sftp: (
|
||||
callback: (err: Error | undefined, sftp: ImageSftpClient) => void,
|
||||
) => callback(undefined, sftp),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function invoke(options: {
|
||||
file?: { buffer: Buffer; mimetype: string; size: number };
|
||||
instanceId?: string;
|
||||
metadata?: { source?: string; clientUploadTimestamp?: string };
|
||||
}) {
|
||||
const body: Record<string, string> = {};
|
||||
if (options.instanceId) body.instanceId = options.instanceId;
|
||||
if (options.metadata?.source !== undefined)
|
||||
body.source = options.metadata.source;
|
||||
if (options.metadata?.clientUploadTimestamp !== undefined)
|
||||
body.clientUploadTimestamp = options.metadata.clientUploadTimestamp;
|
||||
const req = {
|
||||
userId: state.userId,
|
||||
body,
|
||||
file: options.file,
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
const result = { statusCode: 200, body: null as unknown };
|
||||
const res = {
|
||||
status(code: number) {
|
||||
result.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body: unknown) {
|
||||
result.body = body;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response;
|
||||
await imageUploadHandler(req, res, () => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
const LEGACY_ENV_NAMES = [
|
||||
"TERMIX_IMAGE_STORAGE_MODE",
|
||||
"TERMIX_IMAGE_DIR",
|
||||
"TERMIX_IMAGE_HOST_PATH",
|
||||
"TERMIX_IMAGE_TTL_MS",
|
||||
"TERMIX_MAX_IMAGE_COUNT",
|
||||
"TERMIX_MAX_IMAGE_STORAGE_BYTES",
|
||||
"DATA_DIR",
|
||||
];
|
||||
|
||||
let pngBuffer: Buffer;
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
|
||||
beforeAll(async () => {
|
||||
pngBuffer = await sharp({
|
||||
create: { width: 2, height: 2, channels: 3, background: "#ffffff" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
state.settings = {};
|
||||
state.sessions = [];
|
||||
savedEnv = Object.fromEntries(
|
||||
LEGACY_ENV_NAMES.map((name) => [name, process.env[name]]),
|
||||
);
|
||||
for (const name of LEGACY_ENV_NAMES) delete process.env[name];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of LEGACY_ENV_NAMES) {
|
||||
if (savedEnv[name] === undefined) delete process.env[name];
|
||||
else process.env[name] = savedEnv[name];
|
||||
}
|
||||
});
|
||||
|
||||
describe("terminal image upload route", () => {
|
||||
it("rejects requests without an image file", async () => {
|
||||
const response = await invoke({ instanceId: "tab-1" });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({ code: "IMAGE_FILE_MISSING" });
|
||||
});
|
||||
|
||||
it("requires a connected terminal in explicit remote-sftp mode", async () => {
|
||||
state.settings["terminal_image_storage_mode"] = "remote-sftp";
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_TERMINAL_NOT_CONNECTED",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes over SFTP in auto mode when a session is connected", async () => {
|
||||
const { sftp, written, end } = fakeSftp();
|
||||
state.sessions = [connectedSession("tab-1", sftp)];
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.body as {
|
||||
id: string;
|
||||
filename: string;
|
||||
shellPath: string;
|
||||
storage: string;
|
||||
};
|
||||
expect(body.storage).toBe("remote-sftp");
|
||||
expect(body.filename).toBe(`${body.id}.png`);
|
||||
expect(body.shellPath).toBe(`/tmp/termix-images/${body.filename}`);
|
||||
// Sharp normalized the upload to PNG before the write.
|
||||
const writtenBytes = written.get(body.shellPath)!;
|
||||
expect(writtenBytes.subarray(1, 4).toString()).toBe("PNG");
|
||||
expect(end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("maps SFTP failures to 502 without leaking the raw remote error", async () => {
|
||||
const { sftp, end } = fakeSftp({
|
||||
writeError: new Error("Permission denied"),
|
||||
});
|
||||
state.sessions = [connectedSession("tab-1", sftp)];
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.body).toEqual({
|
||||
error: "Failed to write image to the remote host",
|
||||
code: "IMAGE_REMOTE_WRITE_FAILED",
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain("Permission denied");
|
||||
expect(end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects undecodable image data", async () => {
|
||||
const { sftp } = fakeSftp();
|
||||
state.sessions = [connectedSession("tab-1", sftp)];
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: Buffer.from("definitely not an image"),
|
||||
mimetype: "image/png",
|
||||
size: 23,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({ code: "IMAGE_DECODE_FAILED" });
|
||||
});
|
||||
|
||||
describe("local mapped storage", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// The settings validator rejects backslashes, so store the temp dir in
|
||||
// POSIX form. Windows still resolves it for the real file checks.
|
||||
dir = (
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "termix-route-test-"))
|
||||
).replace(/\\/g, "/");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stores locally without a terminal session and hides backend paths", async () => {
|
||||
state.settings["terminal_image_storage_mode"] = "local";
|
||||
state.settings["terminal_image_local_dir"] = dir;
|
||||
state.settings["terminal_image_host_path"] = "/host-view/images";
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.body as {
|
||||
id: string;
|
||||
filename: string;
|
||||
shellPath: string;
|
||||
storage: string;
|
||||
};
|
||||
expect(body.storage).toBe("local");
|
||||
expect(body.shellPath).toBe(`/host-view/images/${body.filename}`);
|
||||
expect(JSON.stringify(body)).not.toContain(dir);
|
||||
const stored = await fs.readFile(path.join(dir, body.filename));
|
||||
expect(stored.subarray(1, 4).toString()).toBe("PNG");
|
||||
});
|
||||
|
||||
it("answers 507 when the configured image count cap is reached", async () => {
|
||||
state.settings["terminal_image_storage_mode"] = "local";
|
||||
state.settings["terminal_image_local_dir"] = dir;
|
||||
state.settings["terminal_image_host_path"] = "/host-view/images";
|
||||
state.settings["terminal_image_max_count"] = "1";
|
||||
await fs.writeFile(
|
||||
path.join(dir, `${randomUUID()}.png`),
|
||||
Buffer.alloc(16),
|
||||
);
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(507);
|
||||
expect(response.body).toMatchObject({
|
||||
error: "Image storage limit reached",
|
||||
code: "IMAGE_STORAGE_LIMIT_REACHED",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy explicit local mappings on local mode", async () => {
|
||||
process.env.TERMIX_IMAGE_DIR = dir;
|
||||
process.env.TERMIX_IMAGE_HOST_PATH = "/host-view/images";
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toMatchObject({ storage: "local" });
|
||||
});
|
||||
|
||||
it("returns 503 when local storage inspection fails", async () => {
|
||||
const blocked = `${dir}/blocked-file`;
|
||||
await fs.writeFile(blocked, "not a directory");
|
||||
state.settings["terminal_image_storage_mode"] = "local";
|
||||
state.settings["terminal_image_local_dir"] = blocked;
|
||||
state.settings["terminal_image_host_path"] = "/host-view/images";
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.body).toEqual({
|
||||
error: "Unable to inspect local image storage",
|
||||
code: "IMAGE_LOCAL_INSPECTION_FAILED",
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain(blocked);
|
||||
});
|
||||
it("rejects auto mode when no verified storage capability is available", async () => {
|
||||
process.env.DATA_DIR = dir;
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-missing",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_STORAGE_UNAVAILABLE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagnostic metadata", () => {
|
||||
interface UploadLogMeta {
|
||||
operation?: string;
|
||||
requestId?: string;
|
||||
sequence?: number;
|
||||
source?: string;
|
||||
clientUploadTimestamp?: string;
|
||||
serverReceivedAt?: string;
|
||||
bytes?: number;
|
||||
}
|
||||
|
||||
function uploadLogEntries(): UploadLogMeta[] {
|
||||
return vi
|
||||
.mocked(databaseLogger.info)
|
||||
.mock.calls.filter(
|
||||
(call) =>
|
||||
(call[1] as UploadLogMeta | undefined)?.operation ===
|
||||
"terminal_image_upload_received",
|
||||
)
|
||||
.map((call) => call[1] as UploadLogMeta);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(databaseLogger.info).mockClear();
|
||||
});
|
||||
|
||||
it("logs correlation id, receipt time, and propagated source metadata", async () => {
|
||||
const { sftp } = fakeSftp();
|
||||
state.sessions = [connectedSession("tab-1", sftp)];
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
metadata: {
|
||||
source: "clipboard",
|
||||
clientUploadTimestamp: "2026-08-15T12:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const entries = uploadLogEntries();
|
||||
expect(entries).toHaveLength(1);
|
||||
const meta = entries[0]!;
|
||||
expect(meta.requestId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
expect(typeof meta.sequence).toBe("number");
|
||||
expect(meta.source).toBe("clipboard");
|
||||
expect(meta.clientUploadTimestamp).toBe("2026-08-15T12:00:00.000Z");
|
||||
expect(Number.isNaN(Date.parse(meta.serverReceivedAt ?? ""))).toBe(false);
|
||||
expect(meta.bytes).toBe(pngBuffer.length);
|
||||
});
|
||||
|
||||
it("logs a monotonically increasing upload sequence", async () => {
|
||||
const { sftp } = fakeSftp();
|
||||
state.sessions = [connectedSession("tab-1", sftp)];
|
||||
|
||||
await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
metadata: { source: "file" },
|
||||
});
|
||||
await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
metadata: { source: "clipboard" },
|
||||
});
|
||||
|
||||
const sequences = uploadLogEntries().map((entry) => entry.sequence!);
|
||||
expect(sequences).toHaveLength(2);
|
||||
expect(sequences[1]).toBe(sequences[0]! + 1);
|
||||
});
|
||||
|
||||
it("accepts missing metadata and keeps raw paths out of the log", async () => {
|
||||
const dir = (
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "termix-meta-test-"))
|
||||
).replace(/\\/g, "/");
|
||||
try {
|
||||
state.settings["terminal_image_storage_mode"] = "local";
|
||||
state.settings["terminal_image_local_dir"] = dir;
|
||||
state.settings["terminal_image_host_path"] = "/host-view/images";
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const entries = uploadLogEntries();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]!.source).toBeUndefined();
|
||||
expect(entries[0]!.clientUploadTimestamp).toBeUndefined();
|
||||
expect(JSON.stringify(entries[0])).not.toContain(dir);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, RequestHandler, Response } from "express";
|
||||
import { databaseLogger } from "../../../utils/logger.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "user-1",
|
||||
settings: {} as Record<string, string>,
|
||||
sessions: [] as unknown[],
|
||||
}));
|
||||
|
||||
// A package shipped without the native binary for this architecture.
|
||||
vi.mock("sharp", () => {
|
||||
throw new Error(
|
||||
'Could not load the "sharp" module using the darwin-x64 runtime',
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
getUserSessions: () => state.sessions,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings[key] ?? null,
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({}),
|
||||
createCurrentCommandHistoryRepository: () => ({}),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/terminal.js");
|
||||
|
||||
interface RouteLayer {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: RequestHandler }>;
|
||||
};
|
||||
}
|
||||
|
||||
const imageUploadLayer = (
|
||||
router as unknown as { stack: RouteLayer[] }
|
||||
).stack.find(
|
||||
(layer) => layer.route?.path === "/image-upload" && layer.route.methods.post,
|
||||
);
|
||||
const imageUploadHandler =
|
||||
imageUploadLayer!.route!.stack[imageUploadLayer!.route!.stack.length - 1]!
|
||||
.handle;
|
||||
|
||||
async function invoke() {
|
||||
const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const req = {
|
||||
userId: state.userId,
|
||||
body: { instanceId: "tab-1" },
|
||||
file: { buffer, mimetype: "image/png", size: buffer.length },
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
const result = { statusCode: 200, body: null as unknown };
|
||||
const res = {
|
||||
status(code: number) {
|
||||
result.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body: unknown) {
|
||||
result.body = body;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response;
|
||||
await imageUploadHandler(req, res, () => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(databaseLogger.error).mockClear();
|
||||
state.settings = { terminal_image_storage_mode: "remote-sftp" };
|
||||
state.sessions = [{ tabInstanceId: "tab-1", isConnected: true, sshConn: {} }];
|
||||
});
|
||||
|
||||
describe("terminal image upload route when sharp cannot be loaded", () => {
|
||||
it("imports the router instead of failing at module scope", () => {
|
||||
expect(imageUploadHandler).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("answers 503 and logs the reason rather than throwing", async () => {
|
||||
const response = await invoke();
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_PROCESSING_UNAVAILABLE",
|
||||
});
|
||||
expect(databaseLogger.error).toHaveBeenCalledWith(
|
||||
"Image processing unavailable: sharp failed to load",
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
operation: "terminal_image_upload_sharp_unavailable",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps answering 503 on later uploads without retrying the import", async () => {
|
||||
await invoke();
|
||||
const response = await invoke();
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_PROCESSING_UNAVAILABLE",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
exceedsImageStorageLimit,
|
||||
exceedsNormalizedImageSize,
|
||||
imageExtensionForFormat,
|
||||
createConcurrencyLimiter,
|
||||
isExpiredImage,
|
||||
isImageFilename,
|
||||
} from "../../../database/routes/terminal-image-utils.js";
|
||||
|
||||
describe("terminal image utilities", () => {
|
||||
it("accepts UUID-based image filenames", () => {
|
||||
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506.jpeg")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe or unrelated filenames", () => {
|
||||
expect(isImageFilename("../secrets.txt")).toBe(false);
|
||||
expect(isImageFilename("not-an-image.jpeg")).toBe(false);
|
||||
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps decoded raster formats while excluding SVG", () => {
|
||||
expect(imageExtensionForFormat("jpeg")).toBe("jpg");
|
||||
expect(imageExtensionForFormat("heif")).toBe("heif");
|
||||
expect(imageExtensionForFormat("tiff")).toBe("tiff");
|
||||
expect(imageExtensionForFormat("svg")).toBeUndefined();
|
||||
expect(imageExtensionForFormat(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects normalized output beyond the byte ceiling", () => {
|
||||
expect(exceedsNormalizedImageSize(10_000_001, 10_000_000)).toBe(true);
|
||||
expect(exceedsNormalizedImageSize(10_000_000, 10_000_000)).toBe(false);
|
||||
});
|
||||
it("expires files older than the configured TTL", () => {
|
||||
expect(isExpiredImage(1_000, 3_000, 1_000)).toBe(true);
|
||||
expect(isExpiredImage(2_500, 3_000, 1_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a zero TTL as retention disabled", () => {
|
||||
expect(isExpiredImage(1_000, 999_999, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds the admission queue", async () => {
|
||||
const limiter = createConcurrencyLimiter(1, 0);
|
||||
const release = await limiter.acquire();
|
||||
await expect(limiter.acquire()).rejects.toThrow("queue is full");
|
||||
release();
|
||||
});
|
||||
|
||||
it("rejects uploads that exceed the count or byte limit", () => {
|
||||
expect(exceedsImageStorageLimit(100, 10, 1, 100, 1000)).toBe(true);
|
||||
expect(exceedsImageStorageLimit(1, 900, 101, 100, 1000)).toBe(true);
|
||||
expect(exceedsImageStorageLimit(1, 900, 100, 100, 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds concurrent work", async () => {
|
||||
const limiter = createConcurrencyLimiter(1);
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const task = async () => {
|
||||
const release = await limiter.acquire();
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
release();
|
||||
};
|
||||
|
||||
await Promise.all([task(), task(), task()]);
|
||||
|
||||
expect(peak).toBe(1);
|
||||
expect(limiter.active).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, RequestHandler, Response } from "express";
|
||||
import { TOUCH_INPUT_DEFAULTS } from "../../../../types/touch-input-settings.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "admin",
|
||||
admins: new Set(["admin"]),
|
||||
stored: null as string | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: { error: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => ({ id, isAdmin: state.admins.has(id) }),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async () => state.stored,
|
||||
set: async (_key: string, value: string) => {
|
||||
state.stored = value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { registerTouchInputSettingsRoutes } =
|
||||
await import("../../../database/routes/touch-input-settings-routes.js");
|
||||
|
||||
type Registered = { method: string; path: string; handler: RequestHandler };
|
||||
const registered: Registered[] = [];
|
||||
const router = {
|
||||
get: (path: string, ...handlers: RequestHandler[]) =>
|
||||
registered.push({ method: "get", path, handler: handlers.at(-1)! }),
|
||||
patch: (path: string, ...handlers: RequestHandler[]) =>
|
||||
registered.push({ method: "patch", path, handler: handlers.at(-1)! }),
|
||||
} as unknown as import("express").Router;
|
||||
registerTouchInputSettingsRoutes(router, (_req, _res, next) => next());
|
||||
|
||||
async function invoke(method: string, body: unknown = {}) {
|
||||
const handler = registered.find((entry) => entry.method === method)!.handler;
|
||||
const req = { userId: state.userId, body } as unknown as Request;
|
||||
const result = { statusCode: 200, body: null as unknown };
|
||||
const res = {
|
||||
status(code: number) {
|
||||
result.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(bodyValue: unknown) {
|
||||
result.body = bodyValue;
|
||||
return this;
|
||||
},
|
||||
} as Response;
|
||||
await handler(req, res, () => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.userId = "admin";
|
||||
state.stored = null;
|
||||
});
|
||||
|
||||
describe("touch input settings routes", () => {
|
||||
it("allows authenticated non-admin users to read normalized defaults", async () => {
|
||||
state.userId = "user";
|
||||
const response = await invoke("get");
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual(TOUCH_INPUT_DEFAULTS);
|
||||
});
|
||||
|
||||
it("only allows admins to write", async () => {
|
||||
state.userId = "user";
|
||||
const response = await invoke("patch", { enabled: false });
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(state.stored).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects out-of-range values and persists normalized updates", async () => {
|
||||
const invalid = await invoke("patch", { maximumTicksPerFrame: 101 });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
|
||||
const valid = await invoke("patch", {
|
||||
dragThresholdPx: 9,
|
||||
momentumEnabled: false,
|
||||
});
|
||||
expect(valid.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.stored!)).toEqual({
|
||||
...TOUCH_INPUT_DEFAULTS,
|
||||
dragThresholdPx: 9,
|
||||
momentumEnabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -101,8 +101,8 @@ vi.mock("../../../hosts/collab/runtime-store.js", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
vi.mock("../../../hosts/live-terminal-sessions.js", () => ({
|
||||
liveTerminalSessions: {
|
||||
setRoomShareControl: (...args: unknown[]) => {
|
||||
state.control.push(args);
|
||||
},
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findCredentialByIdForUser: async () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { SSHAuthManager } from "../../../hosts/connect/terminal-prompt.js";
|
||||
|
||||
function createManager() {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
|
||||
const manager = new SSHAuthManager({
|
||||
userId: "user-1",
|
||||
ws,
|
||||
hostId: 1,
|
||||
isKeyboardInteractive: false,
|
||||
keyboardInteractiveResponded: false,
|
||||
keyboardInteractiveFinish: null,
|
||||
totpPromptSent: false,
|
||||
warpgateAuthPromptSent: false,
|
||||
totpTimeout: null,
|
||||
warpgateAuthTimeout: null,
|
||||
totpAttempts: 0,
|
||||
});
|
||||
return { manager, sent };
|
||||
}
|
||||
|
||||
describe("SSHAuthManager.handleKeyboardInteractive", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("routes a TOTP verification prompt to the totp flow", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Verification code: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "TOTP verification required",
|
||||
},
|
||||
},
|
||||
{ type: "totp_required", prompt: "Verification code: " },
|
||||
]);
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards echo:true for a JumpCloud-style push/TOTP menu prompt", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Password authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "password_required",
|
||||
prompt: "Choose [1] Push, or [2] TOTP: ",
|
||||
echo: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("silently auto-answers a plain password prompt when a stored password exists", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Password: ", echo: false }],
|
||||
finish,
|
||||
{ username: "root", password: "hunter2", authType: "password" },
|
||||
);
|
||||
|
||||
expect(finish).toHaveBeenCalledWith(["hunter2"]);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("prompts the user for a push-confirm prompt and accepts an empty response", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Press enter to send Push request: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Password authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "password_required",
|
||||
prompt: "Press enter to send Push request: ",
|
||||
echo: true,
|
||||
},
|
||||
]);
|
||||
|
||||
manager.context.keyboardInteractiveFinish?.([""]);
|
||||
|
||||
expect(finish).toHaveBeenCalledWith([""]);
|
||||
});
|
||||
|
||||
it("uses a longer timeout for push-style prompts than generic prompts", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Press enter to send Push request: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(180001);
|
||||
expect(sent.some((m) => m.type === "error")).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(120000);
|
||||
expect(sent.some((m) => m.type === "error")).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes a FortiToken prompt to the totp flow instead of the push-confirm flow", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[
|
||||
{
|
||||
prompt:
|
||||
"Enter your Token or type 'push' to receive a push notification: ",
|
||||
echo: true,
|
||||
},
|
||||
],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "TOTP verification required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "totp_required",
|
||||
prompt:
|
||||
"Enter your Token or type 'push' to receive a push notification: ",
|
||||
},
|
||||
]);
|
||||
|
||||
manager.context.keyboardInteractiveFinish?.(["push"]);
|
||||
|
||||
expect(finish).toHaveBeenCalledWith(["push"]);
|
||||
});
|
||||
|
||||
it("routes Warpgate prompts to the warpgate flow, not the generic path", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"Warpgate Authentication",
|
||||
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
|
||||
"",
|
||||
[{ prompt: "Press enter once done: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Warpgate authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "warpgate_auth_required",
|
||||
url: "https://warpgate.example.com/auth",
|
||||
securityKey: "AB12",
|
||||
instructions:
|
||||
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -52,8 +52,8 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
vi.mock("../../../hosts/live-terminal-sessions.js", () => ({
|
||||
liveTerminalSessions: {
|
||||
getSession: (sessionId: string) => {
|
||||
const session = state.sshSessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
@@ -9,57 +9,10 @@ vi.mock("fs/promises", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
MemoryAgent,
|
||||
FilteredAgent,
|
||||
resolveAgentSocket,
|
||||
} from "../../hosts/terminal-auth-helpers.js";
|
||||
|
||||
describe("MemoryAgent", () => {
|
||||
it("serves identities and signatures over the agent protocol", async () => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const parsed = ssh2Pkg.utils.parseKey(
|
||||
privateKey.export({ type: "pkcs1", format: "pem" }),
|
||||
);
|
||||
expect(parsed).not.toBeInstanceOf(Error);
|
||||
|
||||
const agent = new MemoryAgent(parsed as ParsedKey);
|
||||
const stream = await new Promise<NodeJS.ReadWriteStream>(
|
||||
(resolve, reject) => {
|
||||
agent.getStream((error, result) => {
|
||||
if (error || !result)
|
||||
reject(error ?? new Error("Missing agent stream"));
|
||||
else resolve(result);
|
||||
});
|
||||
},
|
||||
);
|
||||
const client = new ssh2Pkg.AgentProtocol(true);
|
||||
client.pipe(stream).pipe(client);
|
||||
|
||||
const identities = await new Promise<ParsedKey[]>((resolve, reject) => {
|
||||
client.getIdentities((error, keys) => {
|
||||
if (error || !keys) reject(error ?? new Error("Missing identities"));
|
||||
else resolve(keys);
|
||||
});
|
||||
});
|
||||
expect(identities).toHaveLength(1);
|
||||
expect(identities[0].getPublicSSH()).toEqual(
|
||||
(parsed as ParsedKey).getPublicSSH(),
|
||||
);
|
||||
|
||||
const data = Buffer.from("forwarded-agent-test");
|
||||
const signature = await new Promise<Buffer>((resolve, reject) => {
|
||||
client.sign(identities[0], data, (error, result) => {
|
||||
if (error || !result) reject(error ?? new Error("Missing signature"));
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
expect((parsed as ParsedKey).verify(data, signature)).toBe(true);
|
||||
|
||||
client.destroy();
|
||||
stream.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentSocket", () => {
|
||||
const originalEnv = process.env.SSH_AUTH_SOCK;
|
||||
const originalPlatform = process.platform;
|
||||
@@ -228,58 +181,3 @@ describe("FilteredAgent", () => {
|
||||
expect(identities).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryAgent unsupported extension framing", () => {
|
||||
it.each([false, true])(
|
||||
"can enumerate keys after rejecting session-bind (fragmented=%s)",
|
||||
async (fragmented) => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
});
|
||||
const key = ssh2Pkg.utils.parseKey(
|
||||
privateKey.export({ type: "pkcs1", format: "pem" }),
|
||||
) as ParsedKey;
|
||||
const agent = new MemoryAgent(key);
|
||||
const stream = await new Promise<NodeJS.ReadWriteStream>(
|
||||
(resolve, reject) => {
|
||||
agent.getStream((error, result) =>
|
||||
error || !result ? reject(error) : resolve(result),
|
||||
);
|
||||
},
|
||||
);
|
||||
const replies: Buffer[] = [];
|
||||
let buffered = Buffer.alloc(0);
|
||||
stream.on("data", (data: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, data]);
|
||||
while (
|
||||
buffered.length >= 4 &&
|
||||
buffered.length >= buffered.readUInt32BE(0) + 4
|
||||
) {
|
||||
const end = buffered.readUInt32BE(0) + 4;
|
||||
replies.push(buffered.subarray(4, end));
|
||||
buffered = buffered.subarray(end);
|
||||
}
|
||||
});
|
||||
const extension = Buffer.from("session-bind@openssh.com");
|
||||
const request = Buffer.alloc(4 + 1 + 4 + extension.length);
|
||||
request.writeUInt32BE(request.length - 4, 0);
|
||||
request[4] = 27;
|
||||
request.writeUInt32BE(extension.length, 5);
|
||||
extension.copy(request, 9);
|
||||
const identities = Buffer.from([0, 0, 0, 1, 11]);
|
||||
try {
|
||||
const requests = Buffer.concat([request, identities]);
|
||||
if (fragmented) {
|
||||
stream.write(requests.subarray(0, 7));
|
||||
stream.write(requests.subarray(7));
|
||||
} else stream.write(requests);
|
||||
await vi.waitFor(() => expect(replies).toHaveLength(2));
|
||||
expect(replies[0][0]).toBe(5); // Unsupported extension remains refused.
|
||||
expect(replies[1][0]).toBe(12); // The following identities request succeeds.
|
||||
expect(replies[1].readUInt32BE(1)).toBe(1);
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* The terminal WS server's lifecycle, which the ssh-terminal plugin drives.
|
||||
*
|
||||
* There is no terminal port any more: core serves the socket at
|
||||
* /plugin-ws/ssh-terminal/terminal and the plugin hands each upgrade to the
|
||||
* WebSocketServer this module owns. So the thing worth asserting changed with
|
||||
* it. It used to be "the port stops listening"; it is now "the module binds
|
||||
* nothing on import, and disabling the plugin closes the live sessions", which
|
||||
* is what disabling a transport owner has to mean when core owns the socket.
|
||||
*
|
||||
* The terminal module is heavy (it pulls in the whole SSH stack), so this
|
||||
* exercises only the lifecycle surface.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({}),
|
||||
createCurrentCollabRoomRepository: () => ({}),
|
||||
createCurrentSessionShareRepository: () => ({}),
|
||||
createCurrentSettingsRepository: () => ({}),
|
||||
createCurrentSessionRecordingRepository: () => ({}),
|
||||
}));
|
||||
|
||||
describe("terminal server lifecycle", () => {
|
||||
it("binds no port of its own", async () => {
|
||||
const terminal = await import("../../../hosts/terminal/index.js");
|
||||
|
||||
// The old module exported a port and a listen() call. Both are gone, and
|
||||
// their absence is the contract: a plugin that binds its own port is
|
||||
// exactly what A4 removed.
|
||||
expect("TERMINAL_WS_PORT" in terminal).toBe(false);
|
||||
expect("startTerminalServer" in terminal).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes an upgrade handler for core to route to", async () => {
|
||||
const { handleTerminalUpgrade } =
|
||||
await import("../../../hosts/terminal/index.js");
|
||||
|
||||
expect(typeof handleTerminalUpgrade).toBe("function");
|
||||
});
|
||||
|
||||
it("is idempotent on stop, so a double deactivate is safe", async () => {
|
||||
const { stopTerminalServer } =
|
||||
await import("../../../hosts/terminal/index.js");
|
||||
|
||||
await expect(stopTerminalServer()).resolves.toBeUndefined();
|
||||
await expect(stopTerminalServer()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,510 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Stub all external imports before loading the module under test
|
||||
const mockCreate = vi.fn().mockResolvedValue({ id: 1 });
|
||||
const mockUpdateEnded = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSessionRecordingRepository: () => ({
|
||||
create: mockCreate,
|
||||
updateEnded: mockUpdateEnded,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock individual fs.promises methods via a stub object
|
||||
const mockMkdir = vi.fn().mockResolvedValue(undefined);
|
||||
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAppendFile = vi.fn().mockResolvedValue(undefined);
|
||||
const mockUnlink = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("fs", () => ({
|
||||
default: {
|
||||
promises: {
|
||||
mkdir: mockMkdir,
|
||||
writeFile: mockWriteFile,
|
||||
appendFile: mockAppendFile,
|
||||
readFile: vi.fn(),
|
||||
unlink: mockUnlink,
|
||||
},
|
||||
},
|
||||
promises: {
|
||||
mkdir: mockMkdir,
|
||||
writeFile: mockWriteFile,
|
||||
appendFile: mockAppendFile,
|
||||
readFile: vi.fn(),
|
||||
unlink: mockUnlink,
|
||||
},
|
||||
}));
|
||||
|
||||
const { sessionManager, isMessageAllowedForParticipant } =
|
||||
await import("../../../hosts/terminal/session-manager.js");
|
||||
|
||||
// Minimal fake WebSocket - only the surface session-manager touches.
|
||||
function makeFakeWs(readyState = 1 /* OPEN */) {
|
||||
return {
|
||||
readyState,
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
} as unknown as import("ws").WebSocket;
|
||||
}
|
||||
const WS_OPEN = 1;
|
||||
const WS_CLOSED = 3;
|
||||
|
||||
describe("TerminalSessionManager - session logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Re-apply resolved values after clearAllMocks
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockCreate.mockResolvedValue({ id: 1 });
|
||||
mockUpdateEnded.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("createSession stores sessionLoggingEnabled=true by default", () => {
|
||||
const id = sessionManager.createSession("u1", 1, "host", 80, 24);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.sessionLoggingEnabled).toBe(true);
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("createSession stores sessionLoggingEnabled=false when passed", () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.sessionLoggingEnabled).toBe(false);
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("does not write log file when sessionLoggingEnabled=false", async () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
sessionManager.bufferOutput(id, "some output");
|
||||
sessionManager.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes log file and inserts DB row when sessionLoggingEnabled=true", async () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
sessionManager.bufferOutput(id, "terminal output data");
|
||||
sessionManager.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(mockWriteFile).toHaveBeenCalledOnce();
|
||||
expect(mockCreate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not write log file when buffer is empty", async () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
sessionManager.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bufferOutput trims old data when exceeding 512KB", () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
const chunk = "x".repeat(300 * 1024);
|
||||
sessionManager.bufferOutput(id, chunk);
|
||||
sessionManager.bufferOutput(id, chunk);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session!.outputBufferBytes).toBeLessThanOrEqual(512 * 1024);
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalSessionManager - multiplayer participants", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockCreate.mockResolvedValue({ id: 1 });
|
||||
mockUpdateEnded.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function createConnectedSession(): string {
|
||||
const id = sessionManager.createSession(
|
||||
"owner-1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
// Mark connected without a real ssh2 stream - only isConnected is read
|
||||
// by attachWs/joinAsParticipant.
|
||||
const session = sessionManager.getSession(id)!;
|
||||
session.isConnected = true;
|
||||
return id;
|
||||
}
|
||||
|
||||
it("joinAsParticipant adds a participant without evicting the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
const session = sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
guestLabel: "Guest",
|
||||
});
|
||||
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.participants.size).toBe(2);
|
||||
const ownerParticipant = sessionManager.getParticipantForWs(
|
||||
session!,
|
||||
ownerWs,
|
||||
);
|
||||
expect(ownerParticipant?.isOwner).toBe(true);
|
||||
// The join is announced to everyone already in the session - and that is
|
||||
// the only unsolicited message the owner receives.
|
||||
expect(ownerWs.send).toHaveBeenCalledTimes(1);
|
||||
const announced = JSON.parse(
|
||||
(ownerWs.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as string,
|
||||
);
|
||||
expect(announced.type).toBe("participants");
|
||||
expect(announced.participants).toHaveLength(2);
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("setRoomShareControl makes only the controller read-write and never touches the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
const aliceWs = makeFakeWs();
|
||||
const bobWs = makeFakeWs();
|
||||
const session = sessionManager.joinAsParticipant(id, aliceWs, {
|
||||
userId: "alice",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
})!;
|
||||
sessionManager.joinAsParticipant(id, bobWs, {
|
||||
userId: "bob",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
});
|
||||
|
||||
sessionManager.setRoomShareControl(id, "stage-share", "alice");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
|
||||
).toBe("read-write");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, bobWs)?.permissionLevel,
|
||||
).toBe("read-only");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, ownerWs)?.permissionLevel,
|
||||
).toBe("read-write");
|
||||
|
||||
sessionManager.setRoomShareControl(id, "stage-share", null);
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
|
||||
).toBe("read-only");
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("disconnectShareParticipants revokes only the selected share participants", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
const aliceWs = makeFakeWs();
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
const session = sessionManager.joinAsParticipant(id, aliceWs, {
|
||||
userId: "alice",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
})!;
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
});
|
||||
|
||||
expect(
|
||||
sessionManager.disconnectShareParticipants(id, "stage-share", {
|
||||
userId: "alice",
|
||||
reason: "Removed",
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(sessionManager.getParticipantForWs(session, aliceWs)).toBeNull();
|
||||
expect(sessionManager.getParticipantForWs(session, guestWs)).not.toBeNull();
|
||||
expect(sessionManager.getParticipantForWs(session, ownerWs)?.isOwner).toBe(
|
||||
true,
|
||||
);
|
||||
expect(aliceWs.close).toHaveBeenCalledWith(1008, "Removed");
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
|
||||
expect(
|
||||
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs(WS_OPEN);
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const openGuestWs = makeFakeWs(WS_OPEN);
|
||||
const closedGuestWs = makeFakeWs(WS_CLOSED);
|
||||
sessionManager.joinAsParticipant(id, openGuestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
sessionManager.joinAsParticipant(id, closedGuestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
sessionManager.broadcast(id, { type: "data", data: "hello" });
|
||||
|
||||
expect(ownerWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "data", data: "hello" }),
|
||||
);
|
||||
expect(openGuestWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "data", data: "hello" }),
|
||||
);
|
||||
expect(closedGuestWs.send).not.toHaveBeenCalled();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("broadcast does not throw if a socket's send throws", () => {
|
||||
const id = createConnectedSession();
|
||||
const throwingWs = makeFakeWs(WS_OPEN);
|
||||
(throwingWs.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("send failed");
|
||||
});
|
||||
sessionManager.attachWs(id, "owner-1", throwingWs);
|
||||
|
||||
expect(() =>
|
||||
sessionManager.broadcast(id, { type: "data", data: "x" }),
|
||||
).not.toThrow();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("broadcast is a no-op for a nonexistent session", () => {
|
||||
expect(() =>
|
||||
sessionManager.broadcast("does-not-exist", { type: "data" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("owner detach arms the idle timeout (existing behavior)", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
sessionManager.detachWs(id);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.detachTimeout).not.toBeNull();
|
||||
expect(session?.lastDetachedAt).not.toBeNull();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
sessionManager.removeParticipant(id, guestWs);
|
||||
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session?.detachTimeout).toBeNull();
|
||||
expect(session?.participants.size).toBe(1);
|
||||
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("removeParticipant is a no-op when the ws belongs to the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
sessionManager.removeParticipant(id, ownerWs);
|
||||
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.participants.size).toBe(1);
|
||||
expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("destroySession cleans up all participants, not just the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
|
||||
expect(guestWs.send).toHaveBeenCalled();
|
||||
expect(sessionManager.getSession(id)).toBeNull();
|
||||
});
|
||||
|
||||
it("ownerEndSession notifies non-owner participants and destroys the session", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
sessionManager.ownerEndSession(id, "owner ended the session");
|
||||
|
||||
expect(guestWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
type: "sessionTerminatedByOwner",
|
||||
reason: "owner ended the session",
|
||||
}),
|
||||
);
|
||||
expect(sessionManager.getSession(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMessageAllowedForParticipant", () => {
|
||||
it("allows any message type for the owner or when there is no participant", () => {
|
||||
expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: true, permissionLevel: "read-write" },
|
||||
"resize",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops input from a read-only participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"input",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows input from a read-write non-owner participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-write" },
|
||||
"input",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows ping and disconnect for any non-owner participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"ping",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"disconnect",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
|
||||
for (const type of [
|
||||
"resize",
|
||||
"totp_response",
|
||||
"password_response",
|
||||
"tmux_attach",
|
||||
"tmux_detach",
|
||||
"get_cwd",
|
||||
"vault_start_auth",
|
||||
"opkssh_start_auth",
|
||||
]) {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-write" },
|
||||
type,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* ctx.audit.record: a plugin writes an audit line under its own action name,
|
||||
* and the user it names is always the runtime's actor, never one the plugin
|
||||
* picked.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(pluginId: string) {
|
||||
const manifest = {
|
||||
id: pluginId,
|
||||
name: "Audit Fixture",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
auditEntries.length = 0;
|
||||
});
|
||||
|
||||
describe("ctx.audit.record", () => {
|
||||
it("writes the plugin's action for the acting user", async () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await runAsActor("user-3", "request", () =>
|
||||
ctx.audit.record({
|
||||
action: "ssh_connect",
|
||||
resourceType: "host",
|
||||
resourceId: "12",
|
||||
resourceName: "root@10.0.0.1:22",
|
||||
success: true,
|
||||
}),
|
||||
);
|
||||
expect(auditEntries).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: "user-3",
|
||||
action: "ssh_connect",
|
||||
resourceType: "host",
|
||||
resourceId: "12",
|
||||
resourceName: "root@10.0.0.1:22",
|
||||
details: "via plugin audit-fixture",
|
||||
success: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the plugin as the resource and system as the user", async () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await ctx.audit.record({ action: "cleanup", success: false });
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
userId: "system",
|
||||
resourceType: "plugin",
|
||||
resourceId: "audit-fixture",
|
||||
resourceName: "Audit Fixture",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,9 @@ const h = vi.hoisted(() => ({
|
||||
updates: [] as Array<Record<string, unknown>>,
|
||||
updateResult: null as Record<string, unknown> | null,
|
||||
users: [] as Array<{ id: string; username: string }>,
|
||||
activities: [] as Array<Record<string, unknown>>,
|
||||
sessions: [] as number[],
|
||||
released: [] as number[],
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -90,6 +93,23 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
listDecryptedByUserId: async () => h.ownedHosts,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../services/recent-activity.js", () => ({
|
||||
recordRecentActivity: async (
|
||||
userId: string,
|
||||
entry: Record<string, unknown>,
|
||||
) => {
|
||||
h.activities.push({ userId, ...entry });
|
||||
return { status: "logged", id: 1 };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/host-session-status.js", () => ({
|
||||
hostSessionStatus: {
|
||||
register: (hostId: number) => {
|
||||
h.sessions.push(hostId);
|
||||
return () => h.released.push(hostId);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
@@ -347,3 +367,40 @@ describe("ctx.hosts", () => {
|
||||
expect(await hosts.listOwned()).toMatchObject([{ id: 1, name: "own" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts session tracking and activity", () => {
|
||||
it("counts a live session through core and releases it", () => {
|
||||
h.sessions = [];
|
||||
h.released = [];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const release = hosts.trackSession(4);
|
||||
expect(h.sessions).toEqual([4]);
|
||||
release();
|
||||
expect(h.released).toEqual([4]);
|
||||
});
|
||||
|
||||
it("refuses to track a session without hosts:read declared", () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest([]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() => hosts.trackSession(4)).toThrow(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("records activity for the acting user only", async () => {
|
||||
h.activities = [];
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
h.actor = "user-7";
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await hosts.recordActivity(4, "terminal", "web-01");
|
||||
expect(h.activities).toEqual([
|
||||
{ userId: "user-7", type: "terminal", hostId: 4, hostName: "web-01" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* The ctx.ssh members an interactive transport (the terminal) needs: host
|
||||
* resolution with secrets, prepare with its provider hooks bound, and the
|
||||
* browser sign-in start and cancel that used to be reached by import.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
actor: "user-1" as string | undefined,
|
||||
resolved: [] as Array<{ by: string; ref: unknown; userId: string }>,
|
||||
built: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
|
||||
});
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../hosts/connect/core-providers.js", () => ({
|
||||
ensureCoreSshAuthProviders: () => {},
|
||||
}));
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (id: number, userId: string) => {
|
||||
h.resolved.push({ by: "id", ref: id, userId });
|
||||
return id === 404 ? null : { id, ip: "10.0.0.1", password: "pw" };
|
||||
},
|
||||
resolveHostBySyncId: async (syncId: string, userId: string) => {
|
||||
h.resolved.push({ by: "syncId", ref: syncId, userId });
|
||||
return { id: 9, ip: "10.0.0.9" };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/connect/build-connect-config.js", () => ({
|
||||
buildConnectConfig: async (
|
||||
_host: unknown,
|
||||
options: Record<string, unknown>,
|
||||
) => {
|
||||
h.built.push(options);
|
||||
return {
|
||||
config: { host: "10.0.0.1" },
|
||||
outcome: { status: "ready" },
|
||||
env: { interactive: options.interactive },
|
||||
provider: {
|
||||
type: "fixture-auth",
|
||||
onBanner: (banner: string, _host: unknown, env: unknown) => ({
|
||||
action: "hold",
|
||||
timeoutMs: 1,
|
||||
message: banner,
|
||||
details: { env },
|
||||
}),
|
||||
onAuthFailed: () => ({ status: "retry", patch: {}, message: "again" }),
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostById: async (hostId: number) =>
|
||||
hostId === 1
|
||||
? {
|
||||
id: 1,
|
||||
name: "box",
|
||||
ip: "10.0.0.1",
|
||||
username: "root",
|
||||
authType: "fixture-auth",
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createPluginSsh } = await import("../../plugins/ctx-ssh-auth.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { registerSshAuthProvider, resetSshAuthRegistryForTests } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const { PluginCapabilityError, PluginSshInteractionError } =
|
||||
await import("@termix/plugin-sdk/backend");
|
||||
|
||||
const ALL = ["ssh:connect", "credentials:use"];
|
||||
|
||||
function sshWith(capabilities = ALL) {
|
||||
const audit = vi.fn(async () => {});
|
||||
const ssh = createPluginSsh({
|
||||
manifest: {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never,
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
return { ssh, audit };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.granted = new Set(ALL);
|
||||
h.actor = "user-1";
|
||||
h.resolved = [];
|
||||
h.built = [];
|
||||
resetSshAuthRegistryForTests();
|
||||
});
|
||||
|
||||
describe("ctx.ssh.resolveHost", () => {
|
||||
it("resolves by sync id first, as the acting user, and audits", async () => {
|
||||
const { ssh, audit } = sshWith();
|
||||
expect(await ssh.resolveHost(3, { syncId: "abc" })).toMatchObject({
|
||||
id: 9,
|
||||
});
|
||||
expect(await ssh.resolveHost(3)).toMatchObject({ id: 3, password: "pw" });
|
||||
expect(h.resolved).toEqual([
|
||||
{ by: "syncId", ref: "abc", userId: "user-1" },
|
||||
{ by: "id", ref: 3, userId: "user-1" },
|
||||
]);
|
||||
expect(audit).toHaveBeenCalledWith("ssh_resolve_host", "host 3", {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers null for a host this user cannot reach", async () => {
|
||||
const { ssh } = sshWith();
|
||||
expect(await ssh.resolveHost(404)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses without credentials:use", async () => {
|
||||
h.granted = new Set(["ssh:connect"]);
|
||||
const { ssh } = sshWith(["ssh:connect"]);
|
||||
await expect(ssh.resolveHost(3)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(h.resolved).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh.prepare", () => {
|
||||
it("passes the terminal purpose, interactivity and host key socket through", async () => {
|
||||
const { ssh } = sshWith();
|
||||
const socket = { send: vi.fn() };
|
||||
await ssh.prepare(
|
||||
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
|
||||
{
|
||||
purpose: "terminal",
|
||||
client: {},
|
||||
interactive: true,
|
||||
hostKeySocket: socket,
|
||||
},
|
||||
);
|
||||
expect(h.built[0]).toMatchObject({
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
hostKeySocket: socket,
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("hands back the provider's hooks bound to this host", async () => {
|
||||
const { ssh } = sshWith();
|
||||
const prepared = await ssh.prepare(
|
||||
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
|
||||
{ client: {}, interactive: true },
|
||||
);
|
||||
expect(prepared.authType).toBe("fixture-auth");
|
||||
expect(prepared.onBanner?.("waiting")).toMatchObject({
|
||||
action: "hold",
|
||||
message: "waiting",
|
||||
details: { env: { interactive: true } },
|
||||
});
|
||||
expect(
|
||||
prepared.onAuthFailed?.({
|
||||
error: new Error("x"),
|
||||
retries: 0,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
}),
|
||||
).toMatchObject({ status: "retry" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh browser sign-in", () => {
|
||||
it("starts the interaction on the host's own provider", async () => {
|
||||
const started = vi.fn(async () => {});
|
||||
registerSshAuthProvider({
|
||||
type: "fixture-auth",
|
||||
pluginId: "core",
|
||||
labelKey: "k",
|
||||
interaction: "fixture",
|
||||
startInteraction: started,
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
const { ssh } = sshWith();
|
||||
await ssh.startInteraction("fixture", {
|
||||
hostId: 1,
|
||||
socket: "ws",
|
||||
requestOrigin: "https://termix.example",
|
||||
});
|
||||
expect(started).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
socket: "ws",
|
||||
requestOrigin: "https://termix.example",
|
||||
host: { name: "box", ip: "10.0.0.1", username: "root" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("says why when nothing can start it", async () => {
|
||||
const { ssh } = sshWith();
|
||||
await expect(
|
||||
ssh.startInteraction("fixture", {
|
||||
hostId: 1,
|
||||
socket: null,
|
||||
requestOrigin: "",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PluginSshInteractionError);
|
||||
await expect(
|
||||
ssh.startInteraction("fixture", {
|
||||
hostId: 2,
|
||||
socket: null,
|
||||
requestOrigin: "",
|
||||
}),
|
||||
).rejects.toThrow("Host not found");
|
||||
});
|
||||
|
||||
it("cancels on every provider that owns the interaction", async () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const other = vi.fn();
|
||||
for (const [type, interaction, cancel] of [
|
||||
["a", "fixture", first],
|
||||
["b", "fixture", second],
|
||||
["c", "elsewhere", other],
|
||||
] as const) {
|
||||
registerSshAuthProvider({
|
||||
type,
|
||||
pluginId: "core",
|
||||
labelKey: "k",
|
||||
interaction,
|
||||
cancelInteraction: cancel,
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
}
|
||||
const { ssh } = sshWith();
|
||||
await ssh.cancelInteraction("fixture", { requestId: "r1" });
|
||||
expect(first).toHaveBeenCalledWith({ userId: "user-1", requestId: "r1" });
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ const {
|
||||
clearServiceRegistry,
|
||||
createServiceHandle,
|
||||
getRegistration,
|
||||
getServiceImplementation,
|
||||
listServices,
|
||||
provideService,
|
||||
resolveRequirements,
|
||||
@@ -85,6 +86,19 @@ describe("service registry", () => {
|
||||
expect(revokeService("nothing.here")).toBe(false);
|
||||
});
|
||||
|
||||
it("hands core the implementation of a compatible version, or nothing", () => {
|
||||
expect(
|
||||
getServiceImplementation("testplugin.greet", "^1.0.0"),
|
||||
).toBeUndefined();
|
||||
const registration = register({ version: "1.4.0" });
|
||||
expect(getServiceImplementation("testplugin.greet", "^1.0.0")).toBe(
|
||||
registration.implementation,
|
||||
);
|
||||
expect(
|
||||
getServiceImplementation("testplugin.greet", "^2.0.0"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("resolution", () => {
|
||||
it("is satisfied when the version is in range", () => {
|
||||
register({ version: "1.2.0" });
|
||||
|
||||
@@ -37,6 +37,13 @@ vi.mock("../../utils/auth-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
getUserDataKey: (userId: string) =>
|
||||
userId === state.userId ? Buffer.from("key") : null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
hasCapability: async (
|
||||
_pluginId: string,
|
||||
@@ -190,6 +197,54 @@ describe("ctx.ws authentication", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws optional auth", () => {
|
||||
function register(seen: Array<Record<string, unknown>>) {
|
||||
ws.registerPluginWsRoute(
|
||||
"sample-plugin",
|
||||
"/mixed",
|
||||
(connection) => {
|
||||
seen.push({
|
||||
userId: connection.userId,
|
||||
unlocked: connection.isDataUnlocked(),
|
||||
clientIp: connection.clientIp,
|
||||
requestOrigin: connection.requestOrigin,
|
||||
});
|
||||
(connection.socket as WebSocket).send("hi");
|
||||
},
|
||||
DECLARED,
|
||||
{ public: true, optionalAuth: true },
|
||||
);
|
||||
}
|
||||
|
||||
it("names the user when a valid token came with the upgrade", async () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
register(seen);
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/mixed`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
expect(result.outcome).toBe("open");
|
||||
expect(seen[0]).toMatchObject({ userId: state.userId, unlocked: true });
|
||||
expect(typeof seen[0].clientIp).toBe("string");
|
||||
expect(String(seen[0].requestOrigin)).toMatch(/^http/);
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
|
||||
it("still serves a guest, with no user and nothing unlocked", async () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
register(seen);
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/mixed`,
|
||||
authProtocols("nope"),
|
||||
);
|
||||
expect(result.outcome).toBe("open");
|
||||
expect(seen[0]).toMatchObject({ userId: "", unlocked: false });
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws capability", () => {
|
||||
it("refuses the upgrade when network:serve is not granted", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* dispatchLoginEvent used to POST to the metrics service on localhost:30005
|
||||
* with an internal auth token. Host metrics is a plugin now and has no port,
|
||||
* so it publishes on the plugin event bus and the plugin subscribes.
|
||||
*
|
||||
* What is asserted changed with it: the payload a subscriber receives, and
|
||||
* that a throwing subscriber cannot fail the login that caused the event.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
describe("dispatchLoginEvent", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("publishes the login details a subscriber needs", async () => {
|
||||
const received: unknown[] = [];
|
||||
const unsubscribe = pluginEvents.on(TOPICS.hostLogin, (payload) => {
|
||||
received.push(payload);
|
||||
});
|
||||
|
||||
try {
|
||||
await dispatchLoginEvent(42, "user-1", "root", "10.0.0.5");
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(received).toEqual([
|
||||
{ hostId: 42, userId: "user-1", sshUser: "root", fromIp: "10.0.0.5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not log a warning on the happy path", async () => {
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never sends the event over the network", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||
|
||||
// The whole point of the move: no service-to-service HTTP call, and no
|
||||
// internal shared secret to carry on it.
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not propagate a subscriber's throw to the caller", async () => {
|
||||
const unsubscribe = pluginEvents.on(TOPICS.hostLogin, () => {
|
||||
throw new Error("subscriber exploded");
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* The SSH terminal settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: an install's session timeout, command history
|
||||
* switch, touch tuning and image storage settings, and every host that turned
|
||||
* a terminal switch off, must come through into the plugin's own settings.
|
||||
* Running it twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_terminal: number | null;
|
||||
enable_terminal_toolbar: number | null;
|
||||
enable_command_history: number | null;
|
||||
}
|
||||
|
||||
const coreSettings = new Map<string, string>();
|
||||
const pluginRows: Row[] = [];
|
||||
const hostRows: HostRow[] = [];
|
||||
let pluginInstalled = true;
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
pluginInstalled && id === "ssh-terminal" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.encrypted = encrypted;
|
||||
return;
|
||||
}
|
||||
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () =>
|
||||
hostRows.filter(
|
||||
(row) =>
|
||||
row.enable_terminal === 0 ||
|
||||
row.enable_terminal_toolbar === 0 ||
|
||||
row.enable_command_history === 0,
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (plaintext: string) =>
|
||||
plaintext.startsWith("sysenc:") ? plaintext : `sysenc:${plaintext}`,
|
||||
}));
|
||||
|
||||
const { runSshTerminalSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/ssh-terminal-settings-migration.js");
|
||||
|
||||
function admin(key: string): unknown {
|
||||
const row = find("ssh-terminal", "admin", null, key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
function host(id: number, key: string): unknown {
|
||||
const row = find("ssh-terminal", "host", String(id), key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
coreSettings.clear();
|
||||
pluginRows.length = 0;
|
||||
hostRows.length = 0;
|
||||
pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runSshTerminalSettingsMigration", () => {
|
||||
it("moves the admin settings with their types, the local dir as a secret", async () => {
|
||||
coreSettings.set("terminal_session_timeout_minutes", "120");
|
||||
coreSettings.set("terminal_session_persistence_enabled", "false");
|
||||
coreSettings.set("command_history_enabled", "false");
|
||||
coreSettings.set(
|
||||
"touch_input_settings",
|
||||
JSON.stringify({ enabled: false, dragThresholdPx: 12 }),
|
||||
);
|
||||
coreSettings.set("terminal_image_storage_mode", "local");
|
||||
coreSettings.set("terminal_image_local_dir", "/data/images");
|
||||
coreSettings.set("terminal_image_max_count", "50");
|
||||
|
||||
const result = await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(result.moved).toHaveLength(7);
|
||||
expect(admin("sessionTimeoutMinutes")).toBe(120);
|
||||
expect(admin("sessionPersistence")).toBe(false);
|
||||
expect(admin("commandHistoryEnabled")).toBe(false);
|
||||
expect(admin("touchInput")).toEqual({
|
||||
enabled: false,
|
||||
dragThresholdPx: 12,
|
||||
});
|
||||
expect(admin("imageStorageMode")).toBe("local");
|
||||
expect(admin("imageMaxCount")).toBe(50);
|
||||
const localDir = find("ssh-terminal", "admin", null, "imageLocalDir");
|
||||
expect(localDir?.encrypted).toBe(true);
|
||||
expect(JSON.parse(localDir!.value!)).toBe("sysenc:/data/images");
|
||||
});
|
||||
|
||||
it("copies each host that switched something off, keeping the rest on", async () => {
|
||||
hostRows.push(
|
||||
{
|
||||
id: 1,
|
||||
enable_terminal: 1,
|
||||
enable_terminal_toolbar: 0,
|
||||
enable_command_history: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
enable_terminal: 0,
|
||||
enable_terminal_toolbar: 1,
|
||||
enable_command_history: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
enable_terminal: 1,
|
||||
enable_terminal_toolbar: 1,
|
||||
enable_command_history: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(result.hostsMoved).toBe(2);
|
||||
expect(host(1, "enableTerminal")).toBe(true);
|
||||
expect(host(1, "enableTerminalToolbar")).toBe(false);
|
||||
expect(host(1, "enableCommandHistory")).toBe(true);
|
||||
expect(host(2, "enableTerminal")).toBe(false);
|
||||
expect(host(2, "enableCommandHistory")).toBe(false);
|
||||
// All on is the plugin's default, so it needs no row.
|
||||
expect(host(3, "enableTerminal")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("changes nothing on a second run, even after the admin changed a value", async () => {
|
||||
coreSettings.set("terminal_session_timeout_minutes", "120");
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
enable_terminal: 0,
|
||||
enable_terminal_toolbar: 1,
|
||||
enable_command_history: 1,
|
||||
});
|
||||
await runSshTerminalSettingsMigration();
|
||||
|
||||
const changed = find(
|
||||
"ssh-terminal",
|
||||
"admin",
|
||||
null,
|
||||
"sessionTimeoutMinutes",
|
||||
);
|
||||
changed!.value = JSON.stringify(15);
|
||||
const rowsBefore = pluginRows.length;
|
||||
|
||||
const second = await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(second.moved).toEqual([]);
|
||||
expect(second.hostsMoved).toBe(0);
|
||||
expect(second.hostsSkipped).toBe(1);
|
||||
expect(pluginRows).toHaveLength(rowsBefore);
|
||||
expect(admin("sessionTimeoutMinutes")).toBe(15);
|
||||
});
|
||||
|
||||
it("does nothing until the plugin row exists", async () => {
|
||||
pluginInstalled = false;
|
||||
coreSettings.set("terminal_session_timeout_minutes", "120");
|
||||
const result = await runSshTerminalSettingsMigration();
|
||||
expect(result.moved).toEqual([]);
|
||||
expect(pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Moves the terminal's settings out of core and into the ssh-terminal
|
||||
* plugin's own settings.
|
||||
*
|
||||
* Admin keys from the core `settings` table: session persistence, command
|
||||
* history, touch input tuning and terminal image storage. The image storage
|
||||
* local directory was never sent to a browser, so it lands as a secret field,
|
||||
* which keeps it that way. The legacy rows are left in place for a release;
|
||||
* nothing reads them any more.
|
||||
*
|
||||
* Host columns from ssh_data: enable_terminal, enable_terminal_toolbar and
|
||||
* enable_command_history. All three default to on in the plugin, so only
|
||||
* hosts that turned one off need a row.
|
||||
*
|
||||
* Idempotent: an admin key already set in the plugin, or a host that already
|
||||
* has an enableTerminal row, is skipped, so this is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { getDb } from "../../database/db/index.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { encryptSystemSecret } from "../system-secret-crypto.js";
|
||||
|
||||
const PLUGIN_ID = "ssh-terminal";
|
||||
|
||||
type Parse = (raw: string) => unknown;
|
||||
|
||||
const asNumber: Parse = (raw) => {
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
const asBoolean: Parse = (raw) =>
|
||||
raw === "true" || raw === "1"
|
||||
? true
|
||||
: raw === "false" || raw === "0"
|
||||
? false
|
||||
: undefined;
|
||||
const asString: Parse = (raw) => raw;
|
||||
const asJson: Parse = (raw) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Legacy core key -> the admin field the plugin declares. */
|
||||
const ADMIN_MOVES: {
|
||||
legacyKey: string;
|
||||
field: string;
|
||||
parse: Parse;
|
||||
secret?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
legacyKey: "terminal_session_timeout_minutes",
|
||||
field: "sessionTimeoutMinutes",
|
||||
parse: asNumber,
|
||||
},
|
||||
{
|
||||
legacyKey: "terminal_session_persistence_enabled",
|
||||
field: "sessionPersistence",
|
||||
parse: asBoolean,
|
||||
},
|
||||
{
|
||||
legacyKey: "command_history_enabled",
|
||||
field: "commandHistoryEnabled",
|
||||
parse: asBoolean,
|
||||
},
|
||||
{ legacyKey: "touch_input_settings", field: "touchInput", parse: asJson },
|
||||
{
|
||||
legacyKey: "terminal_image_storage_mode",
|
||||
field: "imageStorageMode",
|
||||
parse: asString,
|
||||
},
|
||||
{
|
||||
legacyKey: "terminal_image_local_dir",
|
||||
field: "imageLocalDir",
|
||||
parse: asString,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
legacyKey: "terminal_image_host_path",
|
||||
field: "imageHostPath",
|
||||
parse: asString,
|
||||
},
|
||||
{ legacyKey: "terminal_image_ttl_ms", field: "imageTtlMs", parse: asNumber },
|
||||
{
|
||||
legacyKey: "terminal_image_max_count",
|
||||
field: "imageMaxCount",
|
||||
parse: asNumber,
|
||||
},
|
||||
{
|
||||
legacyKey: "terminal_image_max_storage_bytes",
|
||||
field: "imageMaxBytes",
|
||||
parse: asNumber,
|
||||
},
|
||||
];
|
||||
|
||||
interface LegacyTerminalHostRow {
|
||||
id: number;
|
||||
enable_terminal: number | boolean | null;
|
||||
enable_terminal_toolbar: number | boolean | null;
|
||||
enable_command_history: number | boolean | null;
|
||||
}
|
||||
|
||||
/** A column that was never set keeps the old default, on. */
|
||||
function isOn(value: number | boolean | null): boolean {
|
||||
return value === null || value === undefined ? true : !!value;
|
||||
}
|
||||
|
||||
export interface SshTerminalSettingsMigrationResult {
|
||||
moved: string[];
|
||||
skipped: string[];
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
}
|
||||
|
||||
export async function runSshTerminalSettingsMigration(): Promise<SshTerminalSettingsMigrationResult> {
|
||||
const result: SshTerminalSettingsMigrationResult = {
|
||||
moved: [],
|
||||
skipped: [],
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const move of ADMIN_MOVES) {
|
||||
const legacyValue = await settingsRepository.get(move.legacyKey);
|
||||
if (legacyValue === null || legacyValue === "") continue;
|
||||
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
move.field,
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped.push(move.legacyKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = move.parse(legacyValue);
|
||||
if (parsed === undefined) {
|
||||
result.skipped.push(move.legacyKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stored = move.secret
|
||||
? await encryptSystemSecret(String(parsed))
|
||||
: parsed;
|
||||
await pluginSettingsRepository.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
move.field,
|
||||
JSON.stringify(stored),
|
||||
!!move.secret,
|
||||
);
|
||||
result.moved.push(move.legacyKey);
|
||||
}
|
||||
|
||||
// Raw SQL, not the typed schema, so this keeps working once schema.ts
|
||||
// stops declaring these columns.
|
||||
const rows = await getDb().all<LegacyTerminalHostRow>(sql`
|
||||
SELECT id, enable_terminal, enable_terminal_toolbar, enable_command_history
|
||||
FROM ssh_data
|
||||
WHERE enable_terminal = false
|
||||
OR enable_terminal_toolbar = false
|
||||
OR enable_command_history = false
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
"enableTerminal",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const values: Record<string, boolean> = {
|
||||
enableTerminal: isOn(row.enable_terminal),
|
||||
enableTerminalToolbar: isOn(row.enable_terminal_toolbar),
|
||||
enableCommandHistory: isOn(row.enable_command_history),
|
||||
};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await pluginSettingsRepository.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
result.hostsMoved++;
|
||||
}
|
||||
|
||||
if (result.moved.length > 0 || result.hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved ${result.moved.length} terminal setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
|
||||
{
|
||||
operation: "ssh_terminal_settings_migration",
|
||||
moved: result.moved.join(", "),
|
||||
hostsMoved: result.hostsMoved,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. The terminal falls back
|
||||
// to its defaults, which is a visible, recoverable state.
|
||||
databaseLogger.warn("SSH terminal settings migration failed", {
|
||||
operation: "ssh_terminal_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
import { sshLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Announces an SSH login so anything watching can react to it.
|
||||
*
|
||||
* Was an HTTP POST to the metrics service on localhost:30005. Host metrics is
|
||||
* a plugin now and has no port, so this publishes on the plugin event bus and
|
||||
* the plugin subscribes. Fire and forget: a subscriber that throws must never
|
||||
* fail the login that caused it.
|
||||
*/
|
||||
export async function dispatchLoginEvent(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
sshUser: string,
|
||||
fromIp: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
pluginEvents.emit(TOPICS.hostLogin, { hostId, userId, sshUser, fromIp });
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to dispatch login event", {
|
||||
operation: "login_event_dispatch_error",
|
||||
hostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user