mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: move proxmox discovery/import/sync into a first-party plugin
This commit is contained in:
1 parent
9db66d5780
commit
d440ce9073
21 files changed
+290
-36
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// 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
|
||||
// what produced the reported import bug, so both are kept behaviourally
|
||||
// identical and each is unit-tested against the same matrix.
|
||||
export function resolveProxmoxImportAuth(
|
||||
defaultAuthType: string | undefined,
|
||||
credentialId: number | null | undefined,
|
||||
): {
|
||||
authType: string;
|
||||
credentialId: number | null;
|
||||
overrideCredentialUsername: number;
|
||||
} {
|
||||
// An explicit special auth type (none/opkssh/tailscale/vault/…) wins.
|
||||
if (
|
||||
defaultAuthType &&
|
||||
defaultAuthType !== "credential" &&
|
||||
!["password", "key"].includes(defaultAuthType)
|
||||
) {
|
||||
return {
|
||||
authType: defaultAuthType,
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// A credential (configured default OR inherited from the source host) is a
|
||||
// concrete auth source -> use it, even when defaultAuthType is the
|
||||
// "password"/"key" default.
|
||||
if (credentialId) {
|
||||
return {
|
||||
authType: "credential",
|
||||
credentialId,
|
||||
overrideCredentialUsername: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authType: "none",
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: 0,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
export function parseProxmoxJumpHosts(raw: unknown): unknown[] | null {
|
||||
if (!raw) return null;
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (typeof raw !== "string") return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeProxmoxJumpHosts(raw: unknown): string | null {
|
||||
const parsed = parseProxmoxJumpHosts(raw);
|
||||
return parsed ? JSON.stringify(parsed) : null;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"] }),
|
||||
|
||||
+2
-2
@@ -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", () => {
|
||||
+1
-1
@@ -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 }];
|
||||
+2
-1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user