Files
Termix/plugins/proxmox/backend/routes.ts
T

1159 lines
35 KiB
TypeScript

import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import express from "express";
import { Client as SSHClient } from "ssh2";
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 "../../../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 "../../../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;
const runningSyncs = new Set<string>();
const MIN_SYNC_INTERVAL_MINUTES = 5;
const DEFAULT_SYNC_INTERVAL_MINUTES = 15;
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Helpers
function execCommand(
client: SSHClient,
command: string,
timeoutMs = 25000,
): Promise<string> {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
reject(new Error(`Command timed out after ${timeoutMs}ms`));
}
}, timeoutMs);
client.exec(command, (err, stream) => {
if (err) {
clearTimeout(timer);
return reject(err);
}
let stdout = "";
let stderr = "";
stream.on("close", (code: number) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code !== 0)
reject(new Error(stderr || `Command exited with code ${code}`));
else resolve(stdout);
});
stream.on("data", (data: Buffer) => {
stdout += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
});
});
}
export async function execPveshCommand(
client: SSHClient,
command: string,
sudoPassword: string | undefined,
timeoutMs = 25000,
): Promise<string> {
if (!sudoPassword) {
return execCommand(client, command, timeoutMs);
}
const result = await execElevated(client, command, sudoPassword, {
forceSudo: true,
timeoutMs,
});
if (result.code !== 0) {
throw new Error(result.stderr || `Command exited with code ${result.code}`);
}
return result.stdout;
}
// Parse all IPs from LXC net config, then return the one matching the preferred prefix.
function parseLxcIp(
config: Record<string, unknown>,
preferredPrefixes: string[] = [],
): string | null {
const ips: string[] = [];
for (const [key, value] of Object.entries(config)) {
if (/^net\d+$/.test(key) && typeof value === "string") {
const m = value.match(/ip=(\d{1,3}(?:\.\d{1,3}){3})/);
if (m) ips.push(m[1]);
}
}
if (!ips.length) return null;
for (const prefix of preferredPrefixes) {
const match = ips.find((ip) => ip.startsWith(prefix));
if (match) return match;
}
return ips[0];
}
function matchesAny(name: string, patterns: string[]): boolean {
const lower = name.toLowerCase();
return patterns.some((p) => lower.includes(p.toLowerCase()));
}
function parseProxmoxConfig(raw: unknown): {
windowsPatterns: string[];
dockerPatterns: string[];
preferredPrefixes: string[];
defaultCredentialId: number | null;
defaultAuthType: string;
autoSyncEnabled: boolean;
syncIntervalMinutes: number;
markMissingGuests: boolean;
} {
const split = (s: string) =>
s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
if (!raw || typeof raw !== "object") {
return {
windowsPatterns: ["win", "windows"],
dockerPatterns: ["docker"],
preferredPrefixes: [],
defaultCredentialId: null,
defaultAuthType: "password",
autoSyncEnabled: false,
syncIntervalMinutes: DEFAULT_SYNC_INTERVAL_MINUTES,
markMissingGuests: true,
};
}
const cfg = raw as Record<string, unknown>;
const interval =
typeof cfg.syncIntervalMinutes === "number"
? cfg.syncIntervalMinutes
: Number.parseInt(String(cfg.syncIntervalMinutes ?? ""), 10);
return {
defaultCredentialId:
typeof cfg.defaultCredentialId === "number"
? cfg.defaultCredentialId
: null,
defaultAuthType:
typeof cfg.defaultAuthType === "string"
? cfg.defaultAuthType
: "password",
windowsPatterns: split(
typeof cfg.windowsPatterns === "string"
? cfg.windowsPatterns
: "win,windows",
),
dockerPatterns: split(
typeof cfg.dockerPatterns === "string" ? cfg.dockerPatterns : "docker",
),
preferredPrefixes: split(
typeof cfg.preferredPrefixes === "string" ? cfg.preferredPrefixes : "",
),
autoSyncEnabled: cfg.autoSyncEnabled === true,
syncIntervalMinutes:
Number.isFinite(interval) && interval >= MIN_SYNC_INTERVAL_MINUTES
? interval
: DEFAULT_SYNC_INTERVAL_MINUTES,
markMissingGuests: cfg.markMissingGuests !== false,
};
}
type ProxmoxGuest = {
name: string;
vmid: number;
type: "qemu" | "lxc";
node: string;
status: string;
ip: string | null;
connectionType: "ssh" | "rdp";
enableDocker: boolean;
};
type ProxmoxSource = {
source: "proxmox";
sourceHostId: number;
node: string;
vmid: number;
type: "qemu" | "lxc";
lastSeenAt?: string;
lastStatus?: string;
missingSince?: string | null;
};
type ProxmoxSyncResult = {
created: number;
updated: number;
markedMissing: number;
skipped: number;
errors: string[];
};
function parseJsonObject(value: unknown): Record<string, unknown> {
if (!value) return {};
if (typeof value === "object") return value as Record<string, unknown>;
if (typeof value !== "string") return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function getProxmoxSource(host: Record<string, unknown>): ProxmoxSource | null {
const config = parseJsonObject(host.proxmoxConfig);
const source = config.source;
if (!source || typeof source !== "object") return null;
const src = source as Record<string, unknown>;
if (
src.source !== "proxmox" ||
typeof src.sourceHostId !== "number" ||
typeof src.node !== "string" ||
typeof src.vmid !== "number" ||
(src.type !== "qemu" && src.type !== "lxc")
) {
return null;
}
return src as ProxmoxSource;
}
function proxmoxSourceKey(source: ProxmoxSource): string {
return `${source.sourceHostId}:${source.node}:${source.type}:${source.vmid}`;
}
function guestSourceKey(sourceHostId: number, guest: ProxmoxGuest): string {
return `${sourceHostId}:${guest.node}:${guest.type}:${guest.vmid}`;
}
function guestTags(guest: ProxmoxGuest): string[] {
const idTag = guest.type === "lxc" ? `ct-${guest.vmid}` : `vm-${guest.vmid}`;
return [
"proxmox",
guest.type,
guest.node,
idTag,
...(guest.enableDocker ? ["docker"] : []),
];
}
function mergeTags(
existing: unknown,
additions: string[],
removals: string[] = [],
): string {
const removeSet = new Set(removals);
const base =
typeof existing === "string"
? existing
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
: Array.isArray(existing)
? existing
.map((tag) => (typeof tag === "string" ? tag.trim() : ""))
.filter(Boolean)
: [];
return [...new Set([...base, ...additions])]
.filter((tag) => !removeSet.has(tag))
.join(",");
}
async function discoverProxmoxGuestsForHost(
userId: string,
parsedHostId: number,
onProgress?: (done: number, total: number) => void,
): Promise<{
host: SSHHost;
guests: ProxmoxGuest[];
credentialId: number | null;
defaultCredentialId: number | null;
jumpHosts: unknown[] | null;
config: ReturnType<typeof parseProxmoxConfig>;
}> {
if (!DataCrypto.canUserAccessData(userId)) {
const error = new Error("Session expired — please log in again");
(error as Error & { code?: string }).code = "SESSION_EXPIRED";
throw error;
}
const resolvedHost = await resolveHostById(parsedHostId, userId);
if (!resolvedHost) {
const error = new Error("Host not found");
(error as Error & { status?: number }).status = 404;
throw error;
}
const host = resolvedHost as SSHHost;
const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig);
const config = parseProxmoxConfig(proxmoxCfgRaw);
const resolvedCredentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
} = {
password: host.password,
sshKey: host.key,
keyPassword: host.keyPassword,
authType: host.authType,
};
const hostCredentialId = host.credentialId ?? null;
const sshConfig: Record<string, unknown> = {
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
port: host.port || 22,
username: host.username,
tryKeyboard: false,
readyTimeout: 30000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
parsedHostId,
host.ip,
host.port || 22,
null,
userId,
false,
),
};
const authType = resolvedCredentials.authType;
if (authType === "key" && resolvedCredentials.sshKey) {
sshConfig.privateKey = resolvedCredentials.sshKey;
if (resolvedCredentials.keyPassword)
sshConfig.passphrase = resolvedCredentials.keyPassword;
} else if (authType === "agent") {
const { applyAgentAuth } =
await import("../../../src/backend/hosts/terminal-auth-helpers.js");
const result = await applyAgentAuth(
sshConfig,
host.terminalConfig as unknown as Record<string, unknown> | undefined,
);
if ("error" in result) {
const error = new Error(result.error);
(error as Error & { status?: number }).status = 400;
throw error;
}
} else if (resolvedCredentials.password) {
sshConfig.password = resolvedCredentials.password;
}
const client = new SSHClient();
try {
await new Promise<void>((resolve, reject) => {
client.on("ready", resolve);
client.on("error", reject);
// Reuse the shared jump-host chain (same path terminal/metrics use)
// so Proxmox hosts that are only reachable via a jump host work too
// (otherwise the direct connect fails with EHOSTUNREACH). jumpHosts is
// stored as a JSON string on the decrypted record, so parse it first.
let parsedJumpHosts: Array<{ hostId: number }> = [];
try {
const rawJumpHosts = (host as { jumpHosts?: unknown }).jumpHosts;
const parsed =
typeof rawJumpHosts === "string"
? JSON.parse(rawJumpHosts)
: rawJumpHosts;
if (Array.isArray(parsed)) parsedJumpHosts = parsed;
} catch {
parsedJumpHosts = [];
}
if (parsedJumpHosts.length > 0) {
createJumpHostChain(parsedJumpHosts, userId)
.then((jumpClient) => {
if (!jumpClient) {
reject(new Error("Jump host chain could not be established"));
return;
}
jumpClient.forwardOut(
"127.0.0.1",
0,
sshConfig.host as string,
sshConfig.port as number,
(err, stream) => {
if (err || !stream) {
reject(err || new Error("Jump host forward failed"));
return;
}
sshConfig.sock = stream;
delete sshConfig.host;
delete sshConfig.port;
client.connect(sshConfig as import("ssh2").ConnectConfig);
},
);
})
.catch(reject);
} else {
client.connect(sshConfig as import("ssh2").ConnectConfig);
}
});
proxmoxLogger.info("Proxmox discovery SSH connection established", {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
});
const pveshCheck = await execCommand(
client,
"command -v pvesh >/dev/null 2>&1 && echo ok || echo missing",
);
if (pveshCheck.trim() !== "ok") {
const error = new Error("pvesh not found — is this a Proxmox node?");
(error as Error & { status?: number }).status = 422;
throw error;
}
const resourcesJson = await execPveshCommand(
client,
"pvesh get /cluster/resources --output-format json 2>/dev/null",
host.sudoPassword,
);
let resources: Array<Record<string, unknown>>;
try {
resources = JSON.parse(resourcesJson);
} catch {
const error = new Error(
"Failed to parse pvesh output — unexpected response",
);
(error as Error & { status?: number }).status = 502;
throw error;
}
type GuestBase = {
name: string;
vmid: number;
type: "qemu" | "lxc";
node: string;
status: string;
};
const guestBases: GuestBase[] = [];
for (const r of resources) {
const type = r.type as string;
if (type !== "qemu" && type !== "lxc") continue;
if (r.template) continue;
const node = r.node as string;
if (!isSafeNodeName(node)) {
proxmoxLogger.warn("Skipping guest with unsafe node name", {
operation: "proxmox_discover",
node,
vmid: r.vmid,
});
continue;
}
guestBases.push({
name: (r.name as string) || String(r.vmid),
vmid: Number(r.vmid),
type: type as "qemu" | "lxc",
node,
status: (r.status as string) || "unknown",
});
}
async function resolveIp(g: GuestBase): Promise<string | null> {
if (g.type === "lxc") {
let configIp: string | null = null;
try {
const cfgJson = await execPveshCommand(
client,
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
host.sudoPassword,
25000,
);
configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
} catch {
configIp = null;
}
if (configIp) return configIp;
// Static config parsing found nothing (e.g. net0 uses ip=dhcp).
// Fall back to the live interface list for running containers.
if (g.status === "running") {
try {
const ifRaw = await execPveshCommand(
client,
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/interfaces --output-format json 2>/dev/null`,
host.sudoPassword,
12000,
);
const data = JSON.parse(ifRaw);
const entries: Array<Record<string, unknown>> = Array.isArray(data)
? data
: [];
const allIps: string[] = [];
for (const entry of entries) {
if (entry.name === "lo") continue;
const inet = entry.inet;
if (typeof inet !== "string") continue;
const m = inet.match(/^(\d{1,3}(?:\.\d{1,3}){3})\/\d+$/);
if (m && !m[1].startsWith("127.")) allIps.push(m[1]);
}
if (allIps.length) {
for (const prefix of config.preferredPrefixes) {
const match = allIps.find((ip) => ip.startsWith(prefix));
if (match) return match;
}
return allIps[0];
}
} catch {
// Guest not running or interfaces unavailable
}
}
return null;
}
if (g.type === "qemu" && g.status === "running") {
try {
const ifJson = await execPveshCommand(
client,
`pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`,
host.sudoPassword,
12000,
);
const data = JSON.parse(ifJson);
const ifaces: Array<Record<string, unknown>> = Array.isArray(
data?.result,
)
? data.result
: Array.isArray(data)
? data
: [];
const allIps: string[] = [];
for (const iface of ifaces) {
if (iface.name === "lo") continue;
const addrs =
(iface["ip-addresses"] as Array<Record<string, string>>) ?? [];
for (const a of addrs) {
if (
a["ip-address-type"] === "ipv4" &&
!a["ip-address"].startsWith("127.")
) {
allIps.push(a["ip-address"]);
}
}
}
if (allIps.length) {
for (const prefix of config.preferredPrefixes) {
const match = allIps.find((ip) => ip.startsWith(prefix));
if (match) return match;
}
return allIps[0];
}
} catch {
// Guest agent absent or timed out
}
}
return null;
}
// Low concurrency on purpose: pvesh is heavy and small Proxmox nodes
// (especially reached over a high-latency jump chain) suffer severe
// contention when many run at once — calls then exceed execCommand's
// timeout and IPs come back empty. 2 keeps each call well under budget.
const CONCURRENCY = 2;
const ips: (string | null)[] = new Array(guestBases.length).fill(null);
let cursor = 0;
let completed = 0;
onProgress?.(0, guestBases.length);
async function ipWorker() {
while (cursor < guestBases.length) {
const i = cursor++;
ips[i] = await resolveIp(guestBases[i]);
completed++;
onProgress?.(completed, guestBases.length);
}
}
await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, guestBases.length) }, () =>
ipWorker(),
),
);
const guests: ProxmoxGuest[] = guestBases.map((g, i) => ({
...g,
ip: ips[i],
connectionType: matchesAny(g.name, config.windowsPatterns)
? "rdp"
: "ssh",
enableDocker: matchesAny(g.name, config.dockerPatterns),
}));
proxmoxLogger.info("Proxmox discovery completed", {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
guestCount: guests.length,
});
return {
host,
guests,
credentialId: hostCredentialId,
defaultCredentialId: config.defaultCredentialId,
jumpHosts: parseProxmoxJumpHosts(
(host as unknown as { jumpHosts?: unknown }).jumpHosts,
),
config,
};
} finally {
try {
client.end();
} catch {
// ignore cleanup errors
}
}
}
async function syncProxmoxHost(
userId: string,
sourceHostId: number,
): Promise<ProxmoxSyncResult> {
const lockKey = `${userId}:${sourceHostId}`;
if (runningSyncs.has(lockKey)) {
return {
created: 0,
updated: 0,
markedMissing: 0,
skipped: 0,
errors: ["Sync already running"],
};
}
runningSyncs.add(lockKey);
const result: ProxmoxSyncResult = {
created: 0,
updated: 0,
markedMissing: 0,
skipped: 0,
errors: [],
};
const startedAt = new Date().toISOString();
try {
const discovery = await discoverProxmoxGuestsForHost(userId, sourceHostId);
const sourceHostName = discovery.host.name || "Proxmox";
const defaultCredentialId =
discovery.config.defaultCredentialId ?? discovery.credentialId ?? null;
const importAuth = resolveProxmoxImportAuth(
discovery.config.defaultAuthType,
defaultCredentialId,
);
const now = new Date().toISOString();
const existingHosts =
(await createCurrentHostRepository().listDecryptedByUserId(
userId,
)) as unknown as Record<string, unknown>[];
const existingBySource = new Map<string, Record<string, unknown>>();
for (const host of existingHosts) {
const source = getProxmoxSource(host);
if (source?.sourceHostId === sourceHostId) {
existingBySource.set(proxmoxSourceKey(source), host);
}
}
const seen = new Set<string>();
for (const guest of discovery.guests) {
const key = guestSourceKey(sourceHostId, guest);
seen.add(key);
const existing = existingBySource.get(key);
const source: ProxmoxSource = {
source: "proxmox",
sourceHostId,
node: guest.node,
vmid: guest.vmid,
type: guest.type,
lastSeenAt: now,
lastStatus: guest.status,
missingSince: null,
};
const baseConfig = existing
? parseJsonObject(existing.proxmoxConfig)
: {};
const proxmoxConfig = {
...baseConfig,
source,
};
const existingConnectionType =
existing?.connectionType === "ssh" || existing?.connectionType === "rdp"
? existing.connectionType
: null;
const connectionType = existingConnectionType ?? guest.connectionType;
const usesImportCredential =
connectionType === "ssh" && importAuth.authType === "credential";
const existingUsesImportCredential =
usesImportCredential &&
existing?.authType === "credential" &&
existing?.credentialId === importAuth.credentialId;
const port =
typeof existing?.port === "number"
? existing.port
: connectionType === "rdp"
? 3389
: 22;
const username =
usesImportCredential && (!existing || existingUsesImportCredential)
? ""
: typeof existing?.username === "string" && existing.username
? existing.username
: connectionType === "rdp"
? ""
: "root";
const update: Record<string, unknown> = {
name: guest.name,
ip: guest.ip || existing?.ip || "0.0.0.0",
port,
username,
connectionType,
folder: existing?.folder || sourceHostName,
tags: mergeTags(existing?.tags, guestTags(guest), ["proxmox-missing"]),
proxmoxConfig: JSON.stringify(proxmoxConfig),
updatedAt: now,
};
if (existing) {
if (existingUsesImportCredential) {
update.credentialId = importAuth.credentialId;
update.overrideCredentialUsername = false;
}
await createCurrentHostRepository().updateEncryptedForUser(
userId,
existing.id as number,
update,
);
result.updated++;
continue;
}
Object.assign(update, {
enableTerminal: connectionType !== "rdp",
enableFileManager: connectionType !== "rdp",
enableTunnel: connectionType !== "rdp",
enableDocker: guest.enableDocker,
enableSsh: connectionType === "ssh",
enableRdp: connectionType === "rdp",
});
await createCurrentHostRepository().createEncryptedForUser(userId, {
...update,
userId,
createdAt: now,
pin: false,
authType: connectionType === "rdp" ? "password" : importAuth.authType,
credentialId: connectionType === "ssh" ? importAuth.credentialId : null,
overrideCredentialUsername: importAuth.overrideCredentialUsername,
password: null,
key: null,
keyPassword: null,
keyType: null,
rdpUser: null,
rdpPassword: null,
rdpDomain: null,
rdpSecurity: null,
rdpIgnoreCert: 0,
rdpPort: connectionType === "rdp" ? 3389 : null,
vncUser: null,
vncPassword: null,
vncPort: null,
telnetUser: null,
telnetPassword: null,
telnetPort: null,
defaultPath: "/",
tunnelConnections: "[]",
jumpHosts: serializeProxmoxJumpHosts(discovery.jumpHosts),
quickActions: null,
statsConfig: null,
dockerConfig: null,
terminalConfig: null,
forceKeyboardInteractive: "false",
useSocks5: 0,
socks5Host: null,
socks5Port: null,
socks5Username: null,
socks5Password: null,
socks5ProxyChain: null,
portKnockSequence: null,
showTerminalInSidebar: 0,
showFileManagerInSidebar: 0,
showTunnelInSidebar: 0,
showDockerInSidebar: 0,
showServerStatsInSidebar: 0,
});
result.created++;
}
if (discovery.config.markMissingGuests) {
for (const [key, existing] of existingBySource.entries()) {
if (seen.has(key)) continue;
const config = parseJsonObject(existing.proxmoxConfig);
const source = getProxmoxSource(existing);
if (!source) continue;
const missingSince = source.missingSince || now;
await createCurrentHostRepository().updateEncryptedForUser(
userId,
existing.id as number,
{
tags: mergeTags(existing.tags, ["proxmox-missing"]),
proxmoxConfig: JSON.stringify({
...config,
source: {
...source,
missingSince,
},
}),
updatedAt: now,
},
);
result.markedMissing++;
}
}
await writeSyncStatus(userId, sourceHostId, {
lastSyncAt: startedAt,
lastSyncStatus: "success",
lastSyncError: null,
lastSyncResult: result,
});
return result;
} catch (error) {
const message = getErrorMessage(error);
result.errors.push(message);
await writeSyncStatus(userId, sourceHostId, {
lastSyncAt: startedAt,
lastSyncStatus: "error",
lastSyncError: message,
lastSyncResult: result,
});
throw error;
} finally {
runningSyncs.delete(lockKey);
}
}
async function writeSyncStatus(
userId: string,
hostId: number,
patch: Record<string, unknown>,
): Promise<void> {
const hostRepository = createCurrentHostRepository();
const row = await hostRepository.findByIdForUser(userId, hostId);
if (!row) return;
const config = parseJsonObject(row.proxmoxConfig);
await hostRepository.updateForUser(userId, hostId, {
proxmoxConfig: JSON.stringify({ ...config, ...patch }),
});
}
router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => {
const { hostId } = req.body as { hostId?: unknown };
const userId = (req as unknown as AuthenticatedRequest).userId;
const parsedHostId = Number(hostId);
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
return res.status(400).json({ error: "Missing or invalid hostId" });
}
try {
const result = await syncProxmoxHost(userId, parsedHostId);
return res.json(result);
} catch (err: unknown) {
const message = getErrorMessage(err);
const status =
(err as Error & { code?: string; status?: number }).code ===
"SESSION_EXPIRED"
? 401
: (err as Error & { status?: number }).status || 500;
proxmoxLogger.error("Proxmox sync failed", err, {
operation: "proxmox_sync",
hostId: parsedHostId,
userId,
});
return res.status(status).json({ error: `Sync failed: ${message}` });
}
});
async function runDueProxmoxAutoSyncs(): Promise<void> {
try {
const rows = await createCurrentHostRepository().listProxmoxEnabled();
const now = Date.now();
for (const row of rows) {
const configRaw = parseJsonObject(row.proxmoxConfig);
const config = parseProxmoxConfig(configRaw);
if (!config.autoSyncEnabled) continue;
if (!DataCrypto.canUserAccessData(row.userId)) continue;
const lastSyncAt =
typeof configRaw.lastSyncAt === "string"
? Date.parse(configRaw.lastSyncAt)
: 0;
const intervalMs = config.syncIntervalMinutes * 60 * 1000;
if (lastSyncAt && now - lastSyncAt < intervalMs) continue;
syncProxmoxHost(row.userId, row.id).catch((error) => {
proxmoxLogger.error("Scheduled Proxmox sync failed", error, {
operation: "proxmox_auto_sync",
hostId: row.id,
userId: row.userId,
});
});
}
} catch (error) {
proxmoxLogger.error("Failed to scan Proxmox auto sync jobs", error, {
operation: "proxmox_auto_sync_scan",
});
}
}
let proxmoxAutoSyncTimer: NodeJS.Timeout | undefined;
let proxmoxAutoSyncStartupTimer: NodeJS.Timeout | undefined;
/**
* @openapi
* /proxmox/discover:
* post:
* summary: Discover Proxmox guests on a node
* description: >
* Connects to an existing SSH host (a Proxmox node) using its stored
* credentials, runs pvesh to enumerate all guests (VMs and LXC
* containers) in the cluster, and returns them ready to be imported as
* Termix hosts. No separate Proxmox API token is required.
* tags: [Proxmox]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [hostId]
* properties:
* hostId:
* type: number
* description: ID of the SSH host that is a Proxmox node.
* responses:
* 200:
* description: Discovered guests.
* content:
* application/json:
* schema:
* type: object
* properties:
* guests:
* type: array
* items:
* type: object
* properties:
* name:
* type: string
* vmid:
* type: number
* type:
* type: string
* enum: [qemu, lxc]
* node:
* type: string
* status:
* type: string
* ip:
* type: string
* nullable: true
* connectionType:
* type: string
* enum: [ssh, rdp]
* enableDocker:
* type: boolean
* credentialId:
* type: number
* nullable: true
* defaultCredentialId:
* type: number
* nullable: true
* 400:
* description: Missing or invalid hostId.
* 401:
* description: Authentication required or session expired.
* 403:
* description: Access denied to the host.
* 404:
* description: Host not found.
* 422:
* description: Host is not a Proxmox node or is unreachable.
* 500:
* description: Discovery failed.
*/
router.get(
"/discover/stream",
authenticateJWT,
requireDataAccess,
async (req, res) => {
const userId = (req as unknown as AuthenticatedRequest).userId;
const parsedHostId = Number((req.query as { hostId?: unknown }).hostId);
if (!parsedHostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
return res.status(400).json({ error: "Missing or invalid hostId" });
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders?.();
let closed = false;
const send = (event: string, data: unknown) => {
if (closed) return;
try {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
closed = true;
}
};
const heartbeat = setInterval(() => {
if (closed) return;
try {
res.write(": keepalive\n\n");
} catch {
closed = true;
clearInterval(heartbeat);
}
}, 15000);
req.on("close", () => {
closed = true;
clearInterval(heartbeat);
});
try {
const discovery = await discoverProxmoxGuestsForHost(
userId,
parsedHostId,
(done, total) => send("progress", { done, total }),
);
send("result", {
guests: discovery.guests,
credentialId: discovery.credentialId,
defaultCredentialId: discovery.defaultCredentialId,
jumpHosts: discovery.jumpHosts,
});
} catch (err: unknown) {
const message = getErrorMessage(err);
proxmoxLogger.error("Proxmox discovery (stream) failed", err, {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
});
send("fail", { message });
} finally {
clearInterval(heartbeat);
if (!closed) {
try {
res.end();
} catch {
// ignore end errors
}
}
}
},
);
router.post(
"/discover",
authenticateJWT,
requireDataAccess,
async (req, res) => {
const { hostId } = req.body as { hostId?: unknown };
const userId = (req as unknown as AuthenticatedRequest).userId;
const parsedHostId = Number(hostId);
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
return res.status(400).json({ error: "Missing or invalid hostId" });
}
try {
const discovery = await discoverProxmoxGuestsForHost(
userId,
parsedHostId,
);
return res.json({
guests: discovery.guests,
credentialId: discovery.credentialId,
defaultCredentialId: discovery.defaultCredentialId,
jumpHosts: discovery.jumpHosts,
});
} catch (err: unknown) {
const message = getErrorMessage(err);
proxmoxLogger.error("Proxmox discovery failed", err, {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
});
const status =
(err as Error & { code?: string; status?: number }).code ===
"SESSION_EXPIRED"
? 401
: (err as Error & { status?: number }).status ||
(message.includes("Authentication failed") ||
message.includes("connect ECONNREFUSED") ||
message.includes("connect ETIMEDOUT")
? 422
: 500);
return res.status(status).json({ error: `Discovery failed: ${message}` });
}
},
);
/** 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;
}