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>
105 lines
4.5 KiB
TypeScript
105 lines
4.5 KiB
TypeScript
import type { IntegrationType } from "../db/schema.js";
|
|
import type { IntegrationField } from "./types.js";
|
|
|
|
/**
|
|
* Field schema per integration type. Only types with a working adapter appear
|
|
* here — the "Add integration" form only offers what's actually implemented.
|
|
*/
|
|
export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationField[]>> = {
|
|
tailscale: [
|
|
{
|
|
key: "tailnet",
|
|
label: "Tailnet",
|
|
secret: false,
|
|
placeholder: "yourorg.github — or - for your default tailnet",
|
|
},
|
|
{ key: "apiKey", label: "API key", secret: true, type: "password" },
|
|
],
|
|
gitea: [
|
|
{ key: "url", label: "Gitea URL", secret: false, placeholder: "https://gitea.example.lan" },
|
|
{ key: "token", label: "API token", secret: true, type: "password" },
|
|
],
|
|
dockhand: [
|
|
{ key: "url", label: "Dockhand URL", secret: false, placeholder: "https://dockhand.example.lan" },
|
|
{ key: "token", label: "API token", secret: true, type: "password", placeholder: "dh_..." },
|
|
],
|
|
semaphore: [
|
|
{ key: "url", label: "Semaphore URL", secret: false, placeholder: "https://semaphore.example.lan" },
|
|
{ key: "token", label: "API token", secret: true, type: "password" },
|
|
],
|
|
proxmox: [
|
|
{ key: "url", label: "Proxmox URL", secret: false, placeholder: "https://pve.example.lan:8006" },
|
|
{ key: "tokenId", label: "API token ID", secret: false, placeholder: "root@pam!homelab-manager" },
|
|
{ key: "tokenSecret", label: "API token secret", secret: true, type: "password" },
|
|
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
|
|
],
|
|
synology: [
|
|
{ key: "url", label: "Synology DSM URL", secret: false, placeholder: "https://nas.example.lan:5001" },
|
|
{ key: "username", label: "Username", secret: false },
|
|
{ key: "password", label: "Password", secret: true, type: "password" },
|
|
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
|
|
],
|
|
uptimekuma: [
|
|
{ key: "url", label: "Uptime Kuma URL", secret: false, placeholder: "https://kuma.example.lan" },
|
|
{
|
|
key: "username",
|
|
label: "Username (leave blank when using an API key)",
|
|
secret: false,
|
|
optional: true,
|
|
placeholder: "only needed on very old installs without API keys",
|
|
},
|
|
{ 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. */
|
|
export const INTEGRATION_BASE_URLS: Partial<Record<IntegrationType, string>> = {
|
|
tailscale: "https://api.tailscale.com",
|
|
};
|
|
|
|
/**
|
|
* Resolves the value to store in the integrations.baseUrl column: types that
|
|
* collect their own URL as a config field (e.g. Gitea) use that; others fall
|
|
* back to a fixed constant (e.g. Tailscale's API is always api.tailscale.com).
|
|
*/
|
|
export function resolveBaseUrl(
|
|
type: IntegrationType,
|
|
nonSecretFields: Record<string, string | boolean | undefined>,
|
|
): string {
|
|
const url = nonSecretFields.url;
|
|
if (typeof url === "string" && url) return url;
|
|
return INTEGRATION_BASE_URLS[type] ?? "";
|
|
}
|
|
|
|
export function splitIntegrationConfig(
|
|
type: IntegrationType,
|
|
input: Record<string, string | boolean>,
|
|
): { secretFields: Record<string, string | boolean>; nonSecretFields: Record<string, string | boolean> } {
|
|
const fields = INTEGRATION_FIELDS[type] ?? [];
|
|
const secretFields: Record<string, string | boolean> = {};
|
|
const nonSecretFields: Record<string, string | boolean> = {};
|
|
for (const field of fields) {
|
|
if (input[field.key] === undefined) continue;
|
|
if (field.secret) secretFields[field.key] = input[field.key];
|
|
else nonSecretFields[field.key] = input[field.key];
|
|
}
|
|
return { secretFields, nonSecretFields };
|
|
}
|
|
|
|
export function validateIntegrationConfig(
|
|
type: IntegrationType,
|
|
merged: Record<string, string | boolean | undefined>,
|
|
): string[] {
|
|
const fields = INTEGRATION_FIELDS[type] ?? [];
|
|
return fields
|
|
.filter((f) => f.type !== "checkbox" && !f.optional)
|
|
.filter((f) => merged[f.key] === undefined || merged[f.key] === "")
|
|
.map((f) => f.key);
|
|
}
|