feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
This commit is contained in:
LukeGus committed 2026-09-21 09:55:37 -05:00
1 parent c94a453f1e
commit a2479c19b0
34 files changed
+729 -143

No files matched your search

+8
View File
@@ -13,6 +13,14 @@ dist-ssr
coverage
*.local
# tsc -p tsconfig.plugins.json emits plugin backend .js next to its .ts
# source. It also stray-emits sibling .js next to every core .ts the plugin
# imports for type-checking (src/types, src/backend/utils, repositories...);
# scripts/copy-bundled-plugins.cjs removes those after every compile, this
# is a safety net for running tsc -p tsconfig.plugins.json directly.
/plugins/*/backend/*.js
/src/**/*.js
.vscode/*
!.vscode/settings.json
.idea
@@ -1,32 +1,35 @@
import { getErrorMessage } from "../../utils/error-message.js";
// Core imports below point at TypeScript source so tsc can type-check them.
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import { StringDecoder } from "string_decoder";
import { Client as SSHClient } from "ssh2";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { SSH_ALGORITHMS } from "../../../src/backend/utils/ssh-algorithms.js";
import { WebSocketServer, WebSocket } from "ws";
import { AuthManager } from "../../utils/auth-manager.js";
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
import { systemLogger } from "../../utils/logger.js";
import type { SSHHost } from "../../../types/index.js";
import { applyAgentAuth } from "../terminal-auth-helpers.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { createCurrentHostResolutionRepository } from "../../../src/backend/database/repositories/factory.js";
import { systemLogger } from "../../../src/backend/utils/logger.js";
import type { SSHHost } from "../../../src/types/index.js";
import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
type ContainerRuntime,
} from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
import {
hostAddressMismatch,
HOST_ADDRESS_MISMATCH_MESSAGE,
HOST_NOT_ON_THIS_SERVER_MESSAGE,
} from "../host-identity.js";
import { extractWebSocketToken } from "../../utils/ws-auth.js";
} from "../../../src/backend/hosts/host-identity.js";
import { extractWebSocketToken } from "../../../src/backend/utils/ws-auth.js";
import {
asObject,
asString,
MAX_WS_MESSAGE_BYTES,
parseWsMessage,
toTerminalDimension,
} from "../../utils/ws-message.js";
} from "../../../src/backend/utils/ws-message.js";
const sshLogger = systemLogger;
@@ -404,7 +407,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
try {
// Resolve host with credentials server-side
const { resolveHostById, resolveHostBySyncId } =
await import("../host-resolver.js");
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;
@@ -823,7 +826,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
});
});
process.on("SIGTERM", () => {
/**
* Closes every live console session and the WebSocket server. Called by the
* plugin's deactivate() as well as SIGTERM, since disabling the docker
* plugin has to free port 30009 the same way process shutdown does.
*/
export function closeConsoleServer(): Promise<void> {
activeSessions.forEach((session) => {
if (session.stream) {
session.stream.end();
@@ -833,7 +841,9 @@ process.on("SIGTERM", () => {
activeSessions.clear();
wss.close(() => {
process.exit(0);
});
return new Promise((resolve) => wss.close(() => resolve()));
}
process.on("SIGTERM", () => {
void closeConsoleServer().then(() => process.exit(0));
});
@@ -1,6 +1,9 @@
import { getErrorMessage } from "../../utils/error-message.js";
// Core imports below point at TypeScript source so tsc can type-check them.
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import type express from "express";
import { logger } from "../../utils/logger.js";
import { logger } from "../../../src/backend/utils/logger.js";
import {
containerCommand,
type ContainerRuntime,
+142
View File
@@ -0,0 +1,142 @@
/**
* Docker - first-party, in-process plugin.
*
* This plugin runs on the main thread rather than in a worker for the same
* reason ssh-terminal does (see src/backend/plugins/first-party.ts): its
* container-console WebSocket server holds long-lived ssh2.Client + PTY
* streams, its session-manager reuses a live ssh2.Client across many REST
* calls, and its SSH connect flow implements TOTP/Warpgate as a genuinely
* stateful multi-request handshake. None of that can cross the
* structured-clone postMessage boundary a worker plugin talks over.
*
* Unlike ssh-terminal, Docker's backend has no consumers outside itself, so
* the whole implementation moved here rather than staying in core with only
* a lifecycle wrapper. This file is what used to be
* src/backend/hosts/docker/index.ts, rewritten from a module-scope
* SIGINT/SIGTERM + listenOnServicePort() call into activate()/deactivate().
*
* The moved files (routes.ts, container-routes.ts, container-runtime.ts,
* session-manager.ts, console.ts) are real TypeScript, compiled by
* scripts/copy-bundled-plugins.cjs (see that script for why plugins/ needs
* its own compile step) rather than hand-written like ssh-terminal's .mjs
* wrapper. This file itself stays plain JS since it is a thin entry point
* with no types worth checking.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createServer } from "node:http";
/**
* Loads a module by its path relative to this file's own directory.
*
* The plugin ships beside dist/backend, so in a built server the compiled
* .js for the moved docker files (routes.js, session-manager.js, ...) sits
* right next to this .mjs, and core modules resolve via
* ../../../backend/backend/... from there (dist/plugins/docker/backend ->
* dist/backend/backend). Under vitest/dev nothing imports this plugin
* directly (src/backend/plugins/loader.ts loads in-process plugins with a
* plain dynamic import, no tsx loader involved), so the only layout that
* needs to resolve here is the built one.
*/
async function loadRelative(relativePath) {
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, `${relativePath}.js`);
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
}
let httpServer = null;
let consoleModule = null;
let authManagerInstance = null;
export async function activate(ctx) {
const [
express,
cookieParser,
{ createCorsMiddleware },
{ createCompressionMiddleware },
{ logger },
{ AuthManager },
{ registerDockerContainerRoutes },
sessionManager,
{ DOCKER_TIMESTAMP_RE, getRequestUserId, registerDockerSshRoutes },
] = await Promise.all([
import("express").then((m) => m.default),
import("cookie-parser").then((m) => m.default),
loadRelative("../../../backend/backend/utils/cors-config"),
loadRelative("../../../backend/backend/utils/compression-config"),
loadRelative("../../../backend/backend/utils/logger"),
loadRelative("../../../backend/backend/utils/auth-manager"),
loadRelative("container-routes"),
loadRelative("session-manager"),
loadRelative("routes"),
]);
const app = express();
app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
authManagerInstance = AuthManager.getInstance();
app.use(authManagerInstance.createAuthMiddleware());
app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
registerDockerSshRoutes(app);
registerDockerContainerRoutes(app, {
sshSessions: sessionManager.sshSessions,
pendingTOTPSessions: sessionManager.pendingTOTPSessions,
getRequestUserId,
executeDockerCommand: sessionManager.executeDockerCommand,
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
const port = 30007;
const server = createServer(app);
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
httpServer = server;
try {
await authManagerInstance.initialize();
} catch (err) {
logger.error("Failed to initialize Docker backend", err, {
operation: "startup",
});
}
// Importing console.js binds its WebSocket server on port 30009 as a
// module-scope side effect -- unchanged from before the move, see that
// file's own header.
consoleModule = await loadRelative("console");
ctx.log.info(`Docker plugin listening on ${port} (console on 30009)`);
}
export async function deactivate() {
if (consoleModule?.closeConsoleServer) {
await consoleModule.closeConsoleServer();
}
consoleModule = null;
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise((resolve) => server.close(() => resolve()));
}
authManagerInstance = null;
}
@@ -1,42 +1,45 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
// Core imports below point at TypeScript source so tsc can type-check them.
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js";
import express from "express";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import { logger } from "../../utils/logger.js";
import { logger } from "../../../src/backend/utils/logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import { createCurrentHostRepository } from "../../database/repositories/factory.js";
import { createJumpHostChain } from "../jump-host-chain.js";
import { resolveHostById } from "../host-resolver.js";
import { createConnectionLog } from "../connection-log.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { AuthManager } from "../../utils/auth-manager.js";
import {
type AuthenticatedRequest,
type ProxyNode,
type SSHHost,
} from "../../../types/index.js";
} from "../../../src/backend/utils/audit-logger.js";
import { createCurrentHostRepository } from "../../../src/backend/database/repositories/factory.js";
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
import { createConnectionLog } from "../../../src/backend/hosts/connection-log.js";
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import type {
AuthenticatedRequest,
ProxyNode,
SSHHost,
} from "../../../src/types/index.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../../utils/socks5-helper.js";
} from "../../../src/backend/utils/socks5-helper.js";
import type {
LogEntry,
ConnectionStage,
} from "../../../types/connection-log.js";
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
import { applyAgentAuth } from "../terminal-auth-helpers.js";
} from "../../../src/types/connection-log.js";
import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js";
import { preparePrivateKeyForSSH2 } from "../../../src/backend/utils/ssh-key-utils.js";
import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
getRuntimeLabel,
} from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
import {
type SSHSession,
sshSessions,
@@ -303,7 +306,8 @@ export function registerDockerSshRoutes(app: express.Express): void {
}
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
try {
const { getOPKSSHToken } = await import("../opkssh-auth.js");
const { getOPKSSHToken } =
await import("../../../src/backend/hosts/opkssh-auth.js");
const token = await getOPKSSHToken(userId, hostId);
if (!token) {
@@ -323,7 +327,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
}
const { setupOPKSSHCertAuth } =
await import("../opkssh-cert-auth.js");
await import("../../../src/backend/hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(
config as import("ssh2").ConnectConfig,
client,
@@ -1,5 +1,8 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
import { Client as SSHClient } from "ssh2";
import { logger } from "../../utils/logger.js";
import { logger } from "../../../src/backend/utils/logger.js";
import type { ContainerRuntime } from "./container-runtime.js";
const sshLogger = logger;
@@ -1,6 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { DockerManager } from "@/features/docker/DockerManager.tsx";
import { DockerManager } from "./DockerManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
@@ -1,4 +1,4 @@
import { getErrorMessage } from "../../../lib/error-message.js";
import { getErrorMessage } from "@/lib/error-message.js";
import React from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
@@ -1,4 +1,4 @@
import { getErrorMessage } from "../../../lib/error-message.js";
import { getErrorMessage } from "@/lib/error-message.js";
import React from "react";
import { Card } from "@/components/card.tsx";
import { Button } from "@/components/button.tsx";
@@ -1,4 +1,4 @@
import { getErrorMessage } from "../../../lib/error-message.js";
import { getErrorMessage } from "@/lib/error-message.js";
import React from "react";
import {
Activity,
@@ -1,4 +1,4 @@
import { getErrorMessage } from "../../../lib/error-message.js";
import { getErrorMessage } from "@/lib/error-message.js";
import React from "react";
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
import { toast } from "sonner";
@@ -1,4 +1,4 @@
import { getErrorMessage } from "../../../lib/error-message.js";
import { getErrorMessage } from "@/lib/error-message.js";
import React from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
+68
View File
@@ -0,0 +1,68 @@
/**
* Frontend half of the docker plugin.
*
* Registers the container-management tab through the existing extension
* seams (registerRailItem / registerTabComponent) the same way
* ssh-terminal's frontend does, and the host-editor Docker tab through the
* new registerHostEditorTab seam built for this plugin.
*
* HostDockerTab itself stays in src/ui/sidebar/HostEditorFeatureTabs.tsx
* rather than moving into this plugin: it takes the same {form, setField}
* pair every other host editor tab does, and those types (HostEditorForm,
* the setField signature) are core types HostEditor.tsx owns, the same
* reason ssh-terminal leaves session-manager.ts in core rather than copying
* it here.
*
* register() is called when the plugin is enabled, unregister() when it is
* disabled. As of this change neither is actually invoked anywhere yet --
* see plugins/ssh-terminal/frontend/index.mjs and
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
* "there is no plugin loader yet" for the frontend half. This file follows
* the same shape so it is ready the moment that loader exists.
*/
export const id = "docker";
export const tabId = "docker";
export const hostEditorTabId = "docker";
export async function register({
registerRailItem,
registerTabComponent,
registerHostEditorTab,
icons,
}) {
registerTabComponent(tabId, () =>
import("./DockerManager.tsx").then((m) => ({
default: m.DockerManager,
})),
);
registerRailItem({
id: tabId,
icon: icons.Box,
labelKey: "nav.docker",
kind: "tab",
});
if (registerHostEditorTab) {
const { HostDockerTab } = await import(
"../../../src/ui/sidebar/HostEditorFeatureTabs.tsx"
);
registerHostEditorTab({
id: hostEditorTabId,
labelKey: "hosts.tabDocker",
icon: icons.Box,
component: HostDockerTab,
});
}
}
export async function unregister({
unregisterRailItem,
unregisterTabComponent,
unregisterHostEditorTab,
}) {
unregisterRailItem(tabId);
unregisterTabComponent(tabId);
unregisterHostEditorTab?.(hostEditorTabId);
}
+35
View File
@@ -0,0 +1,35 @@
{
"id": "docker",
"name": "Docker",
"version": "1.0.0",
"description": "Container management over SSH: list, inspect, start, stop and view logs for Docker or Podman containers, plus an interactive container console.",
"author": { "name": "Termix" },
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Infrastructure",
"icon": "Box",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["process:transport-owner"],
"contributes": {
"tabs": [
{
"id": "docker",
"titleKey": "nav.docker",
"icon": "Box",
"openFrom": ["host-context-menu", "palette"]
}
],
"hostCapability": {
"key": "enableDocker",
"labelKey": "hosts.dockerIntegration",
"editorTab": "docker"
}
},
"sidecars": []
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+98 -5
View File
@@ -1,10 +1,24 @@
/**
* Copies plugins/ into dist/plugins so bundled first-party plugins ship with a
* built server.
* Compiles plugin backend TypeScript, then copies plugins/ into dist/plugins
* so bundled first-party plugins ship with a built server.
*
* tsc only emits .ts, and a plugin's backend entry is a hand-written .mjs plus
* a manifest.json, so without this step the plugins directory simply would not
* exist in dist and the loader would find nothing.
* Most plugin backend entries are hand-written .mjs plus a manifest.json --
* tsc only emits .ts, so without a copy step the plugins directory simply
* would not exist in dist and the loader would find nothing.
*
* The docker plugin is the exception: its backend is real TypeScript,
* physically relocated from src/backend/hosts/docker/ rather than kept as
* hand-written JS, because it is ~4000 lines of typed SSH/session logic that
* is not worth hand-transpiling. tsconfig.plugins.json compiles the plugin
* backend TypeScript to a sibling .js next to its source. Its imports
* into core (e.g. "../../../src/backend/utils/logger.js") are written
* relative to the TypeScript SOURCE tree so tsc can type-check them, since
* plugins/ is not under tsconfig.node.json's rootDir and cannot be added to
* it without changing every existing dist/backend/backend/... path in the
* codebase. That means the emitted .js still points at src/backend/, which
* does not exist in a built server -- only its compiled counterpart at
* dist/backend/backend/ does. rewriteCoreImports() below corrects that one
* prefix after compilation, before the directory is copied into dist/.
*
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
@@ -12,6 +26,7 @@
const fs = require("node:fs");
const path = require("node:path");
const { execSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const source = path.join(root, "plugins");
@@ -22,6 +37,84 @@ if (!fs.existsSync(source)) {
process.exit(0);
}
const SRC_BACKEND_PREFIX = "../../../src/backend/";
const COMPILED_BACKEND_PREFIX = "../../../backend/backend/";
function compilePluginBackends() {
execSync("npx tsc -p tsconfig.plugins.json", {
cwd: root,
stdio: "inherit",
});
}
/**
* Rewrites the compiled plugin JS's imports into core from the source-tree
* path tsc needed to resolve them, to the compiled-output path they need to
* resolve at runtime. See the module comment above for why this exists.
*/
function rewriteCoreImports(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
rewriteCoreImports(entryPath);
continue;
}
if (!entry.name.endsWith(".js")) continue;
const original = fs.readFileSync(entryPath, "utf8");
if (!original.includes(SRC_BACKEND_PREFIX)) continue;
const rewritten = original.split(SRC_BACKEND_PREFIX).join(COMPILED_BACKEND_PREFIX);
fs.writeFileSync(entryPath, rewritten);
}
}
/**
* tsconfig.plugins.json has rootDir/outDir "." so the docker plugin's .js
* lands beside its own .ts, but tsc also pulls every file the plugin
* imports into the same program for type-checking -- core utils, repository
* factories, src/types -- and stray-emits a sibling .js next to each of
* those .ts files too, even though none of that code is actually compiled
* by this pass (only referenced for types/signatures). Those stray files
* are not part of the normal build and must not leak into dist/ or the
* working tree, so anything under src/ that is a .js with no git history
* and a same-named .ts sibling gets removed after every compile.
*/
function isGitTracked(filePath) {
try {
execSync(`git ls-files --error-unmatch "${filePath}"`, {
cwd: root,
stdio: "ignore",
});
return true;
} catch {
return false;
}
}
function removeStrayEmits(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
removeStrayEmits(entryPath);
continue;
}
if (!entry.name.endsWith(".js")) continue;
const tsSibling = entryPath.slice(0, -".js".length) + ".ts";
// Never touch a tracked file, even if some future .ts/.js pair
// legitimately coexists -- only remove what is both untracked and has a
// .ts sibling, which is what a stray tsc emit always looks like.
if (fs.existsSync(tsSibling) && !isGitTracked(entryPath)) {
fs.rmSync(entryPath);
}
}
}
compilePluginBackends();
rewriteCoreImports(path.join(source, "docker", "backend"));
removeStrayEmits(path.join(root, "src"));
fs.rmSync(destination, { recursive: true, force: true });
fs.cpSync(source, destination, { recursive: true });
-79
View File
@@ -1,79 +0,0 @@
import express from "express";
import cookieParser from "cookie-parser";
import { createCorsMiddleware } from "../../utils/cors-config.js";
import { createCompressionMiddleware } from "../../utils/compression-config.js";
import { logger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { registerDockerContainerRoutes } from "./container-routes.js";
import {
sshSessions,
pendingTOTPSessions,
cleanupSession,
executeDockerCommand,
} from "./session-manager.js";
import {
DOCKER_TIMESTAMP_RE,
getRequestUserId,
registerDockerSshRoutes,
} from "./routes.js";
import { listenOnServicePort } from "../../utils/service-listen.js";
const sshLogger = logger;
const app = express();
app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
const authManager = AuthManager.getInstance();
app.use(authManager.createAuthMiddleware());
app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
registerDockerSshRoutes(app);
registerDockerContainerRoutes(app, {
sshSessions,
pendingTOTPSessions,
getRequestUserId,
executeDockerCommand,
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
const PORT = 30007;
listenOnServicePort({
app,
port: PORT,
logger: sshLogger,
serviceName: "docker",
onListening: async () => {
try {
await authManager.initialize();
} catch (err) {
sshLogger.error("Failed to initialize Docker backend", err, {
operation: "startup",
});
}
},
});
process.on("SIGINT", () => {
Object.keys(sshSessions).forEach((sessionId) => {
cleanupSession(sessionId);
});
process.exit(0);
});
process.on("SIGTERM", () => {
Object.keys(sshSessions).forEach((sessionId) => {
cleanupSession(sessionId);
});
process.exit(0);
});
+7
View File
@@ -32,9 +32,16 @@
* ssh-terminal is here because it IS the SSH transport rather than a consumer
* of it: it owns a WebSocket server and long-lived ssh2 clients, neither of
* which can cross a postMessage boundary.
*
* docker is here for the same reason: its container console owns a
* WebSocket server with long-lived ssh2 clients and PTY streams, its
* session manager reuses a live ssh2.Client across many REST calls, and its
* SSH connect flow implements TOTP/Warpgate as a stateful multi-request
* handshake the worker ctx.ssh API has no hook for.
*/
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
"ssh-terminal",
"docker",
]);
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
+3 -4
View File
@@ -295,13 +295,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { serverReady } = await import("./database/database.js");
await serverReady;
// Terminal is deliberately absent: the ssh-terminal plugin starts it, so
// disabling that plugin stops the WS server. See plugins/ssh-terminal.
// Terminal and docker are deliberately absent: the ssh-terminal and
// docker plugins start their own servers, so disabling either plugin
// stops its WS/HTTP server. See plugins/ssh-terminal and plugins/docker.
await import("./hosts/tunnel/index.js");
await import("./hosts/file-manager/index.js");
await import("./hosts/metrics/index.js");
await import("./hosts/docker/index.js");
await import("./hosts/docker/console.js");
await import("./hosts/tmux/index.js");
await import("./hosts/serial.js");
await import("./services/dashboard.js");
@@ -82,3 +82,57 @@ describe("bundled ssh-terminal plugin", () => {
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled docker plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "docker");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(readBundledManifest("docker"));
expect(errors).toEqual([]);
expect(manifest?.id).toBe("docker");
expect(manifest?.category).toBe("Infrastructure");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("docker")).toBe(true);
expect(runsInProcess("docker", manifest!.permissions)).toBe(true);
});
it("contributes the docker tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "docker".
expect(tab?.id).toBe("docker");
expect(tab?.openFrom).toContain("host-context-menu");
});
it("declares the enableDocker host capability", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
expect(manifest?.contributes?.hostCapability?.key).toBe("enableDocker");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"docker",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
+21 -3
View File
@@ -39,12 +39,16 @@ function manifest(overrides: Record<string, unknown> = {}) {
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal", () => {
expect([...FIRST_PARTY_PLUGIN_IDS]).toEqual(["ssh-terminal"]);
it("contains only ssh-terminal and docker", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"docker",
"ssh-terminal",
]);
});
it("recognises ssh-terminal and nothing else", () => {
it("recognises ssh-terminal and docker and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
@@ -56,10 +60,12 @@ describe("runsInProcess", () => {
expect(runsInProcess("ssh-terminal", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("docker", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false);
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
@@ -103,6 +109,18 @@ describe("manifest gate on the reserved capability", () => {
expect(parsed?.id).toBe("ssh-terminal");
});
it("allows it for docker", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "docker",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("docker");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),
+3 -1
View File
@@ -54,7 +54,9 @@ const ProxmoxStatsApp = lazy(() =>
})),
);
const DockerApp = lazy(() =>
import("@/features/docker/DockerApp").then((m) => ({ default: m.default })),
import("../plugins/docker/frontend/DockerApp").then((m) => ({
default: m.default,
})),
);
const GuacamoleApp = lazy(() =>
import("@/features/guacamole/GuacamoleApp").then((m) => ({
@@ -0,0 +1,33 @@
import type { ReactNode } from "react";
/**
* Runtime registry for plugin-contributed dashboard cards.
*
* Unlike registerRailItem/registerTabComponent (rail-items.ts, tabUtils.tsx),
* nothing in DashboardTab.tsx consumes this yet: its card system
* (DASHBOARD_CARDS in @/lib/theme, DashboardCardId in @/types/ui-types) is a
* closed, typed set with its own drag/drop layout and per-user persisted
* slot preferences, and no plugin today contributes an actual dashboard
* card. This registry exists so a plugin CAN register one without another
* round of seam-building, but adding it to the DashboardTab render/layout
* system is future work, not part of this change.
*/
export interface RegisteredDashboardCard {
id: string;
titleKey: string;
render: () => ReactNode;
}
const registeredDashboardCards = new Map<string, RegisteredDashboardCard>();
export function registerDashboardCard(def: RegisteredDashboardCard): void {
registeredDashboardCards.set(def.id, def);
}
export function unregisterDashboardCard(id: string): void {
registeredDashboardCards.delete(id);
}
export function registeredDashboardCardList(): RegisteredDashboardCard[] {
return [...registeredDashboardCards.values()];
}
@@ -9,7 +9,7 @@ import type {
import { GRID_SIZE } from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
import { DockerManager } from "@/features/docker/DockerManager";
import { DockerManager } from "../../../../../plugins/docker/frontend/DockerManager";
import type { SSHHost } from "@/types/index";
import { WidgetTitle } from "./WidgetTitle";
+1 -1
View File
@@ -85,7 +85,7 @@ const loadFileManager = () =>
}));
const FileManager = lazy(loadFileManager);
const loadDockerManager = () =>
import("@/features/docker/DockerManager").then((m) => ({
import("../../../plugins/docker/frontend/DockerManager").then((m) => ({
default: m.DockerManager,
}));
const DockerManager = lazy(loadDockerManager);
+8
View File
@@ -95,6 +95,7 @@ import {
} from "./HostEditorGuacamoleTabs";
import { HostStatsTab } from "./HostEditorStatsTab";
import { VaultProfileManager } from "./VaultProfileManager";
import { getRegisteredHostEditorTab } from "./HostManagerTabs";
import {
SecretReferenceHint,
SecretSourceManager,
@@ -2661,6 +2662,13 @@ export function HostEditor({
credentials={availableCredentials}
/>
)}
{(() => {
const registeredTab = getRegisteredHostEditorTab(activeTab);
if (!registeredTab) return null;
const RegisteredTabComponent = registeredTab.component;
return <RegisteredTabComponent form={form} setField={setField} />;
})()}
</div>
</fieldset>
+50 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { useEffect, useRef, type ReactNode } from "react";
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
import {
Activity,
Box,
@@ -52,6 +52,50 @@ export const SSH_GROUP_TABS = new Set<HostTabId>([
"host-metrics",
]);
/**
* A plugin-contributed host editor tab. `component` receives the same
* {form, setField} pair every built-in tab body gets (see HostDockerTab in
* HostEditorFeatureTabs.tsx for the shape a plugin's own tab component
* should match).
*/
export type HostEditorTabComponent = ComponentType<{
form: unknown;
setField: (key: string, value: unknown) => void;
}>;
interface RegisteredHostEditorTab {
id: string;
labelKey: string;
icon: ReactNode;
component: HostEditorTabComponent;
}
/**
* Runtime registry for host editor tabs that don't exist in the built-in
* HostTabId union. A plugin's frontend registers into this from its own
* register() (see plugins/*\/frontend/index.mjs), the same pattern
* rail-items.ts and tabUtils.tsx already use for their seams.
*/
const registeredHostEditorTabs = new Map<string, RegisteredHostEditorTab>();
export function registerHostEditorTab(def: RegisteredHostEditorTab): void {
registeredHostEditorTabs.set(def.id, def);
}
export function unregisterHostEditorTab(id: string): void {
registeredHostEditorTabs.delete(id);
}
export function getRegisteredHostEditorTab(
id: string,
): RegisteredHostEditorTab | undefined {
return registeredHostEditorTabs.get(id);
}
export function registeredHostEditorTabList(): RegisteredHostEditorTab[] {
return [...registeredHostEditorTabs.values()];
}
export function makeHostTabs(t: (key: string) => string): HostTab[] {
return [
{
@@ -124,6 +168,11 @@ export function makeHostSshSubTabs(t: (key: string) => string): HostTab[] {
label: t("hosts.tabHostMetrics"),
icon: <Activity className="size-3" />,
},
...registeredHostEditorTabList().map((tab) => ({
id: tab.id as HostTabId,
label: t(tab.labelKey),
icon: tab.icon,
})),
];
}
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it } from "vitest";
import {
registerDashboardCard,
registeredDashboardCardList,
unregisterDashboardCard,
} from "@/dashboard/dashboard-cards-registry";
const FAKE_CARD_ID = "__test_plugin_dashboard_card__";
describe("registerDashboardCard seam", () => {
afterEach(() => {
unregisterDashboardCard(FAKE_CARD_ID);
});
it("is not registered by default", () => {
expect(registeredDashboardCardList()).toEqual([]);
});
it("registers and unregisters a plugin dashboard card", () => {
registerDashboardCard({
id: FAKE_CARD_ID,
titleKey: "nav.fakePluginItem",
render: () => null,
});
expect(registeredDashboardCardList().map((c) => c.id)).toContain(
FAKE_CARD_ID,
);
unregisterDashboardCard(FAKE_CARD_ID);
expect(registeredDashboardCardList().map((c) => c.id)).not.toContain(
FAKE_CARD_ID,
);
});
});
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it } from "vitest";
import { Puzzle } from "lucide-react";
import {
getRegisteredHostEditorTab,
makeHostSshSubTabs,
registerHostEditorTab,
registeredHostEditorTabList,
unregisterHostEditorTab,
} from "@/sidebar/HostManagerTabs";
const FAKE_TAB_ID = "__test_plugin_host_editor_tab__";
function FakeTabComponent() {
return null;
}
describe("registerHostEditorTab seam", () => {
afterEach(() => {
unregisterHostEditorTab(FAKE_TAB_ID);
});
it("is not registered by default", () => {
expect(getRegisteredHostEditorTab(FAKE_TAB_ID)).toBeUndefined();
expect(registeredHostEditorTabList()).toEqual([]);
});
it("registers and unregisters a plugin host editor tab", () => {
registerHostEditorTab({
id: FAKE_TAB_ID,
labelKey: "nav.fakePluginItem",
icon: <Puzzle />,
component: FakeTabComponent,
});
expect(getRegisteredHostEditorTab(FAKE_TAB_ID)?.component).toBe(
FakeTabComponent,
);
expect(registeredHostEditorTabList().map((t) => t.id)).toContain(
FAKE_TAB_ID,
);
unregisterHostEditorTab(FAKE_TAB_ID);
expect(getRegisteredHostEditorTab(FAKE_TAB_ID)).toBeUndefined();
});
it("appends a registered tab after the built-in SSH sub-tabs", () => {
registerHostEditorTab({
id: FAKE_TAB_ID,
labelKey: "nav.fakePluginItem",
icon: <Puzzle />,
component: FakeTabComponent,
});
const ids: string[] = makeHostSshSubTabs((key) => key).map((tab) => tab.id);
expect(ids).toContain(FAKE_TAB_ID);
expect(ids.indexOf(FAKE_TAB_ID)).toBe(ids.length - 1);
});
});
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.plugins.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "nodenext",
"skipLibCheck": true,
"moduleResolution": "nodenext",
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"esModuleInterop": true,
"noEmit": false,
"rootDir": ".",
"outDir": ".",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": false,
"noUncheckedSideEffectImports": false,
"noImplicitAny": false,
"noImplicitThis": false,
"noUncheckedIndexedAccess": false,
"exactOptionalPropertyTypes": false,
"noPropertyAccessFromIndexSignature": false,
"allowUnusedLabels": true,
"allowUnreachableCode": true
},
"include": ["plugins/docker/backend/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}