feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
This commit is contained in:
LukeGus committed 2026-09-21 09:55:37 -05:00
1 parent c94a453f1e
commit a2479c19b0
34 files changed
+729 -143

No files matched your search

+849
View File
@@ -0,0 +1,849 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import { StringDecoder } from "string_decoder";
import { Client as SSHClient } from "ssh2";
import { SSH_ALGORITHMS } from "../../../src/backend/utils/ssh-algorithms.js";
import { WebSocketServer, WebSocket } from "ws";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { createCurrentHostResolutionRepository } from "../../../src/backend/database/repositories/factory.js";
import { systemLogger } from "../../../src/backend/utils/logger.js";
import type { SSHHost } from "../../../src/types/index.js";
import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
type ContainerRuntime,
} from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
import {
hostAddressMismatch,
HOST_ADDRESS_MISMATCH_MESSAGE,
HOST_NOT_ON_THIS_SERVER_MESSAGE,
} from "../../../src/backend/hosts/host-identity.js";
import { extractWebSocketToken } from "../../../src/backend/utils/ws-auth.js";
import {
asObject,
asString,
MAX_WS_MESSAGE_BYTES,
parseWsMessage,
toTerminalDimension,
} from "../../../src/backend/utils/ws-message.js";
const sshLogger = systemLogger;
interface SSHSession {
client: SSHClient;
stream: import("ssh2").ClientChannel | null;
isConnected: boolean;
containerId?: string;
shell?: string;
hostId?: number;
containerRuntime?: ContainerRuntime;
}
const activeSessions = new Map<string, SSHSession>();
const wss = new WebSocketServer({
host: "127.0.0.1",
port: 30009,
maxPayload: MAX_WS_MESSAGE_BYTES,
});
wss.on("error", (error) => {
sshLogger.error("Docker console WebSocket server error", error, {
operation: "wss_error",
});
});
async function detectShell(
session: SSHSession,
containerId: string,
): Promise<string> {
const shells = ["bash", "sh", "ash"];
for (const shell of shells) {
try {
await new Promise<void>((resolve, reject) => {
session.client.exec(
containerCommand(
session.containerRuntime,
`exec ${containerId} which ${shell}`,
),
(err, stream) => {
if (err) return reject(err);
let output = "";
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.on("close", (code: number) => {
if (code === 0 && output.trim()) {
resolve();
} else {
reject(new Error(`Shell ${shell} not found`));
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
reject(streamErr);
});
},
);
});
return shell;
} catch {
continue;
}
}
return "sh";
}
async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>,
userId: string,
): Promise<SSHClient | null> {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
}
let currentClient: SSHClient | null = null;
const repository = createCurrentHostResolutionRepository();
for (let i = 0; i < jumpHosts.length; i++) {
const jumpHostId = jumpHosts[i].hostId;
const resolvedJumpHost = await repository.findHostById(jumpHostId, userId);
if (!resolvedJumpHost || resolvedJumpHost.userId !== userId) {
throw new Error(`Jump host ${jumpHostId} not found`);
}
const jumpHost = resolvedJumpHost as unknown as SSHHost;
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
try {
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
} catch (e) {
sshLogger.error("Failed to parse jump hosts", e, {
hostId: jumpHost.id,
});
jumpHost.jumpHosts = [];
}
}
let resolvedCredentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
} = {
password: jumpHost.password,
sshKey: jumpHost.key,
keyPassword: jumpHost.keyPassword,
authType: jumpHost.authType,
};
if (jumpHost.credentialId) {
const credential = await repository.findCredentialByIdForUser(
jumpHost.credentialId as number,
userId,
);
if (credential) {
resolvedCredentials = {
password: credential.password as string | undefined,
sshKey: (credential.key || credential.privateKey) as
string | undefined,
keyPassword: credential.keyPassword as string | undefined,
authType: credential.authType as string | undefined,
};
}
}
const client = new SSHClient();
const config: Record<string, unknown> = {
host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip,
port: jumpHost.port || 22,
username: jumpHost.username,
tryKeyboard: resolvedCredentials.authType !== "none",
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 120,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (
resolvedCredentials.authType === "password" &&
resolvedCredentials.password
) {
config.password = resolvedCredentials.password;
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey
) {
const cleanKey = resolvedCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedCredentials.keyPassword) {
config.passphrase = resolvedCredentials.keyPassword;
}
} else if (resolvedCredentials.authType === "agent") {
const result = await applyAgentAuth(
config,
jumpHost.terminalConfig as unknown as
Record<string, unknown> | undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
}
client.on(
"keyboard-interactive",
(
_name: string,
_instructions: string,
_lang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password as string;
}
return "";
});
finish(responses);
},
);
if (currentClient) {
await new Promise<void>((resolve, reject) => {
currentClient!.forwardOut(
"127.0.0.1",
0,
jumpHost.ip,
jumpHost.port || 22,
(err, stream) => {
if (err) return reject(err);
config.sock = stream;
resolve();
},
);
});
}
if (!config.sock) {
await resolveSshConnectConfigHost(config);
}
await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve());
client.on("error", reject);
client.connect(config);
});
currentClient = client;
}
return currentClient;
}
wss.on("connection", async (ws: WebSocket, req) => {
const token = extractWebSocketToken(req);
if (!token) {
ws.close(1008, "Authentication required");
return;
}
const authManagerInstance = AuthManager.getInstance();
let payload;
try {
payload = await authManagerInstance.verifyJWTToken(token);
} catch (error) {
sshLogger.warn("Docker console JWT verification failed", {
operation: "docker_console_auth_error",
error: getErrorMessage(error),
});
ws.close(1008, "Authentication required");
return;
}
if (!payload?.userId || payload.pendingTOTP) {
ws.close(1008, "Authentication required");
return;
}
const userId = payload.userId;
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
sshLogger.info("Docker console WebSocket connected", {
operation: "docker_console_connect",
sessionId,
userId,
});
let sshSession: SSHSession | null = null;
const wsPingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
}
}, 30000);
const cleanup = () => {
clearInterval(wsPingInterval);
if (!sshSession) return;
sshSession.stream?.end();
sshSession.client.end();
activeSessions.delete(sessionId);
sshSession = null;
};
ws.on("message", async (data) => {
try {
const message = parseWsMessage(data);
switch (message.type) {
case "connect": {
const connectData = asObject(message.data);
const hostConfig = asObject(
connectData.hostConfig,
) as unknown as SSHHost;
const containerId = asString(connectData.containerId);
const shell = asString(connectData.shell) || undefined;
const cols = toTerminalDimension(connectData.cols) || 80;
const rows = toTerminalDimension(connectData.rows) || 24;
const hostId = hostConfig?.id;
if (!hostId || !containerId) {
ws.send(
JSON.stringify({
type: "error",
message: "Host configuration and container ID are required",
}),
);
return;
}
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerId)) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid container ID",
}),
);
return;
}
const allowedShells = ["bash", "sh", "ash", "zsh"];
if (shell && !allowedShells.includes(shell)) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid shell",
}),
);
return;
}
if (!hostConfig.enableDocker) {
ws.send(
JSON.stringify({
type: "error",
message: "Docker is not enabled on this host",
}),
);
return;
}
try {
// Resolve host with credentials server-side
const { resolveHostById, resolveHostBySyncId } =
await import("../../../src/backend/hosts/host-resolver.js");
// syncId names the host on both sides of a sync pair; the numeric
// id only names it in the database the client is displaying.
const hostSyncId = hostConfig?.syncId;
const resolvedHost = hostSyncId
? await resolveHostBySyncId(hostSyncId, userId)
: await resolveHostById(hostId, userId);
if (!resolvedHost) {
ws.send(
JSON.stringify({
type: "error",
message: hostSyncId
? HOST_NOT_ON_THIS_SERVER_MESSAGE
: "Host not found",
}),
);
return;
}
// The connection below dials resolvedHost.ip outright, so if this
// server has a different machine under the id the client sent, the
// console opens on that machine's Docker daemon instead.
if (
!hostSyncId &&
hostAddressMismatch(hostConfig?.ip, resolvedHost.ip)
) {
sshLogger.error(
"Refusing Docker console: host id resolves to a different address here",
undefined,
{
operation: "docker_console_host_id_mismatch",
hostId,
userId,
clientIp: hostConfig?.ip,
resolvedIp: resolvedHost.ip,
},
);
ws.send(
JSON.stringify({
type: "error",
message: HOST_ADDRESS_MISMATCH_MESSAGE,
}),
);
return;
}
if (!resolvedHost.enableDocker) {
ws.send(
JSON.stringify({
type: "error",
message:
"Docker is not enabled for this host. Enable it in Host Settings.",
}),
);
return;
}
const client = new SSHClient();
const config: Record<string, unknown> = {
host: resolvedHost.ip?.replace(/^\[|\]$/g, "") || resolvedHost.ip,
port: resolvedHost.port || 22,
username: resolvedHost.username,
tryKeyboard: true,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 120,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
};
if (resolvedHost.authType === "password" && resolvedHost.password) {
config.password = resolvedHost.password;
} else if (resolvedHost.authType === "key" && resolvedHost.key) {
const cleanKey = resolvedHost.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedHost.keyPassword) {
config.passphrase = resolvedHost.keyPassword;
}
} else if (resolvedHost.authType === "agent") {
const result = await applyAgentAuth(
config,
resolvedHost.terminalConfig as unknown as
Record<string, unknown> | undefined,
);
if ("error" in result) {
ws.send(
JSON.stringify({
type: "error",
message: result.error,
}),
);
return;
}
}
if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) {
const jumpClient = await createJumpHostChain(
resolvedHost.jumpHosts,
userId,
);
if (jumpClient) {
const stream = await new Promise<import("ssh2").ClientChannel>(
(resolve, reject) => {
jumpClient.forwardOut(
"127.0.0.1",
0,
resolvedHost.ip,
resolvedHost.port || 22,
(err, stream) => {
if (err) return reject(err);
resolve(stream);
},
);
},
);
config.sock = stream;
}
}
if (!config.sock) {
await resolveSshConnectConfigHost(config);
}
await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve());
client.on("error", reject);
client.connect(config);
});
const { runtime: containerRuntime } = getContainerRuntimeConfig(
resolvedHost.dockerConfig,
);
sshSession = {
client,
stream: null,
isConnected: true,
containerId,
hostId: resolvedHost.id,
containerRuntime,
};
activeSessions.set(sessionId, sshSession);
let shellToUse = shell || "bash";
if (shell) {
try {
await new Promise<void>((resolve, reject) => {
client.exec(
containerCommand(
containerRuntime,
`exec ${containerId} which ${shell}`,
),
(err, stream) => {
if (err) return reject(err);
let output = "";
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.on("close", (code: number) => {
if (code === 0 && output.trim()) {
resolve();
} else {
reject(new Error(`Shell ${shell} not available`));
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
reject(streamErr);
});
},
);
});
} catch {
sshLogger.warn(
`Requested shell ${shell} not found, detecting available shell`,
{
operation: "shell_validation",
sessionId,
containerId,
requestedShell: shell,
},
);
shellToUse = await detectShell(sshSession, containerId);
}
} else {
shellToUse = await detectShell(sshSession, containerId);
}
sshSession.shell = shellToUse;
const execCommand = containerCommand(
containerRuntime,
`exec -it ${containerId} /bin/${shellToUse}`,
);
sshLogger.info("Attaching to Docker container", {
operation: "docker_attach",
sessionId,
userId,
hostId: resolvedHost.id,
containerId,
});
client.exec(
execCommand,
{
pty: {
term: "xterm-256color",
cols,
rows,
},
},
(err, stream) => {
if (err) {
sshLogger.error("Failed to create docker exec", err, {
operation: "docker_exec",
sessionId,
containerId,
});
ws.send(
JSON.stringify({
type: "error",
message: `Failed to start console: ${err.message}`,
}),
);
return;
}
sshSession!.stream = stream;
sshLogger.success("Docker container attached", {
operation: "docker_attach_success",
sessionId,
userId,
hostId: resolvedHost.id,
containerId,
});
// Buffers incomplete multi-byte UTF-8 sequences across chunk
// boundaries so box-drawing/special characters don't get
// corrupted when a character is split across TCP packets.
const decoder = new StringDecoder("utf-8");
stream.on("data", (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
const text = decoder.write(data);
if (!text) return;
ws.send(
JSON.stringify({
type: "output",
data: text,
}),
);
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
sshLogger.error("Docker console stream error", streamErr, {
operation: "docker_console_stream_error",
sessionId,
containerId,
});
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message: `Console error: ${streamErr.message}`,
}),
);
}
});
stream.on("close", () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Console session ended",
}),
);
}
if (sshSession) {
sshSession.client.end();
activeSessions.delete(sessionId);
}
});
ws.send(
JSON.stringify({
type: "connected",
data: {
shell: shellToUse,
requestedShell: shell,
shellChanged: shell && shell !== shellToUse,
},
}),
);
},
);
} catch (error) {
sshLogger.error("Failed to connect to container", error, {
operation: "console_connect",
sessionId,
containerId,
});
ws.send(
JSON.stringify({
type: "error",
message: getErrorMessage(
error,
"Failed to connect to container",
),
}),
);
}
break;
}
case "input": {
if (sshSession && sshSession.stream) {
const input = asString(message.data);
if (input) sshSession.stream.write(input);
}
break;
}
case "resize": {
if (sshSession && sshSession.stream) {
const dimensions = asObject(message.data);
const cols = toTerminalDimension(dimensions.cols);
const rows = toTerminalDimension(dimensions.rows);
if (cols && rows)
sshSession.stream.setWindow(rows, cols, rows, cols);
}
break;
}
case "disconnect": {
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
ws.send(
JSON.stringify({
type: "disconnected",
message: "Disconnected from container",
}),
);
}
break;
}
case "ping": {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" }));
}
break;
}
default:
sshLogger.warn("Unknown message type", {
operation: "ws_message",
type: message.type,
});
}
} catch (error) {
sshLogger.error("WebSocket message error", error, {
operation: "ws_message",
sessionId,
});
ws.send(
JSON.stringify({
type: "error",
message: getErrorMessage(error, "An error occurred"),
}),
);
}
});
ws.on("close", () => {
sshLogger.info("Docker console disconnected", {
operation: "docker_console_disconnect",
sessionId,
userId,
hostId: sshSession?.hostId,
containerId: sshSession?.containerId,
});
cleanup();
});
ws.on("error", (error) => {
sshLogger.error("WebSocket error", error, {
operation: "ws_error",
sessionId,
});
cleanup();
});
});
/**
* Closes every live console session and the WebSocket server. Called by the
* plugin's deactivate() as well as SIGTERM, since disabling the docker
* plugin has to free port 30009 the same way process shutdown does.
*/
export function closeConsoleServer(): Promise<void> {
activeSessions.forEach((session) => {
if (session.stream) {
session.stream.end();
}
session.client.end();
});
activeSessions.clear();
return new Promise((resolve) => wss.close(() => resolve()));
}
process.on("SIGTERM", () => {
void closeConsoleServer().then(() => process.exit(0));
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,51 @@
export type ContainerRuntime = "docker" | "podman";
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
return value === "podman" ? "podman" : "docker";
}
export function getContainerRuntimeConfig(raw: unknown): {
runtime: ContainerRuntime;
} {
if (!raw) {
return { runtime: "docker" };
}
let config: unknown = raw;
if (typeof raw === "string") {
try {
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
return { runtime: "docker" };
}
}
if (!config || typeof config !== "object") {
return { runtime: "docker" };
}
return {
runtime: normalizeContainerRuntime(
(config as Record<string, unknown>).runtime,
),
};
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
* Extend PATH with their common install locations before invoking the CLI.
*/
const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
export function containerCommand(
runtime: ContainerRuntime | undefined,
args: string,
): string {
return `PATH="${EXTRA_PATH_DIRS}:$PATH" ${normalizeContainerRuntime(runtime)} ${args}`;
}
export function getRuntimeLabel(runtime: ContainerRuntime): string {
return runtime === "podman" ? "Podman" : "Docker";
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Docker - first-party, in-process plugin.
*
* This plugin runs on the main thread rather than in a worker for the same
* reason ssh-terminal does (see src/backend/plugins/first-party.ts): its
* container-console WebSocket server holds long-lived ssh2.Client + PTY
* streams, its session-manager reuses a live ssh2.Client across many REST
* calls, and its SSH connect flow implements TOTP/Warpgate as a genuinely
* stateful multi-request handshake. None of that can cross the
* structured-clone postMessage boundary a worker plugin talks over.
*
* Unlike ssh-terminal, Docker's backend has no consumers outside itself, so
* the whole implementation moved here rather than staying in core with only
* a lifecycle wrapper. This file is what used to be
* src/backend/hosts/docker/index.ts, rewritten from a module-scope
* SIGINT/SIGTERM + listenOnServicePort() call into activate()/deactivate().
*
* The moved files (routes.ts, container-routes.ts, container-runtime.ts,
* session-manager.ts, console.ts) are real TypeScript, compiled by
* scripts/copy-bundled-plugins.cjs (see that script for why plugins/ needs
* its own compile step) rather than hand-written like ssh-terminal's .mjs
* wrapper. This file itself stays plain JS since it is a thin entry point
* with no types worth checking.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createServer } from "node:http";
/**
* Loads a module by its path relative to this file's own directory.
*
* The plugin ships beside dist/backend, so in a built server the compiled
* .js for the moved docker files (routes.js, session-manager.js, ...) sits
* right next to this .mjs, and core modules resolve via
* ../../../backend/backend/... from there (dist/plugins/docker/backend ->
* dist/backend/backend). Under vitest/dev nothing imports this plugin
* directly (src/backend/plugins/loader.ts loads in-process plugins with a
* plain dynamic import, no tsx loader involved), so the only layout that
* needs to resolve here is the built one.
*/
async function loadRelative(relativePath) {
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, `${relativePath}.js`);
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
}
let httpServer = null;
let consoleModule = null;
let authManagerInstance = null;
export async function activate(ctx) {
const [
express,
cookieParser,
{ createCorsMiddleware },
{ createCompressionMiddleware },
{ logger },
{ AuthManager },
{ registerDockerContainerRoutes },
sessionManager,
{ DOCKER_TIMESTAMP_RE, getRequestUserId, registerDockerSshRoutes },
] = await Promise.all([
import("express").then((m) => m.default),
import("cookie-parser").then((m) => m.default),
loadRelative("../../../backend/backend/utils/cors-config"),
loadRelative("../../../backend/backend/utils/compression-config"),
loadRelative("../../../backend/backend/utils/logger"),
loadRelative("../../../backend/backend/utils/auth-manager"),
loadRelative("container-routes"),
loadRelative("session-manager"),
loadRelative("routes"),
]);
const app = express();
app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
authManagerInstance = AuthManager.getInstance();
app.use(authManagerInstance.createAuthMiddleware());
app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
registerDockerSshRoutes(app);
registerDockerContainerRoutes(app, {
sshSessions: sessionManager.sshSessions,
pendingTOTPSessions: sessionManager.pendingTOTPSessions,
getRequestUserId,
executeDockerCommand: sessionManager.executeDockerCommand,
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
const port = 30007;
const server = createServer(app);
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
httpServer = server;
try {
await authManagerInstance.initialize();
} catch (err) {
logger.error("Failed to initialize Docker backend", err, {
operation: "startup",
});
}
// Importing console.js binds its WebSocket server on port 30009 as a
// module-scope side effect -- unchanged from before the move, see that
// file's own header.
consoleModule = await loadRelative("console");
ctx.log.info(`Docker plugin listening on ${port} (console on 30009)`);
}
export async function deactivate() {
if (consoleModule?.closeConsoleServer) {
await consoleModule.closeConsoleServer();
}
consoleModule = null;
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise((resolve) => server.close(() => resolve()));
}
authManagerInstance = null;
}
File diff suppressed because it is too large. Load diff
+174
View File
@@ -0,0 +1,174 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
import { Client as SSHClient } from "ssh2";
import { logger } from "../../../src/backend/utils/logger.js";
import type { ContainerRuntime } from "./container-runtime.js";
const sshLogger = logger;
export interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
hostId?: number;
userId?: string;
isWindows?: boolean;
containerRuntime?: ContainerRuntime;
}
export interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: Record<string, unknown>;
createdAt: number;
sessionId: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
userId?: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
totpAttempts: number;
isWarpgate?: boolean;
containerRuntime?: ContainerRuntime;
}
export const sshSessions: Record<string, SSHSession> = {};
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000;
setInterval(() => {
const now = Date.now();
Object.keys(pendingTOTPSessions).forEach((sessionId) => {
const session = pendingTOTPSessions[sessionId];
if (now - session.createdAt > 180000) {
try {
session.client.end();
} catch {
// expected
}
delete pendingTOTPSessions[sessionId];
}
});
}, 60000);
export function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.activeOperations > 0) {
sshLogger.warn(
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
scheduleSessionCleanup(sessionId);
return;
}
try {
session.client.end();
} catch {
// expected
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
}
export function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(() => {
cleanupSession(sessionId);
}, SESSION_IDLE_TIMEOUT);
}
}
export async function executeDockerCommand(
session: SSHSession,
command: string,
sessionId?: string,
userId?: string,
hostId?: number,
): Promise<string> {
const startTime = Date.now();
sshLogger.info("Executing Docker command", {
operation: "docker_command_exec",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
return new Promise((resolve, reject) => {
session.client.exec(command, (err, stream) => {
if (err) {
sshLogger.error("Docker command execution error", err, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
return reject(err);
}
let stdout = "";
let stderr = "";
stream.on("close", (code: number) => {
if (code !== 0) {
sshLogger.error("Docker command failed", undefined, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
exitCode: code,
stderr,
});
reject(new Error(stderr || `Command exited with code ${code}`));
} else {
sshLogger.success("Docker command completed", {
operation: "docker_command_success",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
duration: Date.now() - startTime,
});
resolve(stdout);
}
});
stream.on("data", (data: Buffer) => {
stdout += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
stream.on("error", (streamErr: Error) => {
sshLogger.error("Docker command stream error", streamErr, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
reject(streamErr);
});
});
});
}