From ed6cda4ea46ac7612d184c14dd4a68a103a13b83 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 16 Jul 2026 12:10:12 +0800 Subject: [PATCH] Add Proxmox guest auto sync (#1053) --- .../database/routes/host-bulk-routes.ts | 3 + src/backend/database/routes/proxmox.ts | 1170 ++++++++++++----- src/types/index.ts | 14 + src/types/proxmox.ts | 8 + src/types/ui-types.ts | 13 + src/ui/api/ssh-host-management-api.ts | 17 +- .../proxmox/ProxmoxDiscoverDialog.tsx | 12 + src/ui/locales/en.json | 15 + src/ui/locales/translated/zh_CN.json | 15 + src/ui/main-axios.ts | 1 + src/ui/sidebar/HostEditorData.ts | 3 + src/ui/sidebar/HostEditorFeatureTabs.tsx | 80 ++ 12 files changed, 1006 insertions(+), 345 deletions(-) diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index b7acef60..176439f4 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -276,6 +276,9 @@ export function registerHostBulkRoutes( dockerPatterns: existing.dockerPatterns ?? "docker", preferredPrefixes: existing.preferredPrefixes ?? "10., 192.168.", + autoSyncEnabled: existing.autoSyncEnabled ?? false, + syncIntervalMinutes: existing.syncIntervalMinutes ?? 15, + markMissingGuests: existing.markMissingGuests ?? true, }; await db .update(hosts) diff --git a/src/backend/database/routes/proxmox.ts b/src/backend/database/routes/proxmox.ts index fe4991be..49100e56 100644 --- a/src/backend/database/routes/proxmox.ts +++ b/src/backend/database/routes/proxmox.ts @@ -1,6 +1,6 @@ import express from "express"; import { Client as SSHClient } from "ssh2"; -import { getDb } from "../db/index.js"; +import { db, getDb, DatabaseSaveTrigger } from "../db/index.js"; import { hosts, sshCredentials } from "../db/schema.js"; import { eq, and } from "drizzle-orm"; import { logger } from "../../utils/logger.js"; @@ -12,6 +12,10 @@ import { SSHHostKeyVerifier } from "../../ssh/host-key-verifier.js"; const router = express.Router(); const proxmoxLogger = logger; +const runningSyncs = new Set(); + +const MIN_SYNC_INTERVAL_MINUTES = 5; +const DEFAULT_SYNC_INTERVAL_MINUTES = 15; const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); @@ -97,6 +101,11 @@ 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 @@ -108,10 +117,27 @@ function parseProxmoxConfig(raw: unknown): { windowsPatterns: ["win", "windows"], dockerPatterns: ["docker"], preferredPrefixes: [], + defaultCredentialId: null, + defaultAuthType: "password", + autoSyncEnabled: false, + syncIntervalMinutes: DEFAULT_SYNC_INTERVAL_MINUTES, + markMissingGuests: true, }; } const cfg = raw as Record; + 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 @@ -123,9 +149,790 @@ function parseProxmoxConfig(raw: unknown): { 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 { + if (!value) return {}; + if (typeof value === "object") return value as Record; + if (typeof value !== "string") return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function getProxmoxSource(host: Record): ProxmoxSource | null { + const config = parseJsonObject(host.proxmoxConfig); + const source = config.source; + if (!source || typeof source !== "object") return null; + const src = source as Record; + 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 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(","); +} + +function resolveProxmoxImportAuth( + defaultAuthType: string | undefined, + credentialId: number | null | undefined, +): { + authType: string; + credentialId: number | null; + overrideCredentialUsername: number; +} { + if (defaultAuthType === "credential" || (!defaultAuthType && credentialId)) { + return credentialId + ? { authType: "credential", credentialId, overrideCredentialUsername: 1 } + : { authType: "none", credentialId: null, overrideCredentialUsername: 0 }; + } + + if (defaultAuthType && !["password", "key"].includes(defaultAuthType)) { + return { + authType: defaultAuthType, + credentialId: null, + overrideCredentialUsername: 0, + }; + } + + return { + authType: "none", + credentialId: null, + overrideCredentialUsername: 0, + }; +} + +async function discoverProxmoxGuestsForHost( + userId: string, + parsedHostId: number, +): Promise<{ + host: SSHHost; + guests: ProxmoxGuest[]; + credentialId: number | null; + defaultCredentialId: number | null; + config: ReturnType; +}> { + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + const error = new Error("Session expired — please log in again"); + (error as Error & { code?: string }).code = "SESSION_EXPIRED"; + throw error; + } + + const hostResults = await SimpleDBOps.select( + getDb().select().from(hosts).where(eq(hosts.id, parsedHostId)), + "ssh_data", + userId, + ); + + if (!hostResults.length) { + const error = new Error("Host not found"); + (error as Error & { status?: number }).status = 404; + throw error; + } + + const host = hostResults[0] as unknown as SSHHost; + const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig); + const config = parseProxmoxConfig(proxmoxCfgRaw); + + if (host.userId !== userId) { + const { PermissionManager } = + await import("../../utils/permission-manager.js"); + const pm = PermissionManager.getInstance(); + const access = await pm.canAccessHost(userId, parsedHostId, "execute"); + if (!access.hasAccess) { + const error = new Error("Access denied"); + (error as Error & { status?: number }).status = 403; + throw error; + } + } + + let 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; + + if (host.credentialId) { + if (userId !== host.userId) { + try { + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); + const sharedCred = + await SharedCredentialManager.getInstance().getSharedCredentialForUser( + host.id, + userId, + ); + if (sharedCred) { + resolvedCredentials = { + password: sharedCred.password, + sshKey: sharedCred.key, + keyPassword: sharedCred.keyPassword, + authType: sharedCred.authType, + }; + } + } catch (err) { + proxmoxLogger.error("Failed to resolve shared credential", err, { + operation: "proxmox_discover", + hostId: parsedHostId, + userId, + }); + } + } else { + const creds = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + if (creds.length > 0) { + const c = creds[0]; + resolvedCredentials = { + password: c.password as string | undefined, + sshKey: (c.key || c.privateKey) as string | undefined, + keyPassword: c.keyPassword as string | undefined, + authType: c.authType as string | undefined, + }; + } + } + } + + const sshConfig: Record = { + 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("../../ssh/terminal-auth-helpers.js"); + const result = await applyAgentAuth( + sshConfig, + host.terminalConfig as unknown as Record | 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((resolve, reject) => { + client.on("ready", resolve); + client.on("error", reject); + 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 execCommand( + client, + "pvesh get /cluster/resources --output-format json 2>/dev/null", + ); + + let resources: Array>; + 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 { + if (g.type === "lxc") { + try { + const cfgJson = await execCommand( + client, + `pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`, + 8000, + ); + return parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes); + } catch { + return null; + } + } + if (g.type === "qemu" && g.status === "running") { + try { + const ifJson = await execCommand( + client, + `pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`, + 5000, + ); + const data = JSON.parse(ifJson); + const ifaces: Array> = 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>) ?? []; + 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; + } + + const CONCURRENCY = 6; + const ips: (string | null)[] = new Array(guestBases.length).fill(null); + let cursor = 0; + async function ipWorker() { + while (cursor < guestBases.length) { + const i = cursor++; + ips[i] = await resolveIp(guestBases[i]); + } + } + 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, + config, + }; + } finally { + try { + client.end(); + } catch { + // ignore cleanup errors + } + } +} + +async function syncProxmoxHost( + userId: string, + sourceHostId: number, +): Promise { + 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 SimpleDBOps.select>( + db.select().from(hosts).where(eq(hosts.userId, userId)), + "ssh_data", + userId, + ); + const existingBySource = new Map>(); + for (const host of existingHosts) { + const source = getProxmoxSource(host); + if (source?.sourceHostId === sourceHostId) { + existingBySource.set(proxmoxSourceKey(source), host); + } + } + + const seen = new Set(); + 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, + }; + + if (!existing && !guest.ip) { + result.skipped++; + result.errors.push( + `${guest.name}: skipped because no IP address was discovered`, + ); + continue; + } + + 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 port = + typeof existing?.port === "number" + ? existing.port + : connectionType === "rdp" + ? 3389 + : 22; + const username = + typeof existing?.username === "string" && existing.username + ? existing.username + : connectionType === "rdp" + ? null + : "root"; + const update: Record = { + name: guest.name, + ip: guest.ip || existing?.ip, + port, + username, + connectionType, + folder: existing?.folder || sourceHostName, + tags: mergeTags( + existing?.tags, + ["proxmox", guest.type, guest.node], + ["proxmox-missing"], + ), + proxmoxConfig: JSON.stringify(proxmoxConfig), + updatedAt: now, + }; + + if (existing) { + await SimpleDBOps.update( + hosts, + "ssh_data", + eq(hosts.id, existing.id as number), + update, + userId, + ); + 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 SimpleDBOps.insert( + hosts, + "ssh_data", + { + ...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: null, + 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, + }, + userId, + ); + 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 SimpleDBOps.update( + hosts, + "ssh_data", + eq(hosts.id, existing.id as number), + { + tags: mergeTags(existing.tags, ["proxmox-missing"]), + proxmoxConfig: JSON.stringify({ + ...config, + source: { + ...source, + missingSince, + }, + }), + updatedAt: now, + }, + userId, + ); + result.markedMissing++; + } + } + + await writeSyncStatus(userId, sourceHostId, { + lastSyncAt: startedAt, + lastSyncStatus: "success", + lastSyncError: null, + lastSyncResult: result, + }); + + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown 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, +): Promise { + const rows = await db + .select({ proxmoxConfig: hosts.proxmoxConfig }) + .from(hosts) + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) + .limit(1); + if (!rows.length) return; + const config = parseJsonObject(rows[0].proxmoxConfig); + await db + .update(hosts) + .set({ proxmoxConfig: JSON.stringify({ ...config, ...patch }) }) + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))); + DatabaseSaveTrigger.triggerSave("proxmox_sync_status"); +} + +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 = err instanceof Error ? err.message : "Unknown error"; + 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 { + try { + const rows = await db + .select({ + id: hosts.id, + userId: hosts.userId, + enableProxmox: hosts.enableProxmox, + proxmoxConfig: hosts.proxmoxConfig, + }) + .from(hosts) + .where(eq(hosts.enableProxmox, true)); + + const now = Date.now(); + for (const row of rows) { + const configRaw = parseJsonObject(row.proxmoxConfig); + const config = parseProxmoxConfig(configRaw); + if (!config.autoSyncEnabled) continue; + if (!SimpleDBOps.isUserDataUnlocked(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", + }); + } +} + +const proxmoxAutoSyncTimer = setInterval(runDueProxmoxAutoSyncs, 60 * 1000); +proxmoxAutoSyncTimer.unref?.(); +const proxmoxAutoSyncStartupTimer = setTimeout( + runDueProxmoxAutoSyncs, + 30 * 1000, +); +proxmoxAutoSyncStartupTimer.unref?.(); + /** * @openapi * /proxmox/discover: @@ -215,342 +1022,15 @@ router.post( } try { - if (!SimpleDBOps.isUserDataUnlocked(userId)) { - return res.status(401).json({ - error: "Session expired — please log in again", - code: "SESSION_EXPIRED", - }); - } - - // ----------------------------------------------------------------------- - // Load host from DB - // ----------------------------------------------------------------------- - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, parsedHostId)), - "ssh_data", + const discovery = await discoverProxmoxGuestsForHost( userId, + parsedHostId, ); - - if (!hostResults.length) { - return res.status(404).json({ error: "Host not found" }); - } - - const host = hostResults[0] as unknown as SSHHost; - - // Read discovery settings from the host's proxmoxConfig - const proxmoxCfgRaw = host.proxmoxConfig - ? typeof host.proxmoxConfig === "string" - ? JSON.parse(host.proxmoxConfig) - : host.proxmoxConfig - : null; - const { windowsPatterns, dockerPatterns, preferredPrefixes } = - parseProxmoxConfig(proxmoxCfgRaw); - const proxmoxDefaultCredentialId = - proxmoxCfgRaw && typeof proxmoxCfgRaw === "object" - ? (((proxmoxCfgRaw as Record).defaultCredentialId as - | number - | null) ?? null) - : null; - - // ----------------------------------------------------------------------- - // Permission check - // ----------------------------------------------------------------------- - if (host.userId !== userId) { - const { PermissionManager } = - await import("../../utils/permission-manager.js"); - const pm = PermissionManager.getInstance(); - const access = await pm.canAccessHost(userId, parsedHostId, "execute"); - if (!access.hasAccess) { - return res.status(403).json({ error: "Access denied" }); - } - } - - // ----------------------------------------------------------------------- - // Credential resolution (mirrors docker.ts pattern) - // ----------------------------------------------------------------------- - let 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; - - if (host.credentialId) { - if (userId !== host.userId) { - try { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCred = - await SharedCredentialManager.getInstance().getSharedCredentialForUser( - host.id, - userId, - ); - if (sharedCred) { - resolvedCredentials = { - password: sharedCred.password, - sshKey: sharedCred.key, - keyPassword: sharedCred.keyPassword, - authType: sharedCred.authType, - }; - } - } catch (err) { - proxmoxLogger.error("Failed to resolve shared credential", err, { - operation: "proxmox_discover", - hostId: parsedHostId, - userId, - }); - } - } else { - const creds = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); - if (creds.length > 0) { - const c = creds[0]; - resolvedCredentials = { - password: c.password as string | undefined, - sshKey: (c.key || c.privateKey) as string | undefined, - keyPassword: c.keyPassword as string | undefined, - authType: c.authType as string | undefined, - }; - } - } - } - - // ----------------------------------------------------------------------- - // Build SSH config - // ----------------------------------------------------------------------- - const sshConfig: Record = { - 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("../../ssh/terminal-auth-helpers.js"); - const result = await applyAgentAuth( - sshConfig, - host.terminalConfig as unknown as Record | undefined, - ); - if ("error" in result) { - return res.status(400).json({ error: result.error }); - } - } else if (resolvedCredentials.password) { - sshConfig.password = resolvedCredentials.password; - } - - // ----------------------------------------------------------------------- - // Connect → discover → disconnect - // ----------------------------------------------------------------------- - const client = new SSHClient(); - - try { - await new Promise((resolve, reject) => { - client.on("ready", resolve); - client.on("error", reject); - client.connect(sshConfig as import("ssh2").ConnectConfig); - }); - - proxmoxLogger.info("Proxmox discovery SSH connection established", { - operation: "proxmox_discover", - hostId: parsedHostId, - userId, - }); - - // Verify pvesh is present — fail fast with a clear error - const pveshCheck = await execCommand( - client, - "command -v pvesh >/dev/null 2>&1 && echo ok || echo missing", - ); - if (pveshCheck.trim() !== "ok") { - return res - .status(422) - .json({ error: "pvesh not found — is this a Proxmox node?" }); - } - - // Fetch all cluster resources in one call - const resourcesJson = await execCommand( - client, - "pvesh get /cluster/resources --output-format json 2>/dev/null", - ); - - let resources: Array>; - try { - resources = JSON.parse(resourcesJson); - } catch { - return res.status(502).json({ - error: "Failed to parse pvesh output — unexpected response", - }); - } - - // Collect basic guest metadata first (no IP yet) - 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", - }); - } - - // Resolve IPs for all guests in parallel — keeps total time near - // max(single_resolution) instead of sum(all_resolutions). - async function resolveIp(g: GuestBase): Promise { - if (g.type === "lxc") { - try { - const cfgJson = await execCommand( - client, - `pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`, - 8000, - ); - return parseLxcIp(JSON.parse(cfgJson), preferredPrefixes); - } catch { - return null; - } - } - if (g.type === "qemu" && g.status === "running") { - try { - const ifJson = await execCommand( - client, - `pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`, - 5000, - ); - const data = JSON.parse(ifJson); - const ifaces: Array> = 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>) ?? - []; - 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 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; - } - - // Resolve IPs with bounded concurrency: each lookup opens an SSH exec - // channel, and OpenSSH's default MaxSessions is 10. Capping the number - // of in-flight channels keeps discovery reliable on large clusters. - const CONCURRENCY = 6; - const ips: (string | null)[] = new Array(guestBases.length).fill(null); - let cursor = 0; - async function ipWorker() { - while (cursor < guestBases.length) { - const i = cursor++; - ips[i] = await resolveIp(guestBases[i]); - } - } - await Promise.all( - Array.from({ length: Math.min(CONCURRENCY, guestBases.length) }, () => - ipWorker(), - ), - ); - const guests = guestBases.map((g, i) => ({ - ...g, - ip: ips[i], - connectionType: matchesAny(g.name, windowsPatterns) ? "rdp" : "ssh", - enableDocker: matchesAny(g.name, dockerPatterns), - })); - - proxmoxLogger.info("Proxmox discovery completed", { - operation: "proxmox_discover", - hostId: parsedHostId, - userId, - guestCount: guests.length, - }); - - // Return guests with connection type + docker flag, plus credential info - // so the frontend can pre-populate auth for imported hosts. - return res.json({ - guests, - credentialId: hostCredentialId, - defaultCredentialId: proxmoxDefaultCredentialId, - }); - } finally { - try { - client.end(); - } catch { - // ignore cleanup errors - } - } + return res.json({ + guests: discovery.guests, + credentialId: discovery.credentialId, + defaultCredentialId: discovery.defaultCredentialId, + }); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Unknown error"; proxmoxLogger.error("Proxmox discovery failed", err, { @@ -559,15 +1039,17 @@ router.post( userId, }); - if ( - message.includes("pvesh not found") || - message.includes("Authentication failed") || - message.includes("connect ECONNREFUSED") || - message.includes("connect ETIMEDOUT") - ) { - return res.status(422).json({ error: `Discovery failed: ${message}` }); - } - return res.status(500).json({ error: `Discovery failed: ${message}` }); + 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}` }); } }, ); diff --git a/src/types/index.ts b/src/types/index.ts index 22dacc8e..4a4265b8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -70,9 +70,23 @@ export type GuacamoleAuthType = "password" | "credential"; export interface ProxmoxConfig { defaultCredentialId: number | null; + defaultAuthType?: string; windowsPatterns: string; dockerPatterns: string; preferredPrefixes: string; + autoSyncEnabled?: boolean; + syncIntervalMinutes?: number; + markMissingGuests?: boolean; + lastSyncAt?: string; + lastSyncStatus?: "success" | "error"; + lastSyncError?: string | null; + lastSyncResult?: { + created: number; + updated: number; + markedMissing: number; + skipped: number; + errors: string[]; + }; } export interface HostFeatureFlags { diff --git a/src/types/proxmox.ts b/src/types/proxmox.ts index e2d3549b..e7507e32 100644 --- a/src/types/proxmox.ts +++ b/src/types/proxmox.ts @@ -14,3 +14,11 @@ export interface ProxmoxDiscoverResult { credentialId: number | null; defaultCredentialId: number | null; } + +export interface ProxmoxSyncResult { + created: number; + updated: number; + markedMissing: number; + skipped: number; + errors: string[]; +} diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 4d9e14f4..4c74aa95 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -116,6 +116,19 @@ export type Host = { windowsPatterns: string; dockerPatterns: string; preferredPrefixes: string; + autoSyncEnabled?: boolean; + syncIntervalMinutes?: number; + markMissingGuests?: boolean; + lastSyncAt?: string; + lastSyncStatus?: "success" | "error"; + lastSyncError?: string | null; + lastSyncResult?: { + created: number; + updated: number; + markedMissing: number; + skipped: number; + errors: string[]; + }; } | null; statsConfig?: { diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index e299a8c1..294a65dc 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -7,7 +7,7 @@ import { } from "@/main-axios"; import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index"; import type { ServerStatus, SSHHostWithStatus } from "@/main-axios"; -import type { ProxmoxDiscoverResult } from "@/types/proxmox"; +import type { ProxmoxDiscoverResult, ProxmoxSyncResult } from "@/types/proxmox"; import { getCachedSSHHosts, invalidateHostsAndStatusCaches, @@ -174,6 +174,21 @@ export async function discoverProxmoxGuests( } } +export async function syncProxmoxGuests( + hostId: number, +): Promise { + try { + const response = await authApi.post( + "/proxmox/sync", + { hostId }, + { timeout: 120000 }, + ); + return response.data; + } catch (error) { + handleApiError(error, "sync Proxmox guests"); + } +} + export async function bulkUpdateSSHHosts( hostIds: number[], updates: Record, diff --git a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx index bbbc7a18..7259504a 100644 --- a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx +++ b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx @@ -138,6 +138,18 @@ export function ProxmoxDiscoverDialog({ enableDocker: g.enableDocker, connectionType: g.connectionType, tags: ["proxmox", g.type, g.node], + proxmoxConfig: { + source: { + source: "proxmox", + sourceHostId: Number(effectiveHostId), + node: g.node, + vmid: g.vmid, + type: g.type, + lastSeenAt: new Date().toISOString(), + lastStatus: g.status, + missingSince: null, + }, + }, })); const result = await bulkImportSSHHosts(toImport, false); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 0dec6796..76e59aca 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -607,6 +607,21 @@ "proxmoxDockerDetectionDesc": "Comma-separated name patterns that enable Docker for matching guests", "proxmoxPreferredRanges": "Preferred IP ranges", "proxmoxPreferredRangesDesc": "Comma-separated prefixes in priority order for IP selection when a guest has multiple interfaces", + "proxmoxAutoSync": "Auto sync guests", + "proxmoxAutoSyncDesc": "Periodically discover this Proxmox node and create or update imported guest hosts while your session is unlocked.", + "proxmoxSyncInterval": "Sync interval (minutes)", + "proxmoxSyncIntervalDesc": "Minimum 5 minutes. The scheduler skips locked user sessions.", + "proxmoxMarkMissing": "Mark missing guests", + "proxmoxMarkMissingDesc": "Add a proxmox-missing tag when a previously imported guest disappears instead of deleting it.", + "proxmoxLastSync": "Last sync", + "proxmoxLastSyncNever": "Not synced yet", + "proxmoxLastSyncNoResult": "No result details", + "proxmoxLastSyncSummary": "{{created}} created, {{updated}} updated, {{markedMissing}} missing, {{skipped}} skipped", + "proxmoxLastSyncStatus": { + "success": "Success", + "error": "Failed", + "pending": "Pending" + }, "proxmoxDiscoverAction": "Discover & import Proxmox guests", "proxmoxImportTitle": "Import from Proxmox", "proxmoxSelectHost": "Select a Proxmox host…", diff --git a/src/ui/locales/translated/zh_CN.json b/src/ui/locales/translated/zh_CN.json index 66f76ff7..b423e65b 100644 --- a/src/ui/locales/translated/zh_CN.json +++ b/src/ui/locales/translated/zh_CN.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "逗号分隔的名称模式使 Docker 能够匹配虚拟机", "proxmoxPreferredRanges": "首选 IP 地址范围", "proxmoxPreferredRangesDesc": "当访客拥有多个接口时,用于选择 IP 地址的优先级前缀以逗号分隔", + "proxmoxAutoSync": "自动同步访客", + "proxmoxAutoSyncDesc": "在会话解锁时定期发现此 Proxmox 节点,并创建或更新导入的访客主机。", + "proxmoxSyncInterval": "同步间隔(分钟)", + "proxmoxSyncIntervalDesc": "最小 5 分钟。用户会话锁定时调度器会跳过同步。", + "proxmoxMarkMissing": "标记消失的访客", + "proxmoxMarkMissingDesc": "以前导入的访客消失时添加 proxmox-missing 标签,而不是删除主机。", + "proxmoxLastSync": "上次同步", + "proxmoxLastSyncNever": "尚未同步", + "proxmoxLastSyncNoResult": "暂无结果详情", + "proxmoxLastSyncSummary": "创建 {{created}},更新 {{updated}},消失 {{markedMissing}},跳过 {{skipped}}", + "proxmoxLastSyncStatus": { + "success": "成功", + "error": "失败", + "pending": "等待中" + }, "proxmoxDiscoverAction": "发现并导入 Proxmox 访客", "proxmoxImportTitle": "从 Proxmox 导入", "proxmoxSelectHost": "选择 Proxmox 主机…", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 5ed0de20..d38694cf 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1517,6 +1517,7 @@ export { bulkImportSSHHosts, importSSHConfigHosts, discoverProxmoxGuests, + syncProxmoxGuests, bulkUpdateSSHHosts, deleteSSHHost, getSSHHostById, diff --git a/src/ui/sidebar/HostEditorData.ts b/src/ui/sidebar/HostEditorData.ts index 8e3510c4..8755d1ae 100644 --- a/src/ui/sidebar/HostEditorData.ts +++ b/src/ui/sidebar/HostEditorData.ts @@ -114,6 +114,9 @@ export function createHostEditorForm( windowsPatterns: "win, windows", dockerPatterns: "docker", preferredPrefixes: "10., 192.168.", + autoSyncEnabled: false, + syncIntervalMinutes: 15, + markMissingGuests: true, }, enableTunnel: host?.enableTunnel ?? false, defaultPath: host?.defaultPath ?? "/", diff --git a/src/ui/sidebar/HostEditorFeatureTabs.tsx b/src/ui/sidebar/HostEditorFeatureTabs.tsx index a34123a8..2b11e09b 100644 --- a/src/ui/sidebar/HostEditorFeatureTabs.tsx +++ b/src/ui/sidebar/HostEditorFeatureTabs.tsx @@ -105,7 +105,22 @@ export function HostProxmoxTab({ windowsPatterns: "win, windows", dockerPatterns: "docker", preferredPrefixes: "10., 192.168.", + autoSyncEnabled: false, + syncIntervalMinutes: 15, + markMissingGuests: true, }; + const lastSyncResult = cfg.lastSyncResult; + const lastSyncSummary = lastSyncResult + ? t("hosts.proxmoxLastSyncSummary", { + created: lastSyncResult.created, + updated: lastSyncResult.updated, + markedMissing: lastSyncResult.markedMissing, + skipped: lastSyncResult.skipped, + }) + : t("hosts.proxmoxLastSyncNoResult"); + const lastSyncDescription = cfg.lastSyncAt + ? `${new Date(cfg.lastSyncAt).toLocaleString()} · ${lastSyncSummary}` + : t("hosts.proxmoxLastSyncNever"); return ( + + + setField("proxmoxConfig", { + ...cfg, + autoSyncEnabled: v, + }) + } + /> + + + + setField("proxmoxConfig", { + ...cfg, + syncIntervalMinutes: Math.max( + 5, + Number.parseInt(e.target.value || "15", 10), + ), + }) + } + /> + + + + setField("proxmoxConfig", { + ...cfg, + markMissingGuests: v, + }) + } + /> + + + + {cfg.lastSyncStatus + ? t(`hosts.proxmoxLastSyncStatus.${cfg.lastSyncStatus}`) + : t("hosts.proxmoxLastSyncStatus.pending")} + + )}