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

+20
View File
@@ -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
+5 -4
View File
@@ -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
+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 });
}));
+2 -1
View File
@@ -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<void>(`/api/ipam/${id}`, { method: "DELETE" }),
syncTailscale: () => request<IpamSyncResult>("/api/ipam/sync-tailscale", { method: "POST" }),
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }),
},
dns: {
providerFields: () =>
+1
View File
@@ -18,6 +18,7 @@ const INTEGRATION_TYPE_LABELS: Record<IntegrationType, string> = {
gitea: "Gitea",
dockhand: "Dockhand",
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
};
const DNS_PROVIDER_LABELS: Record<DnsProviderType, string> = {
@@ -9,6 +9,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
gitea: "Gitea",
dockhand: "Dockhand",
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
};
export default function IntegrationEditForm({
+1
View File
@@ -9,6 +9,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
gitea: "Gitea",
dockhand: "Dockhand",
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
};
export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
+1
View File
@@ -15,6 +15,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
gitea: "Gitea",
dockhand: "Dockhand",
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
};
function typeBadgeStyle(colors: Record<string, string>, type: IntegrationType): CSSProperties {
+14 -4
View File
@@ -24,7 +24,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
const [adding, setAdding] = useState(false);
const [form, setForm] = useState<IpamInput>(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<string | null>(null);
const selection = useSelection<number>();
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 }) {
<button className="btn btn-outline-secondary" onClick={() => runSync("proxmox")} disabled={!!syncing}>
{syncing === "proxmox" ? "Syncing…" : "Sync from Proxmox"}
</button>
<button className="btn btn-outline-secondary" onClick={() => runSync("phpipam")} disabled={!!syncing}>
{syncing === "phpipam" ? "Syncing…" : "Sync from phpIPAM"}
</button>
</>
)}
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}>
+1
View File
@@ -18,6 +18,7 @@ const INTEGRATION_NAMES: Record<string, string> = {
gitea: "Gitea",
dockhand: "Dockhand",
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
};
function ColorList({