Files
Termix/src/backend/ssh/docker.ts
2025-12-31 17:11:19 -06:00

1905 lines
50 KiB
TypeScript

import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import type { ClientChannel } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshData, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { logger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import { createSocks5Connection } from "../utils/socks5-helper.js";
import type { AuthenticatedRequest, SSHHost } from "../../types/index.js";
const dockerLogger = logger;
interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
hostId?: number;
}
interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: any;
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;
}
const sshSessions: Record<string, SSHSession> = {};
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 {}
delete pendingTOTPSessions[sessionId];
}
});
}, 60000);
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.activeOperations > 0) {
dockerLogger.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 (error) {}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
}
function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(() => {
cleanupSession(sessionId);
}, SESSION_IDLE_TIMEOUT);
}
}
async function resolveJumpHost(
hostId: number,
userId: string,
): Promise<any | null> {
try {
const hosts = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
"ssh_data",
userId,
);
if (hosts.length === 0) {
return null;
}
const host = hosts[0];
if (host.credentialId) {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (credentials.length > 0) {
const credential = credentials[0];
return {
...host,
password: credential.password,
key:
credential.private_key || credential.privateKey || credential.key,
keyPassword: credential.key_password || credential.keyPassword,
keyType: credential.key_type || credential.keyType,
authType: credential.auth_type || credential.authType,
};
}
}
return host;
} catch (error) {
dockerLogger.error("Failed to resolve jump host", error, {
operation: "resolve_jump_host",
hostId,
userId,
});
return null;
}
}
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 clients: SSHClient[] = [];
try {
const jumpHostConfigs = await Promise.all(
jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)),
);
for (let i = 0; i < jumpHostConfigs.length; i++) {
if (!jumpHostConfigs[i]) {
dockerLogger.error(`Jump host ${i + 1} not found`, undefined, {
operation: "jump_host_chain",
hostId: jumpHosts[i].hostId,
});
clients.forEach((c) => c.end());
return null;
}
}
for (let i = 0; i < jumpHostConfigs.length; i++) {
const jumpHostConfig = jumpHostConfigs[i];
const jumpClient = new SSHClient();
clients.push(jumpClient);
const connected = await new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
resolve(false);
}, 30000);
jumpClient.on("ready", () => {
clearTimeout(timeout);
resolve(true);
});
jumpClient.on("error", (err) => {
clearTimeout(timeout);
dockerLogger.error(`Jump host ${i + 1} connection failed`, err, {
operation: "jump_host_connect",
hostId: jumpHostConfig.id,
ip: jumpHostConfig.ip,
});
resolve(false);
});
const connectConfig: any = {
host: jumpHostConfig.ip,
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: true,
readyTimeout: 30000,
};
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
connectConfig.password = jumpHostConfig.password;
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
const cleanKey = jumpHostConfig.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
if (jumpHostConfig.keyPassword) {
connectConfig.passphrase = jumpHostConfig.keyPassword;
}
}
if (currentClient) {
currentClient.forwardOut(
"127.0.0.1",
0,
jumpHostConfig.ip,
jumpHostConfig.port || 22,
(err, stream) => {
if (err) {
clearTimeout(timeout);
resolve(false);
return;
}
connectConfig.sock = stream;
jumpClient.connect(connectConfig);
},
);
} else {
jumpClient.connect(connectConfig);
}
});
if (!connected) {
clients.forEach((c) => c.end());
return null;
}
currentClient = jumpClient;
}
return currentClient;
} catch (error) {
dockerLogger.error("Failed to create jump host chain", error, {
operation: "jump_host_chain",
});
clients.forEach((c) => c.end());
return null;
}
}
async function executeDockerCommand(
session: SSHSession,
command: string,
): Promise<string> {
return new Promise((resolve, reject) => {
session.client.exec(command, (err, stream) => {
if (err) {
dockerLogger.error("Docker command execution error", err, {
operation: "execute_docker_command",
command,
});
return reject(err);
}
let stdout = "";
let stderr = "";
stream.on("close", (code: number) => {
if (code !== 0) {
dockerLogger.error("Docker command failed", undefined, {
operation: "execute_docker_command",
command,
exitCode: code,
stderr,
});
reject(new Error(stderr || `Command exited with code ${code}`));
} else {
resolve(stdout);
}
});
stream.on("data", (data: Buffer) => {
stdout += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
stream.on("error", (streamErr: Error) => {
dockerLogger.error("Docker command stream error", streamErr, {
operation: "execute_docker_command",
command,
});
reject(streamErr);
});
});
});
}
const app = express();
app.use(
cors({
origin: (origin, callback) => {
if (!origin) {
return callback(null, true);
}
if (origin.startsWith("https://")) {
return callback(null, true);
}
if (origin.startsWith("http://")) {
return callback(null, true);
}
const allowedOrigins = [
"http://localhost:5173",
"http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
];
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error("Not allowed by CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"User-Agent",
"X-Electron-App",
],
}),
);
app.use(cookieParser());
app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true }));
const authManager = AuthManager.getInstance();
app.use(authManager.createAuthMiddleware());
// POST /docker/ssh/connect - Establish SSH session
app.post("/docker/ssh/connect", async (req, res) => {
const {
sessionId,
hostId,
userProvidedPassword,
userProvidedSshKey,
userProvidedKeyPassword,
forceKeyboardInteractive,
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain,
} = req.body;
const userId = (req as any).userId;
if (!userId) {
dockerLogger.error(
"Docker SSH connection rejected: no authenticated user",
{
operation: "docker_connect_auth",
sessionId,
},
);
return res.status(401).json({ error: "Authentication required" });
}
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!sessionId || !hostId) {
dockerLogger.warn("Missing Docker SSH connection parameters", {
operation: "docker_connect",
sessionId,
hasHostId: !!hostId,
});
return res.status(400).json({ error: "Missing sessionId or hostId" });
}
try {
const hosts = await SimpleDBOps.select(
getDb().select().from(sshData).where(eq(sshData.id, hostId)),
"ssh_data",
userId,
);
if (hosts.length === 0) {
return res.status(404).json({ error: "Host not found" });
}
const host = hosts[0] as unknown as SSHHost;
if (host.userId !== userId) {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
"execute",
);
if (!accessInfo.hasAccess) {
dockerLogger.warn("User does not have access to host", {
operation: "docker_connect",
hostId,
userId,
});
return res.status(403).json({ error: "Access denied" });
}
}
if (typeof host.jumpHosts === "string" && host.jumpHosts) {
try {
host.jumpHosts = JSON.parse(host.jumpHosts);
} catch (e) {
dockerLogger.error("Failed to parse jump hosts", e, {
hostId: host.id,
});
host.jumpHosts = [];
}
}
if (!host.enableDocker) {
dockerLogger.warn("Docker not enabled for host", {
operation: "docker_connect",
hostId,
userId,
});
return res.status(403).json({
error:
"Docker is not enabled for this host. Enable it in Host Settings.",
code: "DOCKER_DISABLED",
});
}
if (sshSessions[sessionId]) {
cleanupSession(sessionId);
}
if (pendingTOTPSessions[sessionId]) {
try {
pendingTOTPSessions[sessionId].client.end();
} catch {}
delete pendingTOTPSessions[sessionId];
}
let resolvedCredentials: any = {
password: host.password,
sshKey: host.key,
keyPassword: host.keyPassword,
authType: host.authType,
};
if (userProvidedPassword) {
resolvedCredentials.password = userProvidedPassword;
}
if (userProvidedSshKey) {
resolvedCredentials.sshKey = userProvidedSshKey;
resolvedCredentials.authType = "key";
}
if (userProvidedKeyPassword) {
resolvedCredentials.keyPassword = userProvidedKeyPassword;
}
if (host.credentialId) {
const ownerId = host.userId;
if (userId !== ownerId) {
try {
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id,
userId,
);
if (sharedCred) {
resolvedCredentials = {
password: sharedCred.password,
sshKey: sharedCred.key,
keyPassword: sharedCred.keyPassword,
authType: sharedCred.authType,
};
}
} catch (error) {
dockerLogger.error("Failed to resolve shared credential", error, {
operation: "docker_connect",
hostId,
userId,
});
}
} else {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
sshKey:
credential.private_key || credential.privateKey || credential.key,
keyPassword: credential.key_password || credential.keyPassword,
authType: credential.auth_type || credential.authType,
};
}
}
}
const client = new SSHClient();
const config: any = {
host: host.ip,
port: host.port || 22,
username: host.username,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
};
if (resolvedCredentials.authType === "none") {
} else if (resolvedCredentials.authType === "password") {
if (resolvedCredentials.password) {
config.password = resolvedCredentials.password;
}
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey
) {
try {
if (
!resolvedCredentials.sshKey.includes("-----BEGIN") ||
!resolvedCredentials.sshKey.includes("-----END")
) {
dockerLogger.error("Invalid SSH key format", {
operation: "docker_connect",
sessionId,
hostId,
});
return res.status(400).json({
error: "Invalid private key format",
});
}
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;
}
} catch (error) {
dockerLogger.error("SSH key processing error", error, {
operation: "docker_connect",
sessionId,
hostId,
});
return res.status(400).json({
error: "SSH key format error: Invalid private key format",
});
}
} else if (resolvedCredentials.authType === "key") {
dockerLogger.error(
"SSH key authentication requested but no key provided",
{
operation: "docker_connect",
sessionId,
hostId,
},
);
return res.status(400).json({
error: "SSH key authentication requested but no key provided",
});
}
let responseSent = false;
let keyboardInteractiveResponded = false;
client.on("ready", () => {
if (responseSent) return;
responseSent = true;
sshSessions[sessionId] = {
client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId,
};
scheduleSessionCleanup(sessionId);
res.json({ success: true, message: "SSH connection established" });
});
client.on("error", (err) => {
if (responseSent) return;
responseSent = true;
dockerLogger.error("Docker SSH connection failed", err, {
operation: "docker_connect",
sessionId,
hostId,
userId,
});
if (
resolvedCredentials.authType === "none" &&
(err.message.includes("authentication") ||
err.message.includes("All configured authentication methods failed"))
) {
res.json({
status: "auth_required",
reason: "no_keyboard",
});
} else {
res.status(500).json({
success: false,
message: err.message || "SSH connection failed",
});
}
});
client.on("close", () => {
if (sshSessions[sessionId]) {
sshSessions[sessionId].isConnected = false;
cleanupSession(sessionId);
}
});
client.on(
"keyboard-interactive",
(
name: string,
instructions: string,
instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPromptIndex !== -1) {
if (responseSent) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip: host.ip,
port: host.port || 22,
username: host.username,
userId,
prompts,
totpPromptIndex,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
};
res.json({
requires_totp: true,
sessionId,
prompt: prompts[totpPromptIndex].prompt,
});
} else {
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
if (
resolvedCredentials.authType === "none" &&
passwordPromptIndex !== -1
) {
if (responseSent) return;
responseSent = true;
client.end();
res.json({
status: "auth_required",
reason: "no_keyboard",
});
return;
}
const hasStoredPassword =
resolvedCredentials.password &&
resolvedCredentials.authType !== "none";
if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (responseSent) {
const responses = prompts.map((p) => {
if (
/password/i.test(p.prompt) &&
resolvedCredentials.password
) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (
/password/i.test(p.prompt) &&
resolvedCredentials.password
) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip: host.ip,
port: host.port || 22,
username: host.username,
userId,
prompts,
totpPromptIndex: passwordPromptIndex,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
};
res.json({
requires_totp: true,
sessionId,
prompt: prompts[passwordPromptIndex].prompt,
isPassword: true,
});
return;
}
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
}
},
);
if (
useSocks5 &&
(socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0))
) {
try {
const socks5Socket = await createSocks5Connection(
host.ip,
host.port || 22,
{
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain: socks5ProxyChain as any,
},
);
if (socks5Socket) {
config.sock = socks5Socket;
client.connect(config);
return;
}
} catch (socks5Error) {
dockerLogger.error("SOCKS5 connection failed", socks5Error, {
operation: "docker_socks5_connect",
sessionId,
hostId,
proxyHost: socks5Host,
proxyPort: socks5Port || 1080,
});
if (!responseSent) {
responseSent = true;
return res.status(500).json({
error:
"SOCKS5 proxy connection failed: " +
(socks5Error instanceof Error
? socks5Error.message
: "Unknown error"),
});
}
return;
}
} else if (host.jumpHosts && host.jumpHosts.length > 0) {
const jumpClient = await createJumpHostChain(
host.jumpHosts as Array<{ hostId: number }>,
userId,
);
if (!jumpClient) {
return res.status(500).json({
error: "Failed to establish jump host chain",
});
}
jumpClient.forwardOut(
"127.0.0.1",
0,
host.ip,
host.port || 22,
(err, stream) => {
if (err) {
dockerLogger.error("Failed to forward through jump host", err, {
operation: "docker_jump_forward",
sessionId,
hostId,
});
jumpClient.end();
if (!responseSent) {
responseSent = true;
return res.status(500).json({
error: "Failed to forward through jump host: " + err.message,
});
}
return;
}
config.sock = stream;
client.connect(config);
},
);
} else {
client.connect(config);
}
} catch (error) {
dockerLogger.error("Docker SSH connection error", error, {
operation: "docker_connect",
sessionId,
hostId,
userId,
});
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "Unknown error",
});
}
});
// POST /docker/ssh/disconnect - Close SSH session
app.post("/docker/ssh/disconnect", async (req, res) => {
const { sessionId } = req.body;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
cleanupSession(sessionId);
res.json({ success: true, message: "SSH session disconnected" });
});
// POST /docker/ssh/connect-totp - Verify TOTP and complete connection
app.post("/docker/ssh/connect-totp", async (req, res) => {
const { sessionId, totpCode } = req.body;
const userId = (req as any).userId;
if (!userId) {
dockerLogger.error("TOTP verification rejected: no authenticated user", {
operation: "docker_totp_auth",
sessionId,
});
return res.status(401).json({ error: "Authentication required" });
}
if (!sessionId || !totpCode) {
return res.status(400).json({ error: "Session ID and TOTP code required" });
}
const session = pendingTOTPSessions[sessionId];
if (!session) {
dockerLogger.warn("TOTP session not found or expired", {
operation: "docker_totp_verify",
sessionId,
userId,
availableSessions: Object.keys(pendingTOTPSessions),
});
return res
.status(404)
.json({ error: "TOTP session expired. Please reconnect." });
}
if (Date.now() - session.createdAt > 180000) {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
} catch {}
dockerLogger.warn("TOTP session timeout before code submission", {
operation: "docker_totp_verify",
sessionId,
userId,
age: Date.now() - session.createdAt,
});
return res
.status(408)
.json({ error: "TOTP session timeout. Please reconnect." });
}
const responses = (session.prompts || []).map((p, index) => {
if (index === session.totpPromptIndex) {
return totpCode;
}
if (/password/i.test(p.prompt) && session.resolvedPassword) {
return session.resolvedPassword;
}
return "";
});
let responseSent = false;
let responseTimeout: NodeJS.Timeout;
session.client.once("ready", () => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
setTimeout(() => {
sshSessions[sessionId] = {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId: session.hostId,
};
scheduleSessionCleanup(sessionId);
res.json({
status: "success",
message: "TOTP verified, SSH connection established",
});
if (session.hostId && session.userId) {
(async () => {
try {
const hosts = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(
and(
eq(sshData.id, session.hostId!),
eq(sshData.userId, session.userId!),
),
),
"ssh_data",
session.userId!,
);
const hostName =
hosts.length > 0 && hosts[0].name
? hosts[0].name
: `${session.username}@${session.ip}:${session.port}`;
await axios.post(
"http://localhost:30006/activity/log",
{
type: "docker",
hostId: session.hostId,
hostName,
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`,
},
},
);
} catch (error) {
dockerLogger.warn("Failed to log Docker activity (TOTP)", {
operation: "activity_log_error",
userId: session.userId,
hostId: session.hostId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
})();
}
}, 200);
});
session.client.once("error", (err) => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
dockerLogger.error("TOTP verification failed", {
operation: "docker_totp_verify",
sessionId,
userId,
error: err.message,
});
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
});
responseTimeout = setTimeout(() => {
if (!responseSent) {
responseSent = true;
delete pendingTOTPSessions[sessionId];
dockerLogger.warn("TOTP verification timeout", {
operation: "docker_totp_verify",
sessionId,
userId,
});
res.status(408).json({ error: "TOTP verification timeout" });
}
}, 60000);
session.finish(responses);
});
// POST /docker/ssh/keepalive - Keep session alive
app.post("/docker/ssh/keepalive", async (req, res) => {
const { sessionId } = req.body;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
connected: false,
});
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
res.json({
success: true,
connected: true,
message: "Session keepalive successful",
lastActive: session.lastActive,
});
});
// GET /docker/ssh/status - Check session status
app.get("/docker/ssh/status", async (req, res) => {
const sessionId = req.query.sessionId as string;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
const isConnected = !!sshSessions[sessionId]?.isConnected;
res.json({ success: true, connected: isConnected });
});
// GET /docker/validate/:sessionId - Validate Docker availability
app.get("/docker/validate/:sessionId", async (req, res) => {
const { sessionId } = req.params;
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
try {
const versionOutput = await executeDockerCommand(
session,
"docker --version",
);
const versionMatch = versionOutput.match(/Docker version ([^\s,]+)/);
const version = versionMatch ? versionMatch[1] : "unknown";
try {
await executeDockerCommand(session, "docker ps >/dev/null 2>&1");
session.activeOperations--;
return res.json({
available: true,
version,
});
} catch (daemonError) {
session.activeOperations--;
const errorMsg =
daemonError instanceof Error ? daemonError.message : "";
if (errorMsg.includes("Cannot connect to the Docker daemon")) {
return res.json({
available: false,
error:
"Docker daemon is not running. Start it with: sudo systemctl start docker",
code: "DAEMON_NOT_RUNNING",
});
}
if (errorMsg.includes("permission denied")) {
return res.json({
available: false,
error:
"Permission denied. Add your user to the docker group: sudo usermod -aG docker $USER",
code: "PERMISSION_DENIED",
});
}
return res.json({
available: false,
error: errorMsg,
code: "DOCKER_ERROR",
});
}
} catch (installError) {
session.activeOperations--;
return res.json({
available: false,
error:
"Docker is not installed on this host. Please install Docker to use this feature.",
code: "NOT_INSTALLED",
});
}
} catch (error) {
session.activeOperations--;
dockerLogger.error("Docker validation error", error, {
operation: "docker_validate",
sessionId,
userId,
});
res.status(500).json({
available: false,
error: error instanceof Error ? error.message : "Validation failed",
});
}
});
// GET /docker/containers/:sessionId - List all containers
app.get("/docker/containers/:sessionId", async (req, res) => {
const { sessionId } = req.params;
const all = req.query.all !== "false";
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
const allFlag = all ? "-a " : "";
const command = `docker ps ${allFlag}--format '{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}'`;
const output = await executeDockerCommand(session, command);
const containers = output
.split("\n")
.filter((line) => line.trim())
.map((line) => {
try {
return JSON.parse(line);
} catch (e) {
dockerLogger.warn("Failed to parse container line", {
operation: "parse_container",
line,
});
return null;
}
})
.filter((c) => c !== null);
session.activeOperations--;
res.json(containers);
} catch (error) {
session.activeOperations--;
dockerLogger.error("Failed to list Docker containers", error, {
operation: "list_containers",
sessionId,
userId,
});
res.status(500).json({
error:
error instanceof Error ? error.message : "Failed to list containers",
});
}
});
// GET /docker/containers/:sessionId/:containerId - Get container details
app.get("/docker/containers/:sessionId/:containerId", async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
const command = `docker inspect ${containerId}`;
const output = await executeDockerCommand(session, command);
const details = JSON.parse(output);
session.activeOperations--;
if (details && details.length > 0) {
res.json(details[0]);
} else {
res.status(404).json({
error: "Container not found",
code: "CONTAINER_NOT_FOUND",
});
}
} catch (error) {
session.activeOperations--;
const errorMsg = error instanceof Error ? error.message : "";
if (errorMsg.includes("No such container")) {
return res.status(404).json({
error: "Container not found",
code: "CONTAINER_NOT_FOUND",
});
}
dockerLogger.error("Failed to get container details", error, {
operation: "get_container_details",
sessionId,
containerId,
userId,
});
res.status(500).json({
error: errorMsg || "Failed to get container details",
});
}
});
// POST /docker/containers/:sessionId/:containerId/start - Start container
app.post(
"/docker/containers/:sessionId/:containerId/start",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
await executeDockerCommand(session, `docker start ${containerId}`);
session.activeOperations--;
res.json({
success: true,
message: "Container started successfully",
});
} catch (error) {
session.activeOperations--;
const errorMsg = error instanceof Error ? error.message : "";
if (errorMsg.includes("No such container")) {
return res.status(404).json({
success: false,
error: "Container not found",
code: "CONTAINER_NOT_FOUND",
});
}
dockerLogger.error("Failed to start container", error, {
operation: "start_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to start container",
});
}
},
);
// POST /docker/containers/:sessionId/:containerId/stop - Stop container
app.post(
"/docker/containers/:sessionId/:containerId/stop",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
await executeDockerCommand(session, `docker stop ${containerId}`);
session.activeOperations--;
res.json({
success: true,
message: "Container stopped successfully",
});
} catch (error) {
session.activeOperations--;
const errorMsg = error instanceof Error ? error.message : "";
if (errorMsg.includes("No such container")) {
return res.status(404).json({
success: false,
error: "Container not found",
code: "CONTAINER_NOT_FOUND",
});
}
dockerLogger.error("Failed to stop container", error, {
operation: "stop_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to stop container",
});
}
},
);
// POST /docker/containers/:sessionId/:containerId/restart - Restart container
app.post(
"/docker/containers/:sessionId/:containerId/restart",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
await executeDockerCommand(session, `docker restart ${containerId}`);
session.activeOperations--;
res.json({
success: true,
message: "Container restarted successfully",
});
} catch (error) {
session.activeOperations--;
const errorMsg = error instanceof Error ? error.message : "";
if (errorMsg.includes("No such container")) {
return res.status(404).json({
success: false,
error: "Container not found",
code: "CONTAINER_NOT_FOUND",
});
}
dockerLogger.error("Failed to restart container", error, {
operation: "restart_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to restart container",
});
}
},
);
// POST /docker/containers/:sessionId/:containerId/pause - Pause container
app.post(
"/docker/containers/:sessionId/:containerId/pause",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
await executeDockerCommand(session, `docker pause ${containerId}`);
session.activeOperations--;
res.json({
success: true,
message: "Container paused successfully",
});
} catch (error) {
session.activeOperations--;
const errorMsg = error instanceof Error ? error.message : "";
if (errorMsg.includes("No such container")) {
return res.status(404).json({
success: false,
error: "Container not found",
code: "CONTAINER_NOT_FOUND",
});
}
dockerLogger.error("Failed to pause container", error, {
operation: "pause_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to pause container",
});
}
},
);
// POST /docker/containers/:sessionId/:containerId/unpause - Unpause container
app.post(
"/docker/containers/:sessionId/:containerId/unpause",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
await executeDockerCommand(session, `docker unpause ${containerId}`);
session.activeOperations--;
res.json({
success: true,
message: "Container unpaused successfully",
});
} catch (error) {
session.activeOperations--;
const errorMsg = error instanceof Error ? error.message : "";
if (errorMsg.includes("No such container")) {
return res.status(404).json({
success: false,
error: "Container not found",
code: "CONTAINER_NOT_FOUND",
});
}
dockerLogger.error("Failed to unpause container", error, {
operation: "unpause_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to unpause container",
});
}
},
);
// DELETE /docker/containers/:sessionId/:containerId/remove - Remove container
app.delete(
"/docker/containers/:sessionId/:containerId/remove",
async (req, res) => {
const { sessionId, containerId } = req.params;
const force = req.query.force === "true";
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
const forceFlag = force ? "-f " : "";
await executeDockerCommand(
session,
`docker rm ${forceFlag}${containerId}`,
);
session.activeOperations--;
res.json({
success: true,
message: "Container removed successfully",
});
} catch (error) {
session.activeOperations--;
const errorMsg = error instanceof Error ? error.message : "";
if (errorMsg.includes("No such container")) {
return res.status(404).json({
success: false,
error: "Container not found",
code: "CONTAINER_NOT_FOUND",
});
}
if (errorMsg.includes("cannot remove a running container")) {
return res.status(400).json({
success: false,
error:
"Cannot remove a running container. Stop it first or use force.",
code: "CONTAINER_RUNNING",
});
}
dockerLogger.error("Failed to remove container", error, {
operation: "remove_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to remove container",
});
}
},
);
// GET /docker/containers/:sessionId/:containerId/logs - Get container logs
app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => {
const { sessionId, containerId } = req.params;
const tail = req.query.tail ? parseInt(req.query.tail as string) : 100;
const timestamps = req.query.timestamps === "true";
const since = req.query.since as string;
const until = req.query.until as string;
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
let command = `docker logs ${containerId}`;
if (tail && tail > 0) {
command += ` --tail ${tail}`;
}
if (timestamps) {
command += " --timestamps";
}
if (since) {
command += ` --since ${since}`;
}
if (until) {
command += ` --until ${until}`;
}
const logs = await executeDockerCommand(session, command);
session.activeOperations--;
res.json({
success: true,
logs,
});
} catch (error) {
session.activeOperations--;
const errorMsg = error instanceof Error ? error.message : "";
if (errorMsg.includes("No such container")) {
return res.status(404).json({
success: false,
error: "Container not found",
code: "CONTAINER_NOT_FOUND",
});
}
dockerLogger.error("Failed to get container logs", error, {
operation: "get_logs",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to get container logs",
});
}
});
// GET /docker/containers/:sessionId/:containerId/stats - Get container stats
app.get(
"/docker/containers/:sessionId/:containerId/stats",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
const command = `docker stats ${containerId} --no-stream --format '{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}'`;
const output = await executeDockerCommand(session, command);
const rawStats = JSON.parse(output.trim());
const memoryParts = rawStats.memory.split(" / ");
const memoryUsed = memoryParts[0]?.trim() || "0B";
const memoryLimit = memoryParts[1]?.trim() || "0B";
const netIOParts = rawStats.netIO.split(" / ");
const netInput = netIOParts[0]?.trim() || "0B";
const netOutput = netIOParts[1]?.trim() || "0B";
const blockIOParts = rawStats.blockIO.split(" / ");
const blockRead = blockIOParts[0]?.trim() || "0B";
const blockWrite = blockIOParts[1]?.trim() || "0B";
const stats = {
cpu: rawStats.cpu,
memoryUsed,
memoryLimit,
memoryPercent: rawStats.memoryPercent,
netInput,
netOutput,
blockRead,
blockWrite,
pids: rawStats.pids,
};
session.activeOperations--;
res.json(stats);
} catch (error) {
session.activeOperations--;
const errorMsg = error instanceof Error ? error.message : "";
if (errorMsg.includes("No such container")) {
return res.status(404).json({
success: false,
error: "Container not found",
code: "CONTAINER_NOT_FOUND",
});
}
dockerLogger.error("Failed to get container stats", error, {
operation: "get_stats",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to get container stats",
});
}
},
);
const PORT = 30007;
app.listen(PORT, async () => {
try {
await authManager.initialize();
} catch (err) {
dockerLogger.error("Failed to initialize Docker backend", err, {
operation: "startup",
});
}
});
process.on("SIGINT", () => {
Object.keys(sshSessions).forEach((sessionId) => {
cleanupSession(sessionId);
});
process.exit(0);
});
process.on("SIGTERM", () => {
Object.keys(sshSessions).forEach((sessionId) => {
cleanupSession(sessionId);
});
process.exit(0);
});