From 9c2742c30f2a455901a226bc6e2283f824cefc3d Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Mon, 14 Sep 2026 23:31:19 +0200 Subject: [PATCH] Add Tailscale integration and harden all routes against crash-on-throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of the six planned live integrations (Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand), reusing Sloth Manager's existing Tailscale adapter logic. Generalizes the "integrations" table already scaffolded in the foundation pass into a working config-in-UI + encrypted-credentials flow, following the same pattern as the DNS providers module — an "Add integration" form only offers types with an implemented adapter (currently just Tailscale), so the framework is ready for the next five integrations without further schema/plumbing changes. - server/src/integrations/tailscale/adapter.ts: ported from Sloth Manager's backend/src/adapters/tailscale.js — listDevices/setAuthorized/deleteDevice against the Tailscale API, now config-based (tailnet + apiKey) instead of reading process.env, and returning a ping() result instead of throwing. - server/src/routes/integrations.ts: generic integration CRUD (admin) + a "test connection" endpoint, plus Tailscale-specific device routes (dashboard-and-basic-actions depth per the plan: authorize/deauthorize/ remove, gated to operator+, audit-logged). - web: an Integrations page (provider-style manage/browse split, matching the DNS page's UX) with a device table, and a live Tailscale widget on the Dashboard. Bug found and fixed while testing: hitting the Tailscale device routes on a non-Tailscale integration row crashed the ENTIRE server process, not just that request — the generic adapter registry throws for unimplemented types, and that throw happened inside an async handler with no surrounding try/catch, which Express 4 doesn't catch, so it became an unhandled rejection that (on modern Node) kills the process. Fixed at the source (check the row's type before ever constructing an adapter) and, since the same "a helper throws before any local try/catch runs" shape existed wherever a route calls into loadDnsProviderConfig/loadIntegrationConfig (both call decryptSecret, which throws if CREDENTIALS_ENCRYPTION_KEY is ever wrong/missing after data was already encrypted with a different key), added a small asyncHandler() wrapper and applied it to every route handler across every router — a single bad request should never be able to take the whole app down for every user. Verified: full build passes. Fresh HTTP-layer tests against a running server (17 checks) cover not-implemented-type rejection, missing-field validation, a real network call to api.tailscale.com with a bogus key (clean ok:false, not a crash), role gating at every tier, credential non-leakage, disabled-integration blocking, and the wrong_type case that originally crashed the server — confirmed it now returns 400 cleanly and the server stays up. Re-ran the existing DNS (13 checks) and Servers/Tasks suites afterward to confirm the asyncHandler sweep didn't regress anything — all passing. Authorizing/removing a real device still needs a real Tailscale API key to verify end-to-end. Co-Authored-By: Claude Sonnet 5 --- server/src/index.ts | 2 + server/src/integrations/fieldSchemas.ts | 49 +++ server/src/integrations/loadIntegration.ts | 31 ++ server/src/integrations/registry.ts | 12 + server/src/integrations/tailscale/adapter.ts | 99 ++++++ server/src/integrations/types.ts | 9 + server/src/routes/agentReport.ts | 5 +- server/src/routes/auditLog.ts | 5 +- server/src/routes/dns.ts | 45 +-- server/src/routes/integrations.ts | 334 +++++++++++++++++++ server/src/routes/ipam.ts | 17 +- server/src/routes/secrets.ts | 17 +- server/src/routes/servers.ts | 17 +- server/src/routes/tasks.ts | 17 +- server/src/routes/users.ts | 9 +- server/src/utils/asyncHandler.ts | 16 + web/src/App.tsx | 3 +- web/src/api/client.ts | 71 ++++ web/src/components/IntegrationForm.tsx | 173 ++++++++++ web/src/pages/Dashboard.tsx | 95 ++++-- web/src/pages/Integrations.tsx | 292 ++++++++++++++++ 21 files changed, 1234 insertions(+), 84 deletions(-) create mode 100644 server/src/integrations/fieldSchemas.ts create mode 100644 server/src/integrations/loadIntegration.ts create mode 100644 server/src/integrations/registry.ts create mode 100644 server/src/integrations/tailscale/adapter.ts create mode 100644 server/src/integrations/types.ts create mode 100644 server/src/routes/integrations.ts create mode 100644 server/src/utils/asyncHandler.ts create mode 100644 web/src/components/IntegrationForm.tsx create mode 100644 web/src/pages/Integrations.tsx diff --git a/server/src/index.ts b/server/src/index.ts index 78e26b1..ecf3b30 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,6 +17,7 @@ import { dnsRouter } from "./routes/dns.js"; import { serversRouter } from "./routes/servers.js"; import { tasksRouter } from "./routes/tasks.js"; import { agentReportRouter } from "./routes/agentReport.js"; +import { integrationsRouter } from "./routes/integrations.js"; warnIfAuthNotConfigured(); await runMigrations(); @@ -65,6 +66,7 @@ app.use("/api/dns", dnsRouter); app.use("/api/servers", serversRouter); app.use("/api/tasks", tasksRouter); app.use("/api/agent/report", agentReportRouter); +app.use("/api/integrations", integrationsRouter); if (existsSync(webDist)) { app.use(express.static(webDist)); diff --git a/server/src/integrations/fieldSchemas.ts b/server/src/integrations/fieldSchemas.ts new file mode 100644 index 0000000..a2e07a3 --- /dev/null +++ b/server/src/integrations/fieldSchemas.ts @@ -0,0 +1,49 @@ +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> = { + tailscale: [ + { + key: "tailnet", + label: "Tailnet", + secret: false, + placeholder: "yourorg.github — or - for your default tailnet", + }, + { key: "apiKey", label: "API key", secret: true, type: "password" }, + ], +}; + +/** Fixed base URL per integration type, stored on the row for display/reference. */ +export const INTEGRATION_BASE_URLS: Partial> = { + tailscale: "https://api.tailscale.com", +}; + +export function splitIntegrationConfig( + type: IntegrationType, + input: Record, +): { secretFields: Record; nonSecretFields: Record } { + const fields = INTEGRATION_FIELDS[type] ?? []; + const secretFields: Record = {}; + const nonSecretFields: Record = {}; + 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[] { + const fields = INTEGRATION_FIELDS[type] ?? []; + return fields + .filter((f) => f.type !== "checkbox") + .filter((f) => merged[f.key] === undefined || merged[f.key] === "") + .map((f) => f.key); +} diff --git a/server/src/integrations/loadIntegration.ts b/server/src/integrations/loadIntegration.ts new file mode 100644 index 0000000..2110ede --- /dev/null +++ b/server/src/integrations/loadIntegration.ts @@ -0,0 +1,31 @@ +import { eq } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { integrations, integrationCredentials } from "../db/schema.js"; +import { decryptSecret } from "../crypto.js"; +import { createIntegrationAdapter } from "./registry.js"; +import type { IntegrationConfig } from "./types.js"; + +export async function loadIntegrationConfig(integrationId: number) { + const [integration] = await db.select().from(integrations).where(eq(integrations.id, integrationId)).limit(1); + if (!integration) return null; + + let secretFields: Record = {}; + if (integration.credentialId) { + const [cred] = await db + .select() + .from(integrationCredentials) + .where(eq(integrationCredentials.id, integration.credentialId)) + .limit(1); + if (cred) secretFields = JSON.parse(decryptSecret(cred.encryptedSecret)); + } + const nonSecretFields: Record = integration.config ? JSON.parse(integration.config) : {}; + const merged: IntegrationConfig = { ...nonSecretFields, ...secretFields }; + + return { integration, config: merged }; +} + +export async function getIntegrationAdapter(integrationId: number) { + const loaded = await loadIntegrationConfig(integrationId); + if (!loaded) return null; + return { integration: loaded.integration, adapter: createIntegrationAdapter(loaded.integration.type, loaded.config) }; +} diff --git a/server/src/integrations/registry.ts b/server/src/integrations/registry.ts new file mode 100644 index 0000000..861ecd9 --- /dev/null +++ b/server/src/integrations/registry.ts @@ -0,0 +1,12 @@ +import type { IntegrationType } from "../db/schema.js"; +import type { IntegrationConfig } from "./types.js"; +import { createTailscaleAdapter, type TailscaleAdapter } from "./tailscale/adapter.js"; + +export function createIntegrationAdapter(type: IntegrationType, config: IntegrationConfig): TailscaleAdapter { + switch (type) { + case "tailscale": + return createTailscaleAdapter(config as any); + default: + throw new Error(`Integration type "${type}" is not implemented yet`); + } +} diff --git a/server/src/integrations/tailscale/adapter.ts b/server/src/integrations/tailscale/adapter.ts new file mode 100644 index 0000000..69df01f --- /dev/null +++ b/server/src/integrations/tailscale/adapter.ts @@ -0,0 +1,99 @@ +/** + * Tailscale adapter — uses the Tailscale API. + * Requires config: tailnet, apiKey + * + * API docs: https://tailscale.com/api + */ + +const BASE = "https://api.tailscale.com"; + +export interface TailscaleConfig { + tailnet: string; + apiKey: string; +} + +export interface TailscaleDevice { + id: string; // stable numeric ID used for device-level API calls (authorize/delete) + nodeId: string; + hostname: string; + label: string; + addresses: string[]; + primaryAddress: string; + os: string; + lastSeen: string | null; + isExitNode: boolean; + authorized: boolean; + online: boolean | null; +} + +export interface TailscaleAdapter { + ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; + listDevices(): Promise; + setAuthorized(deviceId: string, authorized: boolean): Promise; + deleteDevice(deviceId: string): Promise; +} + +export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapter { + function headers() { + return { + Authorization: `Bearer ${config.apiKey}`, + Accept: "application/json", + "Content-Type": "application/json", + }; + } + + function tailnetPath() { + return encodeURIComponent(config.tailnet || "-"); + } + + async function api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${BASE}${path}`, { + method, + headers: headers(), + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (res.status === 204) return null; + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.message || `Tailscale API error: HTTP ${res.status}`); + } + return data; + } + + async function listDevices(): Promise { + const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices`); + return (data.devices || []).map((d: any) => ({ + id: d.id, + nodeId: d.nodeId || d.id, + hostname: d.hostname || "", + label: d.displayName || d.hostname || "", + addresses: d.addresses || [], + primaryAddress: d.addresses?.[0] || "", + os: d.os || "", + lastSeen: d.lastSeen || null, + isExitNode: !!d.isExitNode, + authorized: !!d.authorized, + online: d.online ?? null, + })); + } + + async function deleteDevice(deviceId: string): Promise { + await api("DELETE", `/api/v2/device/${deviceId}`); + } + + async function setAuthorized(deviceId: string, authorized: boolean): Promise { + await api("POST", `/api/v2/device/${deviceId}/authorized`, { authorized }); + } + + async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + const start = Date.now(); + try { + await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices`); + return { ok: true, latencyMs: Date.now() - start }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + return { ping, listDevices, setAuthorized, deleteDevice }; +} diff --git a/server/src/integrations/types.ts b/server/src/integrations/types.ts new file mode 100644 index 0000000..f4150d6 --- /dev/null +++ b/server/src/integrations/types.ts @@ -0,0 +1,9 @@ +export interface IntegrationField { + key: string; + label: string; + secret: boolean; + type?: "text" | "password" | "checkbox"; + placeholder?: string; +} + +export type IntegrationConfig = Record; diff --git a/server/src/routes/agentReport.ts b/server/src/routes/agentReport.ts index 11090e0..303791e 100644 --- a/server/src/routes/agentReport.ts +++ b/server/src/routes/agentReport.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { db } from "../db/client.js"; import { hashToken } from "../services/tokens.js"; import { syncServerTasks } from "../services/taskSync.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const agentReportRouter = Router(); @@ -24,7 +25,7 @@ const reportSchema = z.object({ ), }); -agentReportRouter.post("/", async (req, res) => { +agentReportRouter.post("/", asyncHandler(async (req, res) => { const authHeader = req.header("authorization") ?? ""; const match = authHeader.match(/^Bearer\s+(.+)$/i); if (!match) { @@ -59,4 +60,4 @@ agentReportRouter.post("/", async (req, res) => { }); res.status(202).json({ ok: true, taskCount: parsed.data.tasks.length }); -}); +})); diff --git a/server/src/routes/auditLog.ts b/server/src/routes/auditLog.ts index c6fbd10..cffc0a3 100644 --- a/server/src/routes/auditLog.ts +++ b/server/src/routes/auditLog.ts @@ -3,13 +3,14 @@ import { desc } from "drizzle-orm"; import { db } from "../db/client.js"; import { auditLog } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const auditLogRouter = Router(); auditLogRouter.use(requireAuth, requireRole("operator")); -auditLogRouter.get("/", async (req, res) => { +auditLogRouter.get("/", asyncHandler(async (req, res) => { const limit = Math.min(Number(req.query.limit ?? 200), 500); const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit); res.json({ entries: rows }); -}); +})); diff --git a/server/src/routes/dns.ts b/server/src/routes/dns.ts index 575a5ed..c3abafc 100644 --- a/server/src/routes/dns.ts +++ b/server/src/routes/dns.ts @@ -13,6 +13,7 @@ import { } from "../dns/providerSchemas.js"; import { createDnsAdapter } from "../dns/registry.js"; import { loadDnsProviderConfig, getDnsAdapterForProvider } from "../dns/loadProvider.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const dnsRouter = Router(); @@ -26,7 +27,7 @@ dnsRouter.get("/provider-fields", (_req, res) => { // ─── Providers ─────────────────────────────────────────────────────────────── -dnsRouter.get("/providers", async (_req, res) => { +dnsRouter.get("/providers", asyncHandler(async (_req, res) => { const rows = await db .select({ id: dnsProviders.id, @@ -37,7 +38,7 @@ dnsRouter.get("/providers", async (_req, res) => { }) .from(dnsProviders); res.json({ providers: rows }); -}); +})); const configValueSchema = z.union([z.string(), z.boolean()]); @@ -47,7 +48,7 @@ const createProviderSchema = z.object({ config: z.record(configValueSchema), }); -dnsRouter.post("/providers", requireRole("admin"), async (req, res) => { +dnsRouter.post("/providers", requireRole("admin"), asyncHandler(async (req, res) => { const parsed = createProviderSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -93,7 +94,7 @@ dnsRouter.post("/providers", requireRole("admin"), async (req, res) => { res.status(201).json({ provider: { id: created.id, providerType: created.providerType, name: created.name, enabled: created.enabled, createdAt: created.createdAt }, }); -}); +})); const updateProviderSchema = z.object({ name: z.string().min(1).max(200).optional(), @@ -101,7 +102,7 @@ const updateProviderSchema = z.object({ config: z.record(configValueSchema).optional(), }); -dnsRouter.patch("/providers/:id", requireRole("admin"), async (req, res) => { +dnsRouter.patch("/providers/:id", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const parsed = updateProviderSchema.safeParse(req.body); if (!parsed.success) { @@ -182,9 +183,9 @@ dnsRouter.patch("/providers/:id", requireRole("admin"), async (req, res) => { res.json({ provider: { id: updated.id, providerType: updated.providerType, name: updated.name, enabled: updated.enabled, createdAt: updated.createdAt }, }); -}); +})); -dnsRouter.delete("/providers/:id", requireRole("admin"), async (req, res) => { +dnsRouter.delete("/providers/:id", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const [existing] = await db.select().from(dnsProviders).where(eq(dnsProviders.id, id)).limit(1); if (!existing) { @@ -206,14 +207,14 @@ dnsRouter.delete("/providers/:id", requireRole("admin"), async (req, res) => { }); res.status(204).end(); -}); +})); const testProviderSchema = z.object({ providerType: z.enum(dnsProviderTypes), config: z.record(configValueSchema), }); -dnsRouter.post("/providers/test", requireRole("admin"), async (req, res) => { +dnsRouter.post("/providers/test", requireRole("admin"), asyncHandler(async (req, res) => { const parsed = testProviderSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -225,11 +226,11 @@ dnsRouter.post("/providers/test", requireRole("admin"), async (req, res) => { } catch (err) { res.json({ ok: false, error: err instanceof Error ? err.message : String(err) }); } -}); +})); // ─── Zones ─────────────────────────────────────────────────────────────────── -dnsRouter.get("/providers/:id/zones", async (req, res) => { +dnsRouter.get("/providers/:id/zones", asyncHandler(async (req, res) => { const id = Number(req.params.id); const found = await getDnsAdapterForProvider(id); if (!found) return res.status(404).json({ error: "not_found" }); @@ -256,11 +257,11 @@ dnsRouter.get("/providers/:id/zones", async (req, res) => { } catch (err) { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } -}); +})); // ─── Records (cached) ──────────────────────────────────────────────────────── -dnsRouter.get("/providers/:id/zones/:zoneId/records", async (req, res) => { +dnsRouter.get("/providers/:id/zones/:zoneId/records", asyncHandler(async (req, res) => { const providerId = Number(req.params.id); const zoneId = req.params.zoneId; @@ -275,11 +276,11 @@ dnsRouter.get("/providers/:id/zones/:zoneId/records", async (req, res) => { .limit(1); res.json({ records, syncedAt: zoneCache?.syncedAt ?? null }); -}); +})); const syncBodySchema = z.object({ zoneName: z.string().optional() }); -dnsRouter.post("/providers/:id/zones/:zoneId/sync", requireRole("operator"), async (req, res) => { +dnsRouter.post("/providers/:id/zones/:zoneId/sync", requireRole("operator"), asyncHandler(async (req, res) => { const providerId = Number(req.params.id); const zoneId = req.params.zoneId; const parsed = syncBodySchema.safeParse(req.body ?? {}); @@ -338,7 +339,7 @@ dnsRouter.post("/providers/:id/zones/:zoneId/sync", requireRole("operator"), asy } catch (err) { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } -}); +})); const recordInputSchema = z.object({ type: z.string().min(1), @@ -349,7 +350,7 @@ const recordInputSchema = z.object({ proxied: z.boolean().optional(), }); -dnsRouter.post("/providers/:id/zones/:zoneId/records", requireRole("operator"), async (req, res) => { +dnsRouter.post("/providers/:id/zones/:zoneId/records", requireRole("operator"), asyncHandler(async (req, res) => { const providerId = Number(req.params.id); const zoneId = req.params.zoneId; const parsed = recordInputSchema.safeParse(req.body); @@ -387,9 +388,9 @@ dnsRouter.post("/providers/:id/zones/:zoneId/records", requireRole("operator"), } catch (err) { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } -}); +})); -dnsRouter.put("/providers/:id/zones/:zoneId/records/:recordId", requireRole("operator"), async (req, res) => { +dnsRouter.put("/providers/:id/zones/:zoneId/records/:recordId", requireRole("operator"), asyncHandler(async (req, res) => { const providerId = Number(req.params.id); const { zoneId, recordId } = req.params; const parsed = recordInputSchema.safeParse(req.body); @@ -437,9 +438,9 @@ dnsRouter.put("/providers/:id/zones/:zoneId/records/:recordId", requireRole("ope } catch (err) { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } -}); +})); -dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("operator"), async (req, res) => { +dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("operator"), asyncHandler(async (req, res) => { const providerId = Number(req.params.id); const { zoneId, recordId } = req.params; @@ -471,4 +472,4 @@ dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole(" } catch (err) { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } -}); +})); diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts new file mode 100644 index 0000000..40d6b90 --- /dev/null +++ b/server/src/routes/integrations.ts @@ -0,0 +1,334 @@ +import { Router, type Request, type Response } from "express"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/client.js"; +import { integrations, integrationCredentials, integrationTypes } from "../db/schema.js"; +import { requireAuth, requireRole } from "../auth/middleware.js"; +import { recordAudit } from "../services/audit.js"; +import { encryptSecret } from "../crypto.js"; +import { + INTEGRATION_FIELDS, + INTEGRATION_BASE_URLS, + splitIntegrationConfig, + validateIntegrationConfig, +} from "../integrations/fieldSchemas.js"; +import { createIntegrationAdapter } from "../integrations/registry.js"; +import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; +import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +export const integrationsRouter = Router(); + +integrationsRouter.use(requireAuth); + +const IMPLEMENTED_TYPES = Object.keys(INTEGRATION_FIELDS) as (keyof typeof INTEGRATION_FIELDS)[]; + +integrationsRouter.get("/fields", (_req, res) => { + res.json({ fields: INTEGRATION_FIELDS }); +}); + +integrationsRouter.get("/", asyncHandler(async (_req, res) => { + const rows = await db + .select({ + id: integrations.id, + type: integrations.type, + name: integrations.name, + baseUrl: integrations.baseUrl, + enabled: integrations.enabled, + createdAt: integrations.createdAt, + }) + .from(integrations); + res.json({ integrations: rows }); +})); + +const configValueSchema = z.union([z.string(), z.boolean()]); + +const createIntegrationSchema = z.object({ + type: z.enum(integrationTypes), + name: z.string().min(1).max(200), + config: z.record(configValueSchema), +}); + +integrationsRouter.post("/", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = createIntegrationSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + const { type, name, config } = parsed.data; + + if (!IMPLEMENTED_TYPES.includes(type)) { + return res.status(400).json({ error: "not_implemented", message: `Integration type "${type}" isn't available yet.` }); + } + + const missing = validateIntegrationConfig(type, config); + if (missing.length > 0) { + return res.status(400).json({ error: "missing_fields", fields: missing }); + } + + const { secretFields, nonSecretFields } = splitIntegrationConfig(type, config); + + let credentialId: number | null = null; + if (Object.keys(secretFields).length > 0) { + const [cred] = await db + .insert(integrationCredentials) + .values({ name: `${type}:${name}`, encryptedSecret: encryptSecret(JSON.stringify(secretFields)) }) + .returning(); + credentialId = cred.id; + } + + const [created] = await db + .insert(integrations) + .values({ + type, + name, + baseUrl: INTEGRATION_BASE_URLS[type] ?? "", + credentialId, + config: JSON.stringify(nonSecretFields), + enabled: true, + }) + .returning(); + + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: "create", + targetType: "integration", + targetId: created.id, + detail: { type, name }, + }); + + res.status(201).json({ + integration: { + id: created.id, + type: created.type, + name: created.name, + baseUrl: created.baseUrl, + enabled: created.enabled, + createdAt: created.createdAt, + }, + }); +})); + +const updateIntegrationSchema = z.object({ + name: z.string().min(1).max(200).optional(), + enabled: z.boolean().optional(), + config: z.record(configValueSchema).optional(), +}); + +integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const parsed = updateIntegrationSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + + const [existing] = await db.select().from(integrations).where(eq(integrations.id, id)).limit(1); + if (!existing) { + return res.status(404).json({ error: "not_found" }); + } + + let credentialId = existing.credentialId; + let configJson = existing.config; + + if (parsed.data.config) { + const loaded = await loadIntegrationConfig(id); + const { secretFields, nonSecretFields } = splitIntegrationConfig(existing.type, parsed.data.config); + const mergedNonSecret = { ...(loaded?.config ?? {}), ...nonSecretFields }; + for (const field of INTEGRATION_FIELDS[existing.type] ?? []) { + if (field.secret) delete (mergedNonSecret as Record)[field.key]; + } + configJson = JSON.stringify(mergedNonSecret); + + if (Object.keys(secretFields).length > 0) { + const existingSecrets: Record = {}; + const fields = (INTEGRATION_FIELDS[existing.type] ?? []).filter((f) => f.secret); + for (const f of fields) { + if (loaded?.config[f.key] !== undefined) existingSecrets[f.key] = loaded.config[f.key]!; + } + const mergedSecrets = { ...existingSecrets, ...secretFields }; + const encrypted = encryptSecret(JSON.stringify(mergedSecrets)); + if (existing.credentialId) { + await db.update(integrationCredentials).set({ encryptedSecret: encrypted }).where(eq(integrationCredentials.id, existing.credentialId)); + } else { + const [cred] = await db + .insert(integrationCredentials) + .values({ name: `${existing.type}:${existing.name}`, encryptedSecret: encrypted }) + .returning(); + credentialId = cred.id; + } + } + } + + const [updated] = await db + .update(integrations) + .set({ + name: parsed.data.name ?? existing.name, + enabled: parsed.data.enabled ?? existing.enabled, + credentialId, + config: configJson, + }) + .where(eq(integrations.id, id)) + .returning(); + + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: "update", + targetType: "integration", + targetId: id, + detail: { name: updated.name }, + }); + + res.json({ + integration: { + id: updated.id, + type: updated.type, + name: updated.name, + baseUrl: updated.baseUrl, + enabled: updated.enabled, + createdAt: updated.createdAt, + }, + }); +})); + +integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const [existing] = await db.select().from(integrations).where(eq(integrations.id, id)).limit(1); + if (!existing) { + return res.status(404).json({ error: "not_found" }); + } + + await db.delete(integrations).where(eq(integrations.id, id)); + if (existing.credentialId) { + await db.delete(integrationCredentials).where(eq(integrationCredentials.id, existing.credentialId)); + } + + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: "delete", + targetType: "integration", + targetId: id, + detail: { name: existing.name }, + }); + + res.status(204).end(); +})); + +const testIntegrationSchema = z.object({ + type: z.enum(integrationTypes), + config: z.record(configValueSchema), +}); + +integrationsRouter.post("/test", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = testIntegrationSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + if (!IMPLEMENTED_TYPES.includes(parsed.data.type)) { + return res.status(400).json({ error: "not_implemented" }); + } + const adapter = createIntegrationAdapter(parsed.data.type, parsed.data.config); + const result = await adapter.ping(); + res.json(result); +})); + +// ─── Tailscale ─────────────────────────────────────────────────────────────── + +/** + * Loads the integration row and checks its type BEFORE constructing an + * adapter — createIntegrationAdapter() throws for any not-yet-implemented + * type, and that must never happen for an existing non-tailscale row (e.g. a + * Proxmox row someone points at these Tailscale-only routes). + */ +async function requireTailscaleAdapter(req: Request, res: Response) { + const id = Number(req.params.id); + const loaded = await loadIntegrationConfig(id); + if (!loaded) { + res.status(404).json({ error: "not_found" }); + return null; + } + if (loaded.integration.type !== "tailscale") { + res.status(400).json({ error: "wrong_type" }); + return null; + } + if (!loaded.integration.enabled) { + res.status(400).json({ error: "integration_disabled" }); + return null; + } + return { integration: loaded.integration, adapter: createTailscaleAdapter(loaded.config as any) }; +} + +integrationsRouter.get("/:id/tailscale/devices", asyncHandler(async (req, res) => { + const found = await requireTailscaleAdapter(req, res); + if (!found) return; + + try { + const devices = await found.adapter.listDevices(); + res.json({ + devices, + summary: { + total: devices.length, + online: devices.filter((d) => d.online).length, + unauthorized: devices.filter((d) => !d.authorized).length, + }, + }); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } +})); + +const authorizeSchema = z.object({ authorized: z.boolean() }); + +integrationsRouter.post( + "/:id/tailscale/devices/:deviceId/authorize", + requireRole("operator"), + asyncHandler(async (req, res) => { + const found = await requireTailscaleAdapter(req, res); + if (!found) return; + + const parsed = authorizeSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + + try { + await found.adapter.setAuthorized(req.params.deviceId, parsed.data.authorized); + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: parsed.data.authorized ? "authorize_device" : "deauthorize_device", + targetType: "tailscale_device", + targetId: req.params.deviceId, + detail: { integrationId: found.integration.id }, + }); + res.status(204).end(); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } + }), +); + +integrationsRouter.delete( + "/:id/tailscale/devices/:deviceId", + requireRole("operator"), + asyncHandler(async (req, res) => { + const found = await requireTailscaleAdapter(req, res); + if (!found) return; + + try { + await found.adapter.deleteDevice(req.params.deviceId); + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: "remove_device", + targetType: "tailscale_device", + targetId: req.params.deviceId, + detail: { integrationId: found.integration.id }, + }); + res.status(204).end(); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } + }), +); diff --git a/server/src/routes/ipam.ts b/server/src/routes/ipam.ts index 0cbced3..5bc7018 100644 --- a/server/src/routes/ipam.ts +++ b/server/src/routes/ipam.ts @@ -6,16 +6,17 @@ import { db } from "../db/client.js"; import { ipamEntries } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const ipamRouter = Router(); ipamRouter.use(requireAuth); -ipamRouter.get("/", async (_req, res) => { +ipamRouter.get("/", asyncHandler(async (_req, res) => { const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress); // matchingDnsRecords will be populated once the DNS module (phase 3) has cached records for cross-reference. res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: [] as string[] })) }); -}); +})); const createInput = z.object({ ipAddress: z.string().refine((v) => isIP(v) !== 0, "Must be a valid IPv4 or IPv6 address"), @@ -27,7 +28,7 @@ const createInput = z.object({ const updateInput = createInput.omit({ ipAddress: true }).partial(); -ipamRouter.post("/", requireRole("operator"), async (req, res) => { +ipamRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => { const parsed = createInput.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -54,9 +55,9 @@ ipamRouter.post("/", requireRole("operator"), async (req, res) => { }); res.status(201).json({ entry: { ...created, matchingDnsRecords: [] } }); -}); +})); -ipamRouter.patch("/:id", requireRole("operator"), async (req, res) => { +ipamRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const parsed = updateInput.safeParse(req.body); if (!parsed.success) { @@ -84,9 +85,9 @@ ipamRouter.patch("/:id", requireRole("operator"), async (req, res) => { }); res.json({ entry: { ...updated, matchingDnsRecords: [] } }); -}); +})); -ipamRouter.delete("/:id", requireRole("operator"), async (req, res) => { +ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.id, id)).limit(1); if (!existing) { @@ -105,4 +106,4 @@ ipamRouter.delete("/:id", requireRole("operator"), async (req, res) => { }); res.status(204).end(); -}); +})); diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 8259d4a..506e255 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -6,17 +6,18 @@ import { secrets, secretTypes } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; import { computeSecretStatus } from "../services/secretStatus.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const secretsRouter = Router(); secretsRouter.use(requireAuth); -secretsRouter.get("/", async (_req, res) => { +secretsRouter.get("/", asyncHandler(async (_req, res) => { const rows = await db.select().from(secrets).orderBy(secrets.expiryDate); res.json({ secrets: rows.map((s) => ({ ...s, ...computeSecretStatus(s.expiryDate, s.warnDays) })), }); -}); +})); const secretInput = z.object({ name: z.string().min(1).max(200), @@ -27,7 +28,7 @@ const secretInput = z.object({ notes: z.string().max(4000).optional(), }); -secretsRouter.post("/", requireRole("operator"), async (req, res) => { +secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => { const parsed = secretInput.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -45,9 +46,9 @@ secretsRouter.post("/", requireRole("operator"), async (req, res) => { }); res.status(201).json({ secret: { ...created, ...computeSecretStatus(created.expiryDate, created.warnDays) } }); -}); +})); -secretsRouter.patch("/:id", requireRole("operator"), async (req, res) => { +secretsRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const parsed = secretInput.partial().safeParse(req.body); if (!parsed.success) { @@ -75,9 +76,9 @@ secretsRouter.patch("/:id", requireRole("operator"), async (req, res) => { }); res.json({ secret: { ...updated, ...computeSecretStatus(updated.expiryDate, updated.warnDays) } }); -}); +})); -secretsRouter.delete("/:id", requireRole("operator"), async (req, res) => { +secretsRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1); if (!existing) { @@ -96,4 +97,4 @@ secretsRouter.delete("/:id", requireRole("operator"), async (req, res) => { }); res.status(204).end(); -}); +})); diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index 365d099..7b865a2 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -6,6 +6,7 @@ import { servers } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { generateApiToken } from "../services/tokens.js"; import { recordAudit } from "../services/audit.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const serversRouter = Router(); serversRouter.use(requireAuth); @@ -17,14 +18,14 @@ const createServerSchema = z.object({ description: z.string().max(500).optional(), }); -serversRouter.get("/", async (_req, res) => { +serversRouter.get("/", asyncHandler(async (_req, res) => { const rows = await db.query.servers.findMany({ orderBy: (s, { asc }) => [asc(s.name)] }); res.json({ servers: rows.map(({ apiTokenHash, ...rest }) => rest), }); -}); +})); -serversRouter.post("/", requireRole("admin"), async (req, res) => { +serversRouter.post("/", requireRole("admin"), asyncHandler(async (req, res) => { const parsed = createServerSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -56,9 +57,9 @@ serversRouter.post("/", requireRole("admin"), async (req, res) => { const { apiTokenHash, ...serverOut } = created; // The full token is only ever shown once, at creation time. res.status(201).json({ server: serverOut, token }); -}); +})); -serversRouter.post("/:id/rotate-token", requireRole("admin"), async (req, res) => { +serversRouter.post("/:id/rotate-token", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); @@ -82,9 +83,9 @@ serversRouter.post("/:id/rotate-token", requireRole("admin"), async (req, res) = const { apiTokenHash, ...serverOut } = updated; res.json({ server: serverOut, token }); -}); +})); -serversRouter.delete("/:id", requireRole("admin"), async (req, res) => { +serversRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); @@ -101,4 +102,4 @@ serversRouter.delete("/:id", requireRole("admin"), async (req, res) => { }); res.status(204).end(); -}); +})); diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 0a675bd..98f196c 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -5,6 +5,7 @@ import { db } from "../db/client.js"; import { scheduledTasks, servers } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const tasksRouter = Router(); tasksRouter.use(requireAuth); @@ -26,7 +27,7 @@ const TASK_COLUMNS = { lastSeenAt: scheduledTasks.lastSeenAt, }; -tasksRouter.get("/", async (req, res) => { +tasksRouter.get("/", asyncHandler(async (req, res) => { const serverId = req.query.serverId ? Number(req.query.serverId) : undefined; const scheduleType = typeof req.query.scheduleType === "string" ? req.query.scheduleType : undefined; const search = typeof req.query.search === "string" ? req.query.search.trim() : undefined; @@ -55,7 +56,7 @@ tasksRouter.get("/", async (req, res) => { .orderBy(servers.name, scheduledTasks.scheduleType, scheduledTasks.name); res.json({ tasks: rows }); -}); +})); const manualTaskSchema = z.object({ serverId: z.number().int(), @@ -67,7 +68,7 @@ const manualTaskSchema = z.object({ enabled: z.boolean().optional(), }); -tasksRouter.post("/", requireRole("operator"), async (req, res) => { +tasksRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => { const parsed = manualTaskSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -110,11 +111,11 @@ tasksRouter.post("/", requireRole("operator"), async (req, res) => { .innerJoin(servers, eq(scheduledTasks.serverId, servers.id)) .where(eq(scheduledTasks.id, created.id)); res.status(201).json({ task }); -}); +})); const manualTaskUpdateSchema = manualTaskSchema.omit({ serverId: true }).partial(); -tasksRouter.patch("/:id", requireRole("operator"), async (req, res) => { +tasksRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); @@ -149,9 +150,9 @@ tasksRouter.patch("/:id", requireRole("operator"), async (req, res) => { .innerJoin(servers, eq(scheduledTasks.serverId, servers.id)) .where(eq(scheduledTasks.id, id)); res.json({ task }); -}); +})); -tasksRouter.delete("/:id", requireRole("operator"), async (req, res) => { +tasksRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); @@ -173,4 +174,4 @@ tasksRouter.delete("/:id", requireRole("operator"), async (req, res) => { }); res.status(204).end(); -}); +})); diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 6c06519..a7e3482 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -5,21 +5,22 @@ import { db } from "../db/client.js"; import { users, userRoles } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const usersRouter = Router(); usersRouter.use(requireAuth, requireRole("admin")); -usersRouter.get("/", async (_req, res) => { +usersRouter.get("/", asyncHandler(async (_req, res) => { const rows = await db.select().from(users).orderBy(users.createdAt); res.json({ users: rows }); -}); +})); const updateRoleSchema = z.object({ role: z.enum(userRoles), }); -usersRouter.patch("/:id/role", async (req, res) => { +usersRouter.patch("/:id/role", asyncHandler(async (req, res) => { const id = Number(req.params.id); const parsed = updateRoleSchema.safeParse(req.body); if (!parsed.success) { @@ -58,4 +59,4 @@ usersRouter.patch("/:id/role", async (req, res) => { }); res.json({ user: updated }); -}); +})); diff --git a/server/src/utils/asyncHandler.ts b/server/src/utils/asyncHandler.ts new file mode 100644 index 0000000..89ae783 --- /dev/null +++ b/server/src/utils/asyncHandler.ts @@ -0,0 +1,16 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express"; + +/** + * Wraps an async route handler so a rejected promise (or a thrown error before + * the handler's own try/catch even starts, e.g. in a shared helper it calls) + * is forwarded to Express's error middleware instead of becoming an unhandled + * rejection — which, on modern Node, crashes the whole process rather than + * just failing the one request. + */ +export function asyncHandler( + fn: (req: Request, res: Response, next: NextFunction) => Promise, +): RequestHandler { + return (req, res, next) => { + fn(req, res, next).catch(next); + }; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index ce2fce2..ff1a7c4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import Secrets from "./pages/Secrets"; import Ipam from "./pages/Ipam"; import Dns from "./pages/Dns"; import ServersTasks from "./pages/ServersTasks"; +import Integrations from "./pages/Integrations"; import ComingSoon from "./pages/ComingSoon"; import AppShell from "./layout/AppShell"; @@ -62,7 +63,7 @@ export default function App() { } /> } /> } /> - } /> + } /> (`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) }), remove: (id: number) => request(`/api/tasks/${id}`, { method: "DELETE" }), }, + integrations: { + fields: () => request<{ fields: Partial> }>("/api/integrations/fields"), + list: () => request<{ integrations: IntegrationSummary[] }>("/api/integrations"), + create: (data: { type: IntegrationType; name: string; config: Record }) => + request<{ integration: IntegrationSummary }>("/api/integrations", { + method: "POST", + body: JSON.stringify(data), + }), + update: (id: number, data: { name?: string; enabled?: boolean; config?: Record }) => + request<{ integration: IntegrationSummary }>(`/api/integrations/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }), + remove: (id: number) => request(`/api/integrations/${id}`, { method: "DELETE" }), + test: (data: { type: IntegrationType; config: Record }) => + request<{ ok: boolean; latencyMs?: number; error?: string }>("/api/integrations/test", { + method: "POST", + body: JSON.stringify(data), + }), + tailscale: { + devices: (integrationId: number) => + request(`/api/integrations/${integrationId}/tailscale/devices`), + setAuthorized: (integrationId: number, deviceId: string, authorized: boolean) => + request(`/api/integrations/${integrationId}/tailscale/devices/${encodeURIComponent(deviceId)}/authorize`, { + method: "POST", + body: JSON.stringify({ authorized }), + }), + remove: (integrationId: number, deviceId: string) => + request(`/api/integrations/${integrationId}/tailscale/devices/${encodeURIComponent(deviceId)}`, { + method: "DELETE", + }), + }, + }, }; diff --git a/web/src/components/IntegrationForm.tsx b/web/src/components/IntegrationForm.tsx new file mode 100644 index 0000000..6e392a8 --- /dev/null +++ b/web/src/components/IntegrationForm.tsx @@ -0,0 +1,173 @@ +import { useEffect, useState } from "react"; +import { api, type IntegrationField, type IntegrationType } from "../api/client"; + +const TYPE_LABELS: Record = { + tailscale: "Tailscale", + proxmox: "Proxmox", + synology: "Synology", + semaphore: "Semaphore", + gitea: "Gitea", + dockhand: "Dockhand", +}; + +export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) { + const [allFields, setAllFields] = useState> | null>(null); + const [type, setType] = useState(null); + const [name, setName] = useState(""); + const [values, setValues] = useState>({}); + const [testResult, setTestResult] = useState<{ ok: boolean; latencyMs?: number; error?: string } | null>(null); + const [testing, setTesting] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.integrations.fields().then((res) => { + setAllFields(res.fields); + const firstType = Object.keys(res.fields)[0] as IntegrationType | undefined; + if (firstType) setType(firstType); + }); + }, []); + + useEffect(() => { + setValues({}); + setTestResult(null); + }, [type]); + + const availableTypes = allFields ? (Object.keys(allFields) as IntegrationType[]) : []; + const fields = (type && allFields?.[type]) ?? []; + + function setField(key: string, value: string | boolean) { + setValues((v) => ({ ...v, [key]: value })); + } + + async function testConnection() { + if (!type) return; + setTesting(true); + setTestResult(null); + try { + const result = await api.integrations.test({ type, config: values }); + setTestResult(result); + } catch (err) { + setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } finally { + setTesting(false); + } + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!type) return; + setError(null); + setSaving(true); + try { + await api.integrations.create({ type, name, config: values }); + onCreated(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + if (allFields && availableTypes.length === 0) { + return ( +
+
+ No integration types are implemented yet. +
+
+ ); + } + + return ( +
+
+

Add an integration

+
+
+
+ {error && ( +
+
{error}
+
+ )} +
+ + +
+
+ + setName(e.target.value)} + /> +
+ + {fields.map((field) => ( +
+ {field.type === "checkbox" ? ( + + ) : ( + <> + + setField(field.key, e.target.value)} + /> + + )} +
+ ))} + + {testResult && ( +
+ {testResult.ok ? ( +
+ Connection OK{testResult.latencyMs !== undefined ? ` (${testResult.latencyMs}ms)` : ""}. +
+ ) : ( +
Connection failed: {testResult.error}
+ )} +
+ )} +
+
+ + + +
+
+
+ ); +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 7a761f3..8ad39a3 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,6 +1,36 @@ -import type { CurrentUser } from "../api/client"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type CurrentUser, type IntegrationSummary, type IntegrationType, type TailscaleDevicesResponse } from "../api/client"; + +const WIDGETS: { type: IntegrationType; label: string }[] = [ + { type: "proxmox", label: "Proxmox" }, + { type: "synology", label: "Synology NAS" }, + { type: "semaphore", label: "Semaphore" }, + { type: "tailscale", label: "Tailscale" }, + { type: "gitea", label: "Gitea" }, + { type: "dockhand", label: "Dockhand / Docker" }, +]; export default function Dashboard({ user }: { user: CurrentUser }) { + const [integrations, setIntegrations] = useState(null); + const [tailscaleSummary, setTailscaleSummary] = useState(null); + + useEffect(() => { + api.integrations.list().then((res) => setIntegrations(res.integrations)); + }, []); + + useEffect(() => { + const tailscale = integrations?.find((i) => i.type === "tailscale" && i.enabled); + if (!tailscale) { + setTailscaleSummary(null); + return; + } + api.integrations.tailscale + .devices(tailscale.id) + .then((res) => setTailscaleSummary(res.summary)) + .catch(() => setTailscaleSummary(null)); + }, [integrations]); + return ( <>
@@ -14,30 +44,53 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
- {[ - "Proxmox", - "Synology NAS", - "Semaphore", - "Tailscale", - "Gitea", - "Dockhand / Docker", - ].map((name) => ( -
-
-
-
-
{name}
-
- Not connected + {WIDGETS.map(({ type, label }) => { + const integration = integrations?.find((i) => i.type === type && i.enabled); + const isLive = type === "tailscale" && integration && tailscaleSummary; + + return ( +
+
+
+
+
{label}
+
+ {isLive ? ( + Connected + ) : integration ? ( + Connecting… + ) : ( + Not connected + )} +
-
-
- Configure this integration from the Integrations page. + {isLive ? ( +
+
+ {tailscaleSummary!.online}/{tailscaleSummary!.total} +
+
devices online
+ {tailscaleSummary!.unauthorized > 0 && ( +
{tailscaleSummary!.unauthorized} awaiting authorization
+ )} +
+ ) : ( +
+ {integration ? ( + "Fetching status…" + ) : ( + <> + Configure this integration from the{" "} + Integrations page. + + )} +
+ )}
-
- ))} + ); + })}
); diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx new file mode 100644 index 0000000..97b5070 --- /dev/null +++ b/web/src/pages/Integrations.tsx @@ -0,0 +1,292 @@ +import { useEffect, useState } from "react"; +import { + api, + type CurrentUser, + type IntegrationSummary, + type IntegrationType, + type TailscaleDevice, + type TailscaleDevicesResponse, +} from "../api/client"; +import IntegrationForm from "../components/IntegrationForm"; + +const TYPE_LABELS: Record = { + tailscale: "Tailscale", + proxmox: "Proxmox", + synology: "Synology", + semaphore: "Semaphore", + gitea: "Gitea", + dockhand: "Dockhand", +}; + +export default function Integrations({ user }: { user: CurrentUser }) { + const isAdmin = user.role === "admin"; + const canEdit = user.role === "admin" || user.role === "operator"; + + const [integrations, setIntegrations] = useState(null); + const [managing, setManaging] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [error, setError] = useState(null); + + const [tailscaleData, setTailscaleData] = useState(null); + const [tailscaleError, setTailscaleError] = useState(null); + const [loadingDevices, setLoadingDevices] = useState(false); + + function loadIntegrations() { + api.integrations + .list() + .then((res) => { + setIntegrations(res.integrations); + if (!selectedId && res.integrations.length > 0) setSelectedId(res.integrations[0].id); + }) + .catch((err) => setError(String(err))); + } + + useEffect(loadIntegrations, []); + + const selected = integrations?.find((i) => i.id === selectedId) ?? null; + + function loadTailscaleDevices(id: number) { + setLoadingDevices(true); + setTailscaleError(null); + api.integrations.tailscale + .devices(id) + .then((res) => setTailscaleData(res)) + .catch((err) => setTailscaleError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoadingDevices(false)); + } + + useEffect(() => { + if (selected?.type === "tailscale" && selected.enabled && !managing) { + loadTailscaleDevices(selected.id); + } else { + setTailscaleData(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId, managing, integrations]); + + async function toggleEnabled(i: IntegrationSummary) { + try { + await api.integrations.update(i.id, { enabled: !i.enabled }); + loadIntegrations(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function removeIntegration(i: IntegrationSummary) { + if (!confirm(`Delete integration "${i.name}"?`)) return; + try { + await api.integrations.remove(i.id); + if (selectedId === i.id) setSelectedId(null); + loadIntegrations(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function setDeviceAuthorized(device: TailscaleDevice, authorized: boolean) { + if (!selectedId) return; + try { + await api.integrations.tailscale.setAuthorized(selectedId, device.id, authorized); + loadTailscaleDevices(selectedId); + } catch (err) { + setTailscaleError(err instanceof Error ? err.message : String(err)); + } + } + + async function removeDevice(device: TailscaleDevice) { + if (!selectedId) return; + if (!confirm(`Remove device "${device.label || device.hostname}" from the tailnet?`)) return; + try { + await api.integrations.tailscale.remove(selectedId, device.id); + loadTailscaleDevices(selectedId); + } catch (err) { + setTailscaleError(err instanceof Error ? err.message : String(err)); + } + } + + return ( + <> +
+

Integrations

+ {isAdmin && ( + + )} +
+ {error &&
{error}
} + + {managing ? ( + <> + setManaging(false)} /> +
+
+ + + + + + + + + + + {integrations?.map((i) => ( + + + + + + + ))} + {integrations?.length === 0 && ( + + + + )} + +
NameTypeStatusActions
{i.name}{TYPE_LABELS[i.type]} + + + +
+ No integrations configured yet. +
+
+
+ + ) : ( + <> + {integrations?.length === 0 ? ( +
+
+ No integrations configured yet. + {isAdmin && ( + <> + {" "} + + . + + )} +
+
+ ) : ( + <> +
+ +
+ + {selected?.type === "tailscale" ? ( +
+
+

+ Devices + {tailscaleData && ( + + {tailscaleData.summary.online}/{tailscaleData.summary.total} online + {tailscaleData.summary.unauthorized > 0 && `, ${tailscaleData.summary.unauthorized} awaiting authorization`} + + )} +

+
+ +
+
+ {tailscaleError &&
{tailscaleError}
} +
+ + + + + + + + + {canEdit && } + + + + {tailscaleData?.devices.map((d) => ( + + + + + + + {canEdit && ( + + )} + + ))} + {tailscaleData?.devices.length === 0 && ( + + + + )} + +
DeviceAddressOSStatusLast seenActions
+ {d.label || d.hostname} + {d.isExitNode && Exit node} + {d.primaryAddress}{d.os || "—"} + {d.online ? ( + Online + ) : ( + Offline + )} + {!d.authorized && Unauthorized} + {d.lastSeen ? new Date(d.lastSeen).toLocaleString() : "—"} +
+ {d.authorized ? ( + + ) : ( + + )} + +
+
+ No devices in this tailnet. +
+
+
+ ) : ( +
+
+ {selected ? `${TYPE_LABELS[selected.type]} isn't wired up in the UI yet.` : "Select an integration."} +
+
+ )} + + )} + + )} + + ); +}