Files
Termix/src/backend/ssh/docker-container-routes.ts
T
2026-07-15 14:57:40 +08:00

1131 lines
30 KiB
TypeScript

import type express from "express";
import { logger } from "../utils/logger.js";
import {
containerCommand,
type ContainerRuntime,
} from "./container-runtime.js";
const sshLogger = logger;
type DockerSession = {
isConnected: boolean;
userId?: string;
lastActive: number;
activeOperations: number;
hostId?: number;
isWindows?: boolean;
containerRuntime?: ContainerRuntime;
};
type PendingDockerTotpSession = unknown;
type ExecuteDockerCommand = (
session: DockerSession,
command: string,
sessionId: string,
userId: string,
hostId?: number,
) => Promise<string>;
type DockerContainerRoutesDeps = {
sshSessions: Record<string, DockerSession>;
pendingTOTPSessions: Record<string, PendingDockerTotpSession>;
getRequestUserId: (req: express.Request) => string | undefined;
executeDockerCommand: ExecuteDockerCommand;
dockerTimestampPattern: RegExp;
};
export function registerDockerContainerRoutes(
app: express.Express,
{
sshSessions,
pendingTOTPSessions,
getRequestUserId,
executeDockerCommand,
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
}: DockerContainerRoutesDeps,
): void {
const getRuntime = (session: DockerSession) =>
session.containerRuntime ?? "docker";
const getOwnedSession = (sessionId: string, userId: string) => {
const session = sshSessions[sessionId];
return session?.userId === userId ? session : undefined;
};
/**
* @openapi
* /docker/containers/{sessionId}:
* get:
* summary: List all containers
* description: Lists all Docker containers on the host.
* tags:
* - Docker
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* - in: query
* name: all
* schema:
* type: boolean
* responses:
* 200:
* description: A list of containers.
* 400:
* description: SSH session not found or not connected.
* 500:
* description: Failed to list containers.
*/
app.get("/docker/containers/:sessionId", async (req, res) => {
const { sessionId } = req.params;
const all = req.query.all !== "false";
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (pendingTOTPSessions[sessionId]) {
return res.status(400).json({
error: "Connection pending authentication",
code: "AUTH_PENDING",
});
}
const session = getOwnedSession(sessionId, userId);
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 formatStr = session.isWindows
? `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`
: `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
const command = containerCommand(
getRuntime(session),
`ps ${allFlag}--format ${formatStr}`,
);
const output = await executeDockerCommand(
session,
command,
sessionId,
userId,
session.hostId,
);
const containers = output
.split("\n")
.filter((line) => line.trim())
.map((line) => {
try {
return JSON.parse(line);
} catch {
sshLogger.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--;
sshLogger.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",
});
}
});
/**
* @openapi
* /docker/containers/{sessionId}/{containerId}:
* get:
* summary: Get container details
* description: Retrieves detailed information about a specific container.
* tags:
* - Docker
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* - in: path
* name: containerId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Container details.
* 400:
* description: SSH session not found or not connected.
* 404:
* description: Container not found.
* 500:
* description: Failed to get container details.
*/
app.get("/docker/containers/:sessionId/:containerId", async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = getOwnedSession(sessionId, userId);
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 = containerCommand(
getRuntime(session),
`inspect ${containerId}`,
);
const output = await executeDockerCommand(
session,
command,
sessionId,
userId,
session.hostId,
);
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",
});
}
sshLogger.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",
});
}
});
/**
* @openapi
* /docker/containers/{sessionId}/{containerId}/start:
* post:
* summary: Start container
* description: Starts a specific container.
* tags:
* - Docker
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* - in: path
* name: containerId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Container started successfully.
* 400:
* description: SSH session not found or not connected.
* 404:
* description: Container not found.
* 500:
* description: Failed to start container.
*/
app.post(
"/docker/containers/:sessionId/:containerId/start",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = getOwnedSession(sessionId, userId);
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
sshLogger.info("Docker container operation", {
operation: "docker_container_op",
sessionId,
userId,
hostId: session.hostId,
containerId,
action: "start",
});
await executeDockerCommand(
session,
containerCommand(getRuntime(session), `start ${containerId}`),
sessionId,
userId,
session.hostId,
);
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",
});
}
sshLogger.error("Failed to start container", error, {
operation: "start_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to start container",
});
}
},
);
/**
* @openapi
* /docker/containers/{sessionId}/{containerId}/stop:
* post:
* summary: Stop container
* description: Stops a specific container.
* tags:
* - Docker
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* - in: path
* name: containerId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Container stopped successfully.
* 400:
* description: SSH session not found or not connected.
* 404:
* description: Container not found.
* 500:
* description: Failed to stop container.
*/
app.post(
"/docker/containers/:sessionId/:containerId/stop",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = getOwnedSession(sessionId, userId);
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
sshLogger.info("Docker container operation", {
operation: "docker_container_op",
sessionId,
userId,
hostId: session.hostId,
containerId,
action: "stop",
});
await executeDockerCommand(
session,
containerCommand(getRuntime(session), `stop ${containerId}`),
sessionId,
userId,
session.hostId,
);
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",
});
}
sshLogger.error("Failed to stop container", error, {
operation: "stop_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to stop container",
});
}
},
);
/**
* @openapi
* /docker/containers/{sessionId}/{containerId}/restart:
* post:
* summary: Restart container
* description: Restarts a specific container.
* tags:
* - Docker
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* - in: path
* name: containerId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Container restarted successfully.
* 400:
* description: SSH session not found or not connected.
* 404:
* description: Container not found.
* 500:
* description: Failed to restart container.
*/
app.post(
"/docker/containers/:sessionId/:containerId/restart",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = getOwnedSession(sessionId, userId);
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
sshLogger.info("Docker container operation", {
operation: "docker_container_op",
sessionId,
userId,
hostId: session.hostId,
containerId,
action: "restart",
});
await executeDockerCommand(
session,
containerCommand(getRuntime(session), `restart ${containerId}`),
sessionId,
userId,
session.hostId,
);
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",
});
}
sshLogger.error("Failed to restart container", error, {
operation: "restart_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to restart container",
});
}
},
);
/**
* @openapi
* /docker/containers/{sessionId}/{containerId}/pause:
* post:
* summary: Pause container
* description: Pauses a specific container.
* tags:
* - Docker
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* - in: path
* name: containerId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Container paused successfully.
* 400:
* description: SSH session not found or not connected.
* 404:
* description: Container not found.
* 500:
* description: Failed to pause container.
*/
app.post(
"/docker/containers/:sessionId/:containerId/pause",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = getOwnedSession(sessionId, userId);
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
sshLogger.info("Docker container operation", {
operation: "docker_container_op",
sessionId,
userId,
hostId: session.hostId,
containerId,
action: "pause",
});
await executeDockerCommand(
session,
containerCommand(getRuntime(session), `pause ${containerId}`),
sessionId,
userId,
session.hostId,
);
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",
});
}
sshLogger.error("Failed to pause container", error, {
operation: "pause_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to pause container",
});
}
},
);
/**
* @openapi
* /docker/containers/{sessionId}/{containerId}/unpause:
* post:
* summary: Unpause container
* description: Unpauses a specific container.
* tags:
* - Docker
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* - in: path
* name: containerId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Container unpaused successfully.
* 400:
* description: SSH session not found or not connected.
* 404:
* description: Container not found.
* 500:
* description: Failed to unpause container.
*/
app.post(
"/docker/containers/:sessionId/:containerId/unpause",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = getOwnedSession(sessionId, userId);
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
sshLogger.info("Docker container operation", {
operation: "docker_container_op",
sessionId,
userId,
hostId: session.hostId,
containerId,
action: "unpause",
});
await executeDockerCommand(
session,
containerCommand(getRuntime(session), `unpause ${containerId}`),
sessionId,
userId,
session.hostId,
);
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",
});
}
sshLogger.error("Failed to unpause container", error, {
operation: "unpause_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to unpause container",
});
}
},
);
/**
* @openapi
* /docker/containers/{sessionId}/{containerId}/remove:
* delete:
* summary: Remove container
* description: Removes a specific container.
* tags:
* - Docker
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* - in: path
* name: containerId
* required: true
* schema:
* type: string
* - in: query
* name: force
* schema:
* type: boolean
* responses:
* 200:
* description: Container removed successfully.
* 400:
* description: SSH session not found or not connected, or cannot remove a running container.
* 404:
* description: Container not found.
* 500:
* description: Failed to 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 = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = getOwnedSession(sessionId, userId);
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
});
}
session.lastActive = Date.now();
session.activeOperations++;
try {
sshLogger.info("Docker container operation", {
operation: "docker_container_op",
sessionId,
userId,
hostId: session.hostId,
containerId,
action: "remove",
});
const forceFlag = force ? "-f " : "";
await executeDockerCommand(
session,
containerCommand(
getRuntime(session),
`rm ${forceFlag}${containerId}`,
),
sessionId,
userId,
session.hostId,
);
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",
});
}
sshLogger.error("Failed to remove container", error, {
operation: "remove_container",
sessionId,
containerId,
userId,
});
res.status(500).json({
success: false,
error: errorMsg || "Failed to remove container",
});
}
},
);
/**
* @openapi
* /docker/containers/{sessionId}/{containerId}/logs:
* get:
* summary: Get container logs
* description: Retrieves logs for a specific container.
* tags:
* - Docker
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* - in: path
* name: containerId
* required: true
* schema:
* type: string
* - in: query
* name: tail
* schema:
* type: integer
* - in: query
* name: timestamps
* schema:
* type: boolean
* - in: query
* name: since
* schema:
* type: string
* - in: query
* name: until
* schema:
* type: string
* responses:
* 200:
* description: Container logs.
* 400:
* description: SSH session not found or not connected.
* 404:
* description: Container not found.
* 500:
* description: Failed to 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 = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = getOwnedSession(sessionId, userId);
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 = containerCommand(
getRuntime(session),
`logs ${containerId}`,
);
if (tail && tail > 0) {
command += ` --tail ${Math.floor(tail)}`;
}
if (timestamps) {
command += " --timestamps";
}
if (since && DOCKER_TIMESTAMP_RE.test(since)) {
command += ` --since ${since}`;
}
if (until && DOCKER_TIMESTAMP_RE.test(until)) {
command += ` --until ${until}`;
}
command += " 2>&1";
const logs = await executeDockerCommand(
session,
command,
sessionId,
userId,
session.hostId,
);
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",
});
}
sshLogger.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",
});
}
},
);
/**
* @openapi
* /docker/containers/{sessionId}/{containerId}/stats:
* get:
* summary: Get container stats
* description: Retrieves stats for a specific container.
* tags:
* - Docker
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* - in: path
* name: containerId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Container stats.
* 400:
* description: SSH session not found or not connected.
* 404:
* description: Container not found.
* 500:
* description: Failed to get container stats.
*/
app.get(
"/docker/containers/:sessionId/:containerId/stats",
async (req, res) => {
const { sessionId, containerId } = req.params;
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const session = getOwnedSession(sessionId, userId);
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 statsFormatStr = session.isWindows
? `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`
: `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
const command = containerCommand(
getRuntime(session),
`stats ${containerId} --no-stream --format ${statsFormatStr}`,
);
const output = await executeDockerCommand(
session,
command,
sessionId,
userId,
session.hostId,
);
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",
});
}
sshLogger.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",
});
}
},
);
}