286 lines
7.7 KiB
TypeScript
286 lines
7.7 KiB
TypeScript
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<Response> {
|
|
return async (req: Request): Promise<Response> => {
|
|
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<Response> {
|
|
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<string, string> = {};
|
|
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<Response> {
|
|
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<Response> {
|
|
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 };
|
|
}
|