mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
This commit is contained in:
1 parent
44062e30d1
commit
ffa872ebd3
19 files changed
+270
-292
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user