mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
999 lines
30 KiB
TypeScript
999 lines
30 KiB
TypeScript
import type { Request, Response, Router } from "express";
|
|
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
|
|
import { answerConnect, startConnect } from "./connect.js";
|
|
import { containerCommand, getRuntimeLabel } from "./container-runtime.js";
|
|
import { readDockerHostSettings } from "./host-settings.js";
|
|
import type { DockerSession, DockerSessions } from "./sessions.js";
|
|
import {
|
|
CONTAINER_ID_RE,
|
|
DOCKER_TIMESTAMP_RE,
|
|
connectionLog,
|
|
getErrorMessage,
|
|
type ConnectionLogLine,
|
|
type DockerLogger,
|
|
} from "./helpers.js";
|
|
|
|
const CONTAINER_ACTIONS = [
|
|
"start",
|
|
"stop",
|
|
"restart",
|
|
"pause",
|
|
"unpause",
|
|
] as const;
|
|
type ContainerAction = (typeof CONTAINER_ACTIONS)[number];
|
|
|
|
const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
|
|
const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`;
|
|
const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
|
|
const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`;
|
|
|
|
export function listFormat(isWindows: boolean): string {
|
|
return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX;
|
|
}
|
|
|
|
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
|
|
export function parseContainerList(output: string): Record<string, unknown>[] {
|
|
return output
|
|
.split("\n")
|
|
.filter((line) => line.trim())
|
|
.flatMap((line) => {
|
|
try {
|
|
return [JSON.parse(line) as Record<string, unknown>];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
|
|
function notFound(error: unknown): boolean {
|
|
return getErrorMessage(error, "").includes("No such container");
|
|
}
|
|
|
|
interface Deps {
|
|
ctx: PluginContext;
|
|
sessions: DockerSessions;
|
|
log: DockerLogger;
|
|
}
|
|
|
|
export function registerRoutes(router: Router, { ctx, sessions, log }: Deps) {
|
|
router.use((_req, res, next) => {
|
|
res.setHeader("Cache-Control", "no-store");
|
|
next();
|
|
});
|
|
router.use(ctx.rbac.require("use") as never);
|
|
|
|
router.param("containerId", (_req, res, next, value) => {
|
|
if (!CONTAINER_ID_RE.test(value)) {
|
|
res.status(400).json({ error: "Invalid container ID" });
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
|
|
const userOf = (res: Response): string | null => {
|
|
const userId = ctx.currentActor();
|
|
if (!userId) {
|
|
res.status(401).json({ error: "Authentication required" });
|
|
return null;
|
|
}
|
|
return userId;
|
|
};
|
|
|
|
/** The caller's live session for the path's sessionId, or an error reply. */
|
|
const sessionOf = (req: Request, res: Response): DockerSession | null => {
|
|
const userId = userOf(res);
|
|
if (!userId) return null;
|
|
const sessionId = String(req.params.sessionId ?? "");
|
|
if (sessions.getPending(sessionId)) {
|
|
res.status(400).json({
|
|
error: "Connection pending authentication",
|
|
code: "AUTH_PENDING",
|
|
});
|
|
return null;
|
|
}
|
|
const session = sessions.get(sessionId, userId);
|
|
if (!session) {
|
|
res.status(400).json({ error: "SSH session not found or not connected" });
|
|
return null;
|
|
}
|
|
return session;
|
|
};
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/ssh/connect:
|
|
* post:
|
|
* summary: Establish SSH session for Docker
|
|
* description: Opens an SSH session to a host for Docker operations. When the host asks for a code, a Warpgate sign-in or a password, the reply says so and the session waits for connect-totp or connect-warpgate.
|
|
* tags:
|
|
* - Docker
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - sessionId
|
|
* - hostId
|
|
* properties:
|
|
* sessionId:
|
|
* type: string
|
|
* hostId:
|
|
* type: integer
|
|
* userProvidedPassword:
|
|
* type: string
|
|
* userProvidedSshKey:
|
|
* type: string
|
|
* userProvidedKeyPassword:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Connected, or a prompt the user has to answer.
|
|
* 400:
|
|
* description: Missing sessionId or hostId.
|
|
* 403:
|
|
* description: Docker is not enabled for this host, or the user lacks docker.use.
|
|
* 404:
|
|
* description: Host not found.
|
|
* 500:
|
|
* description: SSH connection failed.
|
|
*/
|
|
router.post("/ssh/connect", async (req, res) => {
|
|
const userId = userOf(res);
|
|
if (!userId) return;
|
|
const {
|
|
sessionId,
|
|
hostId,
|
|
userProvidedPassword,
|
|
userProvidedSshKey,
|
|
userProvidedKeyPassword,
|
|
} = (req.body ?? {}) as Record<string, unknown>;
|
|
const logs: ConnectionLogLine[] = [];
|
|
|
|
if (typeof sessionId !== "string" || !sessionId || !hostId) {
|
|
logs.push(
|
|
connectionLog(
|
|
"error",
|
|
"docker_connecting",
|
|
"Missing connection parameters",
|
|
),
|
|
);
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Missing sessionId or hostId", connectionLogs: logs });
|
|
}
|
|
const numericHostId = Number(hostId);
|
|
|
|
logs.push(
|
|
connectionLog(
|
|
"info",
|
|
"docker_connecting",
|
|
"Initiating Docker SSH connection",
|
|
),
|
|
);
|
|
|
|
try {
|
|
const host = await ctx.ssh.resolveHost(numericHostId);
|
|
if (!host) {
|
|
logs.push(
|
|
connectionLog("error", "docker_connecting", "Host not found"),
|
|
);
|
|
return res
|
|
.status(404)
|
|
.json({ error: "Host not found", connectionLogs: logs });
|
|
}
|
|
|
|
const settings = await readDockerHostSettings(ctx, numericHostId);
|
|
if (!settings.enabled) {
|
|
logs.push(
|
|
connectionLog(
|
|
"error",
|
|
"docker_connecting",
|
|
"Docker is not enabled for this host",
|
|
),
|
|
);
|
|
return res.status(403).json({
|
|
error:
|
|
"Docker is not enabled for this host. Enable it in Host Settings.",
|
|
code: "DOCKER_DISABLED",
|
|
connectionLogs: logs,
|
|
});
|
|
}
|
|
|
|
sessions.close(sessionId);
|
|
logs.push(
|
|
connectionLog(
|
|
"info",
|
|
"docker_auth",
|
|
"Resolving authentication credentials",
|
|
),
|
|
);
|
|
|
|
const target: PluginSshHost = {
|
|
...host,
|
|
id: numericHostId,
|
|
port: host.port || 22,
|
|
};
|
|
if (typeof userProvidedPassword === "string" && userProvidedPassword) {
|
|
target.password = userProvidedPassword;
|
|
target.authType = "password";
|
|
}
|
|
if (typeof userProvidedSshKey === "string" && userProvidedSshKey) {
|
|
target.key = userProvidedSshKey;
|
|
target.authType = "key";
|
|
}
|
|
if (
|
|
typeof userProvidedKeyPassword === "string" &&
|
|
userProvidedKeyPassword
|
|
) {
|
|
target.keyPassword = userProvidedKeyPassword;
|
|
}
|
|
|
|
const step = await startConnect(ctx, sessions, log, {
|
|
sessionId,
|
|
userId,
|
|
host: target,
|
|
runtime: settings.runtime,
|
|
logs,
|
|
});
|
|
return res.status(step.status).json(step.body);
|
|
} catch (error) {
|
|
log.error("Docker SSH connection error", error, {
|
|
hostId: numericHostId,
|
|
userId,
|
|
});
|
|
logs.push(
|
|
connectionLog(
|
|
"error",
|
|
"docker_connecting",
|
|
`Connection error: ${getErrorMessage(error)}`,
|
|
),
|
|
);
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: getErrorMessage(error),
|
|
connectionLogs: logs,
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/ssh/connect-totp:
|
|
* post:
|
|
* summary: Answer a verification prompt
|
|
* description: Sends the code (or password) the host asked for and waits for the next step of the connect.
|
|
* tags:
|
|
* - Docker
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - sessionId
|
|
* - totpCode
|
|
* properties:
|
|
* sessionId:
|
|
* type: string
|
|
* totpCode:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Connected, or another prompt.
|
|
* 400:
|
|
* description: Session ID and code required, or the session is not waiting for a code.
|
|
* 404:
|
|
* description: The connect expired.
|
|
* 408:
|
|
* description: The host did not answer in time.
|
|
*/
|
|
router.post("/ssh/connect-totp", async (req, res) => {
|
|
const userId = userOf(res);
|
|
if (!userId) return;
|
|
const { sessionId, totpCode } = (req.body ?? {}) as Record<string, unknown>;
|
|
if (typeof sessionId !== "string" || typeof totpCode !== "string") {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Session ID and TOTP code required" });
|
|
}
|
|
const step = await answerConnect(
|
|
sessions,
|
|
sessionId,
|
|
userId,
|
|
"totp",
|
|
totpCode.trim(),
|
|
);
|
|
return res.status(step.status).json(step.body);
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/ssh/connect-warpgate:
|
|
* post:
|
|
* summary: Complete Warpgate authentication
|
|
* description: Continues a connect after the user finished the Warpgate sign-in in the browser.
|
|
* tags:
|
|
* - Docker
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - sessionId
|
|
* properties:
|
|
* sessionId:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Connected, or another prompt.
|
|
* 400:
|
|
* description: Session ID required, or the session is not a Warpgate session.
|
|
* 404:
|
|
* description: The connect expired.
|
|
* 408:
|
|
* description: The host did not answer in time.
|
|
*/
|
|
router.post("/ssh/connect-warpgate", async (req, res) => {
|
|
const userId = userOf(res);
|
|
if (!userId) return;
|
|
const { sessionId } = (req.body ?? {}) as Record<string, unknown>;
|
|
if (typeof sessionId !== "string" || !sessionId) {
|
|
return res.status(400).json({ error: "Session ID required" });
|
|
}
|
|
const step = await answerConnect(
|
|
sessions,
|
|
sessionId,
|
|
userId,
|
|
"warpgate",
|
|
"",
|
|
);
|
|
return res.status(step.status).json(step.body);
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/ssh/disconnect:
|
|
* post:
|
|
* summary: Disconnect SSH session
|
|
* description: Closes the caller's Docker SSH session.
|
|
* tags:
|
|
* - Docker
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* sessionId:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: SSH session disconnected.
|
|
* 400:
|
|
* description: Session ID is required.
|
|
*/
|
|
router.post("/ssh/disconnect", (req, res) => {
|
|
const userId = userOf(res);
|
|
if (!userId) return;
|
|
const { sessionId } = (req.body ?? {}) as Record<string, unknown>;
|
|
if (typeof sessionId !== "string" || !sessionId) {
|
|
return res.status(400).json({ error: "Session ID is required" });
|
|
}
|
|
const pending = sessions.getPending(sessionId);
|
|
if (sessions.get(sessionId, userId) || pending?.userId === userId) {
|
|
sessions.close(sessionId);
|
|
}
|
|
return res.json({ success: true, message: "SSH session disconnected" });
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/ssh/keepalive:
|
|
* post:
|
|
* summary: Keep SSH session alive
|
|
* description: Resets the idle timeout of the caller's Docker SSH session.
|
|
* tags:
|
|
* - Docker
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* sessionId:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Session keepalive successful.
|
|
* 400:
|
|
* description: Session ID is required or session not found.
|
|
*/
|
|
router.post("/ssh/keepalive", (req, res) => {
|
|
const userId = userOf(res);
|
|
if (!userId) return;
|
|
const { sessionId } = (req.body ?? {}) as Record<string, unknown>;
|
|
if (typeof sessionId !== "string" || !sessionId) {
|
|
return res.status(400).json({ error: "Session ID is required" });
|
|
}
|
|
const session = sessions.get(sessionId, userId);
|
|
if (!session) {
|
|
return res.status(400).json({
|
|
error: "SSH session not found or not connected",
|
|
connected: false,
|
|
});
|
|
}
|
|
sessions.touch(session);
|
|
return res.json({
|
|
success: true,
|
|
connected: true,
|
|
lastActive: session.lastActive,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/ssh/status:
|
|
* get:
|
|
* summary: Check SSH session status
|
|
* description: Whether the caller's Docker SSH session is connected.
|
|
* tags:
|
|
* - Docker
|
|
* parameters:
|
|
* - in: query
|
|
* name: sessionId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Session status.
|
|
* 400:
|
|
* description: Session ID is required.
|
|
*/
|
|
router.get("/ssh/status", (req, res) => {
|
|
const userId = userOf(res);
|
|
if (!userId) return;
|
|
const sessionId = req.query.sessionId;
|
|
if (typeof sessionId !== "string" || !sessionId) {
|
|
return res.status(400).json({ error: "Session ID is required" });
|
|
}
|
|
return res.json({
|
|
success: true,
|
|
connected: !!sessions.get(sessionId, userId),
|
|
});
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/validate/{sessionId}:
|
|
* get:
|
|
* summary: Validate Docker availability
|
|
* description: Checks that the container runtime is installed and its daemon answers.
|
|
* tags:
|
|
* - Docker
|
|
* parameters:
|
|
* - in: path
|
|
* name: sessionId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Docker availability status.
|
|
* 400:
|
|
* description: SSH session not found or not connected.
|
|
*/
|
|
router.get("/validate/:sessionId", async (req, res) => {
|
|
const session = sessionOf(req, res);
|
|
if (!session) return;
|
|
const runtime = session.runtime;
|
|
const label = getRuntimeLabel(runtime);
|
|
|
|
await sessions.run(session, async () => {
|
|
let version = "unknown";
|
|
try {
|
|
const output = await sessions.exec(
|
|
session,
|
|
containerCommand(runtime, "--version"),
|
|
);
|
|
version =
|
|
output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ??
|
|
"unknown";
|
|
} catch {
|
|
res.json({
|
|
available: false,
|
|
error: `${label} is not installed on this host.`,
|
|
code: "NOT_INSTALLED",
|
|
runtime,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await sessions.exec(session, containerCommand(runtime, "ps"));
|
|
res.json({ available: true, version, runtime });
|
|
} catch (error) {
|
|
const message = getErrorMessage(error, "");
|
|
if (message.includes("Cannot connect to the Docker daemon")) {
|
|
res.json({
|
|
available: false,
|
|
error: `${label} daemon is not running or accessible`,
|
|
code: "DAEMON_NOT_RUNNING",
|
|
runtime,
|
|
});
|
|
} else if (message.includes("permission denied")) {
|
|
res.json({
|
|
available: false,
|
|
error: `Permission denied accessing ${label}`,
|
|
code: "PERMISSION_DENIED",
|
|
runtime,
|
|
});
|
|
} else {
|
|
res.json({
|
|
available: false,
|
|
error: message,
|
|
code: "DOCKER_ERROR",
|
|
runtime,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/containers/{sessionId}:
|
|
* get:
|
|
* summary: List all containers
|
|
* description: Lists the containers on the session's 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.
|
|
*/
|
|
router.get("/containers/:sessionId", async (req, res) => {
|
|
const session = sessionOf(req, res);
|
|
if (!session) return;
|
|
const all = req.query.all !== "false";
|
|
try {
|
|
const output = await sessions.run(session, () =>
|
|
sessions.exec(
|
|
session,
|
|
containerCommand(
|
|
session.runtime,
|
|
`ps ${all ? "-a " : ""}--format ${listFormat(session.isWindows)}`,
|
|
),
|
|
),
|
|
);
|
|
res.json(parseContainerList(output));
|
|
} catch (error) {
|
|
log.error("Failed to list Docker containers", error, {
|
|
hostId: session.hostId,
|
|
});
|
|
res.status(500).json({
|
|
error: getErrorMessage(error, "Failed to list containers"),
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/containers/{sessionId}/{containerId}:
|
|
* get:
|
|
* summary: Get container details
|
|
* description: The container's inspect output.
|
|
* 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.
|
|
*/
|
|
router.get("/containers/:sessionId/:containerId", async (req, res) => {
|
|
const session = sessionOf(req, res);
|
|
if (!session) return;
|
|
const { containerId } = req.params;
|
|
try {
|
|
const output = await sessions.run(session, () =>
|
|
sessions.exec(
|
|
session,
|
|
containerCommand(session.runtime, `inspect ${containerId}`),
|
|
),
|
|
);
|
|
const details = JSON.parse(output) as unknown[];
|
|
if (details?.length > 0) {
|
|
res.json(details[0]);
|
|
} else {
|
|
res
|
|
.status(404)
|
|
.json({ error: "Container not found", code: "CONTAINER_NOT_FOUND" });
|
|
}
|
|
} catch (error) {
|
|
if (notFound(error)) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: "Container not found", code: "CONTAINER_NOT_FOUND" });
|
|
}
|
|
log.error("Failed to get container details", error, {
|
|
hostId: session.hostId,
|
|
containerId,
|
|
});
|
|
res.status(500).json({
|
|
error: getErrorMessage(error, "Failed to get container details"),
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/containers/{sessionId}/{containerId}/{action}:
|
|
* post:
|
|
* summary: Start, stop, restart, pause or unpause a container
|
|
* description: Runs the container action on the session's host.
|
|
* tags:
|
|
* - Docker
|
|
* parameters:
|
|
* - in: path
|
|
* name: sessionId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - in: path
|
|
* name: containerId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - in: path
|
|
* name: action
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* enum: [start, stop, restart, pause, unpause]
|
|
* responses:
|
|
* 200:
|
|
* description: The action ran.
|
|
* 400:
|
|
* description: SSH session not found or not connected.
|
|
* 404:
|
|
* description: Container not found, or an unknown action.
|
|
* 500:
|
|
* description: The action failed.
|
|
*/
|
|
router.post(
|
|
"/containers/:sessionId/:containerId/:action",
|
|
async (req, res) => {
|
|
const action = req.params.action as ContainerAction;
|
|
if (!CONTAINER_ACTIONS.includes(action)) {
|
|
return res.status(404).json({ error: "Unknown container action" });
|
|
}
|
|
const session = sessionOf(req, res);
|
|
if (!session) return;
|
|
const { containerId } = req.params;
|
|
try {
|
|
log.info("Docker container operation", {
|
|
hostId: session.hostId,
|
|
containerId,
|
|
action,
|
|
});
|
|
await sessions.run(session, () =>
|
|
sessions.exec(
|
|
session,
|
|
containerCommand(session.runtime, `${action} ${containerId}`),
|
|
),
|
|
);
|
|
res.json({ success: true, message: `Container ${action} succeeded` });
|
|
} catch (error) {
|
|
if (notFound(error)) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: "Container not found",
|
|
code: "CONTAINER_NOT_FOUND",
|
|
});
|
|
}
|
|
log.error(`Failed to ${action} container`, error, {
|
|
hostId: session.hostId,
|
|
containerId,
|
|
});
|
|
res.status(500).json({
|
|
success: false,
|
|
error: getErrorMessage(error, `Failed to ${action} container`),
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/containers/{sessionId}/{containerId}/remove:
|
|
* delete:
|
|
* summary: Remove container
|
|
* description: Removes a container, optionally by force.
|
|
* 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.
|
|
* 400:
|
|
* description: Session not found, or the container is running and force was not set.
|
|
* 404:
|
|
* description: Container not found.
|
|
* 500:
|
|
* description: Failed to remove container.
|
|
*/
|
|
router.delete(
|
|
"/containers/:sessionId/:containerId/remove",
|
|
async (req, res) => {
|
|
const session = sessionOf(req, res);
|
|
if (!session) return;
|
|
const { containerId } = req.params;
|
|
const force = req.query.force === "true";
|
|
try {
|
|
log.info("Docker container operation", {
|
|
hostId: session.hostId,
|
|
containerId,
|
|
action: "remove",
|
|
});
|
|
await sessions.run(session, () =>
|
|
sessions.exec(
|
|
session,
|
|
containerCommand(
|
|
session.runtime,
|
|
`rm ${force ? "-f " : ""}${containerId}`,
|
|
),
|
|
),
|
|
);
|
|
res.json({ success: true, message: "Container removed successfully" });
|
|
} catch (error) {
|
|
if (notFound(error)) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: "Container not found",
|
|
code: "CONTAINER_NOT_FOUND",
|
|
});
|
|
}
|
|
if (
|
|
getErrorMessage(error, "").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",
|
|
});
|
|
}
|
|
log.error("Failed to remove container", error, {
|
|
hostId: session.hostId,
|
|
containerId,
|
|
});
|
|
res.status(500).json({
|
|
success: false,
|
|
error: getErrorMessage(error, "Failed to remove container"),
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/containers/{sessionId}/{containerId}/logs:
|
|
* get:
|
|
* summary: Get container logs
|
|
* description: The container's log output.
|
|
* 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.
|
|
*/
|
|
router.get("/containers/:sessionId/:containerId/logs", async (req, res) => {
|
|
const session = sessionOf(req, res);
|
|
if (!session) return;
|
|
const { containerId } = req.params;
|
|
const tail = req.query.tail ? parseInt(String(req.query.tail), 10) : 100;
|
|
const since = typeof req.query.since === "string" ? req.query.since : "";
|
|
const until = typeof req.query.until === "string" ? req.query.until : "";
|
|
|
|
let command = containerCommand(session.runtime, `logs ${containerId}`);
|
|
if (tail && tail > 0) command += ` --tail ${Math.floor(tail)}`;
|
|
if (req.query.timestamps === "true") 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";
|
|
|
|
try {
|
|
const logs = await sessions.run(session, () =>
|
|
sessions.exec(session, command),
|
|
);
|
|
res.json({ success: true, logs });
|
|
} catch (error) {
|
|
if (notFound(error)) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: "Container not found",
|
|
code: "CONTAINER_NOT_FOUND",
|
|
});
|
|
}
|
|
log.error("Failed to get container logs", error, {
|
|
hostId: session.hostId,
|
|
containerId,
|
|
});
|
|
res.status(500).json({
|
|
success: false,
|
|
error: getErrorMessage(error, "Failed to get container logs"),
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/docker/containers/{sessionId}/{containerId}/stats:
|
|
* get:
|
|
* summary: Get container stats
|
|
* description: One sample of the container's CPU, memory, network and block IO.
|
|
* 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.
|
|
*/
|
|
router.get("/containers/:sessionId/:containerId/stats", async (req, res) => {
|
|
const session = sessionOf(req, res);
|
|
if (!session) return;
|
|
const { containerId } = req.params;
|
|
const format = session.isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX;
|
|
try {
|
|
const output = await sessions.run(session, () =>
|
|
sessions.exec(
|
|
session,
|
|
containerCommand(
|
|
session.runtime,
|
|
`stats ${containerId} --no-stream --format ${format}`,
|
|
),
|
|
),
|
|
);
|
|
const raw = JSON.parse(output.trim()) as Record<string, string>;
|
|
const split = (value: string | undefined) => {
|
|
const [first, second] = (value ?? "").split(" / ");
|
|
return [first?.trim() || "0B", second?.trim() || "0B"];
|
|
};
|
|
const [memoryUsed, memoryLimit] = split(raw.memory);
|
|
const [netInput, netOutput] = split(raw.netIO);
|
|
const [blockRead, blockWrite] = split(raw.blockIO);
|
|
res.json({
|
|
cpu: raw.cpu,
|
|
memoryUsed,
|
|
memoryLimit,
|
|
memoryPercent: raw.memoryPercent,
|
|
netInput,
|
|
netOutput,
|
|
blockRead,
|
|
blockWrite,
|
|
pids: raw.pids,
|
|
});
|
|
} catch (error) {
|
|
if (notFound(error)) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: "Container not found",
|
|
code: "CONTAINER_NOT_FOUND",
|
|
});
|
|
}
|
|
log.error("Failed to get container stats", error, {
|
|
hostId: session.hostId,
|
|
containerId,
|
|
});
|
|
res.status(500).json({
|
|
success: false,
|
|
error: getErrorMessage(error, "Failed to get container stats"),
|
|
});
|
|
}
|
|
});
|
|
}
|