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[] { return output .split("\n") .filter((line) => line.trim()) .flatMap((line) => { try { return [JSON.parse(line) as Record]; } 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; 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; 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; 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; 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; 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; 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"), }); } }); }