import type { Config } from "./config.ts"; import type { NetbirdClient } from "./netbird/client.ts"; import type { GiteaClient } from "./gitea/client.ts"; import { DesiredStateSchema, validateCrossReferences } from "./state/schema.ts"; import { fetchActualState } from "./state/actual.ts"; import { computeDiff } from "./reconcile/diff.ts"; import { executeOperations } from "./reconcile/executor.ts"; import type { OperationResult } from "./reconcile/operations.ts"; import { type PollerContext, pollOnce } from "./poller/loop.ts"; import { exportState } from "./export.ts"; export interface ServerContext { config: Config; netbird: NetbirdClient; gitea: GiteaClient | null; reconcileInProgress: { value: boolean }; } export function createHandler( ctx: ServerContext, ): (req: Request) => Promise { return async (req: Request): Promise => { const url = new URL(req.url); // Health check — no auth required if (url.pathname === "/health" && req.method === "GET") { return Response.json({ status: "ok" }); } // All other endpoints require bearer token auth const authHeader = req.headers.get("Authorization"); if (authHeader !== `Bearer ${ctx.config.reconcilerToken}`) { return Response.json({ error: "unauthorized" }, { status: 401 }); } if (url.pathname === "/reconcile" && req.method === "POST") { return handleReconcile(req, url, ctx); } if (url.pathname === "/sync-events" && req.method === "POST") { return handleSyncEvents(ctx); } if (url.pathname === "/export" && req.method === "GET") { return handleExport(ctx); } return Response.json({ error: "not found" }, { status: 404 }); }; } // ----------------------------------------------------------------------------- // /reconcile // ----------------------------------------------------------------------------- async function handleReconcile( req: Request, url: URL, ctx: ServerContext, ): Promise { const dryRun = url.searchParams.get("dry_run") === "true"; // Parse and validate the desired state from the request body let body: unknown; try { body = await req.json(); } catch { return Response.json( { status: "error", error: "invalid JSON body" }, { status: 400 }, ); } const parseResult = DesiredStateSchema.safeParse(body); if (!parseResult.success) { return Response.json( { status: "error", error: "schema validation failed", issues: parseResult.error.issues, }, { status: 400 }, ); } const desired = parseResult.data; // Cross-reference validation (e.g. group refs in policies exist) const crossRefErrors = validateCrossReferences(desired); if (crossRefErrors.length > 0) { return Response.json( { status: "error", error: "cross-reference validation failed", issues: crossRefErrors, }, { status: 400 }, ); } ctx.reconcileInProgress.value = true; try { const actual = await fetchActualState(ctx.netbird); const ops = computeDiff(desired, actual); if (dryRun) { return Response.json({ status: "planned", operations: ops.map((op) => ({ type: op.type, name: op.name, })), summary: summarize(ops), }); } if (ops.length === 0) { return Response.json({ status: "applied", operations: [], created_keys: {}, summary: { created: 0, updated: 0, deleted: 0, failed: 0 }, }); } const { results, createdKeys } = await executeOperations( ops, ctx.netbird, actual, ); // Convert Map to plain object for JSON serialization const createdKeysObj: Record = {}; for (const [name, key] of createdKeys) { createdKeysObj[name] = key; } return Response.json({ status: "applied", operations: results.map((r) => ({ type: r.type, name: r.name, status: r.status, })), created_keys: createdKeysObj, summary: summarize(results), }); } catch (err) { console.error( JSON.stringify({ msg: "reconcile_error", error: err instanceof Error ? err.message : String(err), }), ); return Response.json( { status: "error", error: err instanceof Error ? err.message : String(err), }, { status: 500 }, ); } finally { ctx.reconcileInProgress.value = false; } } // ----------------------------------------------------------------------------- // /sync-events // ----------------------------------------------------------------------------- /** * Forces a single poll cycle. Temporarily clears reconcileInProgress so * pollOnce doesn't skip, then restores it afterward. */ async function handleSyncEvents(ctx: ServerContext): Promise { const pollerCtx: PollerContext = { config: ctx.config, netbird: ctx.netbird, gitea: ctx.gitea, reconcileInProgress: { value: false }, }; try { await pollOnce(pollerCtx); return Response.json({ status: "synced" }); } catch (err) { console.error( JSON.stringify({ msg: "sync_events_error", error: err instanceof Error ? err.message : String(err), }), ); return Response.json( { status: "error", error: err instanceof Error ? err.message : String(err), }, { status: 500 }, ); } } // ----------------------------------------------------------------------------- // /export // ----------------------------------------------------------------------------- /** * Fetches the current NetBird state and transforms it into the declarative * `netbird.json` format. Useful for bootstrapping gitops from an existing * account or inspecting what the reconciler "sees". */ async function handleExport(ctx: ServerContext): Promise { try { const actual = await fetchActualState(ctx.netbird); const state = exportState(actual); return Response.json({ status: "ok", state, meta: { exported_at: new Date().toISOString(), source_url: ctx.config.netbirdApiUrl, groups_count: Object.keys(state.groups).length, setup_keys_count: Object.keys(state.setup_keys).length, policies_count: Object.keys(state.policies).length, routes_count: Object.keys(state.routes).length, dns_count: Object.keys(state.dns.nameserver_groups).length, }, }); } catch (err) { console.error( JSON.stringify({ msg: "export_error", error: err instanceof Error ? err.message : String(err), }), ); return Response.json( { status: "error", error: err instanceof Error ? err.message : String(err), }, { status: 500 }, ); } } // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- interface Summary { created: number; updated: number; deleted: number; failed: number; } /** * Counts operations by category. Works on both raw Operation[] (for dry-run * plans) and OperationResult[] (for executed results where failures are * tallied separately). */ function summarize(ops: Array<{ type: string; status?: string }>): Summary { let created = 0; let updated = 0; let deleted = 0; let failed = 0; for (const op of ops) { if ((op as OperationResult).status === "failed") { failed++; continue; } if (op.type.startsWith("create_")) { created++; } else if (op.type.startsWith("update_") || op.type === "rename_peer") { updated++; } else if (op.type.startsWith("delete_")) { deleted++; } } return { created, updated, deleted, failed }; }