From 70ba60c7da440696af73cb69a51bcf3e472b7af8 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 29 Sep 2026 19:49:10 +0200 Subject: [PATCH] Add a phpIPAM import to the IP Addresses page New "Sync from phpIPAM" action on IP Addresses, alongside the existing "Sync from Tailscale" / "Sync from Proxmox" ones and built the same way: a new integration type (config in-app, URL + API app ID + app token, credentials encrypted at rest) that this page pulls from on demand. Deliberately import-only, not a full integration -- no dedicated page, dashboard widget, or nav entry, since that's all this was asked for. Auth is phpIPAM's static "App token" method: create an API app under Administration -> API with its security set to "SSL with App token", and its one-time code goes straight in as the `token` header (also sent as `phpipam-token`, in case a given version expects that name instead) -- no login call, no token to renew. The user/password "User token" method isn't implemented. Addresses are read the standard way: GET /subnets/, then GET /subnets/{id}/addresses/ for each, rather than assuming a single "all addresses" endpoint exists on every version. phpIPAM wraps every response as {code, success, data} -- including an empty result: a subnet with nothing in it answers success:false, message:"No addresses found" rather than success:true, data:[]. That's read as "nothing here", not a failure; anything else with success:false throws with phpIPAM's own message. One subnet failing outright (e.g. the app lacks permission on it) is skipped with a note rather than aborting the whole sync. Every address field is read defensively -- optional, independently type-checked -- so a field phpIPAM renames or drops in some version leaves that value blank instead of breaking the import. Imported entries: label from hostname or description, "phpIPAM" as vendor, the subnet's own description (or its CIDR, if it has none) as location, and description/note/MAC folded into notes. Existing sync plumbing (upsertSyncedEntry) gained a location parameter so this and any future sync can set it; the two existing syncs pass null, unchanged. Endpoints, the token header, and the address/subnet field names are cross-checked against phpIPAM's own published API documentation. Not verified against a live instance -- there wasn't one available while building this, so if a real sync comes back empty or with the wrong fields, that's the next thing to check. Verified with 23 backend checks against a fake phpIPAM server matching that documented shape (the empty-subnet quirk, a subnet that fails outright, malformed/missing fields, a non-JSON response) and the real route (added/updated/skipped counts, a manually-entered IP never overwritten, roles, no-enabled-integration, upstream failure surfaced per-integration rather than as a 500, audit entries) plus a browser check of the real IP Addresses page against the real routers: the sync button, its result message, re-syncing (updates rather than duplicates), the manual entry staying untouched, and the viewer view. Co-Authored-By: Claude Sonnet 5 --- INTEGRATIONS.md | 20 +++ README.md | 9 +- server/src/db/schema.ts | 1 + server/src/integrations/fieldSchemas.ts | 6 + server/src/integrations/phpipam/adapter.ts | 166 +++++++++++++++++++++ server/src/integrations/registry.ts | 3 + server/src/routes/ipam.ts | 63 +++++++- web/src/api/client.ts | 3 +- web/src/components/CommandPalette.tsx | 1 + web/src/components/IntegrationEditForm.tsx | 1 + web/src/components/IntegrationForm.tsx | 1 + web/src/pages/Integrations.tsx | 1 + web/src/pages/Ipam.tsx | 18 ++- web/src/pages/settings/BadgeSettings.tsx | 1 + 14 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 server/src/integrations/phpipam/adapter.ts diff --git a/INTEGRATIONS.md b/INTEGRATIONS.md index d078032..41d1f4d 100644 --- a/INTEGRATIONS.md +++ b/INTEGRATIONS.md @@ -79,6 +79,26 @@ the dashboard views themselves load fine. metrics endpoint only exposes current status, not a monitor's scheduled start/end time, so this reflects what's in maintenance right now rather than mirroring Uptime Kuma's own schedule. +### phpIPAM + +- **Config fields:** phpIPAM URL, API app ID, App token +- **Auth:** the `token` header (and `phpipam-token`, in case your version expects that name instead), set to a + static per-app code. In phpIPAM, go to **Administration → API**, create (or edit) an API app, and set its + **App security** to **"SSL with App token"** (or "App token" if the install isn't served over HTTPS). Saving + it shows the app's code once — that's what goes in this integration's "App token" field, and the app's own + short id (chosen when you created it) goes in "API app ID". phpIPAM's other auth method — logging in as a + real user to get a short-lived token — isn't supported; use an App-token app instead. +- **Required access:** the API app just needs read access to whichever sections/subnets you want imported. +- **What it does:** this is import-only, not a full integration page — on the **IP Addresses** page, "Sync + from phpIPAM" reads every subnet and the addresses in each, and adds or updates an IPAM entry per address + (label from its hostname or description, the subnet's own description as its location, description/note/MAC + folded into notes), the same way "Sync from Tailscale"/"Sync from Proxmox" already work: an address you + entered by hand, or that a different sync source owns, is never overwritten. +- **Not verified against a live instance** — built from phpIPAM's own published API documentation + (endpoints, the `token` header, and the address/subnet field names all cross-checked there), but nobody has + run it against a real phpIPAM yet. If a sync comes back empty or with the wrong fields, tell us what your + instance actually returned and we'll adjust. + ## DNS providers ### Cloudflare diff --git a/README.md b/README.md index a6a2f20..0bfca89 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,11 @@ All modules from the original plan are built: is picked up automatically and an unreachable host is flagged instead of silently going stale - **IP Addresses (IPAM)** — inventory of IPs across vendors/locations, - 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 + with "Sync from Tailscale", "Sync from Proxmox", and "Sync from phpIPAM" + actions to pull in tailnet device IPs, VM/LXC IPs, and phpIPAM's own + addresses (never overwrites a manually-entered IP, or one a different sync + owns), 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/db/schema.ts b/server/src/db/schema.ts index 5b47282..a03dede 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -287,6 +287,7 @@ export const integrationTypes = [ "gitea", "dockhand", "uptimekuma", + "phpipam", ] as const; export type IntegrationType = (typeof integrationTypes)[number]; diff --git a/server/src/integrations/fieldSchemas.ts b/server/src/integrations/fieldSchemas.ts index 964cd4f..91b1810 100644 --- a/server/src/integrations/fieldSchemas.ts +++ b/server/src/integrations/fieldSchemas.ts @@ -50,6 +50,12 @@ export const INTEGRATION_FIELDS: Partial/api//...` and is enabled per "API app" under + * Administration -> API. This adapter expects that app's security method set to **"SSL with App + * token"** (or, on a LAN-only install, "App token" without SSL) — a static code shown once when + * the app is created, sent on every request as the `token` header. That's the simplest of + * phpIPAM's auth methods (no separate login call, no token expiry to renew), so it's the only one + * this adapter implements; the user/password "User token" method (POST /user/ to obtain a + * short-lived token) is not supported. + * + * Every response is wrapped as {code, success, data} — including, unusually, an *empty* result: + * a subnet with no addresses answers `success:false, message:"No addresses found"` rather than + * `success:true, data:[]`. That's read as "nothing here", not an error; anything else with + * success:false is a real failure and throws with phpIPAM's own message. + * + * Addresses are read per subnet (GET /subnets/, then GET /subnets/{id}/addresses/ for each) — the + * standard, long-documented way to enumerate every address in phpIPAM — rather than assuming a + * single "all addresses" endpoint exists across every version. Object fields are read + * defensively (each one is optional and independently type-checked): a field phpIPAM renames or + * drops in some version leaves that value blank rather than breaking the sync. + * + * Endpoints, the `token` header, and the address/subnet field names are cross-checked against + * phpIPAM's own published API documentation (phpipam.net/api-documentation) — but not verified + * against a live instance, and the "no addresses found" empty-result shape specifically is from + * long-standing third-party-client convention rather than the docs themselves. If your instance's + * response shapes differ, tell us what came back and we'll adjust. + */ +import * as http from "node:http"; +import * as https from "node:https"; +import { withDiagLogging } from "../../services/diagLog.js"; + +export interface PhpIpamConfig { + url: string; + appId: string; + token: string; + insecure?: boolean; +} + +export interface PhpIpamAddress { + ip: string; + hostname: string | null; + description: string | null; + note: string | null; + mac: string | null; + /** The subnet's own description, or its CIDR if it has none — "where" this address lives in phpIPAM. */ + subnetLabel: string; +} + +export interface PhpIpamAdapter { + ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; + listAddresses(): Promise; +} + +interface RawResponse { + status: number; + text: () => string; +} + +// phpIPAM is a plain web app (unlike e.g. Synology's fixed 5000/5001) — no default port override, just the URL's own scheme. +function request(url: string, insecure: boolean, token: string): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const isHttps = parsed.protocol === "https:"; + const lib = isHttps ? https : http; + const req = lib.request( + { + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + method: "GET", + // phpIPAM's docs name this header "token"; some versions instead look for "phpipam-token" — send both. + headers: { token, "phpipam-token": token, Accept: "application/json" }, + ...(isHttps ? { rejectUnauthorized: !insecure } : {}), + }, + (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body })); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +const str = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null); + +export function createPhpIpamAdapter(config: PhpIpamConfig): PhpIpamAdapter { + const insecure = config.insecure === true; + + function base() { + return `${config.url.replace(/\/$/, "")}/api/${config.appId.replace(/^\/|\/$/g, "")}`; + } + + /** GETs one endpoint and returns its `data` array — [] for phpIPAM's "no X found" not-really-an-error shape. */ + async function apiList(path: string): Promise { + const res = await request(`${base()}${path}`, insecure, config.token); + let body: any = null; + try { + body = res.text() ? JSON.parse(res.text()) : null; + } catch { + // non-JSON error page + } + if (!body || typeof body !== "object") { + throw new Error(`phpIPAM API error: HTTP ${res.status}`); + } + if (body.success === false) { + if (/no .*found/i.test(String(body.message ?? ""))) return []; + throw new Error(body.message || `phpIPAM API error: HTTP ${res.status}`); + } + if (res.status < 200 || res.status >= 300) { + throw new Error(body.message || `phpIPAM API error: HTTP ${res.status}`); + } + return Array.isArray(body.data) ? body.data : []; + } + + async function listAddresses(): Promise { + const subnets = await apiList("/subnets/"); + const out: PhpIpamAddress[] = []; + for (const s of subnets) { + const subnetId = s?.id; + if (subnetId === undefined || subnetId === null) continue; + const subnetLabel = str(s.description) ?? (str(s.subnet) && str(s.mask) ? `${s.subnet}/${s.mask}` : `subnet ${subnetId}`); + + let addresses: any[]; + try { + addresses = await apiList(`/subnets/${subnetId}/addresses/`); + } catch (err) { + // One unreadable subnet (e.g. this app lacks permission on it) shouldn't fail the whole sync. + console.error(`[phpipam] couldn't read addresses for subnet ${subnetId}:`, err instanceof Error ? err.message : err); + continue; + } + + for (const a of addresses) { + const ip = str(a?.ip); + if (!ip) continue; + out.push({ + ip, + hostname: str(a?.hostname), + description: str(a?.description), + note: str(a?.note), + mac: str(a?.mac), + subnetLabel, + }); + } + } + return out; + } + + async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + const start = Date.now(); + try { + await apiList("/subnets/"); + return { ok: true, latencyMs: Date.now() - start }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + return withDiagLogging("phpipam", { ping, listAddresses }); +} diff --git a/server/src/integrations/registry.ts b/server/src/integrations/registry.ts index a58586e..4211a4a 100644 --- a/server/src/integrations/registry.ts +++ b/server/src/integrations/registry.ts @@ -7,6 +7,7 @@ import { createSemaphoreAdapter } from "./semaphore/adapter.js"; import { createProxmoxAdapter } from "./proxmox/adapter.js"; import { createSynologyAdapter } from "./synology/adapter.js"; import { createUptimeKumaAdapter } from "./uptimekuma/adapter.js"; +import { createPhpIpamAdapter } from "./phpipam/adapter.js"; export interface PingableAdapter { ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; @@ -35,6 +36,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat return createSynologyAdapter(config as any); case "uptimekuma": return createUptimeKumaAdapter(config as any); + case "phpipam": + return createPhpIpamAdapter(config as any); default: throw new Error(`Integration type "${type}" is not implemented yet`); } diff --git a/server/src/routes/ipam.ts b/server/src/routes/ipam.ts index ddd8ae8..bced049 100644 --- a/server/src/routes/ipam.ts +++ b/server/src/routes/ipam.ts @@ -10,6 +10,7 @@ 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"; +import { createPhpIpamAdapter } from "../integrations/phpipam/adapter.js"; export const ipamRouter = Router(); @@ -141,17 +142,18 @@ async function upsertSyncedEntry( label: string, vendor: string, notes: string | null, + location: 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 }); + await db.insert(ipamEntries).values({ ipAddress: ip, label, vendor, notes, location, source }); return "added"; } if (existing.source === source) { await db .update(ipamEntries) - .set({ label, notes, updatedAt: new Date().toISOString() }) + .set({ label, notes, location, updatedAt: new Date().toISOString() }) .where(eq(ipamEntries.id, existing.id)); return "updated"; } @@ -193,7 +195,7 @@ ipamRouter.post("/sync-tailscale", requireRole("operator"), asyncHandler(async ( const label = device.label || device.hostname || ip; const notes = device.os ? `OS: ${device.os}` : null; - const result = await upsertSyncedEntry(ip, label, "Tailscale", notes, "tailscale"); + const result = await upsertSyncedEntry(ip, label, "Tailscale", notes, null, "tailscale"); if (result === "added") added++; else if (result === "updated") updated++; else { @@ -258,7 +260,7 @@ ipamRouter.post("/sync-proxmox", requireRole("operator"), asyncHandler(async (re 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"); + const result = await upsertSyncedEntry(ip, label, "Proxmox", notes, null, "proxmox"); if (result === "added") added++; else if (result === "updated") updated++; else { @@ -278,3 +280,56 @@ ipamRouter.post("/sync-proxmox", requireRole("operator"), asyncHandler(async (re res.json({ added, updated, skipped, skippedIps, errors }); })); + +ipamRouter.post("/sync-phpipam", requireRole("operator"), asyncHandler(async (req, res) => { + const phpIpamIntegrations = await db + .select() + .from(integrations) + .where(and(eq(integrations.type, "phpipam"), eq(integrations.enabled, true))); + + if (phpIpamIntegrations.length === 0) { + return res.status(400).json({ error: "no_phpipam_integration" }); + } + + let added = 0; + let updated = 0; + let skipped = 0; + const skippedIps: string[] = []; + const errors: string[] = []; + + for (const integration of phpIpamIntegrations) { + const loaded = await loadIntegrationConfig(integration.id); + if (!loaded || loaded.integration.type !== "phpipam") continue; + + let addresses; + try { + const adapter = createPhpIpamAdapter(loaded.config as { url: string; appId: string; token: string; insecure?: boolean }); + addresses = await adapter.listAddresses(); + } catch (err) { + errors.push(`${integration.name}: ${err instanceof Error ? err.message : String(err)}`); + continue; + } + + for (const addr of addresses) { + const label = addr.hostname || addr.description || addr.ip; + const notes = [addr.description, addr.note, addr.mac ? `MAC ${addr.mac}` : null].filter((v): v is string => !!v).join(" — ") || null; + + const result = await upsertSyncedEntry(addr.ip, label, "phpIPAM", notes, addr.subnetLabel, "phpipam"); + if (result === "added") added++; + else if (result === "updated") updated++; + else { + skipped++; + skippedIps.push(addr.ip); + } + } + } + + await recordAudit({ + actor: req.currentUser!, + category: "ipam", + action: "sync_phpipam", + 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 332a977..c79ca8a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -428,7 +428,7 @@ export interface ManualTaskInput { enabled?: boolean; } -export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand" | "uptimekuma"; +export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand" | "uptimekuma" | "phpipam"; export interface IntegrationField { key: string; @@ -888,6 +888,7 @@ export const api = { remove: (id: number) => request(`/api/ipam/${id}`, { method: "DELETE" }), syncTailscale: () => request("/api/ipam/sync-tailscale", { method: "POST" }), syncProxmox: () => request("/api/ipam/sync-proxmox", { method: "POST" }), + syncPhpIpam: () => request("/api/ipam/sync-phpipam", { method: "POST" }), }, dns: { providerFields: () => diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx index 2579cd4..18e8115 100644 --- a/web/src/components/CommandPalette.tsx +++ b/web/src/components/CommandPalette.tsx @@ -18,6 +18,7 @@ const INTEGRATION_TYPE_LABELS: Record = { gitea: "Gitea", dockhand: "Dockhand", uptimekuma: "Uptime Kuma", + phpipam: "phpIPAM", }; const DNS_PROVIDER_LABELS: Record = { diff --git a/web/src/components/IntegrationEditForm.tsx b/web/src/components/IntegrationEditForm.tsx index d2986b2..25e9d48 100644 --- a/web/src/components/IntegrationEditForm.tsx +++ b/web/src/components/IntegrationEditForm.tsx @@ -9,6 +9,7 @@ const TYPE_LABELS: Record = { gitea: "Gitea", dockhand: "Dockhand", uptimekuma: "Uptime Kuma", + phpipam: "phpIPAM", }; export default function IntegrationEditForm({ diff --git a/web/src/components/IntegrationForm.tsx b/web/src/components/IntegrationForm.tsx index 227fe4d..d16ef0b 100644 --- a/web/src/components/IntegrationForm.tsx +++ b/web/src/components/IntegrationForm.tsx @@ -9,6 +9,7 @@ const TYPE_LABELS: Record = { gitea: "Gitea", dockhand: "Dockhand", uptimekuma: "Uptime Kuma", + phpipam: "phpIPAM", }; export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) { diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 23d6b23..a05ede9 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -15,6 +15,7 @@ const TYPE_LABELS: Record = { gitea: "Gitea", dockhand: "Dockhand", uptimekuma: "Uptime Kuma", + phpipam: "phpIPAM", }; function typeBadgeStyle(colors: Record, type: IntegrationType): CSSProperties { diff --git a/web/src/pages/Ipam.tsx b/web/src/pages/Ipam.tsx index 9410539..bb6aef3 100644 --- a/web/src/pages/Ipam.tsx +++ b/web/src/pages/Ipam.tsx @@ -24,7 +24,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<"tailscale" | "proxmox" | null>(null); + const [syncing, setSyncing] = useState<"tailscale" | "proxmox" | "phpipam" | null>(null); const [syncResult, setSyncResult] = useState(null); const selection = useSelection(); const [bulkDeleting, setBulkDeleting] = useState(false); @@ -125,15 +125,22 @@ export default function Ipam({ user }: { user: CurrentUser }) { } } - async function runSync(source: "tailscale" | "proxmox") { + const SYNC_LABELS: Record<"tailscale" | "proxmox" | "phpipam", string> = { + tailscale: "Tailscale", + proxmox: "Proxmox", + phpipam: "phpIPAM", + }; + + async function runSync(source: "tailscale" | "proxmox" | "phpipam") { setError(null); setSyncResult(null); setSyncing(source); try { - const res = source === "tailscale" ? await api.ipam.syncTailscale() : await api.ipam.syncProxmox(); + const res = + source === "tailscale" ? await api.ipam.syncTailscale() : source === "proxmox" ? await api.ipam.syncProxmox() : await api.ipam.syncPhpIpam(); const parts = [`${res.added} added`, `${res.updated} updated`]; if (res.skipped > 0) parts.push(`${res.skipped} skipped (already tracked manually)`); - setSyncResult(`${source === "tailscale" ? "Tailscale" : "Proxmox"}: ${parts.join(", ")}`); + setSyncResult(`${SYNC_LABELS[source]}: ${parts.join(", ")}`); if (res.errors.length > 0) setError(res.errors.join("; ")); load(); } catch (err) { @@ -259,6 +266,9 @@ export default function Ipam({ user }: { user: CurrentUser }) { + )}