Add a "Sync from Proxmox" action to IP Addresses (IPAM)

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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 23:45:10 +02:00
1 parent d714a87754
commit e7ac6fea3b
4 files changed
+121 -26

No files matched your search

+4 -3
View File
@@ -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 - Authentik OIDC login, roles (first user to sign in becomes admin), audit log
- **Secrets** — expiry tracking for API tokens/certs/passwords - **Secrets** — expiry tracking for API tokens/certs/passwords
- **IP Addresses (IPAM)** — inventory of IPs across vendors/locations, - **IP Addresses (IPAM)** — inventory of IPs across vendors/locations,
with a "Sync from Tailscale" action to pull in tailnet device IPs with "Sync from Tailscale" and "Sync from Proxmox" actions to pull in
(never overwrites a manually-entered IP), and each entry now shows tailnet device IPs and VM/LXC IPs (never overwrites a
its matching DNS record(s) from the DNS module's cache 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** — zone/record management across Cloudflare, Loopia, Pi-hole, Azure
DNS, cPanel, and Technitium; providers are configured in-app (not via env DNS, cPanel, and Technitium; providers are configured in-app (not via env
vars) and their credentials are encrypted at rest vars) and their credentials are encrypted at rest
+99 -11
View File
@@ -9,6 +9,7 @@ import { recordAudit } from "../services/audit.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js"; import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js";
export const ipamRouter = Router(); export const ipamRouter = Router();
@@ -129,6 +130,34 @@ ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res)
res.status(204).end(); 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) => { ipamRouter.post("/sync-tailscale", requireRole("operator"), asyncHandler(async (req, res) => {
const tailscaleIntegrations = await db const tailscaleIntegrations = await db
.select() .select()
@@ -164,17 +193,10 @@ ipamRouter.post("/sync-tailscale", requireRole("operator"), asyncHandler(async (
const label = device.label || device.hostname || ip; const label = device.label || device.hostname || ip;
const notes = device.os ? `OS: ${device.os}` : null; const notes = device.os ? `OS: ${device.os}` : null;
const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.ipAddress, ip)).limit(1); const result = await upsertSyncedEntry(ip, label, "Tailscale", notes, "tailscale");
if (!existing) { if (result === "added") added++;
await db.insert(ipamEntries).values({ ipAddress: ip, label, vendor: "Tailscale", notes, source: "tailscale" }); else if (result === "updated") updated++;
added++; else {
} else if (existing.source === "tailscale") {
await db
.update(ipamEntries)
.set({ label, notes, updatedAt: new Date().toISOString() })
.where(eq(ipamEntries.id, existing.id));
updated++;
} else {
skipped++; skipped++;
skippedIps.push(ip); skippedIps.push(ip);
} }
@@ -190,3 +212,69 @@ ipamRouter.post("/sync-tailscale", requireRole("operator"), asyncHandler(async (
res.json({ added, updated, skipped, skippedIps, errors }); 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 });
}));
+3 -2
View File
@@ -69,7 +69,7 @@ export interface IpamEntry {
matchingDnsRecords: string[]; matchingDnsRecords: string[];
} }
export interface TailscaleSyncResult { export interface IpamSyncResult {
added: number; added: number;
updated: number; updated: number;
skipped: number; skipped: number;
@@ -488,7 +488,8 @@ export const api = {
update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) => update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) =>
request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }), request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }), remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }),
syncTailscale: () => request<TailscaleSyncResult>("/api/ipam/sync-tailscale", { method: "POST" }), syncTailscale: () => request<IpamSyncResult>("/api/ipam/sync-tailscale", { method: "POST" }),
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
}, },
dns: { dns: {
providerFields: () => providerFields: () =>
+15 -10
View File
@@ -17,7 +17,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [form, setForm] = useState<IpamInput>(emptyForm); const [form, setForm] = useState<IpamInput>(emptyForm);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState<"tailscale" | "proxmox" | null>(null);
const [syncResult, setSyncResult] = useState<string | null>(null); const [syncResult, setSyncResult] = useState<string | null>(null);
function load() { function load() {
@@ -93,21 +93,21 @@ export default function Ipam({ user }: { user: CurrentUser }) {
} }
} }
async function syncTailscale() { async function runSync(source: "tailscale" | "proxmox") {
setError(null); setError(null);
setSyncResult(null); setSyncResult(null);
setSyncing(true); setSyncing(source);
try { 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`]; const parts = [`${res.added} added`, `${res.updated} updated`];
if (res.skipped > 0) parts.push(`${res.skipped} skipped (already tracked manually)`); 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("; ")); if (res.errors.length > 0) setError(res.errors.join("; "));
load(); load();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setSyncing(false); setSyncing(null);
} }
} }
@@ -132,7 +132,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
<> <>
<h2 className="page-title mb-3">IP Addresses</h2> <h2 className="page-title mb-3">IP Addresses</h2>
{error && <div className="alert alert-danger">{error}</div>} {error && <div className="alert alert-danger">{error}</div>}
{syncResult && <div className="alert alert-success">Synced from Tailscale: {syncResult}</div>} {syncResult && <div className="alert alert-success">Synced from {syncResult}</div>}
{canEdit && showForm && ( {canEdit && showForm && (
<div className="card mb-3"> <div className="card mb-3">
@@ -217,9 +217,14 @@ export default function Ipam({ user }: { user: CurrentUser }) {
</button> </button>
)} )}
{canEdit && ( {canEdit && (
<button className="btn btn-outline-secondary" onClick={syncTailscale} disabled={syncing}> <>
{syncing ? "Syncing…" : "Sync from Tailscale"} <button className="btn btn-outline-secondary" onClick={() => runSync("tailscale")} disabled={!!syncing}>
{syncing === "tailscale" ? "Syncing…" : "Sync from Tailscale"}
</button> </button>
<button className="btn btn-outline-secondary" onClick={() => runSync("proxmox")} disabled={!!syncing}>
{syncing === "proxmox" ? "Syncing…" : "Sync from Proxmox"}
</button>
</>
)} )}
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}> <button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}>
Export CSV Export CSV
@@ -250,7 +255,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
<td>{entry.label ?? "—"}</td> <td>{entry.label ?? "—"}</td>
<td> <td>
{entry.vendor ?? "—"} {entry.vendor ?? "—"}
{entry.source === "tailscale" && <span className="badge bg-cyan-lt ms-2">synced</span>} {entry.source && <span className="badge bg-cyan-lt ms-2">synced from {entry.source}</span>}
</td> </td>
<td>{entry.location ?? "—"}</td> <td>{entry.location ?? "—"}</td>
<td className="text-secondary"> <td className="text-secondary">