mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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:
1 parent
c94a453f1e
commit
a2479c19b0
34 files changed
+729
-143
No files matched your search
@@ -13,6 +13,14 @@ dist-ssr
|
|||||||
coverage
|
coverage
|
||||||
*.local
|
*.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/*
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
.idea
|
.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 { StringDecoder } from "string_decoder";
|
||||||
import { Client as SSHClient } from "ssh2";
|
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 { WebSocketServer, WebSocket } from "ws";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||||
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
import { createCurrentHostResolutionRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||||
import { systemLogger } from "../../utils/logger.js";
|
import { systemLogger } from "../../../src/backend/utils/logger.js";
|
||||||
import type { SSHHost } from "../../../types/index.js";
|
import type { SSHHost } from "../../../src/types/index.js";
|
||||||
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js";
|
||||||
import {
|
import {
|
||||||
containerCommand,
|
containerCommand,
|
||||||
getContainerRuntimeConfig,
|
getContainerRuntimeConfig,
|
||||||
type ContainerRuntime,
|
type ContainerRuntime,
|
||||||
} from "./container-runtime.js";
|
} from "./container-runtime.js";
|
||||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
|
||||||
import {
|
import {
|
||||||
hostAddressMismatch,
|
hostAddressMismatch,
|
||||||
HOST_ADDRESS_MISMATCH_MESSAGE,
|
HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||||
} from "../host-identity.js";
|
} from "../../../src/backend/hosts/host-identity.js";
|
||||||
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
import { extractWebSocketToken } from "../../../src/backend/utils/ws-auth.js";
|
||||||
import {
|
import {
|
||||||
asObject,
|
asObject,
|
||||||
asString,
|
asString,
|
||||||
MAX_WS_MESSAGE_BYTES,
|
MAX_WS_MESSAGE_BYTES,
|
||||||
parseWsMessage,
|
parseWsMessage,
|
||||||
toTerminalDimension,
|
toTerminalDimension,
|
||||||
} from "../../utils/ws-message.js";
|
} from "../../../src/backend/utils/ws-message.js";
|
||||||
|
|
||||||
const sshLogger = systemLogger;
|
const sshLogger = systemLogger;
|
||||||
|
|
||||||
@@ -404,7 +407,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
try {
|
try {
|
||||||
// Resolve host with credentials server-side
|
// Resolve host with credentials server-side
|
||||||
const { resolveHostById, resolveHostBySyncId } =
|
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
|
// syncId names the host on both sides of a sync pair; the numeric
|
||||||
// id only names it in the database the client is displaying.
|
// id only names it in the database the client is displaying.
|
||||||
const hostSyncId = hostConfig?.syncId;
|
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) => {
|
activeSessions.forEach((session) => {
|
||||||
if (session.stream) {
|
if (session.stream) {
|
||||||
session.stream.end();
|
session.stream.end();
|
||||||
@@ -833,7 +841,9 @@ process.on("SIGTERM", () => {
|
|||||||
|
|
||||||
activeSessions.clear();
|
activeSessions.clear();
|
||||||
|
|
||||||
wss.close(() => {
|
return new Promise((resolve) => wss.close(() => resolve()));
|
||||||
process.exit(0);
|
}
|
||||||
});
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
void closeConsoleServer().then(() => process.exit(0));
|
||||||
});
|
});
|
||||||
+5
-2
@@ -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 type express from "express";
|
||||||
import { logger } from "../../utils/logger.js";
|
import { logger } from "../../../src/backend/utils/logger.js";
|
||||||
import {
|
import {
|
||||||
containerCommand,
|
containerCommand,
|
||||||
type ContainerRuntime,
|
type ContainerRuntime,
|
||||||
File renamed without changes.
@@ -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";
|
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||||
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
|
// 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 express from "express";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Client as SSHClient } from "ssh2";
|
import { Client as SSHClient } from "ssh2";
|
||||||
import { logger } from "../../utils/logger.js";
|
import { logger } from "../../../src/backend/utils/logger.js";
|
||||||
import {
|
import {
|
||||||
logAudit,
|
logAudit,
|
||||||
getAuditUsername,
|
getAuditUsername,
|
||||||
getRequestMeta,
|
getRequestMeta,
|
||||||
} from "../../utils/audit-logger.js";
|
} from "../../../src/backend/utils/audit-logger.js";
|
||||||
import { createCurrentHostRepository } from "../../database/repositories/factory.js";
|
import { createCurrentHostRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
|
||||||
import { resolveHostById } from "../host-resolver.js";
|
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
|
||||||
import { createConnectionLog } from "../connection-log.js";
|
import { createConnectionLog } from "../../../src/backend/hosts/connection-log.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||||
import {
|
import type {
|
||||||
type AuthenticatedRequest,
|
AuthenticatedRequest,
|
||||||
type ProxyNode,
|
ProxyNode,
|
||||||
type SSHHost,
|
SSHHost,
|
||||||
} from "../../../types/index.js";
|
} from "../../../src/types/index.js";
|
||||||
import {
|
import {
|
||||||
createSocks5Connection,
|
createSocks5Connection,
|
||||||
type SOCKS5Config,
|
type SOCKS5Config,
|
||||||
} from "../../utils/socks5-helper.js";
|
} from "../../../src/backend/utils/socks5-helper.js";
|
||||||
import type {
|
import type {
|
||||||
LogEntry,
|
LogEntry,
|
||||||
ConnectionStage,
|
ConnectionStage,
|
||||||
} from "../../../types/connection-log.js";
|
} from "../../../src/types/connection-log.js";
|
||||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js";
|
||||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
import { preparePrivateKeyForSSH2 } from "../../../src/backend/utils/ssh-key-utils.js";
|
||||||
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js";
|
||||||
import {
|
import {
|
||||||
containerCommand,
|
containerCommand,
|
||||||
getContainerRuntimeConfig,
|
getContainerRuntimeConfig,
|
||||||
getRuntimeLabel,
|
getRuntimeLabel,
|
||||||
} from "./container-runtime.js";
|
} from "./container-runtime.js";
|
||||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
|
||||||
import {
|
import {
|
||||||
type SSHSession,
|
type SSHSession,
|
||||||
sshSessions,
|
sshSessions,
|
||||||
@@ -303,7 +306,8 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
|||||||
}
|
}
|
||||||
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
|
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
|
||||||
try {
|
try {
|
||||||
const { getOPKSSHToken } = await import("../opkssh-auth.js");
|
const { getOPKSSHToken } =
|
||||||
|
await import("../../../src/backend/hosts/opkssh-auth.js");
|
||||||
const token = await getOPKSSHToken(userId, hostId);
|
const token = await getOPKSSHToken(userId, hostId);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -323,7 +327,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { setupOPKSSHCertAuth } =
|
const { setupOPKSSHCertAuth } =
|
||||||
await import("../opkssh-cert-auth.js");
|
await import("../../../src/backend/hosts/opkssh-cert-auth.js");
|
||||||
await setupOPKSSHCertAuth(
|
await setupOPKSSHCertAuth(
|
||||||
config as import("ssh2").ConnectConfig,
|
config as import("ssh2").ConnectConfig,
|
||||||
client,
|
client,
|
||||||
+4
-1
@@ -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 { 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";
|
import type { ContainerRuntime } from "./container-runtime.js";
|
||||||
|
|
||||||
const sshLogger = logger;
|
const sshLogger = logger;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DockerManager } from "@/features/docker/DockerManager.tsx";
|
import { DockerManager } from "./DockerManager.tsx";
|
||||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||||
|
|
||||||
File renamed without changes.
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
import { getErrorMessage } from "@/lib/error-message.js";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useXTerm } from "react-xtermjs";
|
import { useXTerm } from "react-xtermjs";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
import { getErrorMessage } from "@/lib/error-message.js";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card } from "@/components/card.tsx";
|
import { Card } from "@/components/card.tsx";
|
||||||
import { Button } from "@/components/button.tsx";
|
import { Button } from "@/components/button.tsx";
|
||||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
import { getErrorMessage } from "@/lib/error-message.js";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
import { getErrorMessage } from "@/lib/error-message.js";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
|
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
import { getErrorMessage } from "@/lib/error-message.js";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/components/button.tsx";
|
import { Button } from "@/components/button.tsx";
|
||||||
import { Input } from "@/components/input.tsx";
|
import { Input } from "@/components/input.tsx";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Copies plugins/ into dist/plugins so bundled first-party plugins ship with a
|
* Compiles plugin backend TypeScript, then copies plugins/ into dist/plugins
|
||||||
* built server.
|
* 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
|
* Most plugin backend entries are hand-written .mjs plus a manifest.json --
|
||||||
* a manifest.json, so without this step the plugins directory simply would not
|
* tsc only emits .ts, so without a copy step the plugins directory simply
|
||||||
* exist in dist and the loader would find nothing.
|
* 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
|
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
|
||||||
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
||||||
@@ -12,6 +26,7 @@
|
|||||||
|
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const { execSync } = require("node:child_process");
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..");
|
const root = path.resolve(__dirname, "..");
|
||||||
const source = path.join(root, "plugins");
|
const source = path.join(root, "plugins");
|
||||||
@@ -22,6 +37,84 @@ if (!fs.existsSync(source)) {
|
|||||||
process.exit(0);
|
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.rmSync(destination, { recursive: true, force: true });
|
||||||
fs.cpSync(source, destination, { recursive: true });
|
fs.cpSync(source, destination, { recursive: true });
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -32,9 +32,16 @@
|
|||||||
* ssh-terminal is here because it IS the SSH transport rather than a consumer
|
* 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
|
* of it: it owns a WebSocket server and long-lived ssh2 clients, neither of
|
||||||
* which can cross a postMessage boundary.
|
* 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([
|
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||||
"ssh-terminal",
|
"ssh-terminal",
|
||||||
|
"docker",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
||||||
|
|||||||
@@ -295,13 +295,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
|||||||
|
|
||||||
const { serverReady } = await import("./database/database.js");
|
const { serverReady } = await import("./database/database.js");
|
||||||
await serverReady;
|
await serverReady;
|
||||||
// Terminal is deliberately absent: the ssh-terminal plugin starts it, so
|
// Terminal and docker are deliberately absent: the ssh-terminal and
|
||||||
// disabling that plugin stops the WS server. See plugins/ssh-terminal.
|
// 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/tunnel/index.js");
|
||||||
await import("./hosts/file-manager/index.js");
|
await import("./hosts/file-manager/index.js");
|
||||||
await import("./hosts/metrics/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/tmux/index.js");
|
||||||
await import("./hosts/serial.js");
|
await import("./hosts/serial.js");
|
||||||
await import("./services/dashboard.js");
|
await import("./services/dashboard.js");
|
||||||
|
|||||||
@@ -82,3 +82,57 @@ describe("bundled ssh-terminal plugin", () => {
|
|||||||
expect(source).toMatch(/export async function deactivate/);
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,12 +39,16 @@ function manifest(overrides: Record<string, unknown> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("first-party allowlist", () => {
|
describe("first-party allowlist", () => {
|
||||||
it("contains only ssh-terminal", () => {
|
it("contains only ssh-terminal and docker", () => {
|
||||||
expect([...FIRST_PARTY_PLUGIN_IDS]).toEqual(["ssh-terminal"]);
|
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("ssh-terminal")).toBe(true);
|
||||||
|
expect(isFirstParty("docker")).toBe(true);
|
||||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||||
expect(isFirstParty("community-plugin")).toBe(false);
|
expect(isFirstParty("community-plugin")).toBe(false);
|
||||||
expect(isFirstParty("")).toBe(false);
|
expect(isFirstParty("")).toBe(false);
|
||||||
@@ -56,10 +60,12 @@ describe("runsInProcess", () => {
|
|||||||
expect(runsInProcess("ssh-terminal", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
expect(runsInProcess("ssh-terminal", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
expect(runsInProcess("docker", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
|
||||||
|
|
||||||
// On the list but not asking for it: stays in a worker, so the manifest
|
// On the list but not asking for it: stays in a worker, so the manifest
|
||||||
// remains an honest description of what the plugin does.
|
// remains an honest description of what the plugin does.
|
||||||
expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false);
|
expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false);
|
||||||
|
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
|
||||||
|
|
||||||
// Asking for it but not on the list.
|
// Asking for it but not on the list.
|
||||||
expect(
|
expect(
|
||||||
@@ -103,6 +109,18 @@ describe("manifest gate on the reserved capability", () => {
|
|||||||
expect(parsed?.id).toBe("ssh-terminal");
|
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", () => {
|
it("still accepts an ordinary manifest that never mentions it", () => {
|
||||||
const { manifest: parsed, errors } = parseManifest(
|
const { manifest: parsed, errors } = parseManifest(
|
||||||
manifest({ permissions: ["hosts.read"] }),
|
manifest({ permissions: ["hosts.read"] }),
|
||||||
|
|||||||
+3
-1
@@ -54,7 +54,9 @@ const ProxmoxStatsApp = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const DockerApp = 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(() =>
|
const GuacamoleApp = lazy(() =>
|
||||||
import("@/features/guacamole/GuacamoleApp").then((m) => ({
|
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 { GRID_SIZE } from "@/types/homepage-types";
|
||||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||||
import type { SSHHostWithStatus } from "@/main-axios";
|
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 type { SSHHost } from "@/types/index";
|
||||||
import { WidgetTitle } from "./WidgetTitle";
|
import { WidgetTitle } from "./WidgetTitle";
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const loadFileManager = () =>
|
|||||||
}));
|
}));
|
||||||
const FileManager = lazy(loadFileManager);
|
const FileManager = lazy(loadFileManager);
|
||||||
const loadDockerManager = () =>
|
const loadDockerManager = () =>
|
||||||
import("@/features/docker/DockerManager").then((m) => ({
|
import("../../../plugins/docker/frontend/DockerManager").then((m) => ({
|
||||||
default: m.DockerManager,
|
default: m.DockerManager,
|
||||||
}));
|
}));
|
||||||
const DockerManager = lazy(loadDockerManager);
|
const DockerManager = lazy(loadDockerManager);
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import {
|
|||||||
} from "./HostEditorGuacamoleTabs";
|
} from "./HostEditorGuacamoleTabs";
|
||||||
import { HostStatsTab } from "./HostEditorStatsTab";
|
import { HostStatsTab } from "./HostEditorStatsTab";
|
||||||
import { VaultProfileManager } from "./VaultProfileManager";
|
import { VaultProfileManager } from "./VaultProfileManager";
|
||||||
|
import { getRegisteredHostEditorTab } from "./HostManagerTabs";
|
||||||
import {
|
import {
|
||||||
SecretReferenceHint,
|
SecretReferenceHint,
|
||||||
SecretSourceManager,
|
SecretSourceManager,
|
||||||
@@ -2661,6 +2662,13 @@ export function HostEditor({
|
|||||||
credentials={availableCredentials}
|
credentials={availableCredentials}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const registeredTab = getRegisteredHostEditorTab(activeTab);
|
||||||
|
if (!registeredTab) return null;
|
||||||
|
const RegisteredTabComponent = registeredTab.component;
|
||||||
|
return <RegisteredTabComponent form={form} setField={setField} />;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Box,
|
Box,
|
||||||
@@ -52,6 +52,50 @@ export const SSH_GROUP_TABS = new Set<HostTabId>([
|
|||||||
"host-metrics",
|
"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[] {
|
export function makeHostTabs(t: (key: string) => string): HostTab[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -124,6 +168,11 @@ export function makeHostSshSubTabs(t: (key: string) => string): HostTab[] {
|
|||||||
label: t("hosts.tabHostMetrics"),
|
label: t("hosts.tabHostMetrics"),
|
||||||
icon: <Activity className="size-3" />,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user