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
@@ -0,0 +1,284 @@
|
||||
import dns from "dns/promises";
|
||||
import net from "net";
|
||||
import type { RawData } from "ws";
|
||||
import ssh2Pkg, {
|
||||
type GetStreamCallback,
|
||||
type IdentityCallback,
|
||||
type ParsedKey,
|
||||
type SignCallback,
|
||||
type SigningRequestOptions,
|
||||
} from "ssh2";
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
const { AgentProtocol, BaseAgent } = ssh2Pkg;
|
||||
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
/** The terminal's structured logging, on top of ctx.log. */
|
||||
export interface TerminalLogger {
|
||||
info: (message: string, meta?: Record<string, unknown>) => void;
|
||||
success: (message: string, meta?: Record<string, unknown>) => void;
|
||||
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||
error: (
|
||||
message: string,
|
||||
error?: unknown,
|
||||
meta?: Record<string, unknown>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function withMeta(message: string, meta?: Record<string, unknown>): string {
|
||||
if (!meta || Object.keys(meta).length === 0) return message;
|
||||
try {
|
||||
return `${message} ${JSON.stringify(meta)}`;
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTerminalLogger(log: PluginLogger): TerminalLogger {
|
||||
return {
|
||||
info: (message, meta) => log.info(withMeta(message, meta)),
|
||||
success: (message, meta) => log.info(withMeta(message, meta)),
|
||||
warn: (message, meta) => log.warn(withMeta(message, meta)),
|
||||
error: (message, error, meta) => {
|
||||
// Some call sites pass the meta object where the error goes.
|
||||
if (error && !(error instanceof Error) && typeof error === "object") {
|
||||
log.error(withMeta(message, error as Record<string, unknown>));
|
||||
return;
|
||||
}
|
||||
log.error(
|
||||
withMeta(message, meta),
|
||||
error instanceof Error
|
||||
? error
|
||||
: error
|
||||
? new Error(String(error))
|
||||
: undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Cap on a single decoded text frame. Legitimate control messages are tiny,
|
||||
// and terminal input is bounded by what a user can type or paste.
|
||||
export const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
|
||||
|
||||
export class WsMessageError extends Error {}
|
||||
|
||||
function rawByteLength(raw: RawData): number {
|
||||
if (Buffer.isBuffer(raw)) return raw.length;
|
||||
if (Array.isArray(raw))
|
||||
return raw.reduce((sum, part) => sum + part.length, 0);
|
||||
if (raw instanceof ArrayBuffer) return raw.byteLength;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Parses a frame into `{ type, data }`, throwing WsMessageError on anything else. */
|
||||
export function parseWsMessage(raw: RawData): {
|
||||
type: string;
|
||||
data: unknown;
|
||||
} {
|
||||
if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) {
|
||||
throw new WsMessageError("Message too large");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
throw new WsMessageError("Invalid JSON");
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new WsMessageError("Message must be a JSON object");
|
||||
}
|
||||
|
||||
const { type, data } = parsed as { type?: unknown; data?: unknown };
|
||||
if (typeof type !== "string") {
|
||||
throw new WsMessageError("Message type must be a string");
|
||||
}
|
||||
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
export function asObject(data: unknown): Record<string, unknown> {
|
||||
return data !== null && typeof data === "object" && !Array.isArray(data)
|
||||
? (data as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** A sane integer width or height, or 0 when the value is unusable. */
|
||||
export function toTerminalDimension(value: unknown): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
const rounded = Math.floor(n);
|
||||
if (rounded < 1) return 0;
|
||||
return Math.min(rounded, 10000);
|
||||
}
|
||||
|
||||
export function isWindowsSftpPath(path: string): boolean {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
return /^[A-Za-z]:\//.test(normalized) || /^\/[A-Za-z]:\//.test(normalized);
|
||||
}
|
||||
|
||||
export function sftpPathToLocalPath(sftpPath: string): string {
|
||||
const normalized = sftpPath.replace(/\\/g, "/");
|
||||
if (/^\/[A-Za-z]:\//.test(normalized)) {
|
||||
return normalized.slice(1).replace(/\//g, "\\");
|
||||
}
|
||||
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||
return normalized.replace(/\//g, "\\");
|
||||
}
|
||||
return sftpPath;
|
||||
}
|
||||
|
||||
export const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
|
||||
|
||||
type Lookup = typeof dns.lookup;
|
||||
type Sleep = (ms: number) => Promise<void>;
|
||||
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function isRetriableDnsError(error: unknown): boolean {
|
||||
const err = error as { code?: unknown; message?: unknown };
|
||||
return (
|
||||
err.code === "EAI_AGAIN" ||
|
||||
(typeof err.message === "string" && err.message.includes("EAI_AGAIN"))
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves a hostname up front, retrying transient container DNS failures. */
|
||||
export async function resolveHostForSshConnect(
|
||||
host: string,
|
||||
lookup: Lookup = dns.lookup,
|
||||
retryDelaysMs = SSH_DNS_RETRY_DELAYS_MS,
|
||||
wait: Sleep = sleep,
|
||||
): Promise<{ host: string; resolvedAddress?: string; attempts: number }> {
|
||||
const normalized = host.replace(/^\[|\]$/g, "").trim();
|
||||
if (!normalized || net.isIP(normalized) !== 0) {
|
||||
return { host: normalized || host, attempts: 0 };
|
||||
}
|
||||
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
const result = await lookup(normalized);
|
||||
return {
|
||||
host: result.address,
|
||||
resolvedAddress: result.address,
|
||||
attempts: attempt + 1,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isRetriableDnsError(error) || attempt >= retryDelaysMs.length) {
|
||||
throw error;
|
||||
}
|
||||
await wait(retryDelaysMs[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostAddress(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a numeric host id resolved to a different machine than the client
|
||||
* meant. Ids from the desktop app and a sync server drift apart, and the
|
||||
* resolved row supplies the credentials, so a mismatch must be refused.
|
||||
*/
|
||||
export function hostAddressMismatch(
|
||||
clientAddress: unknown,
|
||||
resolvedAddress: unknown,
|
||||
): boolean {
|
||||
const resolved = normalizeHostAddress(resolvedAddress);
|
||||
if (!resolved) return false;
|
||||
return resolved !== normalizeHostAddress(clientAddress);
|
||||
}
|
||||
|
||||
export const HOST_ADDRESS_MISMATCH_MESSAGE =
|
||||
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
|
||||
"The host ids on this device and on the sync server have drifted apart. " +
|
||||
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.';
|
||||
|
||||
export const HOST_NOT_ON_THIS_SERVER_MESSAGE =
|
||||
"This host does not exist on the sync server, so the connection was refused. " +
|
||||
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
|
||||
|
||||
export function resolveServerJumpHosts(
|
||||
clientJumpHosts: Array<{ hostId: number }> | undefined,
|
||||
serverJumpHosts: Array<{ hostId: number }> | undefined,
|
||||
hostSyncId?: string | null,
|
||||
): Array<{ hostId: number }> | undefined {
|
||||
if (hostSyncId) return serverJumpHosts ?? [];
|
||||
return clientJumpHosts?.length ? clientJumpHosts : serverJumpHosts;
|
||||
}
|
||||
|
||||
export function resolveServerHostId(
|
||||
clientHostId: number | null,
|
||||
resolvedHost: { id?: unknown } | null,
|
||||
): number | null {
|
||||
return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId;
|
||||
}
|
||||
|
||||
/** An in-memory SSH agent holding one key, for agent forwarding. */
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// The table comes from ctx.db.define, which the SDK hands back untyped, and
|
||||
// the drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type HistoryRepository = ReturnType<typeof createHistoryRepository>;
|
||||
|
||||
/** Command history storage. Writes are followed by persist(), reads are not. */
|
||||
export function createHistoryRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
return {
|
||||
async create(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
command: string,
|
||||
executedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.insert(table)
|
||||
.values({ userId, hostId, command, executedAt });
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async listUniqueCommandsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 500,
|
||||
): Promise<string[]> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select({
|
||||
command: table.command,
|
||||
maxExecutedAt: sql<number>`MAX(${table.executedAt})`,
|
||||
})
|
||||
.from(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
|
||||
.groupBy(table.command)
|
||||
.orderBy(desc(sql`MAX(${table.executedAt})`))
|
||||
.limit(limit);
|
||||
return rows.map((row: { command: string }) => row.command);
|
||||
},
|
||||
|
||||
async listCommandsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 200,
|
||||
): Promise<Array<{ command: string; executedAt: string }>> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select({ command: table.command, executedAt: table.executedAt })
|
||||
.from(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
|
||||
.orderBy(desc(table.executedAt))
|
||||
.limit(limit);
|
||||
return rows as Array<{ command: string; executedAt: string }>;
|
||||
},
|
||||
|
||||
async deleteCommandForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(
|
||||
and(
|
||||
eq(table.userId, userId),
|
||||
eq(table.hostId, hostId),
|
||||
eq(table.command, command),
|
||||
),
|
||||
);
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Registered as ctx.registry.provide("ssh-terminal.hostImportNormalizer", ...)
|
||||
* so a Termix-JSON host import keeps its terminal switches, which used to be
|
||||
* host columns and are this plugin's host settings now.
|
||||
*/
|
||||
export function hostImportNormalizer(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const keys = [
|
||||
"enableTerminal",
|
||||
"enableTerminalToolbar",
|
||||
"enableCommandHistory",
|
||||
] as const;
|
||||
if (keys.every((key) => raw[key] === undefined)) return null;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
if (raw[key] !== undefined) values[key] = raw[key] !== false;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
export interface SettingSource {
|
||||
get(key: string): Promise<string | null>;
|
||||
/** Told about a stored or environment value that was ignored. */
|
||||
warn?: (message: string) => void;
|
||||
}
|
||||
|
||||
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> {
|
||||
const warnInvalid = (key: string, source: string) =>
|
||||
settings.warn?.(
|
||||
`Ignoring invalid terminal image storage setting ${key} from ${source}`,
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
exceedsImageStorageLimit,
|
||||
isExpiredImage,
|
||||
isImageFilename,
|
||||
} from "./image-utils.js";
|
||||
import type { TerminalImageStorageSettings } from "./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",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 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,32 +1,177 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { createTerminalLogger } from "./helpers.js";
|
||||
import { hostImportNormalizer } from "./host-import.js";
|
||||
import { createHistoryRepository } from "./history-repository.js";
|
||||
import { registerTerminalRoutes } from "./routes.js";
|
||||
import {
|
||||
handleTerminalUpgrade,
|
||||
sessionManager,
|
||||
stopTerminalServer,
|
||||
} from "../../../../src/backend/hosts/terminal/index.js";
|
||||
DEFAULT_TIMEOUT_MINUTES,
|
||||
TerminalSessionManager,
|
||||
type TerminalSession,
|
||||
} from "./session-manager.js";
|
||||
import {
|
||||
SESSION_GUESTS_KEY,
|
||||
type LiveSessionInfo,
|
||||
type LiveSessionsV1,
|
||||
type RecordingsWriterV1,
|
||||
type SessionGuestsV1,
|
||||
type SessionSharingV1,
|
||||
type TerminalHistoryV1,
|
||||
type TmuxSessionsV1,
|
||||
} from "./services.js";
|
||||
import { ADMIN_KEYS } from "./settings.js";
|
||||
import { commandHistory } from "./tables.js";
|
||||
import { createTerminalSocket } from "./terminal-socket.js";
|
||||
|
||||
function toInfo(session: TerminalSession): LiveSessionInfo {
|
||||
return {
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
hostName: session.hostName,
|
||||
isConnected: session.isConnected,
|
||||
createdAt: session.createdAt,
|
||||
lastDetachedAt: session.lastDetachedAt,
|
||||
tabInstanceId:
|
||||
session.attachedTabInstanceId ?? session.tabInstanceId ?? null,
|
||||
tmuxSessionName: session.tmuxSessionName,
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A service another plugin may or may not provide. The handle always exists;
|
||||
* whether its provider is up is asked through the proxy's `in` check.
|
||||
*/
|
||||
function optionalService<T extends object>(
|
||||
ctx: PluginContext,
|
||||
service: string,
|
||||
probe: keyof T & string,
|
||||
): T | null {
|
||||
const handle = ctx.services.get<T>(service);
|
||||
return probe in handle ? handle : null;
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
// Public because the terminal authenticates each socket itself: a share-link
|
||||
// guest arrives with a share token instead of a session, so the check has to
|
||||
// happen where that token is understood.
|
||||
ctx.ws.upgrade(
|
||||
"/terminal",
|
||||
(request, socket, head) =>
|
||||
handleTerminalUpgrade(
|
||||
request as Parameters<typeof handleTerminalUpgrade>[0],
|
||||
socket as Parameters<typeof handleTerminalUpgrade>[1],
|
||||
head as Buffer,
|
||||
),
|
||||
{ public: true },
|
||||
);
|
||||
const log = createTerminalLogger(ctx.log);
|
||||
|
||||
// Published so collab and session-sharing can reach live sessions without
|
||||
// importing a plugin. Revoked automatically when this plugin is disabled.
|
||||
ctx.registry.provide("terminal.sessions", sessionManager ?? null);
|
||||
const table = await ctx.db.define(commandHistory);
|
||||
const history = createHistoryRepository(ctx.db, table);
|
||||
|
||||
// Read on every detach, so it is kept current rather than awaited there.
|
||||
let timeoutMinutes = DEFAULT_TIMEOUT_MINUTES;
|
||||
const applyTimeout = (value: unknown) => {
|
||||
const minutes = Number(value);
|
||||
timeoutMinutes =
|
||||
Number.isFinite(minutes) && minutes > 0
|
||||
? minutes
|
||||
: DEFAULT_TIMEOUT_MINUTES;
|
||||
};
|
||||
const refreshTimeout = async () =>
|
||||
applyTimeout(await ctx.settings.get(ADMIN_KEYS.sessionTimeoutMinutes));
|
||||
await refreshTimeout();
|
||||
ctx.settings.onChange(ADMIN_KEYS.sessionTimeoutMinutes, applyTimeout);
|
||||
|
||||
const sessionManager = new TerminalSessionManager({
|
||||
log,
|
||||
getTimeoutMinutes: () => timeoutMinutes,
|
||||
getRecordings: () =>
|
||||
optionalService<RecordingsWriterV1>(ctx, "recordings.writer", "open"),
|
||||
});
|
||||
ctx.disposables.add(() => sessionManager.destroyAll());
|
||||
|
||||
const socket = createTerminalSocket({
|
||||
ctx,
|
||||
log,
|
||||
sessionManager,
|
||||
getTmux: () =>
|
||||
optionalService<TmuxSessionsV1>(ctx, "tmux.sessions", "detect"),
|
||||
getSharing: () =>
|
||||
optionalService<SessionSharingV1>(
|
||||
ctx,
|
||||
"sessions.sharing",
|
||||
"authorizeJoin",
|
||||
),
|
||||
getGuests: () =>
|
||||
ctx.registry.consume<SessionGuestsV1>(SESSION_GUESTS_KEY) ?? null,
|
||||
});
|
||||
|
||||
// Public with optional auth: a share-link guest arrives with a share token
|
||||
// instead of a session, so that check happens where the token is understood.
|
||||
ctx.ws.route(
|
||||
"/terminal",
|
||||
(connection) => {
|
||||
// Also picks up a value the boot migration wrote after activation.
|
||||
void refreshTimeout().catch(() => {});
|
||||
return socket.handleConnection(connection);
|
||||
},
|
||||
{
|
||||
public: true,
|
||||
optionalAuth: true,
|
||||
},
|
||||
);
|
||||
ctx.disposables.add(() => socket.closeAll());
|
||||
|
||||
registerTerminalRoutes(ctx.http.router<Router>({ rawBody: true }), {
|
||||
ctx,
|
||||
log,
|
||||
sessionManager,
|
||||
history,
|
||||
});
|
||||
|
||||
const liveSessions: LiveSessionsV1 = {
|
||||
getSession: (sessionId) => {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
return session ? toInfo(session) : null;
|
||||
},
|
||||
listForUser: (userId) => {
|
||||
// A plugin caller only ever sees its actor's sessions; core calls this
|
||||
// outside any actor, for the user its own route authenticated.
|
||||
const actor = ctx.currentActor();
|
||||
if (actor && actor !== userId) return [];
|
||||
return sessionManager.getUserSessions(userId).map(toInfo);
|
||||
},
|
||||
ownerEndSession: (sessionId, reason) =>
|
||||
sessionManager.ownerEndSession(sessionId, reason),
|
||||
disconnectParticipants: (sessionId, shareId, options) =>
|
||||
sessionManager.disconnectShareParticipants(sessionId, shareId, options),
|
||||
setRoomShareControl: (sessionId, shareId, controllerUserId) =>
|
||||
sessionManager.setRoomShareControl(sessionId, shareId, controllerUserId),
|
||||
subscribe: (sessionId, onData) => {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session) return () => {};
|
||||
session.dataListeners.add(onData);
|
||||
return () => session.dataListeners.delete(onData);
|
||||
},
|
||||
write: (sessionId, data) => {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session?.sshStream || session.sshStream.destroyed) return false;
|
||||
sessionManager.bufferInput(sessionId, data);
|
||||
session.sshStream.write(data);
|
||||
return true;
|
||||
},
|
||||
idleTimeoutMinutes: () => timeoutMinutes,
|
||||
};
|
||||
ctx.services.provide("sessions.live", liveSessions);
|
||||
|
||||
const terminalHistory: TerminalHistoryV1 = {
|
||||
list: async (hostId, limit = 200) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return [];
|
||||
return history.listCommandsForHost(userId, hostId, limit);
|
||||
},
|
||||
};
|
||||
ctx.services.provide("terminal.history", terminalHistory);
|
||||
|
||||
ctx.registry.provide(
|
||||
"ssh-terminal.hostImportNormalizer",
|
||||
hostImportNormalizer,
|
||||
);
|
||||
|
||||
ctx.log.info("SSH terminal mounted at /plugin-ws/ssh-terminal/terminal");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
await stopTerminalServer();
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Keyboard-interactive answering over the terminal WebSocket.
|
||||
*
|
||||
* Core's classifier (ctx.ssh.classifyKeyboardInteractive) 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 type {
|
||||
PluginKeyboardInteractivePrompt,
|
||||
PluginSsh,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { TerminalLogger } from "./helpers.js";
|
||||
|
||||
type KeyboardInteractivePrompt = PluginKeyboardInteractivePrompt;
|
||||
|
||||
const PASSWORD_PATTERN = /password/i;
|
||||
|
||||
/** The answer at one index, the stored password for password prompts, else empty. */
|
||||
export function responsesWithAnswer(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
answerIndex: number,
|
||||
answer: string,
|
||||
password: string | null | undefined,
|
||||
): string[] {
|
||||
return prompts.map((p, index) => {
|
||||
if (index === answerIndex) return answer;
|
||||
if (PASSWORD_PATTERN.test(p.prompt) && password) return password;
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
interface ResolvedCredentials {
|
||||
username: string;
|
||||
password?: string;
|
||||
authType?: string;
|
||||
}
|
||||
|
||||
interface HostFlags {
|
||||
useWarpgate?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContext {
|
||||
ssh: Pick<PluginSsh, "classifyKeyboardInteractive">;
|
||||
log: TerminalLogger;
|
||||
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 {
|
||||
this.context.isKeyboardInteractive = true;
|
||||
|
||||
const host = {
|
||||
...(hostConfig ?? {}),
|
||||
id: this.context.hostId,
|
||||
ip: "",
|
||||
port: 22,
|
||||
username: resolvedCredentials.username,
|
||||
password: resolvedCredentials.password,
|
||||
authType: resolvedCredentials.authType,
|
||||
} as PluginSshHost;
|
||||
|
||||
const decision = this.context.ssh.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 as string | undefined,
|
||||
);
|
||||
return;
|
||||
case "input":
|
||||
this.handleInput(
|
||||
prompts,
|
||||
decision.promptIndex,
|
||||
decision.isPush,
|
||||
finish,
|
||||
host.password as string | undefined,
|
||||
);
|
||||
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;
|
||||
this.context.log.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) {
|
||||
this.context.log.warn("TOTP prompt asked again - invalid code", {
|
||||
operation: "ssh_keyboard_interactive_totp_retry",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.log.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");
|
||||
this.context.log.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;
|
||||
}
|
||||
this.context.log.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
type Router,
|
||||
} from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import multer from "multer";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { getErrorMessage, type TerminalLogger } from "./helpers.js";
|
||||
import type { HistoryRepository } from "./history-repository.js";
|
||||
import type { TerminalSessionManager } from "./session-manager.js";
|
||||
import {
|
||||
ADMIN_KEYS,
|
||||
HOST_KEYS,
|
||||
readClientSettings,
|
||||
readImageStorageSettings,
|
||||
} from "./settings.js";
|
||||
import {
|
||||
createConcurrencyLimiter,
|
||||
exceedsNormalizedImageSize,
|
||||
imageExtensionForFormat,
|
||||
} from "./images/image-utils.js";
|
||||
import {
|
||||
selectImageStorageMode,
|
||||
probeLocalImageVisibility,
|
||||
storeImageLocally,
|
||||
storeImageViaSftp,
|
||||
TerminalImageStorageError,
|
||||
type ImageSftpClient,
|
||||
} from "./images/image-storage.js";
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
export const SENSITIVE_COMMAND_PATTERNS = [
|
||||
/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,
|
||||
];
|
||||
|
||||
type SharpFactory = typeof import("sharp").default;
|
||||
|
||||
export interface TerminalRouteDeps {
|
||||
ctx: PluginContext;
|
||||
log: TerminalLogger;
|
||||
sessionManager: TerminalSessionManager;
|
||||
history: HistoryRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminal's HTTP routes, on a router core mounts at
|
||||
* /plugin-api/ssh-terminal/. It is created with rawBody so multer can read
|
||||
* the upload stream; JSON routes parse their own body.
|
||||
*/
|
||||
export function registerTerminalRoutes(
|
||||
router: Router,
|
||||
{ ctx, log, sessionManager, history }: TerminalRouteDeps,
|
||||
): void {
|
||||
const json = express.json({ limit: "2mb" });
|
||||
|
||||
// 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");
|
||||
|
||||
let sharpFactory: SharpFactory | null = null;
|
||||
let sharpLoadFailure: string | null = null;
|
||||
|
||||
// A static import of sharp takes the plugin 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) {
|
||||
log.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;
|
||||
}
|
||||
log.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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/image-upload:
|
||||
* post:
|
||||
* summary: Upload an image into a terminal session
|
||||
* description: Normalizes an uploaded or pasted image to PNG and stores it where the terminal's programs can read it, over SFTP on the connected host or in the backend's mapped local storage, depending on the image storage settings. Returns the path to hand to the program.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* image:
|
||||
* type: string
|
||||
* format: binary
|
||||
* instanceId:
|
||||
* type: string
|
||||
* source:
|
||||
* type: string
|
||||
* enum: [file, clipboard]
|
||||
* clientUploadTimestamp:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The stored image's path.
|
||||
* 400:
|
||||
* description: Missing or invalid image or session.
|
||||
* 409:
|
||||
* description: The terminal is not connected.
|
||||
* 413:
|
||||
* description: The normalized image is too large.
|
||||
* 502:
|
||||
* description: Writing to the remote host failed.
|
||||
* 503:
|
||||
* description: Image storage or processing is unavailable.
|
||||
* 507:
|
||||
* description: The image storage limit was reached.
|
||||
*/
|
||||
router.post(
|
||||
"/image-upload",
|
||||
handleImageUploadMiddleware,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
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();
|
||||
log.info("Terminal image upload received", {
|
||||
operation: "terminal_image_upload_received",
|
||||
requestId,
|
||||
sequence,
|
||||
source,
|
||||
clientUploadTimestamp,
|
||||
serverReceivedAt,
|
||||
bytes: req.file.size,
|
||||
});
|
||||
|
||||
const storageSettings = await readImageStorageSettings(ctx);
|
||||
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) {
|
||||
log.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 image = sharp(req.file.buffer, {
|
||||
failOn: "error",
|
||||
limitInputPixels: 40_000_000,
|
||||
});
|
||||
const { format } = await image.metadata();
|
||||
if (!imageExtensionForFormat(format)) {
|
||||
return res.status(400).json({
|
||||
error: "Unsupported image format",
|
||||
code: "IMAGE_FORMAT_UNSUPPORTED",
|
||||
});
|
||||
}
|
||||
normalizedImage = await image.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) {
|
||||
log.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;
|
||||
log.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,
|
||||
});
|
||||
}
|
||||
log.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
|
||||
* /plugin-api/ssh-terminal/image-storage/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:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* instanceId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Visibility test result.
|
||||
* 400:
|
||||
* description: Missing terminal session instanceId.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 500:
|
||||
* description: Failed to run the test.
|
||||
*/
|
||||
router.post("/image-storage/test", json, async (req, res) => {
|
||||
if (!(await ctx.rbac.has("admin.plugins.manage"))) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
const userId = ctx.currentActor() ?? "";
|
||||
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 readImageStorageSettings(ctx);
|
||||
const session = findTerminalSession(userId, instanceId);
|
||||
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) {
|
||||
log.error("Failed to test image storage visibility", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to test image storage visibility" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/client-settings:
|
||||
* get:
|
||||
* summary: Terminal settings every user's terminal needs
|
||||
* description: The admin-set session timeout and persistence flag, whether command history is on, and the touch input tuning. Readable by any signed-in user; changing them goes through the plugin's admin settings.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The settings.
|
||||
* 500:
|
||||
* description: Failed to load settings.
|
||||
*/
|
||||
router.get("/client-settings", async (_req, res) => {
|
||||
try {
|
||||
res.json(await readClientSettings(ctx));
|
||||
} catch (err) {
|
||||
log.error("Failed to load terminal client settings", err);
|
||||
res.status(500).json({ error: "Failed to load settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history:
|
||||
* post:
|
||||
* summary: Save command to history
|
||||
* description: Saves a command to the caller's history for a host. Commands that look like they carry a secret, and anything while command history is off globally or for the host, are acknowledged but not stored.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* command:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Command saved, or skipped on purpose.
|
||||
* 400:
|
||||
* description: Missing required parameters.
|
||||
* 500:
|
||||
* description: Failed to save command.
|
||||
*/
|
||||
router.post("/command-history", json, async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
const { hostId, command } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
|
||||
log.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 hostIdNum = parseInt(hostId, 10);
|
||||
const trimmedCommand = command.trim();
|
||||
const skipped = {
|
||||
id: 0,
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
command: trimmedCommand,
|
||||
executedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (SENSITIVE_COMMAND_PATTERNS.some((p) => p.test(trimmedCommand))) {
|
||||
return res.status(201).json(skipped);
|
||||
}
|
||||
if ((await ctx.settings.get(ADMIN_KEYS.commandHistoryEnabled)) === false) {
|
||||
return res.status(201).json(skipped);
|
||||
}
|
||||
if (
|
||||
(await ctx.settings.getHost(
|
||||
hostIdNum,
|
||||
HOST_KEYS.enableCommandHistory,
|
||||
)) === false
|
||||
) {
|
||||
return res.status(201).json(skipped);
|
||||
}
|
||||
|
||||
try {
|
||||
const executedAt = new Date().toISOString();
|
||||
await history.create(userId, hostIdNum, trimmedCommand, executedAt);
|
||||
res.status(201).json({
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
command: trimmedCommand,
|
||||
executedAt,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error("Failed to save command to history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to save command"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history/{hostId}:
|
||||
* get:
|
||||
* summary: Get command history
|
||||
* description: The caller's distinct commands on a host, most recently used first, for autocomplete.
|
||||
* 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", async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
const hostIdNum = parseInt(String(req.params.hostId), 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||
log.warn("Invalid command history fetch request", {
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid request parameters" });
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(await history.listUniqueCommandsForHost(userId, hostIdNum));
|
||||
} catch (err) {
|
||||
log.error("Failed to fetch command history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to fetch history"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history/{hostId}/recent:
|
||||
* get:
|
||||
* summary: Get recent command history
|
||||
* description: The caller's commands on a host, newest first, duplicates included. Needs hosts.view.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of commands.
|
||||
* 400:
|
||||
* description: Invalid host id.
|
||||
* 403:
|
||||
* description: Missing the hosts.view permission.
|
||||
* 500:
|
||||
* description: Failed to fetch command history.
|
||||
*/
|
||||
router.get("/command-history/:hostId/recent", async (req, res) => {
|
||||
if (!(await ctx.rbac.has("hosts.view"))) {
|
||||
return res.status(403).json({ error: "Insufficient permissions" });
|
||||
}
|
||||
const userId = ctx.currentActor();
|
||||
const hostIdNum = parseInt(String(req.params.hostId), 10);
|
||||
if (!isNonEmptyString(userId) || !hostIdNum) {
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
}
|
||||
try {
|
||||
const rows = await history.listCommandsForHost(userId, hostIdNum);
|
||||
res.json(rows.map((row) => row.command));
|
||||
} catch (err) {
|
||||
log.error("Failed to fetch command history from database", err, {
|
||||
operation: "command_history_fetch",
|
||||
hostId: hostIdNum,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch command history" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history/delete:
|
||||
* post:
|
||||
* summary: Delete a specific command from history
|
||||
* description: Deletes one command from the caller's history for 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", json, async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
const { hostId, command } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
|
||||
log.warn("Invalid command delete request", {
|
||||
operation: "command_history_delete",
|
||||
userId,
|
||||
hasHostId: !!hostId,
|
||||
hasCommand: !!command,
|
||||
});
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
try {
|
||||
await history.deleteCommandForHost(
|
||||
userId,
|
||||
parseInt(hostId, 10),
|
||||
command.trim(),
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.error("Failed to delete command from history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to delete command"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history/{hostId}:
|
||||
* delete:
|
||||
* summary: Clear command history
|
||||
* description: Clears the caller's whole command history for a 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", async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
const hostIdNum = parseInt(String(req.params.hostId), 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||
log.warn("Invalid command history clear request");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
await history.deleteByUserAndHost(userId, hostIdNum);
|
||||
log.info("Terminal history cleared", {
|
||||
operation: "terminal_history_clear",
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.error("Failed to clear command history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to clear history"),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* The services this plugin provides and consumes.
|
||||
*
|
||||
* Provided: "sessions.live" (live SSH sessions, for session sharing, collab
|
||||
* and recording) and "terminal.history" (command history, for the AI
|
||||
* assistant). Consumed, all optional: "tmux.sessions" (B10),
|
||||
* "sessions.sharing" (B12) and "recordings.writer" (B13). The terminal keeps
|
||||
* working without any of them: no tmux attach, no shared joins, no recording.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface LiveSessionsV1 {
|
||||
getSession: (sessionId: string) => LiveSessionInfo | null;
|
||||
/** The caller's own sessions. A plugin caller only ever gets its actor's. */
|
||||
listForUser: (userId: string) => LiveSessionInfo[];
|
||||
/** Tells every guest the owner ended it, then closes the session. */
|
||||
ownerEndSession: (sessionId: string, reason: string) => void;
|
||||
/** Removes the participants that joined through one share. */
|
||||
disconnectParticipants: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { reason: string; userId?: string | null },
|
||||
) => number;
|
||||
/** Hands input control of a room share to one member, or back to the owner. */
|
||||
setRoomShareControl: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
) => void;
|
||||
/** Output as it arrives. Returns the unsubscribe. */
|
||||
subscribe: (sessionId: string, onData: (data: string) => void) => () => void;
|
||||
/** Types into the session. False when it is not live. */
|
||||
write: (sessionId: string, data: string) => boolean;
|
||||
/** How long a detached session is kept, in minutes. */
|
||||
idleTimeoutMinutes: () => number;
|
||||
}
|
||||
|
||||
export interface CommandHistoryEntry {
|
||||
command: string;
|
||||
executedAt: string;
|
||||
}
|
||||
|
||||
export interface TerminalHistoryV1 {
|
||||
/** The acting user's most recent commands on one host, newest first. */
|
||||
list: (hostId: number, limit?: number) => Promise<CommandHistoryEntry[]>;
|
||||
}
|
||||
|
||||
export interface TmuxDetection {
|
||||
available: boolean;
|
||||
sessions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the terminal asks of the tmux plugin. Clients are ssh2 objects. Every
|
||||
* method is async to a consumer: a service call goes through the permission
|
||||
* check and the audit line first.
|
||||
*/
|
||||
export interface TmuxSessionsV1 {
|
||||
detect: (client: unknown) => Promise<TmuxDetection>;
|
||||
/** Writes the attach (or new-session) command into a shell stream. */
|
||||
attachOrCreate: (
|
||||
stream: unknown,
|
||||
name?: string,
|
||||
newName?: string,
|
||||
) => Promise<void>;
|
||||
/** Waits for a new session to exist and returns its confirmed name. */
|
||||
waitForSession: (client: unknown, name: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface SharedSessionRef {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
}
|
||||
|
||||
/** For signed-in members; every call runs as the acting user. */
|
||||
export interface SessionSharingV1 {
|
||||
/** A user share targeting the actor, or a room stage they are a member of. */
|
||||
authorizeJoin: (
|
||||
shareId: string,
|
||||
) => Promise<{ share: SharedSessionRef; displayName: string } | null>;
|
||||
recordJoin: (shareId: string) => Promise<void>;
|
||||
/** Joins the socket to a collab room's event fan-out. False when not a member. */
|
||||
subscribeRoom: (roomId: string, socket: unknown) => Promise<boolean>;
|
||||
unsubscribeRoom: (socket: unknown, roomId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest joins through a share link or a room guest link. A guest has no user,
|
||||
* so the per-call RBAC check a service runs cannot apply: the token is the
|
||||
* authority. Published on ctx.registry as SESSION_GUESTS_KEY instead.
|
||||
*/
|
||||
export interface SessionGuestsV1 {
|
||||
resolve: (request: {
|
||||
shareToken?: string;
|
||||
roomGuestToken?: string;
|
||||
clientIp: string;
|
||||
}) => Promise<
|
||||
{ ok: true; share: SharedSessionRef } | { ok: false; reason: string }
|
||||
>;
|
||||
recordJoin: (shareId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SESSION_GUESTS_KEY = "sessions.sharing.guests";
|
||||
|
||||
export interface RecordingSink {
|
||||
/** Appends one batch; the first batch starts with the asciicast header. */
|
||||
append: (chunk: string) => Promise<void>;
|
||||
/** Writes or updates the recording row. */
|
||||
persist: (summary: {
|
||||
endedAt: number;
|
||||
durationSeconds: number;
|
||||
terminatedByOwner: boolean;
|
||||
terminationReason: string | null;
|
||||
}) => Promise<void>;
|
||||
/** Nothing was recorded; drop whatever was set up. */
|
||||
discard: () => void;
|
||||
}
|
||||
|
||||
export interface RecordingsWriterV1 {
|
||||
/** Null when recording is off for this user or host. */
|
||||
open: (meta: {
|
||||
sessionId: string;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
protocol: "ssh";
|
||||
format: "asciicast";
|
||||
startedAt: number;
|
||||
}) => Promise<RecordingSink | null>;
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { type Client, type ClientChannel } from "ssh2";
|
||||
import { WebSocket } from "ws";
|
||||
import type { TerminalLogger } from "./helpers.js";
|
||||
import type { RecordingSink, RecordingsWriterV1 } from "./services.js";
|
||||
|
||||
const MAX_BUFFER_BYTES = 512 * 1024;
|
||||
export 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;
|
||||
/** Output listeners from the sessions.live service. */
|
||||
dataListeners: Set<(data: string) => void>;
|
||||
/** Resolves once the recordings service answered; null when off. */
|
||||
recordingSink: Promise<RecordingSink | null> | null;
|
||||
recordingHeader: string | null;
|
||||
recordingBytes: number;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface SessionManagerDeps {
|
||||
log: TerminalLogger;
|
||||
/** Minutes a detached session is kept; read on every detach. */
|
||||
getTimeoutMinutes: () => number;
|
||||
/** The recordings.writer service, when a plugin provides it. */
|
||||
getRecordings: () => RecordingsWriterV1 | null;
|
||||
}
|
||||
|
||||
export class TerminalSessionManager {
|
||||
private sessions = new Map<string, TerminalSession>();
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private readonly log: TerminalLogger;
|
||||
|
||||
constructor(private readonly deps: SessionManagerDeps) {
|
||||
this.log = deps.log;
|
||||
this.healthCheckTimer = setInterval(
|
||||
() => this.healthCheck(),
|
||||
HEALTH_CHECK_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
this.log.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;
|
||||
}
|
||||
this.log.warn("Tab instance already has session, destroying old", {
|
||||
operation: "session_tab_duplicate_cleanup",
|
||||
existingSessionId: existing.id,
|
||||
tabInstanceId,
|
||||
});
|
||||
this.destroySession(existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
let recordingSink: Promise<RecordingSink | null> | null = null;
|
||||
let recordingHeader: string | null = null;
|
||||
const recordings = sessionLoggingEnabled ? this.deps.getRecordings() : null;
|
||||
if (recordings) {
|
||||
// Events recorded before this resolves wait in pendingRecordingData.
|
||||
recordingSink = Promise.resolve()
|
||||
.then(() =>
|
||||
recordings.open({
|
||||
sessionId: id,
|
||||
hostId,
|
||||
userId,
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
startedAt: now,
|
||||
}),
|
||||
)
|
||||
.catch((err) => {
|
||||
this.log.warn("Could not start a session recording", {
|
||||
operation: "session_recording_open_error",
|
||||
sessionId: id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
if (recordingSink) {
|
||||
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,
|
||||
dataListeners: new Set(),
|
||||
recordingSink,
|
||||
recordingHeader,
|
||||
recordingBytes: 0,
|
||||
recordingWriteChain: Promise.resolve(),
|
||||
recordingPersistChain: Promise.resolve(),
|
||||
pendingRecordingData: "",
|
||||
recordingFlushTimer: null,
|
||||
tmuxSessionName: null,
|
||||
sessionLoggingEnabled: !!recordingSink,
|
||||
sessionStartedAt: now,
|
||||
lastPersistedBytes: 0,
|
||||
terminatedByOwner: false,
|
||||
terminationReason: null,
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
|
||||
this.log.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) {
|
||||
this.log.warn("Session not found for attachment", {
|
||||
operation: "session_attach_not_found",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (session.userId !== userId) {
|
||||
this.log.warn("Session userId mismatch", {
|
||||
operation: "session_attach_user_mismatch",
|
||||
sessionId,
|
||||
expectedUserId: session.userId,
|
||||
providedUserId: userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!session.isConnected) {
|
||||
this.log.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
|
||||
) {
|
||||
this.log.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
|
||||
) {
|
||||
this.log.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 = randomUUID();
|
||||
session.participants.set(participantId, {
|
||||
ws,
|
||||
userId,
|
||||
permissionLevel: "read-write",
|
||||
isOwner: true,
|
||||
tabInstanceId,
|
||||
});
|
||||
session.attachedTabInstanceId = tabInstanceId;
|
||||
session.lastDetachedAt = null;
|
||||
this.broadcastParticipants(sessionId);
|
||||
|
||||
this.log.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 = 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);
|
||||
|
||||
this.log.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);
|
||||
this.log.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;
|
||||
|
||||
this.log.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(() => {
|
||||
this.log.info("Session idle timeout expired", {
|
||||
operation: "session_idle_timeout",
|
||||
sessionId,
|
||||
userId: session.userId,
|
||||
});
|
||||
this.destroySession(sessionId);
|
||||
}, timeoutMs);
|
||||
|
||||
this.log.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.recordingSink && session.recordingBytes === 0) {
|
||||
void session.recordingSink.then((sink) => sink?.discard());
|
||||
}
|
||||
session.dataListeners.clear();
|
||||
|
||||
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);
|
||||
|
||||
this.log.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) => {
|
||||
this.log.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.recordingSink) return;
|
||||
await session.recordingWriteChain;
|
||||
const sink = await session.recordingSink;
|
||||
if (!sink) return;
|
||||
const endedAt = Date.now();
|
||||
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
|
||||
|
||||
try {
|
||||
await sink.persist({
|
||||
endedAt,
|
||||
durationSeconds: duration,
|
||||
terminatedByOwner: session.terminatedByOwner,
|
||||
terminationReason: session.terminationReason,
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.warn("Failed to insert session recording row", {
|
||||
operation: "session_recording_insert_error",
|
||||
sessionId: session.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
this.log.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;
|
||||
}
|
||||
|
||||
for (const listener of session.dataListeners) {
|
||||
try {
|
||||
listener(data);
|
||||
} catch {
|
||||
// A listener that throws must not break the session.
|
||||
}
|
||||
}
|
||||
|
||||
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.recordingSink || !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 one write, chained so they land
|
||||
* in order. Never one write per chunk: that starved the libuv threadpool
|
||||
* (issue #1049).
|
||||
*/
|
||||
private flushRecording(session: TerminalSession): void {
|
||||
const pendingSink = session.recordingSink;
|
||||
if (!pendingSink || !session.pendingRecordingData) return;
|
||||
const chunk = session.pendingRecordingData;
|
||||
session.pendingRecordingData = "";
|
||||
const firstWrite = session.recordingBytes === Buffer.byteLength(chunk);
|
||||
|
||||
session.recordingWriteChain = session.recordingWriteChain
|
||||
.then(async () => {
|
||||
const sink = await pendingSink;
|
||||
if (!sink) return;
|
||||
await sink.append(
|
||||
firstWrite ? `${session.recordingHeader}${chunk}` : chunk,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.log.warn("Failed to write session recording", {
|
||||
operation: "session_recording_write_error",
|
||||
sessionId: session.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
const minutes = this.deps.getTimeoutMinutes();
|
||||
return (
|
||||
(Number.isFinite(minutes) && minutes > 0
|
||||
? minutes
|
||||
: 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) {
|
||||
this.log.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
normalizeTouchInputSettings,
|
||||
type TouchInputSettings,
|
||||
} from "../shared/touch-input-settings.js";
|
||||
import {
|
||||
resolveTerminalImageStorageSettings,
|
||||
TERMINAL_IMAGE_STORAGE_KEYS,
|
||||
type TerminalImageStorageSettings,
|
||||
} from "./images/image-storage-settings.js";
|
||||
import { DEFAULT_TIMEOUT_MINUTES } from "./session-manager.js";
|
||||
|
||||
/**
|
||||
* Admin setting keys, as declared in manifest.json. The image storage fields
|
||||
* have no default on purpose: an unset field falls through to the old
|
||||
* TERMIX_IMAGE_* environment variables, the way the settings table did.
|
||||
*/
|
||||
export const ADMIN_KEYS = {
|
||||
sessionTimeoutMinutes: "sessionTimeoutMinutes",
|
||||
sessionPersistence: "sessionPersistence",
|
||||
commandHistoryEnabled: "commandHistoryEnabled",
|
||||
touchInput: "touchInput",
|
||||
imageStorageMode: "imageStorageMode",
|
||||
imageLocalDir: "imageLocalDir",
|
||||
imageHostPath: "imageHostPath",
|
||||
imageTtlMs: "imageTtlMs",
|
||||
imageMaxCount: "imageMaxCount",
|
||||
imageMaxBytes: "imageMaxBytes",
|
||||
} as const;
|
||||
|
||||
export const HOST_KEYS = {
|
||||
enableTerminal: "enableTerminal",
|
||||
enableTerminalToolbar: "enableTerminalToolbar",
|
||||
enableCommandHistory: "enableCommandHistory",
|
||||
} as const;
|
||||
|
||||
const IMAGE_FIELD_BY_LEGACY_KEY: Record<string, string> = {
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: ADMIN_KEYS.imageStorageMode,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: ADMIN_KEYS.imageLocalDir,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.hostPath]: ADMIN_KEYS.imageHostPath,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: ADMIN_KEYS.imageTtlMs,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.maxCount]: ADMIN_KEYS.imageMaxCount,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.maxBytes]: ADMIN_KEYS.imageMaxBytes,
|
||||
};
|
||||
|
||||
export async function readImageStorageSettings(
|
||||
ctx: PluginContext,
|
||||
): Promise<TerminalImageStorageSettings> {
|
||||
return resolveTerminalImageStorageSettings({
|
||||
get: async (legacyKey) => {
|
||||
const field = IMAGE_FIELD_BY_LEGACY_KEY[legacyKey];
|
||||
if (!field) return null;
|
||||
const value = await ctx.settings.get(field);
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
return String(value);
|
||||
},
|
||||
warn: (message) => ctx.log.warn(message),
|
||||
});
|
||||
}
|
||||
|
||||
export interface ClientSettings {
|
||||
sessionTimeoutMinutes: number;
|
||||
sessionPersistence: boolean;
|
||||
commandHistoryEnabled: boolean;
|
||||
touchInput: TouchInputSettings;
|
||||
}
|
||||
|
||||
/** What every signed-in user's terminal needs, whether or not they are an admin. */
|
||||
export async function readClientSettings(
|
||||
ctx: PluginContext,
|
||||
): Promise<ClientSettings> {
|
||||
const all = await ctx.settings.getAll("admin");
|
||||
const timeout = Number(all[ADMIN_KEYS.sessionTimeoutMinutes]);
|
||||
let touch: unknown = all[ADMIN_KEYS.touchInput];
|
||||
if (typeof touch === "string") {
|
||||
try {
|
||||
touch = JSON.parse(touch);
|
||||
} catch {
|
||||
touch = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sessionTimeoutMinutes:
|
||||
Number.isFinite(timeout) && timeout > 0
|
||||
? timeout
|
||||
: DEFAULT_TIMEOUT_MINUTES,
|
||||
sessionPersistence: all[ADMIN_KEYS.sessionPersistence] !== false,
|
||||
commandHistoryEnabled: all[ADMIN_KEYS.commandHistoryEnabled] !== false,
|
||||
touchInput: normalizeTouchInputSettings(touch),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* Commands typed into terminal sessions, per user and host.
|
||||
*
|
||||
* Adopted from core's command_history, so the column and index names are the
|
||||
* legacy ones: the rename carries the existing rows and index across.
|
||||
*/
|
||||
export const commandHistory = adoptLegacyTable(
|
||||
"command_history",
|
||||
defineTable(
|
||||
"command_history",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
command: text().notNull(),
|
||||
executedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_command_history_user_host",
|
||||
columns: ["userId", "hostId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [commandHistory];
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user