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

+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({