feat: move proxmox discovery/import/sync into a first-party plugin

This commit is contained in:
LukeGus committed 2026-09-21 19:20:17 -05:00
1 parent 9db66d5780
commit d440ce9073
21 files changed
+290 -36

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
/**
* Proxmox discovery/import/sync - first-party, in-process plugin.
*
* This plugin runs on the main thread rather than in a worker for the same
* reason ai does (see src/backend/plugins/first-party.ts): it writes
* directly across hosts through the host repository, resolves and connects
* to hosts over the shared SSH pool via resolveHostById and
* createJumpHostChain, verifies host keys via SSHHostKeyVerifier, and
* streams its discovery progress as server-sent events. None of that fits
* the worker ctx -- ctx.hosts is a 13-field read-only view with no write
* methods, and ctx.http.route replies with a single postMessage value
* rather than a stream.
*
* Like ai, this plugin does not own a live transport (no WebSocket server,
* no dedicated port), so it registers its Express router with the
* always-mounted /proxmox dispatcher in
* src/backend/database/routes/proxmox-dispatch.ts instead of getting its
* own port.
*
* routes.ts is what used to be src/backend/database/routes/proxmox.ts,
* moved here with its sibling modules (proxmox-import-auth.ts,
* proxmox-jump-hosts.ts) and rewritten from `export default router` into
* startProxmoxService()/stopProxmoxService(), called from
* activate()/deactivate() below. Those two functions also start and stop
* the background auto-sync interval that used to run at module scope in
* proxmox.ts -- keeping it there would mean it fires even when this plugin
* is disabled and can never be torn down.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* 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 Proxmox files (routes.js, ...) sits right next to this
* .mjs, and core modules resolve via ../../../backend/backend/... from
* there (dist/plugins/proxmox/backend -> dist/backend/backend). Under
* vitest/dev nothing imports this plugin directly (the loader 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 routesModule = null;
export async function activate(ctx) {
routesModule = await loadRelative("routes");
routesModule.startProxmoxService();
ctx.log.info("Proxmox router registered at /proxmox");
}
export async function deactivate() {
if (routesModule) {
routesModule.stopProxmoxService();
routesModule = null;
}
}
@@ -1,7 +1,7 @@
// Pure decision: which auth settings an imported Proxmox guest inherits.
//
// The frontend carries a parallel copy in
// src/ui/components/proxmox/proxmox-import-auth.ts. The two drifting apart is
// plugins/proxmox/frontend/proxmox-import-auth.ts. The two drifting apart is
// what produced the reported import bug, so both are kept behaviourally
// identical and each is unit-tested against the same matrix.
export function resolveProxmoxImportAuth(
@@ -1,24 +1,28 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import express from "express";
import { Client as SSHClient } from "ssh2";
import { logger } from "../../utils/logger.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { createCurrentHostRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { logger } from "../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
import { createCurrentHostRepository } from "../../../src/backend/database/repositories/factory.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import {
type AuthenticatedRequest,
type SSHHost,
} from "../../../types/index.js";
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
import { createJumpHostChain } from "../../hosts/jump-host-chain.js";
} from "../../../src/types/index.js";
import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js";
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
import {
parseProxmoxJumpHosts,
serializeProxmoxJumpHosts,
} from "./proxmox-jump-hosts.js";
import { isSafeNodeName } from "../../hosts/proxmox-shared.js";
import { execElevated } from "../../hosts/metrics-shared/exec-elevated.js";
import { isSafeNodeName } from "../../../src/backend/hosts/proxmox-shared.js";
import { execElevated } from "../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import {
registerProxmoxRouter,
unregisterProxmoxRouter,
} from "../../../src/backend/database/routes/proxmox-dispatch.js";
const router = express.Router();
const proxmoxLogger = logger;
@@ -346,7 +350,7 @@ async function discoverProxmoxGuestsForHost(
sshConfig.passphrase = resolvedCredentials.keyPassword;
} else if (authType === "agent") {
const { applyAgentAuth } =
await import("../../hosts/terminal-auth-helpers.js");
await import("../../../src/backend/hosts/terminal-auth-helpers.js");
const result = await applyAgentAuth(
sshConfig,
host.terminalConfig as unknown as Record<string, unknown> | undefined,
@@ -931,13 +935,8 @@ async function runDueProxmoxAutoSyncs(): Promise<void> {
}
}
const proxmoxAutoSyncTimer = setInterval(runDueProxmoxAutoSyncs, 60 * 1000);
proxmoxAutoSyncTimer.unref?.();
const proxmoxAutoSyncStartupTimer = setTimeout(
runDueProxmoxAutoSyncs,
30 * 1000,
);
proxmoxAutoSyncStartupTimer.unref?.();
let proxmoxAutoSyncTimer: NodeJS.Timeout | undefined;
let proxmoxAutoSyncStartupTimer: NodeJS.Timeout | undefined;
/**
* @openapi
@@ -1136,4 +1135,24 @@ router.post(
},
);
export default router;
/** Called from activate(). Mounts this router at /proxmox via the shared
* dispatcher, and starts the background auto-sync scan (a 60s interval plus
* a one-off 30s-delayed startup run, both unref'd so they never keep the
* process alive on their own). */
export function startProxmoxService(): void {
registerProxmoxRouter(router);
proxmoxAutoSyncTimer = setInterval(runDueProxmoxAutoSyncs, 60 * 1000);
proxmoxAutoSyncTimer.unref?.();
proxmoxAutoSyncStartupTimer = setTimeout(runDueProxmoxAutoSyncs, 30 * 1000);
proxmoxAutoSyncStartupTimer.unref?.();
}
/** Called from deactivate(). /proxmox/* falls back to 404 until reactivated,
* and the auto-sync timers are cleared so a disabled plugin stops syncing. */
export function stopProxmoxService(): void {
unregisterProxmoxRouter();
if (proxmoxAutoSyncTimer) clearInterval(proxmoxAutoSyncTimer);
if (proxmoxAutoSyncStartupTimer) clearTimeout(proxmoxAutoSyncStartupTimer);
proxmoxAutoSyncTimer = undefined;
proxmoxAutoSyncStartupTimer = undefined;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Frontend half of the proxmox plugin.
*
* Unlike ssh-terminal/docker, proxmox has no rail tab or standalone view —
* only a discovery dialog (opened imperatively from HostsPanel.tsx, not
* through a tab seam) and a host-editor settings tab. Registers the
* host-editor tab through the same registerHostEditorTab seam docker uses.
*
* HostProxmoxTab 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. The dialog
* is reached today through a direct import in HostsPanel.tsx, not through
* this seam.
*/
export const id = "proxmox";
export const hostEditorTabId = "proxmox";
export async function register({ registerHostEditorTab, icons }) {
if (registerHostEditorTab) {
const { HostProxmoxTab } =
await import("../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
registerHostEditorTab({
id: hostEditorTabId,
labelKey: "hosts.tabProxmox",
icon: icons.Server,
component: HostProxmoxTab,
});
}
}
export async function unregister({ unregisterHostEditorTab }) {
unregisterHostEditorTab?.(hostEditorTabId);
}
+27
View File
@@ -0,0 +1,27 @@
{
"id": "proxmox",
"name": "Proxmox",
"version": "1.0.0",
"description": "Discover Proxmox VE guests over an existing SSH host, import them as Termix hosts, and keep them in sync.",
"author": { "name": "Termix" },
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Infrastructure",
"icon": "Server",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["process:transport-owner"],
"contributes": {
"hostCapability": {
"key": "enableProxmox",
"labelKey": "hosts.proxmoxIntegration",
"editorTab": "proxmox"
}
},
"sidecars": []
}
+2 -2
View File
@@ -25,7 +25,7 @@ import userPreferencesRoutes from "./routes/user-preferences.js";
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js";
import proxmoxRoutes from "./routes/proxmox.js";
import proxmoxDispatch from "./routes/proxmox-dispatch.js";
import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
@@ -1768,7 +1768,7 @@ app.use("/user-preferences", userPreferencesRoutes);
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
app.use("/proxmox", proxmoxRoutes);
app.use("/proxmox", proxmoxDispatch);
app.use("/termix-id", termixIdRoutes);
registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT);
@@ -0,0 +1,48 @@
// Mounted at /proxmox. The Proxmox discovery/import/sync feature ships as a
// first-party in-process plugin (plugins/proxmox) rather than a route file
// imported directly here, so this module exists to keep /proxmox/* working
// at its original path without handing the plugin a reference to the shared
// app instance. The plugin runtime registers its router via
// registerProxmoxRouter when it activates, and clears it on deactivate, so a
// disabled plugin falls back to the 404 below.
//
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
// to exactly one first-party router (the Proxmox plugin's own), and every
// route in that router already applies authenticateJWT/requireDataAccess
// itself, the same as every other router mounted alongside it here (/users,
// /host, /snippets, ...). /plugin-api gates at the mount point because it
// forwards to arbitrary third-party plugin code that cannot be assumed to
// gate itself; that reasoning does not apply to a single first-party router
// that already does.
import type { Request, Response, NextFunction, Router } from "express";
import { databaseLogger } from "../../utils/logger.js";
let activeRouter: Router | null = null;
export function registerProxmoxRouter(router: Router): void {
activeRouter = router;
databaseLogger.info("Registered Proxmox plugin router", {
operation: "proxmox_dispatch_register",
});
}
export function unregisterProxmoxRouter(): void {
if (!activeRouter) return;
activeRouter = null;
databaseLogger.info("Unregistered Proxmox plugin router", {
operation: "proxmox_dispatch_register",
});
}
export default function proxmoxDispatch(
req: Request,
res: Response,
next: NextFunction,
): void {
if (!activeRouter) {
res.status(404).json({ error: "Proxmox discovery is not available" });
return;
}
activeRouter(req, res, next);
}
+9
View File
@@ -54,12 +54,21 @@
* with no write methods and no snippets/fleets/alerts/automations surface at
* all, and ctx.http.route replies with one postMessage value rather than a
* stream, so none of that fits the worker ctx either.
*
* proxmox is here for the same reason as ai: it does not own a transport,
* but it writes directly across hosts through the host repository during
* discovery and sync, resolves and connects to hosts via resolveHostById and
* createJumpHostChain the same way the terminal and ai's command executor
* do, verifies host keys via SSHHostKeyVerifier, and streams its discovery
* progress as server-sent events. Neither the worker's 13-field read-only
* ctx.hosts nor its single-postMessage-reply ctx.http.route can support that.
*/
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
"ssh-terminal",
"docker",
"host-metrics",
"ai",
"proxmox",
]);
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
+5 -3
View File
@@ -299,9 +299,11 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// ssh-terminal, docker and host-metrics plugins start their own servers,
// so disabling any of them stops its WS/HTTP server. See
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
// AI is also absent: the ai plugin registers its router with the /ai
// dispatcher in database.ts on activate, so disabling it makes /ai/* 404
// instead of leaving a dead import here. See plugins/ai.
// AI and Proxmox are also absent for the same reason: each plugin
// registers its router with its own dispatcher in database.ts on
// activate (/ai, /proxmox), so disabling either makes its routes 404
// instead of leaving a dead import here. See plugins/ai and
// plugins/proxmox.
await import("./hosts/tunnel/index.js");
await import("./hosts/file-manager/index.js");
await import("./hosts/tmux/index.js");
+18 -2
View File
@@ -39,20 +39,22 @@ function manifest(overrides: Record<string, unknown> = {}) {
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal, docker, host-metrics and ai", () => {
it("contains only ssh-terminal, docker, host-metrics, ai and proxmox", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"ai",
"docker",
"host-metrics",
"proxmox",
"ssh-terminal",
]);
});
it("recognises ssh-terminal, docker, host-metrics and ai and nothing else", () => {
it("recognises ssh-terminal, docker, host-metrics, ai and proxmox and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
expect(isFirstParty("ai")).toBe(true);
expect(isFirstParty("proxmox")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
@@ -69,6 +71,7 @@ describe("runsInProcess", () => {
true,
);
expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("proxmox", [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.
@@ -76,6 +79,7 @@ describe("runsInProcess", () => {
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
@@ -155,6 +159,18 @@ describe("manifest gate on the reserved capability", () => {
expect(parsed?.id).toBe("ai");
});
it("allows it for proxmox", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "proxmox",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("proxmox");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { resolveProxmoxImportAuth } from "../../../database/routes/proxmox-import-auth.js";
import { resolveProxmoxImportAuth } from "../../../../../plugins/proxmox/backend/proxmox-import-auth.js";
// The frontend carries its own copy of this decision in
// src/ui/components/proxmox/proxmox-import-auth.ts. The two drifting apart is
// plugins/proxmox/frontend/proxmox-import-auth.ts. The two drifting apart is
// what produced the reported bug, so both are held to the same matrix.
describe("resolveProxmoxImportAuth", () => {
it("uses the default credential for key auth when one is configured", () => {
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
parseProxmoxJumpHosts,
serializeProxmoxJumpHosts,
} from "../../../database/routes/proxmox-jump-hosts.js";
} from "../../../../../plugins/proxmox/backend/proxmox-jump-hosts.js";
describe("Proxmox jump-host persistence", () => {
const jumpHosts = [{ hostId: 7 }, { hostId: 9 }];
@@ -6,7 +6,8 @@ vi.mock("../../../hosts/metrics-shared/exec-elevated.js", () => ({
execElevated: (...args: unknown[]) => execElevated(...args),
}));
import { execPveshCommand } from "../../../database/routes/proxmox.js";
const { execPveshCommand } =
await import("../../../../../plugins/proxmox/backend/routes.js");
const client = {} as Client;
+5 -1
View File
@@ -84,6 +84,7 @@ import {
HostDockerTab,
HostWebUiTab,
HostProxmoxTab,
HostProxmoxStatsTab,
HostFilesTab,
} from "./HostEditorFeatureTabs";
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
@@ -2597,7 +2598,10 @@ export function HostEditor({
)}
{activeTab === "proxmox" && (
<HostProxmoxTab form={form} setField={setField} />
<>
<HostProxmoxTab form={form} setField={setField} />
<HostProxmoxStatsTab form={form} setField={setField} />
</>
)}
{activeTab === "files" && (
+20
View File
@@ -317,6 +317,26 @@ export function HostProxmoxTab({
</SettingRow>
</>
)}
</div>
</SectionCard>
);
}
export function HostProxmoxStatsTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.enableProxmoxStats")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableProxmoxStats")}
description={t("hosts.enableProxmoxStatsDesc")}
+1 -1
View File
@@ -28,7 +28,7 @@ import { HostManager } from "@/sidebar/HostManager";
import { HostShareModal } from "@/sidebar/HostShareModal";
import { HostExportDialog } from "@/sidebar/HostExportDialog";
import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel";
import { ProxmoxDiscoverDialog } from "@/components/proxmox/ProxmoxDiscoverDialog";
import { ProxmoxDiscoverDialog } from "../../../plugins/proxmox/frontend/ProxmoxDiscoverDialog";
import { Button } from "@/components/button";
import {
DropdownMenu,
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveProxmoxImportAuth } from "../../../components/proxmox/proxmox-import-auth";
import { resolveProxmoxImportAuth } from "../../../../../plugins/proxmox/frontend/proxmox-import-auth";
describe("resolveProxmoxImportAuth", () => {
it("inherits an available credential even with the default 'password' authType", () => {
+2 -1
View File
@@ -29,7 +29,8 @@
"include": [
"plugins/docker/backend/**/*.ts",
"plugins/host-metrics/backend/**/*.ts",
"plugins/ai/backend/**/*.ts"
"plugins/ai/backend/**/*.ts",
"plugins/proxmox/backend/**/*.ts"
],
"exclude": ["src/**/*.test.ts"]
}