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);
}
+5 -3
View File
@@ -1,8 +1,8 @@
import { ConnectionScreen, FullScreenAppWrapper } from "@termix/plugin-sdk/ui";
import React from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { DockerManager } from "./DockerManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { toDockerHost } from "./types";
interface DockerAppProps {
hostId?: string;
@@ -37,7 +37,9 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
return (
<DockerManager
hostConfig={hostConfig}
host={toDockerHost(
hostConfig as unknown as Record<string, unknown>,
)}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
+258 -433
View File
@@ -1,10 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { Separator } from "@/components/separator.tsx";
import { Alert, AlertDescription } from "@/components/alert.tsx";
import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.tsx";
import { Input } from "@/components/input.tsx";
import {
AlertCircle,
Box,
@@ -14,44 +9,46 @@ import {
RefreshCw,
Search,
} from "lucide-react";
import {
useTranslation,
useConnectionRetry,
useHost,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
import { logActivity, getSSHHosts } from "@/main-axios";
import {
connectDockerSession,
disconnectDockerSession,
listDockerContainers,
validateDockerAvailability,
keepaliveDockerSession,
verifyDockerTOTP,
verifyDockerWarpgate,
} from "./docker-api";
Alert,
AlertDescription,
Button,
Card,
ConnectionLogProvider,
ConnectionScreen,
Input,
SSHAuthDialog,
Select2,
Separator,
TOTPDialog,
WarpgateDialog,
logActivity,
useAdaptivePolling,
useAreaPreferences,
useConnectionLog,
useTabsSafe,
useUiPreferencesContext,
} from "@termix/plugin-sdk/ui";
import { DockerApiError, useDockerApi, type ConnectResult } from "./docker-api";
import {
dockerEnabled,
hostTitle,
toDockerHost,
type DockerContainer,
type DockerHost,
type DockerValidation,
} from "./types";
import { ContainerList } from "./components/ContainerList.tsx";
import { ContainerTable } from "./components/ContainerTable.tsx";
import { ContainerDetail } from "./components/ContainerDetail.tsx";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
import { useTabsSafe } from "@/shell/TabContext.tsx";
import {
useAreaPreferences,
useUiPreferencesContext,
} from "@/contexts/UiPreferencesContext";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
import type { LogEntry } from "@/types/connection-log.ts";
import { Select2 } from "@/components/select2";
interface DockerManagerProps {
hostConfig?: SSHHost;
host?: DockerHost;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
@@ -59,15 +56,21 @@ interface DockerManagerProps {
onClose?: () => void;
}
type ConnectionLogInput = Omit<LogEntry, "id" | "timestamp">;
interface PromptState {
kind: "totp" | "warpgate";
sessionId: string;
prompt?: string;
isPassword?: boolean;
url?: string;
securityKey?: string;
}
interface DockerConnectionError {
message?: string;
connectionLogs?: ConnectionLogInput[];
function newSessionId(): string {
return `docker-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
function DockerManagerInner({
hostConfig,
host,
title,
isVisible = true,
isTopbarOpen = true,
@@ -75,11 +78,24 @@ function DockerManagerInner({
onClose,
}: DockerManagerProps): React.ReactElement {
const { t } = useTranslation();
const docker = useDockerApi();
const { addLog, setLogs, clearLogs } = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
const dockerPrefs = useAreaPreferences("docker");
const uiPrefsCtx = useUiPreferencesContext();
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
// The shell's host list keeps the host current after an edit; a
// standalone window has no shell, so it keeps the host it was given.
const liveRecord = useHost(host?.id);
const currentHost = React.useMemo(
() =>
liveRecord
? toDockerHost(liveRecord as unknown as Record<string, unknown>)
: host,
[liveRecord, host],
);
const enabled = dockerEnabled(currentHost);
const [sessionId, setSessionId] = React.useState<string | null>(null);
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
const containersSignatureRef = React.useRef("");
@@ -105,23 +121,14 @@ function DockerManagerInner({
React.useState<DockerValidation | null>(null);
const [isValidating, setIsValidating] = React.useState(false);
// Initial view follows the interface preset; switching it afterwards is a
// per-session choice, same as before.
// per-session choice.
const [viewMode, setViewMode] = React.useState<"list" | "detail">(
dockerPrefs.viewMode,
);
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
const [hasLoadedContainersOnce, setHasLoadedContainersOnce] =
React.useState(false);
const [totpRequired, setTotpRequired] = React.useState(false);
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
const [warpgateRequired, setWarpgateRequired] = React.useState(false);
const [warpgateSessionId, setWarpgateSessionId] = React.useState<
string | null
>(null);
const [warpgateUrl, setWarpgateUrl] = React.useState<string>("");
const [warpgateSecurityKey, setWarpgateSecurityKey] =
React.useState<string>("");
const [prompt, setPrompt] = React.useState<PromptState | null>(null);
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
const [authReason, setAuthReason] = React.useState<
"no_keyboard" | "auth_failed" | "timeout"
@@ -131,214 +138,168 @@ function DockerManagerInner({
const [retryCount, setRetryCount] = React.useState(0);
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const logDockerActivity = async () => {
if (
!currentHostConfig?.id ||
activityLoggedRef.current ||
activityLoggingRef.current
) {
return;
}
activityLoggingRef.current = true;
const logDockerActivity = () => {
if (!currentHost?.id || activityLoggedRef.current) return;
activityLoggedRef.current = true;
try {
const hostName =
currentHostConfig.name ||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
await logActivity("docker", currentHostConfig.id, hostName);
} catch (err) {
console.warn("Failed to log docker activity:", err);
logActivity("docker", currentHost.id, hostTitle(currentHost)).catch(() => {
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
});
};
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setCurrentHostConfig(hostConfig);
setContainers([]);
setSelectedContainer(null);
setSessionId(null);
setDockerValidation(null);
setViewMode("list");
setContainers([]);
setSelectedContainer(null);
setSessionId(null);
setDockerValidation(null);
setViewMode("list");
}, [host?.id]);
const retryRef = React.useRef<ReturnType<typeof useConnectionRetry> | null>(
null,
);
const validate = async (sid: string, clearAfter: boolean) => {
setSessionId(sid);
setIsValidating(true);
try {
const validation = await docker.validate(sid);
setDockerValidation(validation);
if (!validation.available) {
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
retryRef.current?.markFailed();
} else {
logDockerActivity();
if (clearAfter) setTimeout(() => clearLogs(), 1000);
retryRef.current?.markConnected();
}
} finally {
setIsValidating(false);
}
}, [hostConfig?.id]);
};
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
/** Acts on one step of a connect, whichever request it answered. */
const applyConnectResult = async (
sid: string,
result: ConnectResult,
clearAfter: boolean,
) => {
if (result.connectionLogs) setLogs(result.connectionLogs);
if (result.requires_warpgate) {
setPrompt({
kind: "warpgate",
sessionId: sid,
url: result.url || "",
securityKey: result.securityKey || "",
});
return;
}
if (result.requires_totp) {
if (result.retry) {
addLog({
type: "error",
stage: "auth",
message: t("docker.totpVerificationFailed"),
});
}
};
setPrompt({
kind: "totp",
sessionId: sid,
prompt: result.prompt || t("docker.verificationCodePrompt"),
isPassword: result.isPassword,
});
return;
}
if (result.status === "auth_required") {
setAuthReason(
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
);
setShowAuthDialog(true);
return;
}
setPrompt(null);
await validate(sid, clearAfter);
};
fetchLatestHostConfig();
const reportFailure = (error: unknown, fallback: string) => {
if (error instanceof DockerApiError && error.connectionLogs?.length) {
setLogs(error.connectionLogs);
} else {
addLog({
type: "error",
stage: "connection",
message: error instanceof Error ? error.message : fallback,
});
}
retryRef.current?.markFailed();
};
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
const connect = async (credentials?: {
userProvidedPassword?: string;
userProvidedSshKey?: string;
userProvidedKeyPassword?: string;
}) => {
if (!currentHost?.id) return;
const sid = newSessionId();
setIsConnecting(true);
try {
const result = await docker.connect(sid, currentHost.id, credentials);
await applyConnectResult(sid, result, !credentials);
} catch (error) {
reportFailure(error, t("docker.connectionFailed"));
} finally {
setIsConnecting(false);
}
};
const initializingRef = React.useRef(false);
React.useEffect(() => {
const initSession = async () => {
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
return;
}
if (initializingRef.current) return;
initializingRef.current = true;
if (sessionId) {
initializingRef.current = false;
return;
}
setIsConnecting(true);
clearLogs();
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
const result = await connectDockerSession(sid, currentHostConfig.id, {
useSocks5: currentHostConfig.useSocks5,
socks5Host: currentHostConfig.socks5Host,
socks5Port: currentHostConfig.socks5Port,
socks5Username: currentHostConfig.socks5Username,
socks5Password: currentHostConfig.socks5Password,
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
});
if (result?.requires_warpgate) {
setWarpgateRequired(true);
setWarpgateSessionId(sid);
setWarpgateUrl(result.url || "");
setWarpgateSecurityKey(result.securityKey || "");
setIsConnecting(false);
return;
}
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sid);
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
setIsConnecting(false);
return;
}
if (result?.status === "auth_required") {
setShowAuthDialog(true);
setAuthReason(
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
);
setIsConnecting(false);
return;
}
setSessionId(sid);
setIsValidating(true);
const validation = await validateDockerAvailability(sid);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
setTimeout(() => clearLogs(), 1000);
dockerConnectRetryRef.current.markConnected();
}
} catch (error) {
const dockerError = error as DockerConnectionError;
setIsConnecting(false);
setIsValidating(false);
if (Array.isArray(dockerError.connectionLogs)) {
setLogs(dockerError.connectionLogs);
} else {
addLog({
type: "error",
stage: "connection",
message: dockerError.message || t("docker.connectionFailed"),
});
}
dockerConnectRetryRef.current.markFailed();
} finally {
setIsConnecting(false);
}
};
initSession();
if (!currentHost?.id || !enabled) return;
if (initializingRef.current || sessionId) return;
initializingRef.current = true;
clearLogs();
void connect();
return () => {
initializingRef.current = false;
if (sessionId) {
disconnectDockerSession(sessionId).catch(() => {
// Silently handle disconnect errors
});
}
};
}, [currentHostConfig?.id, currentHostConfig?.enableDocker, retryCount]);
}, [currentHost?.id, enabled, retryCount]);
React.useEffect(() => {
if (!sessionId) return;
return () => {
docker.disconnect(sessionId).catch(() => {});
};
}, [sessionId]);
React.useEffect(() => {
if (!sessionId || !isVisible) return;
const keepalive = setInterval(
() => {
keepaliveDockerSession(sessionId).catch(() => {
// Silently handle keepalive errors
});
docker.keepalive(sessionId).catch(() => {});
},
10 * 60 * 1000,
);
return () => clearInterval(keepalive);
}, [sessionId, isVisible]);
const refreshContainers = React.useCallback(async () => {
if (!sessionId) return;
try {
const data = await listDockerContainers(sessionId, true);
setContainers(data);
setContainers(await docker.listContainers(sessionId, true));
} catch {
// Silently handle polling errors
// the next poll tries again
}
}, [sessionId]);
}, [sessionId, docker]);
React.useEffect(() => {
setHasLoadedContainersOnce(false);
@@ -350,7 +311,7 @@ function DockerManagerInner({
setIsLoadingContainers((loading) =>
hasLoadedContainersOnce ? loading : true,
);
const data = await listDockerContainers(sessionId, true);
const data = await docker.listContainers(sessionId, true);
const signature = JSON.stringify(data);
const changed = signature !== containersSignatureRef.current;
containersSignatureRef.current = signature;
@@ -378,124 +339,48 @@ function DockerManagerInner({
setSelectedContainer(null);
}, []);
const handleTotpSubmit = async (code: string) => {
if (!totpSessionId || !code) return;
try {
setIsConnecting(true);
const result = await verifyDockerTOTP(totpSessionId, code);
if (result?.status === "success") {
setTotpRequired(false);
setTotpPrompt("");
setSessionId(totpSessionId);
setTotpSessionId(null);
setIsValidating(true);
const validation = await validateDockerAvailability(totpSessionId);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
dockerConnectRetryRef.current.markConnected();
}
}
} catch (error) {
console.error("TOTP verification failed:", error);
addLog({
type: "error",
stage: "auth",
message: t("docker.totpVerificationFailed"),
});
dockerConnectRetryRef.current.markFailed();
} finally {
setIsConnecting(false);
}
const closeTab = () => {
if (currentTab !== null) removeTab(currentTab);
};
const handleTotpCancel = () => {
setTotpRequired(false);
setTotpSessionId(null);
setTotpPrompt("");
setIsConnecting(false);
if (currentTab !== null) {
removeTab(currentTab);
const handleTotpSubmit = async (code: string) => {
if (!prompt || prompt.kind !== "totp" || !code) return;
const sid = prompt.sessionId;
setPrompt(null);
setIsConnecting(true);
try {
await applyConnectResult(sid, await docker.answerTotp(sid, code), false);
} catch (error) {
reportFailure(error, t("docker.totpVerificationFailed"));
} finally {
setIsConnecting(false);
}
};
const handleWarpgateContinue = async () => {
if (!warpgateSessionId) return;
if (!prompt || prompt.kind !== "warpgate") return;
const sid = prompt.sessionId;
setPrompt(null);
setIsConnecting(true);
try {
setIsConnecting(true);
const result = await verifyDockerWarpgate(warpgateSessionId);
if (result?.status === "success") {
setWarpgateRequired(false);
setWarpgateUrl("");
setWarpgateSecurityKey("");
setSessionId(warpgateSessionId);
setWarpgateSessionId(null);
setIsValidating(true);
const validation = await validateDockerAvailability(warpgateSessionId);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
dockerConnectRetryRef.current.markConnected();
}
}
await applyConnectResult(sid, await docker.continueWarpgate(sid), false);
} catch (error) {
console.error("Warpgate verification failed:", error);
addLog({
type: "error",
stage: "auth",
message: t("docker.warpgateVerificationFailed"),
});
dockerConnectRetryRef.current.markFailed();
reportFailure(error, t("docker.warpgateVerificationFailed"));
} finally {
setIsConnecting(false);
}
};
const handleWarpgateCancel = () => {
setWarpgateRequired(false);
setWarpgateSessionId(null);
setWarpgateUrl("");
setWarpgateSecurityKey("");
const handlePromptCancel = () => {
const sid = prompt?.sessionId;
setPrompt(null);
setIsConnecting(false);
if (currentTab !== null) {
removeTab(currentTab);
}
if (sid) docker.disconnect(sid).catch(() => {});
closeTab();
};
const handleWarpgateOpenUrl = () => {
if (warpgateUrl) {
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
}
if (prompt?.url) window.open(prompt.url, "_blank", "noopener,noreferrer");
};
const handleAuthSubmit = async (credentials: {
@@ -503,75 +388,12 @@ function DockerManagerInner({
sshKey?: string;
keyPassword?: string;
}) => {
if (!currentHostConfig?.id) return;
setShowAuthDialog(false);
setIsConnecting(true);
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
const result = await connectDockerSession(sid, currentHostConfig.id, {
userProvidedPassword: credentials.password,
userProvidedSshKey: credentials.sshKey,
userProvidedKeyPassword: credentials.keyPassword,
useSocks5: currentHostConfig.useSocks5,
socks5Host: currentHostConfig.socks5Host,
socks5Port: currentHostConfig.socks5Port,
socks5Username: currentHostConfig.socks5Username,
socks5Password: currentHostConfig.socks5Password,
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
});
if (result?.requires_warpgate) {
setWarpgateRequired(true);
setWarpgateSessionId(sid);
setWarpgateUrl(result.url || "");
setWarpgateSecurityKey(result.securityKey || "N/A");
setIsConnecting(false);
return;
}
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sid);
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
setIsConnecting(false);
return;
}
if (result?.status === "auth_required") {
setShowAuthDialog(true);
setAuthReason("auth_failed");
setIsConnecting(false);
return;
}
setSessionId(sid);
setIsValidating(true);
const validation = await validateDockerAvailability(sid);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
dockerConnectRetryRef.current.markConnected();
}
} catch (error) {
setIsConnecting(false);
setIsValidating(false);
addLog({
type: "error",
stage: "connection",
message: error?.message || t("docker.connectionFailed"),
});
dockerConnectRetryRef.current.markFailed();
} finally {
setIsConnecting(false);
}
await connect({
userProvidedPassword: credentials.password,
userProvidedSshKey: credentials.sshKey,
userProvidedKeyPassword: credentials.keyPassword,
});
};
const handleAuthCancel = () => {
@@ -592,15 +414,10 @@ function DockerManagerInner({
connect: () => {
handleRetry();
},
enabled:
!!currentHostConfig?.enableDocker &&
!totpRequired &&
!warpgateRequired &&
!showAuthDialog,
enabled: enabled && !prompt && !showAuthDialog,
autoStart: false,
});
const dockerConnectRetryRef = React.useRef(dockerConnectRetry);
dockerConnectRetryRef.current = dockerConnectRetry;
retryRef.current = dockerConnectRetry;
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = 8;
@@ -619,9 +436,9 @@ function DockerManagerInner({
const containerClass = embedded
? "h-full w-full text-foreground overflow-hidden bg-transparent"
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
: "bg-canvas text-foreground border-2 border-edge overflow-hidden";
if (!currentHostConfig?.enableDocker) {
if (!enabled) {
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
@@ -649,6 +466,40 @@ function DockerManagerInner({
);
}
const dialogs = (
<>
<TOTPDialog
isOpen={prompt?.kind === "totp"}
prompt={prompt?.prompt ?? ""}
mode={prompt?.isPassword ? "password" : "totp"}
onSubmit={handleTotpSubmit}
onCancel={handlePromptCancel}
/>
<WarpgateDialog
isOpen={prompt?.kind === "warpgate"}
url={prompt?.url ?? ""}
securityKey={prompt?.securityKey ?? ""}
onContinue={handleWarpgateContinue}
onCancel={handlePromptCancel}
onOpenUrl={handleWarpgateOpenUrl}
/>
{currentHost && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authReason}
onSubmit={handleAuthSubmit}
onCancel={handleAuthCancel}
hostInfo={{
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username ?? "",
name: currentHost.name,
}}
/>
)}
</>
);
if (isConnecting || isValidating) {
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
@@ -662,6 +513,7 @@ function DockerManagerInner({
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
onManualRetry={dockerConnectRetry.retryNow}
/>
{dialogs}
</div>
);
}
@@ -689,12 +541,12 @@ function DockerManagerInner({
{viewMode === "detail" &&
sessionId &&
selectedContainer &&
currentHostConfig ? (
currentHost ? (
<ContainerDetail
sessionId={sessionId}
containerId={selectedContainer}
containers={containers}
hostConfig={currentHostConfig}
hostConfig={currentHost}
onBack={handleBack}
initialTab={detailInitialTab}
/>
@@ -833,34 +685,7 @@ function DockerManagerInner({
</div>
)}
</div>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
<WarpgateDialog
isOpen={warpgateRequired}
url={warpgateUrl}
securityKey={warpgateSecurityKey}
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
/>
{currentHostConfig && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authReason}
onSubmit={handleAuthSubmit}
onCancel={handleAuthCancel}
hostInfo={{
ip: currentHostConfig.ip,
port: currentHostConfig.port,
username: currentHostConfig.username,
name: currentHostConfig.name,
}}
/>
)}
{dialogs}
<ConnectionScreen
status={dockerConnectRetry.status}
message={t("docker.connecting")}
+15 -50
View File
@@ -1,44 +1,24 @@
import { useState, useEffect } from "react";
import { Box } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useHost, useTranslation } from "@termix/plugin-sdk/frontend";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
import { DockerWidgetEditForm } from "./DockerWidgetEditForm";
import type {
DockerWidgetConfig,
WidgetComponentProps,
WidgetTypeDefinition,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
import { DockerManager } from "./DockerManager";
import type { SSHHost } from "@/types/index";
import { WidgetTitle } from "@/features/homepage/widgets/WidgetTitle";
import {
GRID_SIZE,
type DockerWidgetConfig,
type WidgetComponentProps,
type WidgetDefinition,
} from "./homepage";
import { dockerEnabled, hostTitle, toDockerHost } from "./types";
function DockerWidget({
widget,
config,
}: WidgetComponentProps<DockerWidgetConfig>) {
const { t } = useTranslation();
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
const [loading, setLoading] = useState(true);
const record = useHost(config.hostId || undefined);
useEffect(() => {
if (!config.hostId) {
setLoading(false);
return;
}
getSSHHosts()
.then((hosts) => {
const found = hosts.find(
(h) => h.id === config.hostId && h.enableDocker,
);
setHost(found ?? null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [config.hostId]);
if (!config.hostId) {
if (!config.hostId || !record || !dockerEnabled(record)) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
<Box size={20} />
@@ -47,22 +27,7 @@ function DockerWidget({
);
}
if (loading) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
{t("homepage.loading")}
</div>
);
}
if (!host) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
{t("homepage.widgetNoHostSelected")}
</div>
);
}
const host = toDockerHost(record as unknown as Record<string, unknown>);
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Box size={11} />} />
@@ -71,8 +36,8 @@ function DockerWidget({
onMouseDown={(e) => e.stopPropagation()}
>
<DockerManager
hostConfig={host as unknown as SSHHost}
title={host.name || host.ip}
host={host}
title={hostTitle(host)}
isVisible={true}
isTopbarOpen={false}
embedded={true}
@@ -83,7 +48,7 @@ function DockerWidget({
}
/** Registered by the plugin while it runs. */
export const dockerWidget: WidgetTypeDefinition<DockerWidgetConfig> = {
export const dockerWidget: WidgetDefinition<DockerWidgetConfig> = {
id: "docker_widget",
name: "Docker Manager",
description: "Embedded Docker container manager for a configured host",
@@ -1,18 +1,37 @@
import type {
DockerWidgetConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { SingleHostEditForm } from "@/features/homepage/dialogs/SingleHostEditForm";
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import type { DockerWidgetConfig, WidgetEditFormProps } from "./homepage";
import { dockerEnabled } from "./types";
export function DockerWidgetEditForm({
config,
onChange,
}: WidgetEditFormProps<DockerWidgetConfig>) {
const { t } = useTranslation();
const { hosts } = useHosts();
const options = hosts.filter(
(host) => host.enableSsh !== false && dockerEnabled(host),
);
return (
<SingleHostEditForm
hostId={config.hostId}
onChange={(hostId) => onChange({ ...config, hostId })}
filter={(h) => !!(h.enableSsh && h.enableDocker)}
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<Select2
value={config.hostId || ""}
onChange={(e) =>
onChange({ ...config, hostId: Number(e.target.value) })
}
className="h-8 text-xs border border-border bg-background px-2"
>
<option value="">{t("homepage.selectHost")}</option>
{options.map((host) => (
<option key={host.id} value={host.id}>
{host.name || host.ip}
</option>
))}
</Select2>
</div>
);
}
@@ -1,68 +0,0 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Box } from "lucide-react";
import { Select2 } from "@/components/select2";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostDockerTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
const dockerConfig = form.dockerConfig ?? { runtime: "docker" as const };
const runtime =
dockerConfig.runtime === "podman"
? ("podman" as const)
: ("docker" as const);
return (
<SectionCard
title={t("hosts.dockerIntegration")}
icon={<Box className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableDockerMonitor")}
description={t("hosts.enableDockerMonitorDesc")}
>
<FakeSwitch
checked={form.enableDocker}
onChange={(v) => setField("enableDocker", v)}
/>
</SettingRow>
{form.enableDocker && (
<SettingRow
label={t("hosts.containerRuntime")}
description={t("hosts.containerRuntimeDesc")}
>
<Select2
value={runtime}
onChange={(e) =>
setField("dockerConfig", {
...dockerConfig,
runtime: e.target.value as "docker" | "podman",
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="docker">
{t("hosts.containerRuntimeDocker")}
</option>
<option value="podman">
{t("hosts.containerRuntimePodman")}
</option>
</Select2>
</SettingRow>
)}
</div>
</SectionCard>
);
}
@@ -1,39 +1,42 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../error-message";
import {
Button,
Card,
CardContent,
ConnectionLogProvider,
ConnectionScreen,
DEFAULT_TERMINAL_CONFIG,
RobustClipboardProvider,
Select2,
TERMINAL_FONTS,
copyToClipboard,
ensureTerminalFontsLoaded,
isElectron,
pluginWsUrl,
readFromClipboard,
resolveConnectionOrigin,
resolveTermixThemeColors,
useAppTheme as useTheme,
useConnectionLog,
} from "@termix/plugin-sdk/ui";
import type { DockerHost } from "../types";
import React from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
import { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Button } from "@/components/button.tsx";
import { Select2 } from "@/components/select2";
import { Card, CardContent } from "@/components/card.tsx";
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
import { pluginWsUrl } from "@/lib/plugin-transport";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
import { isElectron } from "@/main-axios.ts";
import {
useTranslation,
useConnectionRetry,
} from "@termix/plugin-sdk/frontend";
import { resolveTermixThemeColors } from "@/lib/terminal-look/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { ensureTerminalFontsLoaded } from "@/lib/terminal-look/terminal-global-styles";
import { useTheme } from "@/components/theme-provider";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
interface ConsoleTerminalProps {
containerId: string;
containerName: string;
containerState: string;
hostConfig: SSHHost;
hostConfig: DockerHost;
}
export function ConsoleTerminal(
@@ -58,7 +61,12 @@ function ConsoleTerminalInner({
const { addLog, clearLogs } = useConnectionLog();
const terminalConfig = React.useMemo(
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }),
() => ({
...DEFAULT_TERMINAL_CONFIG,
...(hostConfig.terminalConfig as Partial<
typeof DEFAULT_TERMINAL_CONFIG
> | null),
}),
[hostConfig.terminalConfig],
);
@@ -321,7 +329,11 @@ function ConsoleTerminalInner({
JSON.stringify({
type: "connect",
data: {
hostConfig,
hostConfig: {
id: hostConfig.id,
syncId: hostConfig.syncId ?? null,
ip: hostConfig.ip,
},
containerId,
shell: selectedShell,
cols,
@@ -1,7 +1,7 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../error-message";
import { Button, Card, useConfirmation } from "@termix/plugin-sdk/ui";
import type { DockerContainer } from "../types";
import React from "react";
import { Card } from "@/components/card.tsx";
import { Button } from "@/components/button.tsx";
import {
Box,
Play,
@@ -14,16 +14,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer } from "@/types";
import {
startDockerContainer,
stopDockerContainer,
restartDockerContainer,
pauseDockerContainer,
unpauseDockerContainer,
removeDockerContainer,
} from "../docker-api";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { useDockerApi } from "../docker-api";
interface ContainerCardProps {
container: DockerContainer;
@@ -61,6 +52,7 @@ export function ContainerCard({
}: ContainerCardProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const docker = useDockerApi();
const [isStarting, setIsStarting] = React.useState(false);
const [isStopping, setIsStopping] = React.useState(false);
const [isRestarting, setIsRestarting] = React.useState(false);
@@ -82,7 +74,7 @@ export function ContainerCard({
e.stopPropagation();
setIsStarting(true);
try {
await startDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "start");
toast.success(t("docker.containerStarted", { name: containerName }));
onRefresh?.();
} catch (err) {
@@ -100,7 +92,7 @@ export function ContainerCard({
e.stopPropagation();
setIsStopping(true);
try {
await stopDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "stop");
toast.success(t("docker.containerStopped", { name: containerName }));
onRefresh?.();
} catch (err) {
@@ -118,7 +110,7 @@ export function ContainerCard({
e.stopPropagation();
setIsRestarting(true);
try {
await restartDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "restart");
toast.success(t("docker.containerRestarted", { name: containerName }));
onRefresh?.();
} catch (err) {
@@ -137,10 +129,10 @@ export function ContainerCard({
setIsPausing(true);
try {
if (container.state === "paused") {
await unpauseDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "unpause");
toast.success(t("docker.containerUnpaused", { name: containerName }));
} else {
await pauseDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "pause");
toast.success(t("docker.containerPaused", { name: containerName }));
}
onRefresh?.();
@@ -166,7 +158,7 @@ export function ContainerCard({
async () => {
setIsRemoving(true);
try {
await removeDockerContainer(
await docker.removeContainer(
sessionId,
container.id,
container.state === "running",
@@ -1,7 +1,6 @@
import { Button, Card, Separator } from "@termix/plugin-sdk/ui";
import type { DockerContainer, DockerHost } from "../types";
import React from "react";
import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.tsx";
import { Separator } from "@/components/separator.tsx";
import {
Activity,
ArrowLeft,
@@ -11,7 +10,6 @@ import {
Terminal,
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer, SSHHost } from "@/types";
import { LogViewer } from "./LogViewer.tsx";
import { ContainerStats } from "./ContainerStats.tsx";
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
@@ -23,7 +21,7 @@ interface ContainerDetailProps {
sessionId: string;
containerId: string;
containers: DockerContainer[];
hostConfig: SSHHost;
hostConfig: DockerHost;
onBack: () => void;
initialTab?: DetailTab;
}
@@ -1,7 +1,7 @@
import type { DockerContainer } from "../types";
import React from "react";
import { Box } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer } from "@/types";
import { ContainerCard } from "./ContainerCard.tsx";
interface ContainerListProps {
@@ -1,4 +1,6 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../error-message";
import { SectionCard, useAdaptivePolling } from "@termix/plugin-sdk/ui";
import type { DockerStats } from "../types";
import React from "react";
import {
Activity,
@@ -9,12 +11,9 @@ import {
Network,
RefreshCw,
} from "lucide-react";
import type { DockerStats } from "@/types";
import { getContainerStats } from "../docker-api";
import { useDockerApi } from "../docker-api";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { SectionCard } from "@/components/section-card";
import { DockerBadge } from "./ContainerCard.tsx";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
interface ContainerStatsProps {
sessionId: string;
@@ -30,6 +29,7 @@ export function ContainerStats({
containerState,
}: ContainerStatsProps): React.ReactElement {
const { t } = useTranslation();
const docker = useDockerApi();
const [stats, setStats] = React.useState<DockerStats | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
@@ -41,7 +41,7 @@ export function ContainerStats({
setIsLoading(true);
setError(null);
try {
const data = await getContainerStats(sessionId, containerId);
const data = await docker.stats(sessionId, containerId);
const previous = statsRef.current;
const changed =
!previous ||
@@ -58,7 +58,7 @@ export function ContainerStats({
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, containerState, t]);
}, [sessionId, containerId, containerState, t, docker]);
useAdaptivePolling(
fetchStats,
@@ -246,7 +246,7 @@ export function ContainerStats({
</span>
<DockerBadge
state={
containerState as import("@/types").DockerContainer["state"]
containerState as import("../types").DockerContainer["state"]
}
/>
</div>
@@ -1,11 +1,11 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../error-message";
import { Button } from "@termix/plugin-sdk/ui";
import type { DockerContainer } from "../types";
import React from "react";
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@/components/button.tsx";
import type { DockerContainer } from "@/types";
import { startDockerContainer, stopDockerContainer } from "../docker-api";
import { useDockerApi } from "../docker-api";
import { DockerBadge } from "./ContainerCard.tsx";
type DetailTab = "logs" | "stats" | "console";
@@ -30,6 +30,7 @@ export function ContainerTable({
statusFilter = "all",
}: ContainerTableProps): React.ReactElement {
const { t } = useTranslation();
const docker = useDockerApi();
const [pendingId, setPendingId] = React.useState<string | null>(null);
const filtered = React.useMemo(() => {
@@ -56,10 +57,10 @@ export function ContainerTable({
setPendingId(container.id);
try {
if (container.state === "running") {
await stopDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "stop");
toast.success(t("docker.containerStopped", { name: containerName }));
} else {
await startDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "start");
toast.success(t("docker.containerStarted", { name: containerName }));
}
onRefresh?.();
@@ -1,15 +1,17 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../error-message";
import {
Button,
Input,
Select2,
Separator,
useAdaptivePolling,
} from "@termix/plugin-sdk/ui";
import type { DockerLogOptions } from "../types";
import React from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { Separator } from "@/components/separator.tsx";
import { Download, RefreshCw, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerLogOptions } from "@/types";
import { getContainerLogs, downloadContainerLogs } from "../docker-api";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
import { Select2 } from "@/components/select2";
import { useDockerApi } from "../docker-api";
interface LogViewerProps {
sessionId: string;
@@ -60,6 +62,7 @@ export function LogViewer({
containerName,
}: LogViewerProps): React.ReactElement {
const { t } = useTranslation();
const docker = useDockerApi();
const [rawLogs, setRawLogs] = React.useState<string[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false);
@@ -78,7 +81,7 @@ export function LogViewer({
tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
timestamps: showTimestamps,
};
const data = await getContainerLogs(sessionId, containerId, options);
const data = await docker.logs(sessionId, containerId, options);
const next = data.logs.split("\n").filter(Boolean);
const changed =
next.length !== rawLogsRef.current.length ||
@@ -89,11 +92,13 @@ export function LogViewer({
}
return changed;
} catch (error) {
toast.error(`Failed to fetch logs: ${getErrorMessage(error)}`);
toast.error(
t("docker.failedToFetchLogs", { error: getErrorMessage(error) }),
);
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, tailLines, showTimestamps]);
}, [sessionId, containerId, tailLines, showTimestamps, docker, t]);
React.useEffect(() => {
fetchLogs();
@@ -120,9 +125,10 @@ export function LogViewer({
const handleDownload = async () => {
setIsDownloading(true);
try {
const blob = await downloadContainerLogs(sessionId, containerId, {
const data = await docker.logs(sessionId, containerId, {
timestamps: showTimestamps,
});
const blob = new Blob([data.logs], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -133,7 +139,9 @@ export function LogViewer({
document.body.removeChild(a);
toast.success(t("docker.logsDownloaded"));
} catch (error) {
toast.error(`Failed to download logs: ${getErrorMessage(error)}`);
toast.error(
t("docker.failedToDownloadLogs", { error: getErrorMessage(error) }),
);
} finally {
setIsDownloading(false);
}
+139 -314
View File
@@ -1,345 +1,170 @@
import axios from "axios";
import { dockerApi, handleApiError } from "@/main-axios";
import { useMemo } from "react";
import {
usePluginApi,
type PluginApiClient,
} from "@termix/plugin-sdk/frontend";
import type { ConnectionStage } from "@termix/plugin-sdk/ui";
import type {
DockerContainer,
DockerLogOptions,
DockerStats,
DockerValidation,
} from "@/types/index";
} from "./types";
type ApiConnectionLog = {
export interface ApiConnectionLog {
type: "info" | "success" | "warning" | "error";
stage: string;
stage: ConnectionStage;
message: string;
details?: Record<string, unknown>;
};
details?: string;
}
type ConnectErrorResponse = {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
/** One step of a connect: connected, a prompt to answer, or auth needed. */
export interface ConnectResult {
success?: boolean;
status?: string;
reason?: string;
};
export async function connectDockerSession(
sessionId: string,
hostId: number,
config?: {
userProvidedPassword?: string;
userProvidedSshKey?: string;
userProvidedKeyPassword?: string;
forceKeyboardInteractive?: boolean;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
},
): Promise<{
success?: boolean;
message?: string;
requires_totp?: boolean;
prompt?: string;
isPassword?: boolean;
status?: string;
reason?: string;
connectionLogs?: ApiConnectionLog[];
retry?: boolean;
requires_warpgate?: boolean;
url?: string;
url?: string | null;
securityKey?: string;
}> {
try {
const response = await dockerApi.post("/ssh/connect", {
sessionId,
hostId,
...config,
});
return response.data;
} catch (error: unknown) {
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.status === "auth_required"
) {
return error.response.data;
connectionLogs?: ApiConnectionLog[];
}
/** An API failure carrying the server's message and connection logs. */
export class DockerApiError extends Error {
constructor(
message: string,
readonly status?: number,
readonly connectionLogs?: ApiConnectionLog[],
) {
super(message);
this.name = "DockerApiError";
}
}
function apiError(error: unknown, action: string): DockerApiError {
const response = (
error as {
response?: {
status?: number;
data?: {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
};
};
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.requires_totp
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.requires_warpgate
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.connectionLogs
) {
const data = error.response.data;
const errorWithLogs = new Error(
data.error || data.message || error.message,
);
Object.assign(errorWithLogs, {
connectionLogs: data.connectionLogs,
});
throw errorWithLogs;
}
throw handleApiError(error, "connect to Docker SSH session");
}
)?.response;
const data = response?.data;
const message =
data?.error ||
data?.message ||
(error instanceof Error ? error.message : `Failed to ${action}`);
return new DockerApiError(message, response?.status, data?.connectionLogs);
}
export async function verifyDockerTOTP(
sessionId: string,
totpCode: string,
): Promise<{ status: string; message: string }> {
async function call<T>(
action: string,
request: () => Promise<{ data: T }>,
): Promise<T> {
try {
const response = await dockerApi.post("/ssh/connect-totp", {
sessionId,
totpCode,
});
return response.data;
return (await request()).data;
} catch (error) {
throw handleApiError(error, "verify Docker TOTP");
throw apiError(error, action);
}
}
export async function verifyDockerWarpgate(
sessionId: string,
): Promise<{ status: string; message: string }> {
try {
const response = await dockerApi.post("/ssh/connect-warpgate", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "verify Docker Warpgate");
}
}
export type ContainerAction =
"start" | "stop" | "restart" | "pause" | "unpause";
export async function disconnectDockerSession(
sessionId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post("/ssh/disconnect", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "disconnect from Docker SSH session");
}
}
export async function keepaliveDockerSession(
sessionId: string,
): Promise<{ success: boolean }> {
try {
const response = await dockerApi.post("/ssh/keepalive", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "keepalive Docker SSH session");
}
}
export async function getDockerSessionStatus(
sessionId: string,
): Promise<{ success: boolean; connected: boolean }> {
try {
const response = await dockerApi.get("/ssh/status", {
params: { sessionId },
});
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker session status");
}
}
export async function validateDockerAvailability(
sessionId: string,
): Promise<DockerValidation> {
try {
const response = await dockerApi.get(`/validate/${sessionId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "validate Docker availability");
}
}
export async function listDockerContainers(
sessionId: string,
all: boolean = true,
): Promise<DockerContainer[]> {
try {
const response = await dockerApi.get(`/containers/${sessionId}`, {
params: { all },
});
return response.data;
} catch (error) {
throw handleApiError(error, "list Docker containers");
}
}
export async function getDockerContainerDetails(
sessionId: string,
containerId: string,
): Promise<DockerContainer> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker container details");
}
}
export async function startDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/start`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "start Docker container");
}
}
export async function stopDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/stop`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "stop Docker container");
}
}
export async function restartDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/restart`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "restart Docker container");
}
}
export async function pauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/pause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "pause Docker container");
}
}
export async function unpauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/unpause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "unpause Docker container");
}
}
export async function removeDockerContainer(
sessionId: string,
containerId: string,
force: boolean = false,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.delete(
`/containers/${sessionId}/${containerId}/remove`,
{
params: { force },
export function createDockerApi(api: PluginApiClient) {
return {
connect: (
sessionId: string,
hostId: number,
credentials?: {
userProvidedPassword?: string;
userProvidedSshKey?: string;
userProvidedKeyPassword?: string;
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove Docker container");
}
) =>
call<ConnectResult>("connect to Docker", () =>
api.post("/ssh/connect", { sessionId, hostId, ...credentials }),
),
answerTotp: (sessionId: string, totpCode: string) =>
call<ConnectResult>("verify the code", () =>
api.post("/ssh/connect-totp", { sessionId, totpCode }),
),
continueWarpgate: (sessionId: string) =>
call<ConnectResult>("complete Warpgate authentication", () =>
api.post("/ssh/connect-warpgate", { sessionId }),
),
disconnect: (sessionId: string) =>
call("disconnect from Docker", () =>
api.post("/ssh/disconnect", { sessionId }),
),
keepalive: (sessionId: string) =>
call("keep the Docker session alive", () =>
api.post("/ssh/keepalive", { sessionId }),
),
validate: (sessionId: string) =>
call<DockerValidation>("validate Docker", () =>
api.get(`/validate/${sessionId}`),
),
listContainers: (sessionId: string, all = true) =>
call<DockerContainer[]>("list containers", () =>
api.get(`/containers/${sessionId}`, { params: { all } }),
),
containerAction: (
sessionId: string,
containerId: string,
action: ContainerAction,
) =>
call<{ success: boolean; message: string }>(
`${action} the container`,
() => api.post(`/containers/${sessionId}/${containerId}/${action}`),
),
removeContainer: (sessionId: string, containerId: string, force = false) =>
call<{ success: boolean; message: string }>("remove the container", () =>
api.delete(`/containers/${sessionId}/${containerId}/remove`, {
params: { force },
}),
),
logs: (
sessionId: string,
containerId: string,
options?: DockerLogOptions,
) =>
call<{ logs: string }>("load container logs", () =>
api.get(`/containers/${sessionId}/${containerId}/logs`, {
params: options,
}),
),
stats: (sessionId: string, containerId: string) =>
call<DockerStats>("load container stats", () =>
api.get(`/containers/${sessionId}/${containerId}/stats`),
),
};
}
export async function getContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<{ logs: string }> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: options,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container logs");
}
}
export type DockerApi = ReturnType<typeof createDockerApi>;
export async function downloadContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<Blob> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: { ...options, download: true },
responseType: "blob",
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "download container logs");
}
}
export async function getContainerStats(
sessionId: string,
containerId: string,
): Promise<DockerStats> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/stats`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container stats");
}
/** The Docker API on this plugin's own client. */
export function useDockerApi(): DockerApi {
const api = usePluginApi();
return useMemo(() => createDockerApi(api), [api]);
}
@@ -0,0 +1,6 @@
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
+24
View File
@@ -0,0 +1,24 @@
import type { HomepageWidgetContribution } from "@termix/plugin-sdk/frontend";
/** The homepage's grid step, in pixels. */
export const GRID_SIZE = 30;
export interface DockerWidgetConfig {
hostId: number;
}
/** What the homepage hands a widget; only the fields this plugin reads. */
export interface WidgetComponentProps<C> {
widget: { title?: string | null };
config: C;
}
export interface WidgetEditFormProps<C> {
config: C;
onChange: (config: C) => void;
}
export type WidgetDefinition<C> = Omit<
HomepageWidgetContribution<C>,
"defaultConfig"
> & { defaultConfig: C };
+11 -24
View File
@@ -2,21 +2,20 @@ import type { ComponentType } from "react";
import { Box } from "lucide-react";
import type {
HomepageWidgetContribution,
HostEditorSectionProps,
StandaloneViewProps,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import type { SSHHost } from "@/types";
import { DockerManager } from "./DockerManager";
import DockerApp from "./DockerApp";
import { HostDockerTab } from "./HostDockerTab";
import { dockerWidget } from "./DockerWidget";
import { dockerEnabled, toDockerHost } from "./types";
function DockerTab({ sshHost, label, isVisible }: TabProps) {
function DockerTab({ host, sshHost, label, isVisible }: TabProps) {
const record = (host ?? sshHost) as Record<string, unknown> | undefined;
return (
<DockerManager
hostConfig={sshHost as unknown as SSHHost}
host={record ? toDockerHost(record) : undefined}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
@@ -29,16 +28,13 @@ function DockerStandalone({ hostId }: StandaloneViewProps) {
return <DockerApp hostId={hostId} />;
}
function DockerHostSection({ form, setField }: HostEditorSectionProps) {
return (
<HostDockerTab
form={form}
setField={setField as Parameters<typeof HostDockerTab>[0]["setField"]}
/>
);
}
export function activate(app: TermixApp): void {
// Host actions filter synchronously, so the permission is read once here.
let canUse = true;
void app.hasPermission("use").then((allowed) => {
canUse = allowed;
});
app.registerTab("docker", DockerTab, {
icon: Box,
titleKey: "nav.docker",
@@ -58,16 +54,7 @@ export function activate(app: TermixApp): void {
order: 30,
tabType: "docker",
copyUrlView: "docker",
when: (host) => !!host.enableSsh && !!host.enableDocker,
});
app.registerHostEditorSection({
id: "docker",
group: "ssh",
titleKey: "hosts.tabDocker",
icon: Box,
order: 30,
component: DockerHostSection,
when: (host) => canUse && !!host.enableSsh && dockerEnabled(host),
});
app.registerHomepageWidget(
+86
View File
@@ -0,0 +1,86 @@
export interface DockerContainer {
id: string;
name: string;
image: string;
status: string;
state:
| "created"
| "running"
| "paused"
| "restarting"
| "removing"
| "exited"
| "dead";
ports: string;
created: string;
command?: string;
labels?: Record<string, string>;
networks?: string[];
mounts?: string[];
}
export interface DockerStats {
cpu: string;
memoryUsed: string;
memoryLimit: string;
memoryPercent: string;
netInput: string;
netOutput: string;
blockRead: string;
blockWrite: string;
pids?: string;
}
export interface DockerLogOptions {
tail?: number;
timestamps?: boolean;
since?: string;
until?: string;
follow?: boolean;
}
export interface DockerValidation {
available: boolean;
version?: string;
runtime?: "docker" | "podman";
error?: string;
code?: string;
}
/** What the Docker views read from a host. */
export interface DockerHost {
id: number;
name?: string;
ip: string;
port: number;
username?: string;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
terminalConfig?: Record<string, unknown> | null;
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
}
/** Any host record the shell or an API hands over, as a DockerHost. */
export function toDockerHost(record: Record<string, unknown>): DockerHost {
return {
...(record as unknown as DockerHost),
id: Number(record.id),
};
}
function dockerSettings(
host: object | null | undefined,
): Record<string, unknown> {
const all = (host as { pluginSettings?: unknown } | null | undefined)
?.pluginSettings as
Record<string, Record<string, unknown> | undefined> | undefined;
return all?.docker ?? {};
}
export function dockerEnabled(host: object | null | undefined): boolean {
return dockerSettings(host).enableDocker === true;
}
export function hostTitle(host: DockerHost): string {
return host.name || `${host.username ?? ""}@${host.ip}`;
}