From e7ac6fea3b344380cf3fdb48be89c6fe49edf4a1 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 15 Sep 2026 23:45:10 +0200 Subject: [PATCH] Add a "Sync from Proxmox" action to IP Addresses (IPAM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the Tailscale IPAM sync to Proxmox: pulls every VM/LXC's IP address(es) across all enabled Proxmox integrations into the inventory, reusing the same never-overwrite-a-manual-entry semantics. Proxmox guests can have multiple NICs (each IP synced as its own entry) and, for QEMU VMs, IP discovery depends on a responsive guest agent — a VM with none simply contributes zero entries rather than erroring, matching getGuestDetail()'s existing best-effort behavior. listGuests() doesn't include IPs, so this fetches getGuestDetail() per guest; acceptable for an explicit, user-triggered sync rather than a background poll. Extracted the shared "insert, update only if I own this row, else skip and report" upsert logic (previously inline in the Tailscale sync handler) into upsertSyncedEntry(), now used by both sync routes so the core safety rule can't drift between them. Verified end-to-end against a mock Proxmox server covering: an LXC with two NICs (both synced), a QEMU VM with a responsive guest agent, a QEMU VM with no agent (zero entries, no error), a manual-entry collision (skipped and reported, never overwritten), and idempotent re-sync (add -> update on the second run). Co-Authored-By: Claude Sonnet 5 --- README.md | 7 +-- server/src/routes/ipam.ts | 110 ++++++++++++++++++++++++++++++++++---- web/src/api/client.ts | 5 +- web/src/pages/Ipam.tsx | 27 ++++++---- 4 files changed, 122 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c88e8e6..c0e4e10 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ All modules from the original plan are built: - Authentik OIDC login, roles (first user to sign in becomes admin), audit log - **Secrets** — expiry tracking for API tokens/certs/passwords - **IP Addresses (IPAM)** — inventory of IPs across vendors/locations, - with a "Sync from Tailscale" action to pull in tailnet device IPs - (never overwrites a manually-entered IP), and each entry now shows - its matching DNS record(s) from the DNS module's cache + with "Sync from Tailscale" and "Sync from Proxmox" actions to pull in + tailnet device IPs and VM/LXC IPs (never overwrites a + manually-entered IP), and each entry now shows its matching DNS + record(s) from the DNS module's cache - **DNS** — zone/record management across Cloudflare, Loopia, Pi-hole, Azure DNS, cPanel, and Technitium; providers are configured in-app (not via env vars) and their credentials are encrypted at rest diff --git a/server/src/routes/ipam.ts b/server/src/routes/ipam.ts index 67055c0..ddd8ae8 100644 --- a/server/src/routes/ipam.ts +++ b/server/src/routes/ipam.ts @@ -9,6 +9,7 @@ import { recordAudit } from "../services/audit.js"; import { asyncHandler } from "../utils/asyncHandler.js"; import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js"; +import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js"; export const ipamRouter = Router(); @@ -129,6 +130,34 @@ ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) res.status(204).end(); })); +/** + * Inserts a new IPAM entry for `ip`, or updates one this same sync source + * previously created — but never touches an entry that already exists from + * a manual entry or a different sync source, so two syncs (or a sync and a + * human) can never clobber each other. + */ +async function upsertSyncedEntry( + ip: string, + label: string, + vendor: string, + notes: string | null, + source: string, +): Promise<"added" | "updated" | "skipped"> { + const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.ipAddress, ip)).limit(1); + if (!existing) { + await db.insert(ipamEntries).values({ ipAddress: ip, label, vendor, notes, source }); + return "added"; + } + if (existing.source === source) { + await db + .update(ipamEntries) + .set({ label, notes, updatedAt: new Date().toISOString() }) + .where(eq(ipamEntries.id, existing.id)); + return "updated"; + } + return "skipped"; +} + ipamRouter.post("/sync-tailscale", requireRole("operator"), asyncHandler(async (req, res) => { const tailscaleIntegrations = await db .select() @@ -164,17 +193,10 @@ ipamRouter.post("/sync-tailscale", requireRole("operator"), asyncHandler(async ( const label = device.label || device.hostname || ip; const notes = device.os ? `OS: ${device.os}` : null; - const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.ipAddress, ip)).limit(1); - if (!existing) { - await db.insert(ipamEntries).values({ ipAddress: ip, label, vendor: "Tailscale", notes, source: "tailscale" }); - added++; - } else if (existing.source === "tailscale") { - await db - .update(ipamEntries) - .set({ label, notes, updatedAt: new Date().toISOString() }) - .where(eq(ipamEntries.id, existing.id)); - updated++; - } else { + const result = await upsertSyncedEntry(ip, label, "Tailscale", notes, "tailscale"); + if (result === "added") added++; + else if (result === "updated") updated++; + else { skipped++; skippedIps.push(ip); } @@ -190,3 +212,69 @@ ipamRouter.post("/sync-tailscale", requireRole("operator"), asyncHandler(async ( res.json({ added, updated, skipped, skippedIps, errors }); })); + +ipamRouter.post("/sync-proxmox", requireRole("operator"), asyncHandler(async (req, res) => { + const proxmoxIntegrations = await db + .select() + .from(integrations) + .where(and(eq(integrations.type, "proxmox"), eq(integrations.enabled, true))); + + if (proxmoxIntegrations.length === 0) { + return res.status(400).json({ error: "no_proxmox_integration" }); + } + + let added = 0; + let updated = 0; + let skipped = 0; + const skippedIps: string[] = []; + const errors: string[] = []; + + for (const integration of proxmoxIntegrations) { + const loaded = await loadIntegrationConfig(integration.id); + if (!loaded || loaded.integration.type !== "proxmox") continue; + + const adapter = createProxmoxAdapter( + loaded.config as { url: string; tokenId: string; tokenSecret: string; insecure?: boolean }, + ); + + let guests; + try { + guests = await adapter.listGuests(); + } catch (err) { + errors.push(`${integration.name}: ${err instanceof Error ? err.message : String(err)}`); + continue; + } + + for (const guest of guests) { + let detail; + try { + detail = await adapter.getGuestDetail(guest.node, guest.type, guest.vmid); + } catch (err) { + errors.push(`${integration.name}/${guest.name}: ${err instanceof Error ? err.message : String(err)}`); + continue; + } + + const label = guest.name || `${guest.type}/${guest.vmid}`; + const notes = `Proxmox ${guest.type === "qemu" ? "VM" : "LXC"} #${guest.vmid} on ${guest.node}`; + + for (const ip of detail.ipAddresses) { + const result = await upsertSyncedEntry(ip, label, "Proxmox", notes, "proxmox"); + if (result === "added") added++; + else if (result === "updated") updated++; + else { + skipped++; + skippedIps.push(ip); + } + } + } + } + + await recordAudit({ + actor: req.currentUser!, + category: "ipam", + action: "sync_proxmox", + detail: { added, updated, skipped }, + }); + + res.json({ added, updated, skipped, skippedIps, errors }); +})); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 44e7795..55ab519 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -69,7 +69,7 @@ export interface IpamEntry { matchingDnsRecords: string[]; } -export interface TailscaleSyncResult { +export interface IpamSyncResult { added: number; updated: number; skipped: number; @@ -488,7 +488,8 @@ export const api = { update: (id: number, data: Partial>) => request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }), remove: (id: number) => request(`/api/ipam/${id}`, { method: "DELETE" }), - syncTailscale: () => request("/api/ipam/sync-tailscale", { method: "POST" }), + syncTailscale: () => request("/api/ipam/sync-tailscale", { method: "POST" }), + syncProxmox: () => request("/api/ipam/sync-proxmox", { method: "POST" }), }, dns: { providerFields: () => diff --git a/web/src/pages/Ipam.tsx b/web/src/pages/Ipam.tsx index 9c87d92..365b629 100644 --- a/web/src/pages/Ipam.tsx +++ b/web/src/pages/Ipam.tsx @@ -17,7 +17,7 @@ export default function Ipam({ user }: { user: CurrentUser }) { const [adding, setAdding] = useState(false); const [form, setForm] = useState(emptyForm); const [saving, setSaving] = useState(false); - const [syncing, setSyncing] = useState(false); + const [syncing, setSyncing] = useState<"tailscale" | "proxmox" | null>(null); const [syncResult, setSyncResult] = useState(null); function load() { @@ -93,21 +93,21 @@ export default function Ipam({ user }: { user: CurrentUser }) { } } - async function syncTailscale() { + async function runSync(source: "tailscale" | "proxmox") { setError(null); setSyncResult(null); - setSyncing(true); + setSyncing(source); try { - const res = await api.ipam.syncTailscale(); + const res = source === "tailscale" ? await api.ipam.syncTailscale() : await api.ipam.syncProxmox(); const parts = [`${res.added} added`, `${res.updated} updated`]; if (res.skipped > 0) parts.push(`${res.skipped} skipped (already tracked manually)`); - setSyncResult(parts.join(", ")); + setSyncResult(`${source === "tailscale" ? "Tailscale" : "Proxmox"}: ${parts.join(", ")}`); if (res.errors.length > 0) setError(res.errors.join("; ")); load(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { - setSyncing(false); + setSyncing(null); } } @@ -132,7 +132,7 @@ export default function Ipam({ user }: { user: CurrentUser }) { <>

IP Addresses

{error &&
{error}
} - {syncResult &&
Synced from Tailscale: {syncResult}
} + {syncResult &&
Synced from {syncResult}
} {canEdit && showForm && (
@@ -217,9 +217,14 @@ export default function Ipam({ user }: { user: CurrentUser }) { )} {canEdit && ( - + <> + + + )}