Files
Homelab-manager/server/src/integrations/fieldSchemas.ts
T
bobbanandClaude Sonnet 5 70ba60c7da 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>
2026-09-29 19:49:10 +02:00

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);
}