fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

This commit is contained in:
LukeGus committed 2026-09-28 21:59:37 -05:00
1 parent 44062e30d1
commit ffa872ebd3
19 files changed
+270 -292

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
export type ProxmoxSource = {
source: "proxmox";
sourceHostId: number;
node: string;
vmid: number;
type: "qemu" | "lxc";
lastSeenAt?: string;
lastStatus?: string;
missingSince?: string | null;
};
export 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 {};
}
}
/** The guest a host was imported from, read from its proxmoxConfig setting. */
export function getProxmoxSource(config: unknown): ProxmoxSource | null {
const source = parseJsonObject(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;
}
export function proxmoxSourceKey(source: {
sourceHostId: number;
node: string;
type: string;
vmid: number;
}): string {
return `${source.sourceHostId}:${source.node}:${source.type}:${source.vmid}`;
}
export interface ImportedGuest {
host: Record<string, unknown>;
config: Record<string, unknown>;
source: ProxmoxSource;
}
/**
* The hosts already imported from one Proxmox node, keyed by guest. The
* source lives in each guest's proxmoxConfig host setting, not on the host
* row, so the caller passes both.
*/
export function indexImportedGuests(
hosts: Record<string, unknown>[],
configs: Array<{ hostId: number; value: unknown }>,
sourceHostId: number,
): Map<string, ImportedGuest> {
const configById = new Map(configs.map((c) => [c.hostId, c.value]));
const out = new Map<string, ImportedGuest>();
for (const host of hosts) {
const config = parseJsonObject(configById.get(Number(host.id)));
const source = getProxmoxSource(config);
if (source?.sourceHostId === sourceHostId) {
out.set(proxmoxSourceKey(source), { host, config, source });
}
}
return out;
}
+31 -79
View File
@@ -14,6 +14,11 @@ import {
serializeProxmoxJumpHosts,
} from "./proxmox-jump-hosts.js";
import { isSafeNodeName } from "./proxmox-shared.js";
import {
indexImportedGuests,
parseJsonObject,
type ProxmoxSource,
} from "./guest-sync.js";
/** A thrown value's message, or the fallback when it is not an Error. */
function getErrorMessage(error: unknown, fallback = "Unknown error"): string {
@@ -187,17 +192,6 @@ type ProxmoxGuest = {
/** The resolved host ctx.ssh.connect hands back, plus the sudo secret proxmox needs for pvesh. */
type PluginSshHostWithSudo = PluginHostRecord & { sudoPassword?: string };
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;
@@ -206,41 +200,6 @@ type ProxmoxSyncResult = {
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}`;
}
@@ -552,19 +511,18 @@ async function syncProxmoxHost(
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 existingBySource = indexImportedGuests(
existingHosts,
await ctx.settings.listHostValues("proxmoxConfig"),
sourceHostId,
);
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 imported = existingBySource.get(key);
const existing = imported?.host;
const source: ProxmoxSource = {
source: "proxmox",
sourceHostId,
@@ -576,13 +534,7 @@ async function syncProxmoxHost(
missingSince: null,
};
const baseConfig = existing
? parseJsonObject(existing.proxmoxConfig)
: {};
const proxmoxConfig = {
...baseConfig,
source,
};
const proxmoxConfig = { ...(imported?.config ?? {}), source };
const existingConnectionType =
existing?.connectionType === "ssh" || existing?.connectionType === "rdp"
? existing.connectionType
@@ -616,7 +568,6 @@ async function syncProxmoxHost(
connectionType,
folder: existing?.folder || sourceHostName,
tags: mergeTags(existing?.tags, guestTags(guest), ["proxmox-missing"]),
proxmoxConfig: JSON.stringify(proxmoxConfig),
updatedAt: now,
};
@@ -629,6 +580,11 @@ async function syncProxmoxHost(
existing.id as number,
update as PluginHostUpdateInput,
);
await ctx.settings.setHost(
existing.id as number,
"proxmoxConfig",
proxmoxConfig,
);
result.updated++;
continue;
}
@@ -639,7 +595,7 @@ async function syncProxmoxHost(
enableRdp: connectionType === "rdp",
});
await ctx.hosts.create({
const created = await ctx.hosts.create({
...update,
createdAt: now,
pin: false,
@@ -660,8 +616,6 @@ async function syncProxmoxHost(
telnetPassword: null,
jumpHosts: serializeProxmoxJumpHosts(discovery.jumpHosts),
quickActions: null,
statsConfig: null,
dockerConfig: null,
terminalConfig: null,
forceKeyboardInteractive: "false",
useSocks5: 0,
@@ -672,30 +626,28 @@ async function syncProxmoxHost(
socks5ProxyChain: null,
portKnockSequence: null,
} as unknown as PluginHostCreateInput);
await ctx.settings.setHost(
Number(created.id),
"proxmoxConfig",
proxmoxConfig,
);
result.created++;
}
if (discovery.config.markMissingGuests) {
for (const [key, existing] of existingBySource.entries()) {
for (const [key, { host, config, source }] of existingBySource) {
if (seen.has(key)) continue;
const config = parseJsonObject(existing.proxmoxConfig);
const source = getProxmoxSource(existing);
if (!source) continue;
const missingSince = source.missingSince || now;
await ctx.hosts.update(
existing.id as number,
host.id as number,
{
tags: mergeTags(existing.tags, ["proxmox-missing"]),
proxmoxConfig: JSON.stringify({
...config,
source: {
...source,
missingSince,
},
}),
tags: mergeTags(host.tags, ["proxmox-missing"]),
updatedAt: now,
} as PluginHostUpdateInput,
);
await ctx.settings.setHost(host.id as number, "proxmoxConfig", {
...config,
source: { ...source, missingSince: source.missingSince || now },
});
result.markedMissing++;
}
}
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
getProxmoxSource,
indexImportedGuests,
proxmoxSourceKey,
} from "../../src/backend/guest-sync.js";
const source = {
source: "proxmox",
sourceHostId: 1,
node: "pve",
vmid: 101,
type: "qemu",
};
describe("getProxmoxSource", () => {
it("reads the source from an object or a JSON string", () => {
expect(getProxmoxSource({ source })).toEqual(source);
expect(getProxmoxSource(JSON.stringify({ source }))).toEqual(source);
});
it("refuses a malformed source", () => {
expect(getProxmoxSource(null)).toBeNull();
expect(getProxmoxSource({ source: { ...source, vmid: "101" } })).toBeNull();
expect(getProxmoxSource({ source: { ...source, type: "vm" } })).toBeNull();
});
});
describe("indexImportedGuests", () => {
// The host row carries no proxmoxConfig since 2.9.0; the source only
// exists in the plugin's host settings.
const hosts = [
{ id: 10, name: "guest-a" },
{ id: 11, name: "guest-b" },
{ id: 12, name: "manual" },
];
it("matches hosts to guests through their settings", () => {
const index = indexImportedGuests(
hosts,
[
{ hostId: 10, value: { source, lastSyncAt: "x" } },
{ hostId: 11, value: { source: { ...source, sourceHostId: 2 } } },
],
1,
);
expect([...index.keys()]).toEqual([proxmoxSourceKey(source as never)]);
const entry = index.get("1:pve:qemu:101")!;
expect(entry.host.id).toBe(10);
expect(entry.config.lastSyncAt).toBe("x");
});
it("ignores settings for hosts the user does not own", () => {
const index = indexImportedGuests(
hosts,
[{ hostId: 99, value: { source } }],
1,
);
expect(index.size).toBe(0);
});
});