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 && ( - + <> + + + )}