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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-29 19:49:10 +02:00
1 parent bf7f73f6b6
commit 70ba60c7da
14 files changed
+281 -13

No files matched your search

+1
View File
@@ -287,6 +287,7 @@ export const integrationTypes = [
"gitea",
"dockhand",
"uptimekuma",
"phpipam",
] as const;
export type IntegrationType = (typeof integrationTypes)[number];
+6
View File
@@ -50,6 +50,12 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
},
{ key: "password", label: "API key (or password, on old installs)", secret: true, type: "password" },
],
phpipam: [
{ key: "url", label: "phpIPAM URL", secret: false, placeholder: "https://ipam.example.lan" },
{ key: "appId", label: "API app ID", secret: false, placeholder: "as set under Administration → API" },
{ key: "token", label: "App token (API code)", secret: true, type: "password" },
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
],
};
/** Fixed base URL per integration type, stored on the row for display/reference. */
+166
View File
@@ -0,0 +1,166 @@
/**
* phpIPAM adapter.
* Requires config: url, appId, token; optional: insecure
*
* Auth: phpIPAM's REST API lives at `<url>/api/<appId>/...` 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<PhpIpamAddress[]>;
}
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<RawResponse> {
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<any[]> {
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<PhpIpamAddress[]> {
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 });
}
+3
View File
@@ -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`);
}
+59 -4
View File
@@ -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 });
}));