feat: finish the docker plugin (B15)

This commit is contained in:
LukeGus committed 2026-09-24 11:58:20 -05:00
1 parent 0a3f05d99d
commit 993a91c899
167 files changed
+26299 -5136

No files matched your search

+404
View File
@@ -0,0 +1,404 @@
/**
* The Docker panel's interactive connect.
*
* Core's pipeline does the handshake; this only bridges its prompt channel to
* HTTP. A connect that needs a person (a TOTP code, a Warpgate sign-in, a
* password the host does not store) answers the request that is waiting with
* what to ask, and the next request (connect-totp, connect-warpgate) carries
* the answer back and waits for whatever comes after it: the session, another
* prompt, or an error.
*/
import type { Client } from "ssh2";
import type {
PluginContext,
PluginSshHost,
PluginSshPromptRequest,
} from "@termix/plugin-sdk/backend";
import type { ContainerRuntime } from "./container-runtime.js";
import type { DockerSessions } from "./sessions.js";
import {
connectionLog,
getErrorMessage,
type ConnectionLogLine,
type DockerLogger,
} from "./helpers.js";
/** What one HTTP request answers with. */
export interface ConnectStep {
status: number;
body: Record<string, unknown>;
}
const TOTP_WAIT_MS = 3 * 60 * 1000;
const WARPGATE_WAIT_MS = 5 * 60 * 1000;
const STEP_WAIT_MS = 90 * 1000;
// Covers the longest a person may take on a prompt, plus the handshake.
const CONNECT_TIMEOUT_MS = WARPGATE_WAIT_MS + 30 * 1000;
const PASSWORD_PROMPT = /password/i;
/** Hands each step to whichever request is waiting for one. */
export class PendingConnect {
constructor(
readonly userId: string,
readonly hostId: number,
) {}
expiresAt = Date.now() + TOTP_WAIT_MS;
/** What the open prompt expects, or null when nothing is being asked. */
awaiting: "totp" | "warpgate" | null = null;
abandoned = false;
private waiter: ((step: ConnectStep) => void) | null = null;
private queued: ConnectStep | null = null;
private answer: ((value: string | null) => void) | null = null;
emit(step: ConnectStep): void {
if (this.waiter) {
const waiter = this.waiter;
this.waiter = null;
waiter(step);
} else {
this.queued = step;
}
}
next(timeoutMs = STEP_WAIT_MS): Promise<ConnectStep> {
if (this.queued) {
const step = this.queued;
this.queued = null;
return Promise.resolve(step);
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (this.waiter !== settle) return;
this.waiter = null;
this.cancel();
resolve({
status: 408,
body: { error: "Connection timed out. Please reconnect." },
});
}, timeoutMs);
const settle = (step: ConnectStep) => {
clearTimeout(timer);
resolve(step);
};
this.waiter = settle;
});
}
ask(kind: "totp" | "warpgate", waitMs: number): Promise<string | null> {
this.awaiting = kind;
this.expiresAt = Date.now() + waitMs;
return new Promise((resolve) => {
this.answer = resolve;
});
}
respond(value: string | null): void {
const answer = this.answer;
this.answer = null;
this.awaiting = null;
answer?.(value);
}
/** Nobody will read the result: give up the prompt and drop the client. */
cancel(): void {
this.abandoned = true;
this.respond(null);
}
}
function classifyConnectError(
message: string,
logs: ConnectionLogLine[],
): { hostKeyChanged: boolean; authFailed: boolean } {
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
logs.push(
connectionLog("error", "dns", `DNS resolution failed: ${message}`),
);
} else if (
message.includes("ECONNREFUSED") ||
message.includes("ETIMEDOUT")
) {
logs.push(
connectionLog("error", "tcp", `TCP connection failed: ${message}`),
);
} else if (message.includes("verification failed")) {
logs.push(
connectionLog(
"error",
"handshake",
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
),
);
return { hostKeyChanged: true, authFailed: false };
} else if (
message.includes("handshake") ||
message.includes("key exchange")
) {
logs.push(
connectionLog("error", "handshake", `SSH handshake failed: ${message}`),
);
} else if (/authentication/i.test(message)) {
logs.push(
connectionLog("error", "auth", `Authentication failed: ${message}`),
);
return { hostKeyChanged: false, authFailed: true };
} else {
logs.push(
connectionLog("error", "error", `SSH connection failed: ${message}`),
);
}
return { hostKeyChanged: false, authFailed: false };
}
export interface StartConnectInput {
sessionId: string;
userId: string;
host: PluginSshHost;
runtime: ContainerRuntime;
logs: ConnectionLogLine[];
}
/**
* Starts a connect and returns the first step for the request that asked.
* The pending entry lives in `sessions` until the connect settles, so the
* answer routes can find it.
*/
export async function startConnect(
ctx: PluginContext,
sessions: DockerSessions,
log: DockerLogger,
input: StartConnectInput,
): Promise<ConnectStep> {
const { sessionId, userId, host, runtime, logs } = input;
const hostId = host.id;
const authType = (host.authType as string) || "none";
const credentialless = !ctx.ssh.requiresSecret(authType);
const pending = new PendingConnect(userId, hostId);
sessions.setPending(sessionId, pending);
const parkTotp = (prompt: string, extra: Record<string, unknown>) => {
logs.push(connectionLog("info", "docker_auth", "Verification required"));
const answer = pending.ask("totp", TOTP_WAIT_MS);
pending.emit({
status: 200,
body: {
requires_totp: true,
sessionId,
prompt,
connectionLogs: logs,
...extra,
},
});
return answer;
};
const ask = async (
request: PluginSshPromptRequest,
): Promise<string | null> => {
if (pending.abandoned) return null;
switch (request.kind) {
case "totp":
return parkTotp(request.prompt, request.retry ? { retry: true } : {});
case "warpgate": {
logs.push(
connectionLog(
"info",
"docker_auth",
"Warpgate authentication required",
),
);
const answer = pending.ask("warpgate", WARPGATE_WAIT_MS);
pending.emit({
status: 200,
body: {
requires_warpgate: true,
sessionId,
url: request.url,
securityKey: request.securityKey,
connectionLogs: logs,
},
});
return answer;
}
case "input": {
const isPassword = PASSWORD_PROMPT.test(request.prompt);
if (isPassword && credentialless) {
pending.abandoned = true;
pending.emit({
status: 200,
body: { status: "auth_required", reason: "no_keyboard" },
});
return null;
}
if (!isPassword && !request.isPush) return "";
return parkTotp(request.prompt, isPassword ? { isPassword: true } : {});
}
}
};
logs.push(connectionLog("info", "dns", `Resolving DNS for ${host.ip}`));
logs.push(
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
);
logs.push(connectionLog("info", "handshake", "Initiating SSH handshake"));
ctx.ssh
.connect<Client>(host, {
purpose: "docker",
timeoutMs: CONNECT_TIMEOUT_MS,
prompt: { ask },
})
.then(
(connection) => {
sessions.clearPending(sessionId);
ctx.hosts.status.reportLogin(hostId, { ok: true });
if (pending.abandoned) {
connection.dispose();
return;
}
const session = {
id: sessionId,
client: connection.client,
dispose: connection.dispose,
hostId,
userId,
runtime,
isWindows: false,
lastActive: Date.now(),
activeOperations: 0,
};
sessions.add(session);
connection.client.exec("ver", (err, stream) => {
if (err || !stream) return;
let output = "";
stream.on("data", (d: Buffer) => {
output += d.toString();
});
stream.on("close", () => {
if (output.toLowerCase().includes("windows")) {
session.isWindows = true;
}
});
stream.stderr.on("data", () => {});
});
void ctx.audit.record({
action: "docker_connect",
resourceType: "host",
resourceId: String(hostId),
resourceName: (host.name as string) || undefined,
success: true,
});
logs.push(
connectionLog(
"success",
"connected",
"SSH connection established successfully",
),
);
pending.emit({
status: 200,
body: { success: true, status: "success", connectionLogs: logs },
});
},
(error: unknown) => {
sessions.clearPending(sessionId);
const message = getErrorMessage(error, "SSH connection failed");
if (pending.abandoned) return;
const outcome = (error as { outcome?: Record<string, unknown> })
.outcome;
if (outcome?.status === "interaction-required") {
logs.push(connectionLog("error", "docker_auth", message));
pending.emit({
status: 401,
body: {
error: message,
requiresAuthInteraction: outcome.interaction,
...(typeof outcome.flag === "string"
? { [outcome.flag]: true }
: {}),
connectionLogs: logs,
},
});
return;
}
if (outcome?.status === "error") {
logs.push(connectionLog("error", "docker_auth", message));
pending.emit({
status: 400,
body: { error: message, connectionLogs: logs },
});
return;
}
const { hostKeyChanged, authFailed } = classifyConnectError(
message,
logs,
);
if (hostKeyChanged || authFailed) {
ctx.hosts.status.reportLogin(hostId, { ok: false, hostKeyChanged });
}
log.error("Docker SSH connection failed", {
hostId,
userId,
error: message,
});
if (credentialless && authFailed) {
pending.emit({
status: 200,
body: {
status: "auth_required",
reason: "no_keyboard",
connectionLogs: logs,
},
});
return;
}
pending.emit({
status: 500,
body: { success: false, message, connectionLogs: logs },
});
},
);
return pending.next();
}
/** Carries a person's answer to a parked connect and waits for what's next. */
export async function answerConnect(
sessions: DockerSessions,
sessionId: string,
userId: string,
kind: "totp" | "warpgate",
value: string,
): Promise<ConnectStep> {
const pending = sessions.getPending(sessionId);
if (!(pending instanceof PendingConnect) || pending.userId !== userId) {
return {
status: 404,
body: { error: "Authentication session expired. Please reconnect." },
};
}
if (pending.awaiting !== kind) {
return {
status: 400,
body: {
error:
kind === "warpgate"
? "Session is not a Warpgate session"
: "Session is not waiting for a code",
},
};
}
pending.respond(value);
return pending.next();
}
+297 -606
View File
@@ -1,652 +1,343 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { StringDecoder } from "string_decoder";
import type { Client as SSHClient } from "ssh2";
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
import { connectSsh } from "./ssh.js";
import { WebSocketServer, WebSocket } from "ws";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { systemLogger } from "../../../../src/backend/utils/logger.js";
import type { SSHHost } from "../../../../src/types/index.js";
import { AsyncResource } from "node:async_hooks";
import { StringDecoder } from "node:string_decoder";
import type { Client, ClientChannel } from "ssh2";
import { WebSocket, type RawData } from "ws";
import type {
PluginContext,
PluginWebSocketConnection,
} from "@termix/plugin-sdk/backend";
import {
containerCommand,
getContainerRuntimeConfig,
type ContainerRuntime,
} from "./container-runtime.js";
import { readDockerHostSettings } from "./host-settings.js";
import {
hostAddressMismatch,
CONTAINER_ID_RE,
HOST_ADDRESS_MISMATCH_MESSAGE,
HOST_NOT_ON_THIS_SERVER_MESSAGE,
} from "../../../../src/backend/hosts/host-identity.js";
import { extractWebSocketToken } from "../../../../src/backend/utils/ws-auth.js";
import {
asObject,
asString,
MAX_WS_MESSAGE_BYTES,
getErrorMessage,
hostAddressMismatch,
parseWsMessage,
toTerminalDimension,
} from "../../../../src/backend/utils/ws-message.js";
type DockerLogger,
} from "./helpers.js";
const sshLogger = systemLogger;
const SHELLS = ["bash", "sh", "ash", "zsh"];
const PING_MS = 30_000;
interface SSHSession {
client: SSHClient;
stream: import("ssh2").ClientChannel | null;
isConnected: boolean;
containerId?: string;
shell?: string;
hostId?: number;
containerRuntime?: ContainerRuntime;
interface ConsoleSession {
client: Client;
dispose: () => void;
stream: ClientChannel | null;
hostId: number;
containerId: string;
runtime: ContainerRuntime;
}
const activeSessions = new Map<string, SSHSession>();
/**
* Created by startConsoleServer(), not at module load.
*
* Importing this file used to bind a port as a side effect, which meant the
* plugin could not be disabled and re-enabled: the module stays in the ESM
* cache, so the second activate() got a server that had already been closed.
* There is no port now -- core routes /plugin-ws/docker/console here -- but
* the same lifecycle applies to the server object itself.
*/
let wss: WebSocketServer | null = null;
function handleConnection(ws: WebSocket, req: import("http").IncomingMessage) {
void onConsoleConnection(ws, req);
}
/** Builds the console WebSocket server. Call from the plugin's activate(). */
export function startConsoleServer(): Promise<void> {
if (wss) return Promise.resolve();
const server = new WebSocketServer({
noServer: true,
maxPayload: MAX_WS_MESSAGE_BYTES,
});
// One bad socket must not take the server down.
server.on("error", (error) => {
sshLogger.error("Docker console WebSocket server error", error, {
operation: "wss_error",
});
});
server.on("connection", handleConnection);
wss = server;
return Promise.resolve();
}
/**
* Hands one upgrade to the console server.
*
* Registered as a public ctx.ws route because onConsoleConnection does its own
* per-message authentication, which is where the container and host checks
* live too.
*/
export function handleConsoleUpgrade(
request: import("http").IncomingMessage,
socket: import("stream").Duplex,
head: Buffer,
): void {
const server = wss;
if (!server) {
socket.destroy();
return;
}
server.handleUpgrade(request, socket, head, (ws) => {
server.emit("connection", ws, request);
});
}
async function detectShell(
session: SSHSession,
function shellExists(
client: Client,
runtime: ContainerRuntime,
containerId: string,
): Promise<string> {
const shells = ["bash", "sh", "ash"];
for (const shell of shells) {
try {
await new Promise<void>((resolve, reject) => {
session.client.exec(
containerCommand(
session.containerRuntime,
`exec ${containerId} which ${shell}`,
),
(err, stream) => {
if (err) return reject(err);
let output = "";
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.on("close", (code: number) => {
if (code === 0 && output.trim()) {
resolve();
} else {
reject(new Error(`Shell ${shell} not found`));
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
reject(streamErr);
});
},
shell: string,
): Promise<boolean> {
return new Promise((resolve) => {
client.exec(
containerCommand(runtime, `exec ${containerId} which ${shell}`),
(err, stream) => {
if (err) return resolve(false);
let output = "";
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.on("close", (code: number) =>
resolve(code === 0 && output.trim().length > 0),
);
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", () => resolve(false));
},
);
});
}
return shell;
} catch {
continue;
}
async function pickShell(
client: Client,
runtime: ContainerRuntime,
containerId: string,
requested: string | undefined,
): Promise<string> {
if (requested && (await shellExists(client, runtime, containerId, requested)))
return requested;
for (const shell of ["bash", "sh", "ash"]) {
if (await shellExists(client, runtime, containerId, shell)) return shell;
}
return "sh";
}
async function onConsoleConnection(
ws: WebSocket,
req: import("http").IncomingMessage,
) {
const token = extractWebSocketToken(req);
if (!token) {
ws.close(1008, "Authentication required");
return;
}
const authManagerInstance = AuthManager.getInstance();
let payload;
try {
payload = await authManagerInstance.verifyJWTToken(token);
} catch (error) {
sshLogger.warn("Docker console JWT verification failed", {
operation: "docker_console_auth_error",
error: getErrorMessage(error),
});
ws.close(1008, "Authentication required");
return;
}
if (!payload?.userId || payload.pendingTOTP) {
ws.close(1008, "Authentication required");
return;
}
const userId = payload.userId;
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
sshLogger.info("Docker console WebSocket connected", {
operation: "docker_console_connect",
sessionId,
userId,
/**
* The container console at /plugin-ws/docker/console.
*
* Public with optionalAuth: core still resolves the caller's token, and the
* handler refuses a socket without a user or without docker.use. Every live
* console is closed when the plugin deactivates.
*/
export function registerConsole(ctx: PluginContext, log: DockerLogger): void {
const live = new Set<() => void>();
ctx.disposables.add(() => {
for (const end of [...live]) end();
live.clear();
});
let sshSession: SSHSession | null = null;
const wsPingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
const onConnection = async (connection: PluginWebSocketConnection) => {
const ws = connection.socket as WebSocket;
const userId = connection.userId;
if (!userId || !(await ctx.rbac.hasFor(userId, "use"))) {
ws.close(1008, "Authentication required");
return;
}
}, 30000);
const cleanup = () => {
clearInterval(wsPingInterval);
if (!sshSession) return;
sshSession.stream?.end();
sshSession.client.end();
activeSessions.delete(sessionId);
sshSession = null;
};
let session: ConsoleSession | null = null;
const send = (message: Record<string, unknown>) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
};
ws.on("message", async (data) => {
try {
const message = parseWsMessage(data);
const endSession = () => {
if (!session) return;
const current = session;
session = null;
current.stream?.end();
current.dispose();
};
switch (message.type) {
case "connect": {
const connectData = asObject(message.data);
const hostConfig = asObject(
connectData.hostConfig,
) as unknown as SSHHost;
const containerId = asString(connectData.containerId);
const shell = asString(connectData.shell) || undefined;
const cols = toTerminalDimension(connectData.cols) || 80;
const rows = toTerminalDimension(connectData.rows) || 24;
const pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.ping();
}, PING_MS);
const hostId = hostConfig?.id;
const cleanup = () => {
clearInterval(pingTimer);
endSession();
live.delete(shutdown);
};
const shutdown = () => {
cleanup();
try {
ws.close(1001, "Docker plugin disabled");
} catch {
// already closed
}
};
live.add(shutdown);
if (!hostId || !containerId) {
ws.send(
JSON.stringify({
type: "error",
message: "Host configuration and container ID are required",
}),
);
return;
}
const connect = async (data: unknown) => {
if (!connection.isDataUnlocked()) {
send({
type: "error",
message: "Session expired - please log in again",
});
return;
}
const payload = asObject(data);
const hostConfig = asObject(payload.hostConfig);
const hostId = Number(hostConfig.id);
const syncId =
typeof hostConfig.syncId === "string" ? hostConfig.syncId : null;
const containerId = asString(payload.containerId);
const shell = asString(payload.shell) || undefined;
const cols = toTerminalDimension(payload.cols) || 80;
const rows = toTerminalDimension(payload.rows) || 24;
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerId)) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid container ID",
}),
);
return;
}
if (!hostId || !containerId) {
send({
type: "error",
message: "Host configuration and container ID are required",
});
return;
}
if (!CONTAINER_ID_RE.test(containerId)) {
send({ type: "error", message: "Invalid container ID" });
return;
}
if (shell && !SHELLS.includes(shell)) {
send({ type: "error", message: "Invalid shell" });
return;
}
const allowedShells = ["bash", "sh", "ash", "zsh"];
if (shell && !allowedShells.includes(shell)) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid shell",
}),
);
return;
}
endSession();
if (!hostConfig.enableDocker) {
ws.send(
JSON.stringify({
type: "error",
message: "Docker is not enabled on this host",
}),
);
return;
}
try {
// Resolve host with credentials server-side
const { resolveHostById, resolveHostBySyncId } =
await import("../../../../src/backend/hosts/host-resolver.js");
// syncId names the host on both sides of a sync pair; the numeric
// id only names it in the database the client is displaying.
const hostSyncId = hostConfig?.syncId;
const resolvedHost = hostSyncId
? await resolveHostBySyncId(hostSyncId, userId)
: await resolveHostById(hostId, userId);
if (!resolvedHost) {
ws.send(
JSON.stringify({
type: "error",
message: hostSyncId
? HOST_NOT_ON_THIS_SERVER_MESSAGE
: "Host not found",
}),
);
return;
}
// The connection below dials resolvedHost.ip outright, so if this
// server has a different machine under the id the client sent, the
// console opens on that machine's Docker daemon instead.
if (
!hostSyncId &&
hostAddressMismatch(hostConfig?.ip, resolvedHost.ip)
) {
sshLogger.error(
"Refusing Docker console: host id resolves to a different address here",
undefined,
{
operation: "docker_console_host_id_mismatch",
hostId,
userId,
clientIp: hostConfig?.ip,
resolvedIp: resolvedHost.ip,
},
);
ws.send(
JSON.stringify({
type: "error",
message: HOST_ADDRESS_MISMATCH_MESSAGE,
}),
);
return;
}
if (!resolvedHost.enableDocker) {
ws.send(
JSON.stringify({
type: "error",
message:
"Docker is not enabled for this host. Enable it in Host Settings.",
}),
);
return;
}
// The console socket is public and authenticates itself, so the
// acting user is carried on the host rather than the request.
const { client } = await connectSsh(
{
...(resolvedHost as unknown as PluginSshHost),
userId,
} as PluginSshHost,
{ purpose: "docker-console", timeoutMs: 65000 },
);
const { runtime: containerRuntime } = getContainerRuntimeConfig(
resolvedHost.dockerConfig,
);
sshSession = {
client,
stream: null,
isConnected: true,
containerId,
hostId: resolvedHost.id,
containerRuntime,
};
activeSessions.set(sessionId, sshSession);
let shellToUse = shell || "bash";
if (shell) {
try {
await new Promise<void>((resolve, reject) => {
client.exec(
containerCommand(
containerRuntime,
`exec ${containerId} which ${shell}`,
),
(err, stream) => {
if (err) return reject(err);
let output = "";
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.on("close", (code: number) => {
if (code === 0 && output.trim()) {
resolve();
} else {
reject(new Error(`Shell ${shell} not available`));
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
reject(streamErr);
});
},
);
});
} catch {
sshLogger.warn(
`Requested shell ${shell} not found, detecting available shell`,
{
operation: "shell_validation",
sessionId,
containerId,
requestedShell: shell,
},
);
shellToUse = await detectShell(sshSession, containerId);
}
} else {
shellToUse = await detectShell(sshSession, containerId);
}
sshSession.shell = shellToUse;
const execCommand = containerCommand(
containerRuntime,
`exec -it ${containerId} /bin/${shellToUse}`,
);
sshLogger.info("Attaching to Docker container", {
operation: "docker_attach",
sessionId,
userId,
hostId: resolvedHost.id,
containerId,
});
client.exec(
execCommand,
{
pty: {
term: "xterm-256color",
cols,
rows,
},
},
(err, stream) => {
if (err) {
sshLogger.error("Failed to create docker exec", err, {
operation: "docker_exec",
sessionId,
containerId,
});
ws.send(
JSON.stringify({
type: "error",
message: `Failed to start console: ${err.message}`,
}),
);
return;
}
sshSession!.stream = stream;
sshLogger.success("Docker container attached", {
operation: "docker_attach_success",
sessionId,
userId,
hostId: resolvedHost.id,
containerId,
});
// Buffers incomplete multi-byte UTF-8 sequences across chunk
// boundaries so box-drawing/special characters don't get
// corrupted when a character is split across TCP packets.
const decoder = new StringDecoder("utf-8");
stream.on("data", (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
const text = decoder.write(data);
if (!text) return;
ws.send(
JSON.stringify({
type: "output",
data: text,
}),
);
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
sshLogger.error("Docker console stream error", streamErr, {
operation: "docker_console_stream_error",
sessionId,
containerId,
});
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message: `Console error: ${streamErr.message}`,
}),
);
}
});
stream.on("close", () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Console session ended",
}),
);
}
if (sshSession) {
sshSession.client.end();
activeSessions.delete(sessionId);
}
});
ws.send(
JSON.stringify({
type: "connected",
data: {
shell: shellToUse,
requestedShell: shell,
shellChanged: shell && shell !== shellToUse,
},
}),
);
},
);
} catch (error) {
sshLogger.error("Failed to connect to container", error, {
operation: "console_connect",
sessionId,
containerId,
});
ws.send(
JSON.stringify({
type: "error",
message: getErrorMessage(
error,
"Failed to connect to container",
),
}),
);
}
break;
try {
// syncId names the host on both sides of a sync pair; the numeric
// id only names it in the database the client is displaying.
const host = await ctx.ssh.resolveHost(hostId, { syncId });
if (!host) {
send({
type: "error",
message: syncId
? HOST_NOT_ON_THIS_SERVER_MESSAGE
: "Host not found",
});
return;
}
if (!syncId && hostAddressMismatch(hostConfig.ip, host.ip)) {
log.error("Refusing Docker console: host id resolves elsewhere", {
hostId,
userId,
});
send({ type: "error", message: HOST_ADDRESS_MISMATCH_MESSAGE });
return;
}
case "input": {
if (sshSession && sshSession.stream) {
const input = asString(message.data);
if (input) sshSession.stream.write(input);
}
break;
const settings = await readDockerHostSettings(ctx, host.id);
if (!settings.enabled) {
send({
type: "error",
message:
"Docker is not enabled for this host. Enable it in Host Settings.",
});
return;
}
case "resize": {
if (sshSession && sshSession.stream) {
const dimensions = asObject(message.data);
const cols = toTerminalDimension(dimensions.cols);
const rows = toTerminalDimension(dimensions.rows);
const { client, dispose } = await ctx.ssh.connect<Client>(host, {
purpose: "docker-console",
timeoutMs: 65_000,
});
if (ws.readyState !== WebSocket.OPEN) {
dispose();
return;
}
const current: ConsoleSession = {
client,
dispose,
stream: null,
hostId: host.id,
containerId,
runtime: settings.runtime,
};
session = current;
const shellToUse = await pickShell(
client,
settings.runtime,
containerId,
shell,
);
if (session !== current) return;
client.exec(
containerCommand(
settings.runtime,
`exec -it ${containerId} /bin/${shellToUse}`,
),
{ pty: { term: "xterm-256color", cols, rows } },
(err, stream) => {
if (err) {
send({
type: "error",
message: `Failed to start console: ${err.message}`,
});
return;
}
if (session !== current) {
stream.end();
return;
}
current.stream = stream;
// Buffers split multi-byte UTF-8 sequences across chunks.
const decoder = new StringDecoder("utf-8");
stream.on("data", (chunk: Buffer) => {
const text = decoder.write(chunk);
if (text) send({ type: "output", data: text });
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr: Error) => {
send({
type: "error",
message: `Console error: ${streamErr.message}`,
});
});
stream.on("close", () => {
send({ type: "disconnected", message: "Console session ended" });
if (session === current) endSession();
});
send({
type: "connected",
data: {
shell: shellToUse,
requestedShell: shell,
shellChanged: !!shell && shell !== shellToUse,
},
});
},
);
} catch (error) {
log.error("Failed to connect to container", error, {
hostId,
containerId,
});
send({
type: "error",
message: getErrorMessage(error, "Failed to connect to container"),
});
}
};
ws.on(
"message",
AsyncResource.bind(async (raw: RawData) => {
let type: string;
let data: unknown;
try {
({ type, data } = parseWsMessage(raw));
} catch (error) {
send({ type: "error", message: getErrorMessage(error) });
return;
}
switch (type) {
case "connect":
await connect(data);
break;
case "input": {
const input = asString(data);
if (input) session?.stream?.write(input);
break;
}
case "resize": {
const size = asObject(data);
const cols = toTerminalDimension(size.cols);
const rows = toTerminalDimension(size.rows);
if (cols && rows)
sshSession.stream.setWindow(rows, cols, rows, cols);
session?.stream?.setWindow(rows, cols, rows, cols);
break;
}
break;
}
case "disconnect": {
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
ws.send(
JSON.stringify({
case "disconnect":
if (session) {
endSession();
send({
type: "disconnected",
message: "Disconnected from container",
}),
);
}
break;
});
}
break;
case "ping":
send({ type: "pong" });
break;
default:
log.warn("Unknown docker console message", { type });
}
}),
);
case "ping": {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" }));
}
break;
}
default:
sshLogger.warn("Unknown message type", {
operation: "ws_message",
type: message.type,
});
}
} catch (error) {
sshLogger.error("WebSocket message error", error, {
operation: "ws_message",
sessionId,
});
ws.send(
JSON.stringify({
type: "error",
message: getErrorMessage(error, "An error occurred"),
}),
);
}
});
ws.on("close", () => {
sshLogger.info("Docker console disconnected", {
operation: "docker_console_disconnect",
sessionId,
userId,
hostId: sshSession?.hostId,
containerId: sshSession?.containerId,
ws.on("close", cleanup);
ws.on("error", (error: Error) => {
log.error("Docker console socket error", error);
cleanup();
});
cleanup();
});
};
ws.on("error", (error) => {
sshLogger.error("WebSocket error", error, {
operation: "ws_error",
sessionId,
});
cleanup();
});
}
/**
* Closes every live console session and the WebSocket server. Called from the
* plugin's deactivate().
*
* There is deliberately no process signal handler here. Core owns shutdown:
* gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which
* runs every plugin's deactivate. A plugin calling process.exit() itself
* skipped the rest of that sequence, including other plugins and the
* database flush.
*/
export function closeConsoleServer(): Promise<void> {
activeSessions.forEach((session) => {
if (session.stream) {
session.stream.end();
}
session.client.end();
});
activeSessions.clear();
const server = wss;
wss = null;
if (!server) return Promise.resolve();
server.off("connection", handleConnection);
for (const client of server.clients) {
try {
client.close(1001, "Docker plugin disabled");
} catch {
// Already gone.
}
}
return new Promise((resolve) => server.close(() => resolve()));
ctx.ws.route("/console", onConnection, { public: true, optionalAuth: true });
}
File diff suppressed because it is too large. Load diff
@@ -4,33 +4,6 @@ export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
return value === "podman" ? "podman" : "docker";
}
export function getContainerRuntimeConfig(raw: unknown): {
runtime: ContainerRuntime;
} {
if (!raw) {
return { runtime: "docker" };
}
let config: unknown = raw;
if (typeof raw === "string") {
try {
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
return { runtime: "docker" };
}
}
if (!config || typeof config !== "object") {
return { runtime: "docker" };
}
return {
runtime: normalizeContainerRuntime(
(config as Record<string, unknown>).runtime,
),
};
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
@@ -0,0 +1,84 @@
/**
* Container state snapshots and the events two of them imply, for the
* docker.events service.
*
* Events come from diffing successive `ps -a` snapshots, so the poll interval
* is the resolution: a container that stops and starts between two polls is
* not reported. That is the tradeoff for not holding a `docker events` stream
* open against every watched host.
*/
export interface ContainerState {
/** Docker's own state word: running, exited, restarting, ... */
state: string;
/** Health from the status text, when the image declares a healthcheck. */
unhealthy: boolean;
}
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
export function parseContainerStates(
output: string,
): Map<string, ContainerState> {
const states = new Map<string, ContainerState>();
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as {
name?: string;
state?: string;
status?: string;
};
if (!parsed.name) continue;
states.set(parsed.name, {
state: (parsed.state ?? "").toLowerCase(),
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
});
} catch {
// A partial line is not worth failing the whole poll over.
}
}
return states;
}
/**
* The events a pair of snapshots implies. A container missing from the
* previous snapshot counts as newly seen rather than started, so the first
* poll after a restart does not replay every running container.
*/
export function diffContainerStates(
previous: Map<string, ContainerState>,
current: Map<string, ContainerState>,
): Array<{ container: string; event: DockerEventName }> {
const events: Array<{ container: string; event: DockerEventName }> = [];
for (const [name, now] of current) {
const before = previous.get(name);
if (!before) continue;
if (before.state !== now.state) {
if (now.state === "exited")
events.push({ container: name, event: "exited" });
else if (now.state === "running")
events.push({ container: name, event: "started" });
else if (now.state === "restarting")
events.push({ container: name, event: "restarting" });
}
// Health is independent of state: a container can go unhealthy while it
// stays up, which is exactly the case worth alerting on.
if (!before.unhealthy && now.unhealthy) {
events.push({ container: name, event: "unhealthy" });
}
}
return events;
}
+154
View File
@@ -0,0 +1,154 @@
import type { RawData } from "ws";
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
export type Meta = Record<string, unknown>;
export interface DockerLogger {
info: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
/** An error, or structured context, or both. */
error: (message: string, errorOrMeta?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
if (!meta || Object.keys(meta).length === 0) return message;
try {
return `${message} ${JSON.stringify(meta)}`;
} catch {
return message;
}
}
/** ctx.log with the structured context the old core logger carried. */
export function createLogger(log: PluginLogger): DockerLogger {
return {
info: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, errorOrMeta, meta) => {
if (errorOrMeta instanceof Error) {
log.error(withMeta(message, meta), errorOrMeta);
return;
}
log.error(
withMeta(message, {
...((errorOrMeta as Meta | undefined) ?? {}),
...meta,
}),
);
},
};
}
export type LogLevel = "info" | "success" | "warning" | "error";
/** One line of the connection log the panel shows while connecting. */
export interface ConnectionLogLine {
type: LogLevel;
stage: string;
message: string;
}
export function connectionLog(
type: LogLevel,
stage: string,
message: string,
): ConnectionLogLine {
return { type, stage, message };
}
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
// Cap on a single decoded text frame. Control messages are tiny, and console
// input is bounded by what a user can type or paste.
export const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
function rawByteLength(raw: RawData): number {
if (Buffer.isBuffer(raw)) return raw.length;
if (Array.isArray(raw))
return raw.reduce((sum, part) => sum + part.length, 0);
if (raw instanceof ArrayBuffer) return raw.byteLength;
return 0;
}
/** Parses a frame into `{ type, data }`, throwing on anything else. */
export function parseWsMessage(raw: RawData): { type: string; data: unknown } {
if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) {
throw new Error("Message too large");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
throw new Error("Invalid JSON");
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Message must be a JSON object");
}
const { type, data } = parsed as { type?: unknown; data?: unknown };
if (typeof type !== "string") {
throw new Error("Message type must be a string");
}
return { type, data };
}
export function asObject(data: unknown): Record<string, unknown> {
return data !== null && typeof data === "object" && !Array.isArray(data)
? (data as Record<string, unknown>)
: {};
}
export function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
/** A sane integer width or height, or 0 when the value is unusable. */
export function toTerminalDimension(value: unknown): number {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return 0;
const rounded = Math.floor(n);
if (rounded < 1) return 0;
return Math.min(rounded, 10000);
}
function normalizeHostAddress(value: unknown): string {
if (typeof value !== "string") return "";
return value
.replace(/^\[|\]$/g, "")
.trim()
.toLowerCase();
}
/**
* Whether a numeric host id resolved to a different machine than the client
* meant. Ids from the desktop app and a sync server drift apart, and the
* resolved row supplies the credentials, so a mismatch must be refused.
*/
export function hostAddressMismatch(
clientAddress: unknown,
resolvedAddress: unknown,
): boolean {
const resolved = normalizeHostAddress(resolvedAddress);
if (!resolved) return false;
return resolved !== normalizeHostAddress(clientAddress);
}
export const HOST_ADDRESS_MISMATCH_MESSAGE =
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
"The host ids on this device and on the sync server have drifted apart. " +
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.';
export const HOST_NOT_ON_THIS_SERVER_MESSAGE =
"This host does not exist on the sync server, so the connection was refused. " +
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
export function newSessionId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
@@ -0,0 +1,70 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
normalizeContainerRuntime,
type ContainerRuntime,
} from "./container-runtime.js";
export const PLUGIN_ID = "docker";
export interface DockerHostSettings {
enabled: boolean;
runtime: ContainerRuntime;
}
export async function readDockerHostSettings(
ctx: PluginContext,
hostId: number,
): Promise<DockerHostSettings> {
const values = await ctx.settings.getAll("host", hostId);
return {
enabled: values.enableDocker === true,
runtime: normalizeContainerRuntime(values.containerRuntime),
};
}
function asObject(value: unknown): Record<string, unknown> | undefined {
let parsed = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return undefined;
}
}
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
}
function asBool(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (value === 1 || value === "1" || value === "true") return true;
if (value === 0 || value === "0" || value === "false") return false;
return undefined;
}
/**
* The docker host settings in an imported or created host row: the nested
* `pluginSettings.docker` of a Termix export first, then the flat
* `enableDocker` and `dockerConfig.runtime` hosts had before 2.9.0 (older
* exports, proxmox guests marked as Docker hosts).
*/
export function normalizeImportedHost(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const nested = asObject(asObject(raw.pluginSettings)?.[PLUGIN_ID]) ?? {};
const out: Record<string, unknown> = {};
const enabled = asBool(
nested.enableDocker !== undefined ? nested.enableDocker : raw.enableDocker,
);
if (enabled !== undefined) out.enableDocker = enabled;
const runtime =
nested.containerRuntime ?? asObject(raw.dockerConfig)?.runtime;
if (runtime === "docker" || runtime === "podman") {
out.containerRuntime = runtime;
}
return Object.keys(out).length > 0 ? out : null;
}
+22 -71
View File
@@ -1,82 +1,33 @@
import express, { type Router } from "express";
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { registerDockerContainerRoutes } from "./container-routes.js";
import {
sshSessions,
pendingTOTPSessions,
executeDockerCommand,
} from "./session-manager.js";
import {
DOCKER_TIMESTAMP_RE,
getRequestUserId,
registerDockerSshRoutes,
} from "./routes.js";
import {
closeConsoleServer,
handleConsoleUpgrade,
startConsoleServer,
} from "./console.js";
import { createLogger } from "./helpers.js";
import { createDockerSessions } from "./sessions.js";
import { registerRoutes } from "./routes.js";
import { registerConsole } from "./console.js";
import { registerServices } from "./services.js";
import { normalizeImportedHost } from "./host-settings.js";
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
null;
export type {
DockerContainerAction,
DockerContainerSummary,
DockerEvent,
DockerEventsV1,
DockerServiceV1,
} from "./services.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
// An express app rather than the router directly, so the existing
// register*Routes helpers keep the Application they expect. Core mounts it
// at /plugin-api/docker and runs compression, CORS, cookies, auth and body
// parsing in front of it.
const app = express();
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
const log = createLogger(ctx.log);
const sessions = createDockerSessions(ctx.schedule, log);
ctx.disposables.add(() => sessions.closeAll());
registerDockerSshRoutes(app);
registerDockerContainerRoutes(app, {
sshSessions,
pendingTOTPSessions,
getRequestUserId,
executeDockerCommand,
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
ctx.http.router<Router>({ bodyLimit: "100mb" }).use(app);
authManagerInstance = AuthManager.getInstance();
try {
await authManagerInstance.initialize();
} catch (err) {
logger.error("Failed to initialize Docker backend", err, {
operation: "startup",
});
}
await startConsoleServer();
// Public because the console authenticates each session over the socket
// itself, where the container and host checks also live.
ctx.ws.upgrade(
"/console",
(request, socket, head) =>
handleConsoleUpgrade(
request as Parameters<typeof handleConsoleUpgrade>[0],
socket as Parameters<typeof handleConsoleUpgrade>[1],
head as Buffer,
),
{ public: true },
);
registerRoutes(ctx.http.router<Router>(), { ctx, sessions, log });
registerConsole(ctx, log);
registerServices(ctx, log);
ctx.registry.provide("docker.hostImportNormalizer", normalizeImportedHost);
ctx.log.info(
"Docker mounted at /plugin-api/docker and /plugin-ws/docker/console",
);
}
export async function deactivate() {
await closeConsoleServer();
authManagerInstance = null;
}
export async function deactivate() {}
File diff suppressed because it is too large. Load diff
+256
View File
@@ -0,0 +1,256 @@
import type { Client } from "ssh2";
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
import { containerCommand } from "./container-runtime.js";
import {
PS_STATE_FORMAT,
diffContainerStates,
parseContainerStates,
type ContainerState,
type DockerEventName,
} from "./container-state.js";
import { readDockerHostSettings } from "./host-settings.js";
import { execOnClient } from "./sessions.js";
import { listFormat, parseContainerList } from "./routes.js";
import {
CONTAINER_ID_RE,
getErrorMessage,
type DockerLogger,
} from "./helpers.js";
export const POOL = "docker";
const TICK_MS = 15_000;
const POLL_INTERVAL_MS = 60_000;
const HOST_CACHE_MS = 5 * 60_000;
export interface DockerContainerSummary {
id: string;
name: string;
image: string;
status: string;
state: string;
ports: string;
created: string;
}
export type DockerContainerAction =
"start" | "stop" | "restart" | "pause" | "unpause" | "remove";
const ACTIONS: DockerContainerAction[] = [
"start",
"stop",
"restart",
"pause",
"unpause",
"remove",
];
/** The "docker.containers" service, version 1. Runs as the caller's user. */
export interface DockerServiceV1 {
listContainers: (
hostId: number,
options?: { all?: boolean },
) => Promise<DockerContainerSummary[]>;
action: (
hostId: number,
container: string,
action: DockerContainerAction,
) => Promise<void>;
}
export interface DockerEvent {
hostId: number;
container: string;
event: DockerEventName;
}
/** The "docker.events" service, version 1. Runs as the caller's user. */
export interface DockerEventsV1 {
/**
* Calls `listener` for container state changes on a host, polled about
* once a minute as the subscribing user. Subscribing the same listener
* again is a no-op, so a consumer can re-subscribe on a timer and pick up
* a restarted docker plugin. Resolves to an unsubscribe function.
*/
subscribe: (
hostId: number,
listener: (event: DockerEvent) => void,
) => Promise<() => void>;
}
function actorOf(ctx: PluginContext): string {
const userId = ctx.currentActor();
if (!userId) throw new Error("The docker service needs a calling user");
return userId;
}
export function registerServices(ctx: PluginContext, log: DockerLogger): void {
const runOnHost = async <T>(
hostId: number,
command: (runtime: "docker" | "podman") => string,
handle: (output: string) => T,
): Promise<T> => {
const { runtime } = await readDockerHostSettings(ctx, hostId);
const output = await ctx.ssh.withConnection<string, Client>(
hostId,
{ pool: POOL, purpose: "docker" },
(client) => execOnClient(client, command(runtime)),
);
return handle(output);
};
ctx.services.provide<DockerServiceV1>("docker.containers", {
listContainers: async (hostId, options) => {
actorOf(ctx);
const all = options?.all !== false;
return runOnHost(
hostId,
(runtime) =>
containerCommand(
runtime,
`ps ${all ? "-a " : ""}--format ${listFormat(false)}`,
),
(output) =>
parseContainerList(output) as unknown as DockerContainerSummary[],
);
},
action: async (hostId, container, action) => {
actorOf(ctx);
if (!CONTAINER_ID_RE.test(container)) {
throw new Error("Invalid container name");
}
if (!ACTIONS.includes(action)) {
throw new Error(`Unknown container action: ${action}`);
}
const args =
action === "remove" ? `rm ${container}` : `${action} ${container}`;
await runOnHost(
hostId,
(runtime) => containerCommand(runtime, args),
() => undefined,
);
},
});
interface Watch {
userId: string;
hostId: number;
listeners: Set<(event: DockerEvent) => void>;
snapshot?: Map<string, ContainerState>;
host?: { value: PluginSshHost; at: number };
lastPolledAt: number;
failing: boolean;
}
const watches = new Map<string, Watch>();
ctx.disposables.add(() => watches.clear());
const hostFor = async (watch: Watch): Promise<PluginSshHost | null> => {
if (watch.host && Date.now() - watch.host.at < HOST_CACHE_MS) {
return watch.host.value;
}
const host = await ctx.asUser(watch.userId, () =>
ctx.ssh.resolveHost(watch.hostId),
);
watch.host = host ? { value: host, at: Date.now() } : undefined;
return host;
};
const pollOne = async (watch: Watch) => {
try {
const host = await hostFor(watch);
if (!host) throw new Error("Host not found");
const { runtime } = await readDockerHostSettings(ctx, watch.hostId);
const output = await ctx.ssh.withConnection<string, Client>(
host,
{ pool: POOL, purpose: "docker" },
(client) =>
execOnClient(
client,
containerCommand(runtime, `ps -a --format ${PS_STATE_FORMAT}`),
),
);
const current = parseContainerStates(output);
const previous = watch.snapshot;
watch.snapshot = current;
watch.failing = false;
if (!previous) return;
for (const { container, event } of diffContainerStates(
previous,
current,
)) {
for (const listener of [...watch.listeners]) {
try {
listener({ hostId: watch.hostId, container, event });
} catch (error) {
log.warn("Docker event listener failed", {
hostId: watch.hostId,
error: getErrorMessage(error),
});
}
}
}
} catch (error) {
// Docker missing, the host down or the owner's data locked. A later
// success is a first observation again rather than a diff.
watch.snapshot = undefined;
watch.host = undefined;
if (!watch.failing) {
log.warn("Docker event poll failed", {
hostId: watch.hostId,
error: getErrorMessage(error),
});
}
watch.failing = true;
}
};
ctx.schedule.every(TICK_MS, async () => {
const now = Date.now();
for (const watch of [...watches.values()]) {
if (now - watch.lastPolledAt < POLL_INTERVAL_MS) continue;
watch.lastPolledAt = now;
await pollOne(watch);
}
});
ctx.events.on("host.updated", (payload) => {
const { hostId } = payload as { hostId?: number };
if (!hostId) return;
ctx.ssh.dropPooled(POOL, hostId);
for (const watch of watches.values()) {
if (watch.hostId === hostId) watch.host = undefined;
}
});
ctx.events.on("host.deleted", (payload) => {
const { hostId } = payload as { hostId?: number };
if (!hostId) return;
for (const [key, watch] of watches) {
if (watch.hostId === hostId) watches.delete(key);
}
});
ctx.services.provide<DockerEventsV1>("docker.events", {
subscribe: async (hostId, listener) => {
const userId = actorOf(ctx);
const key = `${userId}:${hostId}`;
let watch = watches.get(key);
if (!watch) {
watch = {
userId,
hostId,
listeners: new Set(),
lastPolledAt: 0,
failing: false,
};
watches.set(key, watch);
}
watch.listeners.add(listener);
const current = watch;
return () => {
current.listeners.delete(listener);
if (current.listeners.size === 0 && watches.get(key) === current) {
watches.delete(key);
}
};
},
});
}
@@ -1,174 +0,0 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { Client as SSHClient } from "ssh2";
import { logger } from "../../../../src/backend/utils/logger.js";
import type { ContainerRuntime } from "./container-runtime.js";
const sshLogger = logger;
export interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
hostId?: number;
userId?: string;
isWindows?: boolean;
containerRuntime?: ContainerRuntime;
}
export interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: Record<string, unknown>;
createdAt: number;
sessionId: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
userId?: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
totpAttempts: number;
isWarpgate?: boolean;
containerRuntime?: ContainerRuntime;
}
export const sshSessions: Record<string, SSHSession> = {};
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000;
setInterval(() => {
const now = Date.now();
Object.keys(pendingTOTPSessions).forEach((sessionId) => {
const session = pendingTOTPSessions[sessionId];
if (now - session.createdAt > 180000) {
try {
session.client.end();
} catch {
// expected
}
delete pendingTOTPSessions[sessionId];
}
});
}, 60000);
export function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.activeOperations > 0) {
sshLogger.warn(
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
scheduleSessionCleanup(sessionId);
return;
}
try {
session.client.end();
} catch {
// expected
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
}
export function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(() => {
cleanupSession(sessionId);
}, SESSION_IDLE_TIMEOUT);
}
}
export async function executeDockerCommand(
session: SSHSession,
command: string,
sessionId?: string,
userId?: string,
hostId?: number,
): Promise<string> {
const startTime = Date.now();
sshLogger.info("Executing Docker command", {
operation: "docker_command_exec",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
return new Promise((resolve, reject) => {
session.client.exec(command, (err, stream) => {
if (err) {
sshLogger.error("Docker command execution error", err, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
return reject(err);
}
let stdout = "";
let stderr = "";
stream.on("close", (code: number) => {
if (code !== 0) {
sshLogger.error("Docker command failed", undefined, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
exitCode: code,
stderr,
});
reject(new Error(stderr || `Command exited with code ${code}`));
} else {
sshLogger.success("Docker command completed", {
operation: "docker_command_success",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
duration: Date.now() - startTime,
});
resolve(stdout);
}
});
stream.on("data", (data: Buffer) => {
stdout += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
stream.on("error", (streamErr: Error) => {
sshLogger.error("Docker command stream error", streamErr, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
reject(streamErr);
});
});
});
}
+183
View File
@@ -0,0 +1,183 @@
import type { Client } from "ssh2";
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
import type { ContainerRuntime } from "./container-runtime.js";
import type { DockerLogger } from "./helpers.js";
const SESSION_IDLE_MS = 60 * 60 * 1000;
const PENDING_SWEEP_MS = 60 * 1000;
/** A live SSH connection behind the Docker panel, owned by one user. */
export interface DockerSession {
id: string;
client: Client;
dispose: () => void;
hostId: number;
userId: string;
runtime: ContainerRuntime;
isWindows: boolean;
lastActive: number;
activeOperations: number;
cancelIdle?: () => void;
}
/** A connect still waiting on a person (TOTP, Warpgate). */
export interface PendingEntry {
userId: string;
hostId: number;
expiresAt: number;
/** Gives up: answers the open prompt with nothing. */
cancel: () => void;
}
export interface DockerSessions {
add: (session: DockerSession) => void;
/** The session when `userId` owns it. */
get: (id: string, userId: string) => DockerSession | undefined;
close: (id: string) => void;
touch: (session: DockerSession) => void;
/** Runs `fn` with the session marked busy, so idle cleanup waits. */
run: <T>(session: DockerSession, fn: () => Promise<T>) => Promise<T>;
exec: (session: DockerSession, command: string) => Promise<string>;
setPending: (id: string, entry: PendingEntry) => void;
getPending: (id: string) => PendingEntry | undefined;
clearPending: (id: string) => void;
closeAll: () => void;
}
/** Runs one command and resolves stdout, rejecting on a non-zero exit. */
export function execOnClient(client: Client, command: string): Promise<string> {
return new Promise((resolve, reject) => {
client.exec(command, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let stderr = "";
stream.on("close", (code: number) => {
if (code !== 0 && code !== null && code !== undefined) {
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) => reject(streamErr));
});
});
}
export function createDockerSessions(
schedule: PluginSchedule,
log: DockerLogger,
): DockerSessions {
const sessions = new Map<string, DockerSession>();
const pending = new Map<string, PendingEntry>();
const end = (session: DockerSession) => {
session.cancelIdle?.();
sessions.delete(session.id);
session.dispose();
};
const scheduleIdle = (session: DockerSession) => {
session.cancelIdle?.();
session.cancelIdle = schedule.after(SESSION_IDLE_MS, () => {
if (sessions.get(session.id) !== session) return;
if (session.activeOperations > 0) {
scheduleIdle(session);
return;
}
end(session);
});
};
schedule.every(PENDING_SWEEP_MS, () => {
const now = Date.now();
for (const [id, entry] of pending) {
if (now < entry.expiresAt) continue;
pending.delete(id);
entry.cancel();
}
});
return {
add: (session) => {
const existing = sessions.get(session.id);
if (existing) end(existing);
sessions.set(session.id, session);
session.client.once("close", () => {
if (sessions.get(session.id) === session) {
session.cancelIdle?.();
sessions.delete(session.id);
}
});
scheduleIdle(session);
},
get: (id, userId) => {
const session = sessions.get(id);
return session && session.userId === userId ? session : undefined;
},
close: (id) => {
const session = sessions.get(id);
if (session) end(session);
const entry = pending.get(id);
if (entry) {
pending.delete(id);
entry.cancel();
}
},
touch: (session) => {
session.lastActive = Date.now();
scheduleIdle(session);
},
run: async (session, fn) => {
session.lastActive = Date.now();
session.activeOperations++;
try {
return await fn();
} finally {
session.activeOperations--;
}
},
exec: async (session, command) => {
const started = Date.now();
try {
return await execOnClient(session.client, command);
} catch (error) {
log.warn("Docker command failed", {
hostId: session.hostId,
command: command.split(" ")[2],
durationMs: Date.now() - started,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
setPending: (id, entry) => {
const existing = pending.get(id);
if (existing) existing.cancel();
pending.set(id, entry);
},
getPending: (id) => pending.get(id),
clearPending: (id) => {
pending.delete(id);
},
closeAll: () => {
for (const session of [...sessions.values()]) end(session);
for (const entry of pending.values()) entry.cancel();
pending.clear();
},
};
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}