From b4f3ce7870627c60e06f7c4163faf14488c6ed29 Mon Sep 17 00:00:00 2001 From: Prox Date: Fri, 6 Mar 2026 13:21:08 +0200 Subject: [PATCH] reconciler PoC --- .beads/README.md | 22 +- .beads/metadata.json | 2 +- .gitea/workflows/dry-run.yml | 2 +- .gitea/workflows/reconcile.yml | 2 +- .gitea/workflows/release.yml | 2 +- AGENTS.md | 14 +- deno.json | 3 +- deploy/.env.example | 5 + .../2026-03-03-netbird-reconciler-design.md | 155 +- ...03-03-netbird-reconciler-implementation.md | 410 +++-- ...026-03-06-reconciler-poc-implementation.md | 1375 +++++++++++++++++ .../2026-03-06-reconciler-poc-validation.md | 272 ++++ poc/README.md | 169 ++ poc/ansible/.gitignore | 1 + poc/ansible/files/netbird-seed.json | 32 + poc/ansible/group_vars/all/vars.yml | 26 + poc/ansible/group_vars/all/vault.yml.example | 20 + poc/ansible/inventory.yml | 9 + poc/ansible/playbook.yml | 366 +++++ poc/ansible/templates/Caddyfile.j2 | 46 + poc/ansible/templates/dashboard.env.j2 | 12 + poc/ansible/templates/docker-compose.yml.j2 | 145 ++ poc/ansible/templates/management.json.j2 | 49 + poc/ansible/templates/reconciler.env.j2 | 12 + poc/ansible/templates/relay.env.j2 | 2 + poc/ansible/templates/turnserver.conf.j2 | 10 + src/config.ts | 50 +- src/export.test.ts | 523 +++++++ src/export.ts | 243 +++ src/gitea/client.test.ts | 4 +- src/integration.test.ts | 211 +++ src/main.ts | 52 +- src/netbird/client.test.ts | 4 +- src/poller/loop.ts | 152 +- src/reconcile/executor.test.ts | 3 +- src/reconcile/executor.ts | 4 +- src/server.ts | 58 +- src/state/actual.test.ts | 56 +- 38 files changed, 4286 insertions(+), 237 deletions(-) create mode 100644 docs/plans/2026-03-06-reconciler-poc-implementation.md create mode 100644 docs/plans/2026-03-06-reconciler-poc-validation.md create mode 100644 poc/README.md create mode 100644 poc/ansible/.gitignore create mode 100644 poc/ansible/files/netbird-seed.json create mode 100644 poc/ansible/group_vars/all/vars.yml create mode 100644 poc/ansible/group_vars/all/vault.yml.example create mode 100644 poc/ansible/inventory.yml create mode 100644 poc/ansible/playbook.yml create mode 100644 poc/ansible/templates/Caddyfile.j2 create mode 100644 poc/ansible/templates/dashboard.env.j2 create mode 100644 poc/ansible/templates/docker-compose.yml.j2 create mode 100644 poc/ansible/templates/management.json.j2 create mode 100644 poc/ansible/templates/reconciler.env.j2 create mode 100644 poc/ansible/templates/relay.env.j2 create mode 100644 poc/ansible/templates/turnserver.conf.j2 create mode 100644 src/export.test.ts create mode 100644 src/export.ts diff --git a/.beads/README.md b/.beads/README.md index 1029bf1..c5c22a8 100644 --- a/.beads/README.md +++ b/.beads/README.md @@ -1,12 +1,16 @@ # Beads - AI-Native Issue Tracking -Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, +AI-native tool designed to live directly in your codebase alongside your code. ## What is Beads? -Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. +Beads is issue tracking that lives in your repo, making it perfect for AI coding +agents and developers who want their issues close to their code. No web UI +required - everything works through the CLI and integrates seamlessly with git. -**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) +**Learn more:** +[github.com/steveyegge/beads](https://github.com/steveyegge/beads) ## Quick Start @@ -33,6 +37,7 @@ bd dolt push ### Working with Issues Issues in Beads are: + - **Git-native**: Stored in `.beads/issues.jsonl` and synced like code - **AI-friendly**: CLI-first design works perfectly with AI coding agents - **Branch-aware**: Issues can follow your branch workflow @@ -41,16 +46,19 @@ Issues in Beads are: ## Why Beads? ✨ **AI-Native Design** + - Built specifically for AI-assisted development workflows - CLI-first interface works seamlessly with AI coding agents - No context switching to web UIs 🚀 **Developer Focused** + - Issues live in your repo, right next to your code - Works offline, syncs when you push - Fast, lightweight, and stays out of your way 🔧 **Git Integration** + - Automatic sync with git commits - Branch-aware issue tracking - Intelligent JSONL merge resolution @@ -72,10 +80,12 @@ bd create "Try out Beads" ## Learn More -- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Documentation**: + [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) - **Quick Start Guide**: Run `bd quickstart` -- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) +- **Examples**: + [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) --- -*Beads: Issue tracking that moves at the speed of thought* ⚡ +_Beads: Issue tracking that moves at the speed of thought_ ⚡ diff --git a/.beads/metadata.json b/.beads/metadata.json index 5e1bd6b..ef2b0b1 100644 --- a/.beads/metadata.json +++ b/.beads/metadata.json @@ -4,4 +4,4 @@ "backend": "dolt", "dolt_mode": "server", "dolt_database": "beads_netbird-gitops" -} \ No newline at end of file +} diff --git a/.gitea/workflows/dry-run.yml b/.gitea/workflows/dry-run.yml index 596aba9..4b6b4f6 100644 --- a/.gitea/workflows/dry-run.yml +++ b/.gitea/workflows/dry-run.yml @@ -3,7 +3,7 @@ name: Dry Run on: pull_request: paths: - - 'netbird.json' + - "netbird.json" jobs: dry-run: diff --git a/.gitea/workflows/reconcile.yml b/.gitea/workflows/reconcile.yml index d05529c..a0f9107 100644 --- a/.gitea/workflows/reconcile.yml +++ b/.gitea/workflows/reconcile.yml @@ -5,7 +5,7 @@ on: branches: - main paths: - - 'netbird.json' + - "netbird.json" jobs: reconcile: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index dd99bcc..e8f113d 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - 'v*' + - "v*" jobs: build: diff --git a/AGENTS.md b/AGENTS.md index 711d76d..437e990 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # Agent Instructions -This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started. +This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get +started. ## Quick Reference @@ -13,9 +14,11 @@ bd sync # Sync with git ``` + ## Issue Tracking with bd (beads) -**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. +**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT +use markdown TODOs, task lists, or other tracking methods. ### Why bd? @@ -101,11 +104,13 @@ For more details, see README.md and docs/QUICKSTART.md. ## Landing the Plane (Session Completion) -**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. +**When ending a work session**, you MUST complete ALL steps below. Work is NOT +complete until `git push` succeeds. **MANDATORY WORKFLOW:** -1. **File issues for remaining work** - Create issues for anything that needs follow-up +1. **File issues for remaining work** - Create issues for anything that needs + follow-up 2. **Run quality gates** (if code changed) - Tests, linters, builds 3. **Update issue status** - Close finished work, update in-progress items 4. **PUSH TO REMOTE** - This is MANDATORY: @@ -120,6 +125,7 @@ For more details, see README.md and docs/QUICKSTART.md. 7. **Hand off** - Provide context for next session **CRITICAL RULES:** + - Work is NOT complete until `git push` succeeds - NEVER stop before pushing - that leaves work stranded locally - NEVER say "ready to push when you are" - YOU must push diff --git a/deno.json b/deno.json index 7ff1383..57e489c 100644 --- a/deno.json +++ b/deno.json @@ -7,7 +7,8 @@ "test": "deno test --allow-net --allow-read --allow-write --allow-env", "check": "deno check src/main.ts", "lint": "deno lint", - "fmt": "deno fmt" + "fmt": "deno fmt", + "export": "deno run --allow-net --allow-env src/main.ts --export" }, "imports": { "@std/assert": "jsr:@std/assert@^1.0.0", diff --git a/deploy/.env.example b/deploy/.env.example index 08d2a89..89b22d3 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,8 +1,13 @@ NETBIRD_API_URL=https://netbird.example.com/api NETBIRD_API_TOKEN= + +# Set to false to run without Gitea integration (standalone mode). +# When false, GITEA_URL/TOKEN/REPO are not required. +GITEA_ENABLED=true GITEA_URL=https://gitea.example.com GITEA_TOKEN= GITEA_REPO=BlastPilot/netbird-gitops + RECONCILER_TOKEN= POLL_INTERVAL_SECONDS=30 PORT=8080 diff --git a/docs/plans/2026-03-03-netbird-reconciler-design.md b/docs/plans/2026-03-03-netbird-reconciler-design.md index d996edc..4d0ad10 100644 --- a/docs/plans/2026-03-03-netbird-reconciler-design.md +++ b/docs/plans/2026-03-03-netbird-reconciler-design.md @@ -1,25 +1,31 @@ # NetBird Reconciler — Design Document -> **Status:** Approved -> **Author:** @prox -> **Date:** 2026-03-03 -> **Proposal:** NetBird GitOps Proposal (rev2) +> **Status:** Approved **Author:** @prox **Date:** 2026-03-03 **Proposal:** +> NetBird GitOps Proposal (rev2) ## Overview -A dedicated backend service that provides declarative GitOps-driven reconciliation for NetBird VPN configuration. Engineers declare desired state in `netbird.json`; the reconciler computes diffs and applies changes with all-or-nothing semantics. +A dedicated backend service that provides declarative GitOps-driven +reconciliation for NetBird VPN configuration. Engineers declare desired state in +`netbird.json`; the reconciler computes diffs and applies changes with +all-or-nothing semantics. **Repo:** `BlastPilot/netbird-gitops` (service code + state file in one repo) -**Runtime:** TypeScript / Deno -**Deployment:** Docker Compose on the NetBird VPS, behind Traefik +**Runtime:** TypeScript / Deno **Deployment:** Docker Compose on the NetBird +VPS, behind Traefik ## Architecture The reconciler has two responsibilities: -1. **Reconciliation API** — Called by Gitea Actions CI on PR events. Accepts desired state (`netbird.json`), fetches actual state from NetBird API, computes a diff, and either returns a plan (dry-run) or applies changes. +1. **Reconciliation API** — Called by Gitea Actions CI on PR events. Accepts + desired state (`netbird.json`), fetches actual state from NetBird API, + computes a diff, and either returns a plan (dry-run) or applies changes. -2. **Event Poller** — Background loop polling NetBird `/api/events` every 30s to detect peer enrollments. When a peer enrolls via a known setup key, the poller renames it, assigns it to the correct group, and commits `enrolled: true` back to git via Gitea API. +2. **Event Poller** — Background loop polling NetBird `/api/events` every 30s to + detect peer enrollments. When a peer enrolls via a known setup key, the + poller renames it, assigns it to the correct group, and commits + `enrolled: true` back to git via Gitea API. ### Data Flow @@ -41,12 +47,17 @@ Event poller (background): The existing enrollment pipeline in `blastpilot-public` changes: -- **Before:** `handleApproval()` creates `peers/enrollment-{N}.json`, `handlePRMerge()` calls NetBird API directly to create setup keys, emails PDF. -- **After:** `handleApproval()` modifies `netbird.json` (adds setup key + group entries) and creates PR. Key creation is handled by the reconciler on merge. Key delivery starts as manual (engineer downloads encrypted artifact), with automation added later. +- **Before:** `handleApproval()` creates `peers/enrollment-{N}.json`, + `handlePRMerge()` calls NetBird API directly to create setup keys, emails PDF. +- **After:** `handleApproval()` modifies `netbird.json` (adds setup key + group + entries) and creates PR. Key creation is handled by the reconciler on merge. + Key delivery starts as manual (engineer downloads encrypted artifact), with + automation added later. ## State File Format -`netbird.json` at repo root. All resources referenced by name, never by NetBird ID. +`netbird.json` at repo root. All resources referenced by name, never by NetBird +ID. ```json { @@ -96,6 +107,7 @@ The existing enrollment pipeline in `blastpilot-public` changes: ``` **Conventions:** + - Setup key name = expected peer hostname - `enrolled: false` — setup key should exist, peer hasn't connected yet - `enrolled: true` — peer detected, renamed, assigned to groups @@ -109,24 +121,37 @@ All endpoints authenticated via `Authorization: Bearer `. ### `POST /reconcile` -**Query params:** `dry_run=true|false` (default: false) -**Request body:** Contents of `netbird.json` +**Query params:** `dry_run=true|false` (default: false) **Request body:** +Contents of `netbird.json` Behavior: -1. Fetch actual state from NetBird API (groups, setup keys, peers, policies, routes, DNS) + +1. Fetch actual state from NetBird API (groups, setup keys, peers, policies, + routes, DNS) 2. Process pending enrollments from event poller state 3. Compute diff between desired and actual 4. If `dry_run=true`: return plan without applying -5. If `dry_run=false`: execute in dependency order — groups, setup keys, peers, policies, routes. Abort on first failure. +5. If `dry_run=false`: execute in dependency order — groups, setup keys, peers, + policies, routes. Abort on first failure. Response: + ```json { "status": "applied | planned | error", "operations": [ { "type": "create_group", "name": "pilots", "status": "success" }, - { "type": "create_setup_key", "name": "Pilot-hawk-72", "status": "success" }, - { "type": "create_policy", "name": "pilots-to-gs", "status": "failed", "error": "..." } + { + "type": "create_setup_key", + "name": "Pilot-hawk-72", + "status": "success" + }, + { + "type": "create_policy", + "name": "pilots-to-gs", + "status": "failed", + "error": "..." + } ], "created_keys": { "Pilot-hawk-72": "XXXXXX-XXXXXX-XXXXXX" @@ -135,16 +160,23 @@ Response: } ``` -`created_keys` only contains keys created in this run. CI uses this for encrypted artifacts. +`created_keys` only contains keys created in this run. CI uses this for +encrypted artifacts. ### `POST /sync-events` -Forces the event poller to process pending events immediately. Returns detected enrollments. +Forces the event poller to process pending events immediately. Returns detected +enrollments. ```json { "enrollments": [ - { "setup_key_name": "GS-hawk-72", "peer_id": "abc123", "renamed": true, "groups_assigned": true } + { + "setup_key_name": "GS-hawk-72", + "peer_id": "abc123", + "renamed": true, + "groups_assigned": true + } ] } ``` @@ -156,22 +188,29 @@ No auth. Returns service status for Docker healthcheck. ## Event Poller **Mechanism:** -- Polls `GET /api/events` every 30 seconds (configurable via `POLL_INTERVAL_SECONDS`) + +- Polls `GET /api/events` every 30 seconds (configurable via + `POLL_INTERVAL_SECONDS`) - Persists `last_event_timestamp` to `/data/poller-state.json` (Docker volume) -- Loads last-known `netbird.json` desired state on startup and after each reconcile +- Loads last-known `netbird.json` desired state on startup and after each + reconcile **Enrollment detection:** + 1. Filter events for `peer.setupkey.add` activity 2. Extract `setup_key_name` from event metadata 3. Look up in desired state — if found and `enrolled: false`: - Rename peer to match setup key name via `PUT /api/peers/{id}` - Assign peer to groups from `setup_keys[name].auto_groups` - - Commit `enrolled: true` to git via Gitea API (optimistic concurrency with SHA check) + - Commit `enrolled: true` to git via Gitea API (optimistic concurrency with + SHA check) - Commit message: `chore: mark {key_name} as enrolled [automated]` 4. If not found: log warning (unknown peer enrolled outside GitOps) **Edge cases:** -- Race with reconcile: if reconcile is in progress, enrollment processing queues until complete + +- Race with reconcile: if reconcile is in progress, enrollment processing queues + until complete - Duplicate events: idempotent — skip if peer already renamed and enrolled - Unknown peers: logged but not touched @@ -182,10 +221,11 @@ No auth. Returns service status for Docker healthcheck. ```yaml on: pull_request: - paths: ['netbird.json'] + paths: ["netbird.json"] ``` Steps: + 1. Checkout PR branch 2. `POST /reconcile?dry_run=true` with `netbird.json` 3. Format response as markdown table @@ -197,25 +237,27 @@ Steps: on: push: branches: [main] - paths: ['netbird.json'] + paths: ["netbird.json"] ``` Steps: + 1. Checkout repo 2. `POST /sync-events` — process pending enrollments 3. `POST /reconcile` with `netbird.json` -4. If `created_keys` non-empty: encrypt with `age`, upload as Gitea Actions artifact +4. If `created_keys` non-empty: encrypt with `age`, upload as Gitea Actions + artifact 5. Pull latest (poller may have committed) 6. On failure: job fails, engineer investigates ### Gitea Secrets -| Secret | Purpose | -|--------|---------| -| `RECONCILER_URL` | Reconciler service URL | -| `RECONCILER_TOKEN` | Bearer token for CI auth | -| `AGE_PUBLIC_KEY` | Encrypts setup key artifacts | -| `GITEA_TOKEN` | PR comment posting (achilles-ci-bot) | +| Secret | Purpose | +| ------------------ | ------------------------------------ | +| `RECONCILER_URL` | Reconciler service URL | +| `RECONCILER_TOKEN` | Bearer token for CI auth | +| `AGE_PUBLIC_KEY` | Encrypts setup key artifacts | +| `GITEA_TOKEN` | PR comment posting (achilles-ci-bot) | ## Deployment @@ -248,20 +290,21 @@ services: ### Environment Variables -| Variable | Required | Description | -|----------|----------|-------------| -| `NETBIRD_API_URL` | yes | NetBird management API base URL | -| `NETBIRD_API_TOKEN` | yes | NetBird API token | -| `GITEA_URL` | yes | Gitea instance URL | -| `GITEA_TOKEN` | yes | Gitea API token for commits | -| `GITEA_REPO` | yes | `owner/repo` for netbird-gitops | -| `RECONCILER_TOKEN` | yes | Bearer token for CI auth | -| `POLL_INTERVAL_SECONDS` | no | Poll interval (default: 30) | -| `PORT` | no | Listen port (default: 8080) | +| Variable | Required | Description | +| ----------------------- | -------- | ------------------------------- | +| `NETBIRD_API_URL` | yes | NetBird management API base URL | +| `NETBIRD_API_TOKEN` | yes | NetBird API token | +| `GITEA_URL` | yes | Gitea instance URL | +| `GITEA_TOKEN` | yes | Gitea API token for commits | +| `GITEA_REPO` | yes | `owner/repo` for netbird-gitops | +| `RECONCILER_TOKEN` | yes | Bearer token for CI auth | +| `POLL_INTERVAL_SECONDS` | no | Poll interval (default: 30) | +| `PORT` | no | Listen port (default: 8080) | ### Container Image Build Tag-triggered CI (`v*`) in netbird-gitops: + 1. `deno compile` to single binary 2. Docker build (`FROM denoland/deno:distroless`) 3. Push to Gitea container registry @@ -269,29 +312,35 @@ Tag-triggered CI (`v*`) in netbird-gitops: ## Error Handling & Rollback **Validation phase (before mutations):** + - Parse and validate `netbird.json` schema - Fetch all actual state - Compute diff and verify all operations are possible - If validation fails: return error, no mutations **Apply phase:** + - Execute in dependency order (groups -> keys -> peers -> policies -> routes) - On any failure: abort immediately, return partial results - No automatic rollback — git revert is the rollback mechanism **Why no automatic rollback:** + - Partial rollback is harder to get right than partial apply - Git history provides clear, auditable rollback path - `git revert` + re-reconcile converges to correct state - Reconciler is idempotent — running twice with same state is safe **Recovery pattern:** + 1. Reconcile fails mid-apply 2. CI job fails, engineer notified -3. Engineer either forward-fixes `netbird.json` or `git revert`s the merge commit +3. Engineer either forward-fixes `netbird.json` or `git revert`s the merge + commit 4. New push triggers reconcile, converging to correct state **Logging:** + - Structured JSON logs - Every NetBird API call logged (method, path, status) - Every state mutation logged (before/after) @@ -299,11 +348,11 @@ Tag-triggered CI (`v*`) in netbird-gitops: ## Resources Managed -| Resource | NetBird API | Create | Update | Delete | -|----------|-------------|--------|--------|--------| -| Groups | `/api/groups` | yes | yes (peers) | yes | -| Setup Keys | `/api/setup-keys` | yes | no (immutable) | yes | -| Peers | `/api/peers` | no (self-enroll) | yes (rename, groups) | yes | -| Policies | `/api/policies` | yes | yes | yes | -| Routes | `/api/routes` | yes | yes | yes | -| DNS | `/api/dns/nameservers` | yes | yes | yes | +| Resource | NetBird API | Create | Update | Delete | +| ---------- | ---------------------- | ---------------- | -------------------- | ------ | +| Groups | `/api/groups` | yes | yes (peers) | yes | +| Setup Keys | `/api/setup-keys` | yes | no (immutable) | yes | +| Peers | `/api/peers` | no (self-enroll) | yes (rename, groups) | yes | +| Policies | `/api/policies` | yes | yes | yes | +| Routes | `/api/routes` | yes | yes | yes | +| DNS | `/api/dns/nameservers` | yes | yes | yes | diff --git a/docs/plans/2026-03-03-netbird-reconciler-implementation.md b/docs/plans/2026-03-03-netbird-reconciler-implementation.md index 32f70ca..b16bf53 100644 --- a/docs/plans/2026-03-03-netbird-reconciler-implementation.md +++ b/docs/plans/2026-03-03-netbird-reconciler-implementation.md @@ -1,18 +1,27 @@ # NetBird Reconciler Implementation Plan -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to +> implement this plan task-by-task. -**Goal:** Build a Deno-based HTTP service that reconciles NetBird VPN configuration from a declarative `netbird.json` state file, with event-driven peer enrollment detection and Gitea Actions CI integration. +**Goal:** Build a Deno-based HTTP service that reconciles NetBird VPN +configuration from a declarative `netbird.json` state file, with event-driven +peer enrollment detection and Gitea Actions CI integration. -**Architecture:** Three-layer design. A NetBird API client wraps all management API calls. A reconciliation engine diffs desired vs actual state and produces an ordered operation plan. An HTTP server exposes `/reconcile`, `/sync-events`, and `/health` endpoints. A background event poller detects peer enrollments and commits state updates via Gitea API. +**Architecture:** Three-layer design. A NetBird API client wraps all management +API calls. A reconciliation engine diffs desired vs actual state and produces an +ordered operation plan. An HTTP server exposes `/reconcile`, `/sync-events`, and +`/health` endpoints. A background event poller detects peer enrollments and +commits state updates via Gitea API. -**Tech Stack:** Deno 2.x, Zod (schema validation), Deno standard library (HTTP server), Docker +**Tech Stack:** Deno 2.x, Zod (schema validation), Deno standard library (HTTP +server), Docker --- ## Task 0: Scaffold project structure **Files:** + - Create: `deno.json` - Create: `src/main.ts` - Create: `src/config.ts` @@ -108,8 +117,7 @@ ENTRYPOINT ["reconciler"] **Step 6: Verify project compiles** -Run: `deno check src/main.ts` -Expected: no errors +Run: `deno check src/main.ts` Expected: no errors **Step 7: Commit** @@ -122,13 +130,15 @@ feat: scaffold netbird-reconciler project ## Task 1: NetBird API client — types and base client **Files:** + - Create: `src/netbird/types.ts` - Create: `src/netbird/client.ts` - Create: `src/netbird/client.test.ts` **Step 1: Define NetBird API response types in `src/netbird/types.ts`** -These types model the NetBird Management API responses. Only the fields we need for reconciliation are included. +These types model the NetBird Management API responses. Only the fields we need +for reconciliation are included. ```typescript /** Group as returned by GET /api/groups */ @@ -242,7 +252,8 @@ export interface NbEvent { **Step 2: Write test for base HTTP client in `src/netbird/client.test.ts`** -Test the client can be constructed and makes authenticated requests. Use a mock fetch pattern: inject a fetch function so tests don't hit a real API. +Test the client can be constructed and makes authenticated requests. Use a mock +fetch pattern: inject a fetch function so tests don't hit a real API. ```typescript import { assertEquals } from "jsr:@std/assert"; @@ -255,8 +266,8 @@ function mockFetch( const url = typeof input === "string" ? input : input instanceof URL - ? input.toString() - : input.url; + ? input.toString() + : input.url; const method = init?.method ?? "GET"; const key = `${method} ${url}`; const resp = responses.get(key); @@ -271,13 +282,24 @@ function mockFetch( } Deno.test("NetbirdClient.listGroups sends auth header and parses response", async () => { - const groups = [{ id: "g1", name: "pilots", peers_count: 1, peers: [], issued: "api" }]; + const groups = [{ + id: "g1", + name: "pilots", + peers_count: 1, + peers: [], + issued: "api", + }]; const client = new NetbirdClient( "https://nb.example.com/api", "test-token", - mockFetch(new Map([ - ["GET https://nb.example.com/api/groups", { status: 200, body: groups }], - ])), + mockFetch( + new Map([ + ["GET https://nb.example.com/api/groups", { + status: 200, + body: groups, + }], + ]), + ), ); const result = await client.listGroups(); assertEquals(result.length, 1); @@ -288,9 +310,14 @@ Deno.test("NetbirdClient throws on non-2xx response", async () => { const client = new NetbirdClient( "https://nb.example.com/api", "test-token", - mockFetch(new Map([ - ["GET https://nb.example.com/api/groups", { status: 401, body: { message: "unauthorized" } }], - ])), + mockFetch( + new Map([ + ["GET https://nb.example.com/api/groups", { + status: 401, + body: { message: "unauthorized" }, + }], + ]), + ), ); try { await client.listGroups(); @@ -303,8 +330,8 @@ Deno.test("NetbirdClient throws on non-2xx response", async () => { **Step 3: Run tests to verify they fail** -Run: `deno test src/netbird/client.test.ts` -Expected: FAIL — `NetbirdClient` not found +Run: `deno test src/netbird/client.test.ts` Expected: FAIL — `NetbirdClient` not +found **Step 4: Implement base client in `src/netbird/client.ts`** @@ -340,7 +367,11 @@ export class NetbirdClient { private readonly fetchFn: FetchFn = fetch, ) {} - private async request(method: string, path: string, body?: unknown): Promise { + private async request( + method: string, + path: string, + body?: unknown, + ): Promise { const url = `${this.baseUrl}${path}`; const headers: Record = { "Authorization": `Token ${this.token}`, @@ -371,7 +402,11 @@ export class NetbirdClient { return this.request("POST", "/groups", { name, peers }); } - updateGroup(id: string, name: string, peers: string[] = []): Promise { + updateGroup( + id: string, + name: string, + peers: string[] = [], + ): Promise { return this.request("PUT", `/groups/${id}`, { name, peers }); } @@ -546,8 +581,7 @@ export class NetbirdClient { **Step 5: Run tests** -Run: `deno test src/netbird/client.test.ts` -Expected: PASS +Run: `deno test src/netbird/client.test.ts` Expected: PASS **Step 6: Commit** @@ -560,6 +594,7 @@ feat: add NetBird API client with types and tests ## Task 2: State file schema and validation **Files:** + - Create: `src/state/schema.ts` - Create: `src/state/schema.test.ts` @@ -626,7 +661,9 @@ export const DesiredStateSchema = z.object({ routes: z.record(z.string(), RouteSchema).default({}), dns: z .object({ - nameserver_groups: z.record(z.string(), DnsNameserverGroupSchema).default({}), + nameserver_groups: z.record(z.string(), DnsNameserverGroupSchema).default( + {}, + ), }) .default({}), }); @@ -775,7 +812,8 @@ Deno.test("DesiredStateSchema parses valid state", () => { Deno.test("DesiredStateSchema rejects invalid setup key type", () => { const invalid = structuredClone(VALID_STATE); - (invalid.setup_keys["GS-hawk-72"] as Record).type = "invalid"; + (invalid.setup_keys["GS-hawk-72"] as Record).type = + "invalid"; assertThrows(() => DesiredStateSchema.parse(invalid)); }); @@ -806,8 +844,7 @@ Deno.test("validateCrossReferences catches peer without setup key", () => { **Step 3: Run tests** -Run: `deno test src/state/schema.test.ts` -Expected: PASS +Run: `deno test src/state/schema.test.ts` Expected: PASS **Step 4: Commit** @@ -820,17 +857,28 @@ feat: add desired state schema with Zod validation and cross-reference checks ## Task 3: Actual state fetcher **Files:** + - Create: `src/state/actual.ts` - Create: `src/state/actual.test.ts` -The actual state fetcher calls all NetBird list endpoints and normalizes the results into a structure that can be compared with the desired state. The key job is building bidirectional name<->ID mappings. +The actual state fetcher calls all NetBird list endpoints and normalizes the +results into a structure that can be compared with the desired state. The key +job is building bidirectional name<->ID mappings. **Step 1: Write test in `src/state/actual.test.ts`** ```typescript import { assertEquals } from "jsr:@std/assert"; import { fetchActualState } from "./actual.ts"; -import type { NbGroup, NbPeer, NbPolicy, NbRoute, NbSetupKey, NbDnsNameserverGroup, NbEvent } from "../netbird/types.ts"; +import type { + NbDnsNameserverGroup, + NbEvent, + NbGroup, + NbPeer, + NbPolicy, + NbRoute, + NbSetupKey, +} from "../netbird/types.ts"; /** Minimal mock NetBird client that returns predetermined data */ function mockClient(data: { @@ -859,9 +907,17 @@ Deno.test("fetchActualState builds name-to-id maps", async () => { ], setupKeys: [ { - id: 1, name: "Pilot-hawk-72", type: "one-off", key: "masked", - expires: "2026-04-01T00:00:00Z", valid: true, revoked: false, - used_times: 0, state: "valid", auto_groups: ["g1"], usage_limit: 1, + id: 1, + name: "Pilot-hawk-72", + type: "one-off", + key: "masked", + expires: "2026-04-01T00:00:00Z", + valid: true, + revoked: false, + used_times: 0, + state: "valid", + auto_groups: ["g1"], + usage_limit: 1, }, ], })); @@ -920,7 +976,9 @@ type ClientLike = Pick< | "listDnsNameserverGroups" >; -export async function fetchActualState(client: ClientLike): Promise { +export async function fetchActualState( + client: ClientLike, +): Promise { const [groups, setupKeys, peers, policies, routes, dns] = await Promise.all([ client.listGroups(), client.listSetupKeys(), @@ -956,8 +1014,7 @@ export async function fetchActualState(client: ClientLike): Promise **Step 4: Run tests** -Run: `deno test src/state/actual.test.ts` -Expected: PASS +Run: `deno test src/state/actual.test.ts` Expected: PASS **Step 5: Commit** @@ -970,11 +1027,13 @@ feat: add actual state fetcher with name/ID indexing ## Task 4: Diff engine — compute operations **Files:** + - Create: `src/reconcile/diff.ts` - Create: `src/reconcile/operations.ts` - Create: `src/reconcile/diff.test.ts` -This is the core reconciliation logic. It compares desired state against actual state and produces an ordered list of operations. +This is the core reconciliation logic. It compares desired state against actual +state and produces an ordered list of operations. **Step 1: Define operation types in `src/reconcile/operations.ts`** @@ -1048,12 +1107,20 @@ import type { ActualState } from "../state/actual.ts"; function emptyActual(): ActualState { return { - groups: [], groupsByName: new Map(), groupsById: new Map(), - setupKeys: [], setupKeysByName: new Map(), - peers: [], peersByName: new Map(), peersById: new Map(), - policies: [], policiesByName: new Map(), - routes: [], routesByNetworkId: new Map(), - dns: [], dnsByName: new Map(), + groups: [], + groupsByName: new Map(), + groupsById: new Map(), + setupKeys: [], + setupKeysByName: new Map(), + peers: [], + peersByName: new Map(), + peersById: new Map(), + policies: [], + policiesByName: new Map(), + routes: [], + routesByNetworkId: new Map(), + dns: [], + dnsByName: new Map(), }; } @@ -1061,8 +1128,11 @@ const DESIRED: DesiredState = { groups: { pilots: { peers: ["Pilot-hawk-72"] } }, setup_keys: { "Pilot-hawk-72": { - type: "one-off", expires_in: 604800, usage_limit: 1, - auto_groups: ["pilots"], enrolled: false, + type: "one-off", + expires_in: 604800, + usage_limit: 1, + auto_groups: ["pilots"], + enrolled: false, }, }, policies: {}, @@ -1080,14 +1150,25 @@ Deno.test("computeDiff against empty actual produces create ops", () => { Deno.test("computeDiff with matching state produces no ops", () => { const actual = emptyActual(); actual.groupsByName.set("pilots", { - id: "g1", name: "pilots", peers_count: 1, - peers: [{ id: "p1", name: "Pilot-hawk-72" }], issued: "api", + id: "g1", + name: "pilots", + peers_count: 1, + peers: [{ id: "p1", name: "Pilot-hawk-72" }], + issued: "api", }); actual.groups = [actual.groupsByName.get("pilots")!]; actual.setupKeysByName.set("Pilot-hawk-72", { - id: 1, name: "Pilot-hawk-72", type: "one-off", key: "masked", - expires: "2026-04-01T00:00:00Z", valid: true, revoked: false, - used_times: 0, state: "valid", auto_groups: ["g1"], usage_limit: 1, + id: 1, + name: "Pilot-hawk-72", + type: "one-off", + key: "masked", + expires: "2026-04-01T00:00:00Z", + valid: true, + revoked: false, + used_times: 0, + state: "valid", + auto_groups: ["g1"], + usage_limit: 1, }); actual.setupKeys = [actual.setupKeysByName.get("Pilot-hawk-72")!]; const ops = computeDiff(DESIRED, actual); @@ -1101,7 +1182,8 @@ Run: `deno test src/reconcile/diff.test.ts` **Step 4: Implement `src/reconcile/diff.ts`** -This is a large module. The diff compares each resource type and produces operations. +This is a large module. The diff compares each resource type and produces +operations. ```typescript import type { DesiredState } from "../state/schema.ts"; @@ -1139,7 +1221,11 @@ export function computeDiff( // Groups in actual but not in desired — delete (only API-issued, not system groups) for (const group of actual.groups) { if (!desiredGroupNames.has(group.name) && group.issued === "api") { - ops.push({ type: "delete_group", name: group.name, details: { id: group.id } }); + ops.push({ + type: "delete_group", + name: group.name, + details: { id: group.id }, + }); } } @@ -1167,7 +1253,11 @@ export function computeDiff( } for (const key of actual.setupKeys) { if (!desiredKeyNames.has(key.name)) { - ops.push({ type: "delete_setup_key", name: key.name, details: { id: key.id } }); + ops.push({ + type: "delete_setup_key", + name: key.name, + details: { id: key.id }, + }); } } @@ -1234,7 +1324,11 @@ export function computeDiff( } for (const policy of actual.policies) { if (!desiredPolicyNames.has(policy.name)) { - ops.push({ type: "delete_policy", name: policy.name, details: { id: policy.id } }); + ops.push({ + type: "delete_policy", + name: policy.name, + details: { id: policy.id }, + }); } } @@ -1304,7 +1398,8 @@ export function computeDiff( } else { const needsUpdate = existing.enabled !== dns.enabled || existing.primary !== dns.primary || - JSON.stringify(existing.nameservers) !== JSON.stringify(dns.nameservers); + JSON.stringify(existing.nameservers) !== + JSON.stringify(dns.nameservers); if (needsUpdate) { ops.push({ type: "update_dns", @@ -1329,8 +1424,7 @@ export function computeDiff( **Step 5: Run tests** -Run: `deno test src/reconcile/diff.test.ts` -Expected: PASS +Run: `deno test src/reconcile/diff.test.ts` Expected: PASS **Step 6: Commit** @@ -1343,10 +1437,13 @@ feat: add diff engine computing operations from desired vs actual state ## Task 5: Operation executor **Files:** + - Create: `src/reconcile/executor.ts` - Create: `src/reconcile/executor.test.ts` -The executor takes a list of operations and applies them against the NetBird API. It resolves names to IDs (since the diff uses names but the API needs IDs), executes in order, and aborts on first failure. +The executor takes a list of operations and applies them against the NetBird +API. It resolves names to IDs (since the diff uses names but the API needs IDs), +executes in order, and aborts on first failure. **Step 1: Write test in `src/reconcile/executor.test.ts`** @@ -1363,13 +1460,23 @@ Deno.test("executor calls createGroup for create_group op", async () => { const mockClient = { createGroup: (name: string) => { calls.push(`createGroup:${name}`); - return Promise.resolve({ id: "new-g1", name, peers_count: 0, peers: [], issued: "api" as const }); + return Promise.resolve({ + id: "new-g1", + name, + peers_count: 0, + peers: [], + issued: "api" as const, + }); }, }; const ops: Operation[] = [ { type: "create_group", name: "pilots" }, ]; - const results = await executeOperations(ops, mockClient as never, emptyActual()); + const results = await executeOperations( + ops, + mockClient as never, + emptyActual(), + ); assertEquals(calls, ["createGroup:pilots"]); assertEquals(results[0].status, "success"); }); @@ -1383,19 +1490,31 @@ Deno.test("executor aborts on first failure", async () => { { type: "create_group", name: "pilots" }, { type: "create_setup_key", name: "key1" }, ]; - const results = await executeOperations(ops, mockClient as never, emptyActual()); + const results = await executeOperations( + ops, + mockClient as never, + emptyActual(), + ); assertEquals(results[0].status, "failed"); assertEquals(results.length, 1); // second op never executed }); function emptyActual(): ActualState { return { - groups: [], groupsByName: new Map(), groupsById: new Map(), - setupKeys: [], setupKeysByName: new Map(), - peers: [], peersByName: new Map(), peersById: new Map(), - policies: [], policiesByName: new Map(), - routes: [], routesByNetworkId: new Map(), - dns: [], dnsByName: new Map(), + groups: [], + groupsByName: new Map(), + groupsById: new Map(), + setupKeys: [], + setupKeysByName: new Map(), + peers: [], + peersByName: new Map(), + peersById: new Map(), + policies: [], + policiesByName: new Map(), + routes: [], + routesByNetworkId: new Map(), + dns: [], + dnsByName: new Map(), }; } ``` @@ -1404,7 +1523,10 @@ function emptyActual(): ActualState { **Step 3: Implement `src/reconcile/executor.ts`** -The executor is a large switch/case that dispatches each operation type to the correct client method. It needs the actual state to resolve group name -> ID for policies/routes, and it tracks newly created group IDs to use in subsequent operations. +The executor is a large switch/case that dispatches each operation type to the +correct client method. It needs the actual state to resolve group name -> ID for +policies/routes, and it tracks newly created group IDs to use in subsequent +operations. ```typescript import type { NetbirdClient } from "../netbird/client.ts"; @@ -1555,7 +1677,9 @@ export async function executeOperations( case "create_route": { const d = op.details!; const peerGroupIds = resolveGroupIds(d.peer_groups as string[]); - const distGroupIds = resolveGroupIds(d.distribution_groups as string[]); + const distGroupIds = resolveGroupIds( + d.distribution_groups as string[], + ); await client.createRoute({ description: (d.description as string) ?? "", network_id: op.name, @@ -1573,7 +1697,9 @@ export async function executeOperations( case "update_route": { const d = op.details!; const peerGroupIds = resolveGroupIds(d.peer_groups as string[]); - const distGroupIds = resolveGroupIds(d.distribution_groups as string[]); + const distGroupIds = resolveGroupIds( + d.distribution_groups as string[], + ); await client.updateRoute(d.id as string, { description: (d.description as string) ?? "", network_id: op.name, @@ -1598,12 +1724,15 @@ export async function executeOperations( await client.createDnsNameserverGroup({ name: op.name, description: (d.description as string) ?? "", - nameservers: d.nameservers as Array<{ ip: string; ns_type: string; port: number }>, + nameservers: d.nameservers as Array< + { ip: string; ns_type: string; port: number } + >, enabled: d.enabled as boolean, groups: groupIds, primary: d.primary as boolean, domains: d.domains as string[], - search_domains_enabled: (d.search_domains_enabled as boolean) ?? false, + search_domains_enabled: (d.search_domains_enabled as boolean) ?? + false, }); break; } @@ -1613,12 +1742,15 @@ export async function executeOperations( await client.updateDnsNameserverGroup(d.id as string, { name: op.name, description: (d.description as string) ?? "", - nameservers: d.nameservers as Array<{ ip: string; ns_type: string; port: number }>, + nameservers: d.nameservers as Array< + { ip: string; ns_type: string; port: number } + >, enabled: d.enabled as boolean, groups: groupIds, primary: d.primary as boolean, domains: d.domains as string[], - search_domains_enabled: (d.search_domains_enabled as boolean) ?? false, + search_domains_enabled: (d.search_domains_enabled as boolean) ?? + false, }); break; } @@ -1656,7 +1788,8 @@ export function getCreatedKeys( } ``` -Note: The `createdKeys` map is local to `executeOperations` right now. Refactor the return type to include it: +Note: The `createdKeys` map is local to `executeOperations` right now. Refactor +the return type to include it: ```typescript export interface ExecutionResult { @@ -1665,12 +1798,12 @@ export interface ExecutionResult { } ``` -Update the function signature and return accordingly. The test should verify `createdKeys` is populated when a setup key is created. +Update the function signature and return accordingly. The test should verify +`createdKeys` is populated when a setup key is created. **Step 4: Run tests** -Run: `deno test src/reconcile/executor.test.ts` -Expected: PASS +Run: `deno test src/reconcile/executor.test.ts` Expected: PASS **Step 5: Commit** @@ -1683,6 +1816,7 @@ feat: add operation executor with abort-on-failure semantics ## Task 6: Event poller **Files:** + - Create: `src/poller/poller.ts` - Create: `src/poller/poller.test.ts` @@ -1831,8 +1965,7 @@ export function processEnrollmentEvents( **Step 4: Run tests** -Run: `deno test src/poller/poller.test.ts` -Expected: PASS +Run: `deno test src/poller/poller.test.ts` Expected: PASS **Step 5: Commit** @@ -1845,6 +1978,7 @@ feat: add enrollment event detection from NetBird audit events ## Task 7: Gitea API client (for state commits) **Files:** + - Create: `src/gitea/client.ts` - Create: `src/gitea/client.test.ts` @@ -1861,8 +1995,8 @@ function mockFetch( const url = typeof input === "string" ? input : input instanceof URL - ? input.toString() - : input.url; + ? input.toString() + : input.url; const method = init?.method ?? "GET"; const key = `${method} ${url}`; const resp = responses.get(key); @@ -1881,18 +2015,20 @@ Deno.test("GiteaClient.getFileContent fetches file with SHA", async () => { "https://gitea.example.com", "test-token", "BlastPilot/netbird-gitops", - mockFetch(new Map([ - [ - "GET https://gitea.example.com/api/v1/repos/BlastPilot/netbird-gitops/contents/netbird.json?ref=main", - { - status: 200, - body: { - content: btoa('{"groups":{}}'), - sha: "abc123", + mockFetch( + new Map([ + [ + "GET https://gitea.example.com/api/v1/repos/BlastPilot/netbird-gitops/contents/netbird.json?ref=main", + { + status: 200, + body: { + content: btoa('{"groups":{}}'), + sha: "abc123", + }, }, - }, - ], - ])), + ], + ]), + ), ); const result = await client.getFileContent("netbird.json", "main"); assertEquals(result.sha, "abc123"); @@ -1935,7 +2071,9 @@ export class GiteaClient { }); if (!resp.ok) { const text = await resp.text().catch(() => ""); - throw new Error(`Gitea API ${method} ${path} returned ${resp.status}: ${text}`); + throw new Error( + `Gitea API ${method} ${path} returned ${resp.status}: ${text}`, + ); } return resp.json() as Promise; } @@ -1991,8 +2129,7 @@ export class GiteaClient { **Step 4: Run tests** -Run: `deno test src/gitea/client.test.ts` -Expected: PASS +Run: `deno test src/gitea/client.test.ts` Expected: PASS **Step 5: Commit** @@ -2005,10 +2142,12 @@ feat: add Gitea API client for state commits and PR comments ## Task 8: Poller background loop with Gitea commit **Files:** + - Create: `src/poller/loop.ts` - Create: `src/poller/state.ts` -This task wires the enrollment detection from Task 6 to the Gitea client from Task 7, creating the full background polling loop. +This task wires the enrollment detection from Task 6 to the Gitea client from +Task 7, creating the full background polling loop. **Step 1: Create `src/poller/state.ts`** — persists poll state to disk @@ -2060,7 +2199,9 @@ export interface PollerContext { export async function pollOnce(ctx: PollerContext): Promise { if (ctx.reconcileInProgress.value) { - console.log(JSON.stringify({ msg: "poll_deferred", reason: "reconcile_in_progress" })); + console.log( + JSON.stringify({ msg: "poll_deferred", reason: "reconcile_in_progress" }), + ); return; } @@ -2210,12 +2351,14 @@ feat: add poller background loop with Gitea state commit ## Task 9: HTTP server **Files:** + - Modify: `src/main.ts` - Create: `src/server.ts` **Step 1: Create `src/server.ts`** -This is the HTTP server with three endpoints. It wires together all the components. +This is the HTTP server with three endpoints. It wires together all the +components. ```typescript import type { Config } from "./config.ts"; @@ -2225,7 +2368,7 @@ 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 { pollOnce, type PollerContext } from "./poller/loop.ts"; +import { type PollerContext, pollOnce } from "./poller/loop.ts"; export interface ServerContext { config: Config; @@ -2306,7 +2449,9 @@ async function handleReconcile( type: op.type, name: op.name, })), - summary: summarize(operations.map((op) => ({ ...op, status: "pending" }))), + summary: summarize( + operations.map((op) => ({ ...op, status: "pending" })), + ), }); } @@ -2366,10 +2511,14 @@ function summarize( ): { created: number; updated: number; deleted: number; failed: number } { let created = 0, updated = 0, deleted = 0, failed = 0; for (const r of results) { - if (r.status === "failed") { failed++; continue; } + if (r.status === "failed") { + failed++; + continue; + } if (r.type.startsWith("create_")) created++; - else if (r.type.startsWith("update_") || r.type === "rename_peer") updated++; - else if (r.type.startsWith("delete_")) deleted++; + else if (r.type.startsWith("update_") || r.type === "rename_peer") { + updated++; + } else if (r.type.startsWith("delete_")) deleted++; } return { created, updated, deleted, failed }; } @@ -2425,8 +2574,7 @@ Deno.addSignalListener("SIGTERM", () => { **Step 3: Verify compilation** -Run: `deno check src/main.ts` -Expected: no errors +Run: `deno check src/main.ts` Expected: no errors **Step 4: Commit** @@ -2439,6 +2587,7 @@ feat: add HTTP server with /reconcile, /sync-events, /health endpoints ## Task 10: CI workflow files **Files:** + - Create: `.gitea/workflows/dry-run.yml` - Create: `.gitea/workflows/reconcile.yml` - Create: `.gitea/workflows/release.yml` @@ -2451,7 +2600,7 @@ name: Dry Run on: pull_request: paths: - - 'netbird.json' + - "netbird.json" jobs: dry-run: @@ -2520,7 +2669,7 @@ on: branches: - main paths: - - 'netbird.json' + - "netbird.json" jobs: reconcile: @@ -2587,7 +2736,7 @@ name: Release on: push: tags: - - 'v*' + - "v*" jobs: build: @@ -2624,11 +2773,14 @@ feat: add Gitea Actions CI workflows for dry-run, reconcile, and release ## Task 11: Seed `netbird.json` with initial state **Files:** + - Create: `netbird.json` **Step 1: Create the initial state file** -This should reflect the current BlastPilot NetBird configuration. Start minimal — populate with actual groups/policies after deploying the reconciler and importing existing state. +This should reflect the current BlastPilot NetBird configuration. Start minimal +— populate with actual groups/policies after deploying the reconciler and +importing existing state. ```json { @@ -2653,6 +2805,7 @@ feat: add empty netbird.json state file ## Task 12: Docker Compose deployment config **Files:** + - Create: `deploy/docker-compose.yml` - Create: `deploy/.env.example` @@ -2707,9 +2860,12 @@ feat: add Docker Compose deployment config ## Task 13: Integration test with mock NetBird server **Files:** + - Create: `src/integration.test.ts` -Write an end-to-end test that starts the HTTP server, posts a reconcile request with a known desired state against a mock NetBird API, and verifies the correct API calls were made. +Write an end-to-end test that starts the HTTP server, posts a reconcile request +with a known desired state against a mock NetBird API, and verifies the correct +API calls were made. **Step 1: Write integration test** @@ -2742,7 +2898,9 @@ function createMockNetbird(): { client: NetbirdClient; calls: ApiCall[] } { const mockFetch: typeof fetch = async (input, init) => { const url = typeof input === "string" ? input - : input instanceof URL ? input.toString() : input.url; + : input instanceof URL + ? input.toString() + : input.url; const path = url.replace("https://nb.test/api", ""); const method = init?.method ?? "GET"; calls.push({ @@ -2755,14 +2913,25 @@ function createMockNetbird(): { client: NetbirdClient; calls: ApiCall[] } { if (method === "POST" && path === "/groups") { const body = JSON.parse(init?.body as string); return new Response( - JSON.stringify({ id: `g-${body.name}`, name: body.name, peers_count: 0, peers: [], issued: "api" }), + JSON.stringify({ + id: `g-${body.name}`, + name: body.name, + peers_count: 0, + peers: [], + issued: "api", + }), { status: 201, headers: { "Content-Type": "application/json" } }, ); } if (method === "POST" && path === "/setup-keys") { const body = JSON.parse(init?.body as string); return new Response( - JSON.stringify({ id: 1, name: body.name, key: "TEST-KEY-12345", state: "valid" }), + JSON.stringify({ + id: 1, + name: body.name, + key: "TEST-KEY-12345", + state: "valid", + }), { status: 201, headers: { "Content-Type": "application/json" } }, ); } @@ -2777,7 +2946,9 @@ function createMockNetbird(): { client: NetbirdClient; calls: ApiCall[] } { const key = `${method} ${path}`; const resp = responses.get(key); if (!resp) { - return new Response(JSON.stringify({ message: "not found" }), { status: 404 }); + return new Response(JSON.stringify({ message: "not found" }), { + status: 404, + }); } return new Response(JSON.stringify(resp.body), { status: resp.status, @@ -2901,8 +3072,7 @@ Deno.test("POST /reconcile rejects unauthorized requests", async () => { **Step 2: Run tests** -Run: `deno test src/integration.test.ts` -Expected: PASS +Run: `deno test src/integration.test.ts` Expected: PASS **Step 3: Commit** @@ -2915,14 +3085,17 @@ test: add integration tests for reconcile HTTP endpoint ## Task 14: Update blastpilot-public enrollment pipeline **Files:** + - Modify: `../blastpilot-public/api/src/services/enrollment-pipeline.ts` - Modify: `../blastpilot-public/api/src/services/netbird.ts` -This task modifies the enrollment pipeline to write to `netbird.json` in the `netbird-gitops` repo instead of creating `peers/enrollment-{N}.json` files. +This task modifies the enrollment pipeline to write to `netbird.json` in the +`netbird-gitops` repo instead of creating `peers/enrollment-{N}.json` files. **Step 1: Update `handleApproval()` in enrollment-pipeline.ts** Change from creating a standalone peer JSON file to modifying `netbird.json`: + - Fetch current `netbird.json` from `netbird-gitops` repo via Gitea API - Add setup key entries for GS and Pilot - Add peer references to appropriate groups @@ -2930,7 +3103,8 @@ Change from creating a standalone peer JSON file to modifying `netbird.json`: **Step 2: Remove direct NetBird API calls from `handlePRMerge()`** -The reconciler now handles key creation. `handlePRMerge()` should be simplified or removed (key delivery is manual for now). +The reconciler now handles key creation. `handlePRMerge()` should be simplified +or removed (key delivery is manual for now). **Step 3: Update tests** diff --git a/docs/plans/2026-03-06-reconciler-poc-implementation.md b/docs/plans/2026-03-06-reconciler-poc-implementation.md new file mode 100644 index 0000000..1f3be6c --- /dev/null +++ b/docs/plans/2026-03-06-reconciler-poc-implementation.md @@ -0,0 +1,1375 @@ +# Reconciler PoC Validation — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to +> implement this plan task-by-task. + +**Goal:** Validate the reconciler end-to-end on a fresh, isolated NetBird +instance (VPS-A) with state export and a `GITEA_ENABLED` feature flag. + +**Architecture:** Deploy NetBird + Caddy + Gitea + Reconciler on VPS-A via +Ansible. Add `GITEA_ENABLED` flag to make Gitea optional. Add state export tool +(`GET /export` + `--export` CLI). Test reconcile, enrollment detection, and +state round-trip. + +**Tech Stack:** Deno 2.x / TypeScript, Zod, Docker Compose, Ansible, Caddy, +NetBird self-hosted, Gitea + +**Design doc:** `docs/plans/2026-03-06-reconciler-poc-validation.md` + +--- + +## Task 1: `GITEA_ENABLED` Feature Flag — Config + +Make Gitea env vars conditionally required based on a new `GITEA_ENABLED` env +var. + +**Files:** + +- Modify: `src/config.ts` +- Test: `src/config.test.ts` (create) + +**Step 1: Write config tests** + +Create `src/config.test.ts`: + +```typescript +import { assertEquals, assertThrows } from "@std/assert"; + +// We can't easily test loadConfig() directly because it reads Deno.env. +// Instead, test the schema validation logic by extracting it. +// For now, integration-level tests via the main module are sufficient. +// The key behavioral test is: does the service start without GITEA_* vars +// when GITEA_ENABLED=false? + +// This is tested implicitly via integration.test.ts updates in Task 3. +``` + +Actually — `loadConfig()` reads directly from `Deno.env`, making unit tests +awkward. The feature flag is better tested at integration level. Skip unit test +file creation; test via integration tests in Task 3. + +**Step 2: Modify `src/config.ts`** + +Current code has a single `ConfigSchema` that requires all Gitea fields. Split +into base + conditional: + +```typescript +import { z } from "zod"; + +const BaseConfigSchema = z.object({ + netbirdApiUrl: z.string().url(), + netbirdApiToken: z.string().min(1), + reconcilerToken: z.string().min(1), + giteaEnabled: z.coerce.boolean().default(true), + pollIntervalSeconds: z.coerce.number().int().positive().default(30), + port: z.coerce.number().int().positive().default(8080), + dataDir: z.string().default("/data"), +}); + +const GiteaConfigSchema = z.object({ + giteaUrl: z.string().url(), + giteaToken: z.string().min(1), + giteaRepo: z.string().regex(/^[^/]+\/[^/]+$/), +}); + +// When giteaEnabled=true, Gitea fields are present. +// When false, they are undefined. +export type Config = z.infer & { + giteaUrl?: string; + giteaToken?: string; + giteaRepo?: string; +}; + +export function loadConfig(): Config { + const raw = { + netbirdApiUrl: Deno.env.get("NETBIRD_API_URL"), + netbirdApiToken: Deno.env.get("NETBIRD_API_TOKEN"), + reconcilerToken: Deno.env.get("RECONCILER_TOKEN"), + giteaEnabled: Deno.env.get("GITEA_ENABLED"), + pollIntervalSeconds: Deno.env.get("POLL_INTERVAL_SECONDS"), + port: Deno.env.get("PORT"), + dataDir: Deno.env.get("DATA_DIR"), + }; + + const base = BaseConfigSchema.parse(raw); + + if (base.giteaEnabled) { + const giteaRaw = { + giteaUrl: Deno.env.get("GITEA_URL"), + giteaToken: Deno.env.get("GITEA_TOKEN"), + giteaRepo: Deno.env.get("GITEA_REPO"), + }; + const gitea = GiteaConfigSchema.parse(giteaRaw); + return { ...base, ...gitea }; + } + + return base; +} +``` + +Key behavior: `GITEA_ENABLED` defaults to `true` (backward compat). When +`false`, `GITEA_URL`, `GITEA_TOKEN`, `GITEA_REPO` are not required and not +validated. `z.coerce.boolean()` converts string `"false"` → `false`. + +**Step 3: Run type checker** + +Run: `deno task check` Expected: Type errors in `main.ts` and `server.ts` +because `Config` fields are now optional. Fix in next tasks. + +**Step 4: Commit** + +``` +feat: make Gitea env vars conditional via GITEA_ENABLED flag +``` + +--- + +## Task 2: `GITEA_ENABLED` — Wire Into Main and Server + +Update `main.ts`, `server.ts`, and `poller/loop.ts` to handle +`giteaEnabled=false`. + +**Files:** + +- Modify: `src/main.ts` +- Modify: `src/server.ts` +- Modify: `src/poller/loop.ts` + +**Step 1: Update `src/main.ts`** + +Conditionally create Gitea client and poller: + +```typescript +import { ZodError } from "zod"; +import { loadConfig } from "./config.ts"; +import { NetbirdClient } from "./netbird/client.ts"; +import { GiteaClient } from "./gitea/client.ts"; +import { createHandler } from "./server.ts"; +import { startPollerLoop } from "./poller/loop.ts"; + +let config; +try { + config = loadConfig(); +} catch (err) { + if (err instanceof ZodError) { + console.error( + JSON.stringify({ msg: "invalid config", issues: err.issues }), + ); + Deno.exit(1); + } + throw err; +} + +const netbird = new NetbirdClient(config.netbirdApiUrl, config.netbirdApiToken); + +const gitea = config.giteaEnabled + ? new GiteaClient(config.giteaUrl!, config.giteaToken!, config.giteaRepo!) + : null; + +const reconcileInProgress = { value: false }; + +// Start background poller +const pollerAbort = startPollerLoop({ + config, + netbird, + gitea, + reconcileInProgress, +}); + +// Start HTTP server +const handler = createHandler({ config, netbird, gitea, reconcileInProgress }); +console.log(JSON.stringify({ + msg: "starting", + port: config.port, + gitea_enabled: config.giteaEnabled, +})); +Deno.serve({ port: config.port, handler }); + +// Graceful shutdown +Deno.addSignalListener("SIGTERM", () => { + console.log(JSON.stringify({ msg: "shutting_down" })); + pollerAbort.abort(); + Deno.exit(0); +}); +``` + +**Step 2: Update `ServerContext` in `src/server.ts`** + +Change `gitea` field to nullable: + +```typescript +export interface ServerContext { + config: Config; + netbird: NetbirdClient; + gitea: GiteaClient | null; + reconcileInProgress: { value: boolean }; +} +``` + +The `handleReconcile` function does NOT use `gitea` — it receives desired state +from the request body. No changes needed there. + +`handleSyncEvents` uses `gitea` via `pollOnce`. Guard it: + +```typescript +async function handleSyncEvents(ctx: ServerContext): Promise { + if (!ctx.gitea) { + // Still allow sync-events — poller will work without Gitea commit-back + } + const pollerCtx: PollerContext = { + config: ctx.config, + netbird: ctx.netbird, + gitea: ctx.gitea, + reconcileInProgress: { value: false }, + }; + // ... rest unchanged +} +``` + +**Step 3: Update `PollerContext` in `src/poller/loop.ts`** + +Make `gitea` nullable. Guard all Gitea calls: + +```typescript +export interface PollerContext { + config: Config; + netbird: NetbirdClient; + gitea: GiteaClient | null; + reconcileInProgress: { value: boolean }; +} +``` + +In `pollOnce()`: + +- If `gitea` is null, skip fetching `netbird.json` from Gitea. Instead, read + from local file at `{dataDir}/netbird.json` (or skip polling entirely if no + local file exists). +- Actually — the simplest approach: **if gitea is null, the poller still runs** + but only detects enrollment events and renames peers. It does NOT read desired + state or commit back. It uses the set of ALL setup key names from the last + reconcile call (which we don't have in the poller context). + +Revised approach for `GITEA_ENABLED=false`: + +The poller needs to know which setup keys exist to detect enrollments. Without +Gitea, it can't read `netbird.json`. Two options: + +1. Read from a local file at `{dataDir}/netbird.json`. +2. Fetch setup keys directly from NetBird API (they're already there after + reconcile). + +Option 2 is cleaner — the reconciler just created these keys, they're in +NetBird: + +```typescript +export async function pollOnce(ctx: PollerContext): Promise { + const { config, netbird, gitea, reconcileInProgress } = ctx; + + if (reconcileInProgress.value) { + console.log( + JSON.stringify({ msg: "poll_skipped", reason: "reconcile_in_progress" }), + ); + return; + } + + const pollerState = await loadPollerState(config.dataDir); + + // Determine unenrolled setup keys + let unenrolledKeys: Set; + let desired: DesiredState | null = null; + let fileSha: string | null = null; + + if (gitea) { + // Full Gitea mode: read netbird.json from repo + const file = await gitea.getFileContent("netbird.json", "main"); + desired = DesiredStateSchema.parse(JSON.parse(file.content)); + fileSha = file.sha; + unenrolledKeys = new Set(); + for ( + const [name, key] of Object.entries(desired.setup_keys) as [ + string, + SetupKeyConfig, + ][] + ) { + if (!key.enrolled) unenrolledKeys.add(name); + } + } else { + // Standalone mode: get setup key names from NetBird API directly + const keys = await netbird.listSetupKeys(); + unenrolledKeys = new Set(); + for (const key of keys) { + // Consider unused keys as "unenrolled" + if (key.valid && !key.revoked && key.used_times < key.usage_limit) { + unenrolledKeys.add(key.name); + } + } + } + + if (unenrolledKeys.size === 0) { + console.log(JSON.stringify({ msg: "poll_no_unenrolled_keys" })); + return; + } + + const events = await netbird.listEvents(); + const enrollments = processEnrollmentEvents( + events, + unenrolledKeys, + pollerState.lastEventTimestamp, + ); + + if (enrollments.length === 0) return; + + console.log(JSON.stringify({ + msg: "poll_enrollments_detected", + count: enrollments.length, + })); + + let latestTimestamp = pollerState.lastEventTimestamp; + + for (const enrollment of enrollments) { + if (gitea && desired && fileSha) { + // Full mode: rename peer + commit enrolled:true + await processEnrollment( + ctx, + enrollment, + desired, + fileSha, + (newSha, newDesired) => { + fileSha = newSha; + desired = newDesired; + }, + ); + } else { + // Standalone mode: rename peer only, log enrollment + await processEnrollmentStandalone(netbird, enrollment); + } + + if (!latestTimestamp || enrollment.timestamp > latestTimestamp) { + latestTimestamp = enrollment.timestamp; + } + } + + await savePollerState(config.dataDir, { + lastEventTimestamp: latestTimestamp, + }); +} +``` + +Add a new standalone enrollment processor (same file, after +`processEnrollment`): + +```typescript +async function processEnrollmentStandalone( + netbird: NetbirdClient, + enrollment: EnrollmentDetection, +): Promise { + const { setupKeyName, peerId, peerHostname } = enrollment; + + try { + await netbird.updatePeer(peerId, { name: setupKeyName }); + console.log(JSON.stringify({ + msg: "peer_renamed", + peer_id: peerId, + from: peerHostname, + to: setupKeyName, + mode: "standalone", + })); + } catch (err) { + console.error(JSON.stringify({ + msg: "peer_rename_failed", + peer_id: peerId, + error: err instanceof Error ? err.message : String(err), + })); + } + + console.log(JSON.stringify({ + msg: "enrollment_detected", + setup_key: setupKeyName, + peer_id: peerId, + mode: "standalone", + note: "gitea commit-back skipped (GITEA_ENABLED=false)", + })); +} +``` + +**Step 4: Run type checker and tests** + +Run: `deno task check && deno task test` Expected: Check passes. Some tests may +need updates if they create `PollerContext` with non-null gitea — they should +still pass since they provide a gitea mock. + +**Step 5: Commit** + +``` +feat: wire GITEA_ENABLED flag into main, server, and poller + +When GITEA_ENABLED=false, the reconciler starts without a Gitea client. +The poller detects enrollments by reading setup keys directly from the +NetBird API and renames peers, but skips the commit-back of enrolled:true. +``` + +--- + +## Task 3: Update Integration Tests for GITEA_ENABLED + +Ensure existing tests pass and add a test for standalone (no-Gitea) mode. + +**Files:** + +- Modify: `src/integration.test.ts` + +**Step 1: Read the existing integration test** + +File: `src/integration.test.ts` — understand the current test setup and how it +creates the server handler. + +**Step 2: Update tests** + +The existing tests create a `ServerContext` with a mock `gitea`. They should +continue working since `gitea` is now `GiteaClient | null` and the mocks are +non-null. + +Add one new test: + +```typescript +Deno.test("reconcile works with gitea=null (standalone mode)", async () => { + const ctx: ServerContext = { + config: { ...baseConfig, giteaEnabled: false }, + netbird: mockNetbird, + gitea: null, + reconcileInProgress: { value: false }, + }; + const handler = createHandler(ctx); + + const resp = await handler( + new Request("http://localhost/reconcile?dry_run=true", { + method: "POST", + headers: { "Authorization": `Bearer ${baseConfig.reconcilerToken}` }, + body: JSON.stringify({ + groups: { "test-group": { peers: [] } }, + setup_keys: {}, + }), + }), + ); + + assertEquals(resp.status, 200); + const body = await resp.json(); + assertEquals(body.status, "planned"); +}); +``` + +Exact mock setup depends on the existing test file — read it first to match +patterns. + +**Step 3: Run tests** + +Run: `deno task test` Expected: All tests pass, including the new one. + +**Step 4: Commit** + +``` +test: add integration test for standalone (no-Gitea) reconcile mode +``` + +--- + +## Task 4: State Export — `src/export.ts` + +New module that transforms `ActualState` into a valid `netbird.json`. + +**Files:** + +- Create: `src/export.ts` +- Create: `src/export.test.ts` + +**Step 1: Write the export test** + +```typescript +import { assertEquals } from "@std/assert"; +import { exportState } from "./export.ts"; +import type { ActualState } from "./state/actual.ts"; + +Deno.test("exportState produces valid DesiredState from actual", () => { + const actual: ActualState = { + groups: [ + { + id: "g1", + name: "pilots", + peers_count: 1, + peers: [{ id: "p1", name: "Pilot-Hawk-1" }], + issued: "api", + }, + { + id: "g2", + name: "ground-stations", + peers_count: 0, + peers: [], + issued: "api", + }, + { + id: "g-all", + name: "All", + peers_count: 1, + peers: [{ id: "p1", name: "Pilot-Hawk-1" }], + issued: "integration", + }, + ], + groupsByName: new Map(), + groupsById: new Map(), + setupKeys: [ + { + id: 1, + name: "Pilot-Hawk-1", + type: "one-off", + key: "secret", + expires: "2026-03-13T00:00:00Z", + valid: true, + revoked: false, + used_times: 1, + state: "overused", + auto_groups: ["g1"], + usage_limit: 1, + }, + { + id: 2, + name: "GS-Hawk-1", + type: "one-off", + key: "secret2", + expires: "2026-03-13T00:00:00Z", + valid: true, + revoked: false, + used_times: 0, + state: "valid", + auto_groups: ["g2"], + usage_limit: 1, + }, + ], + setupKeysByName: new Map(), + peers: [{ + id: "p1", + name: "Pilot-Hawk-1", + ip: "100.64.0.1", + connected: true, + hostname: "laptop", + os: "linux", + version: "0.35.0", + groups: [{ id: "g1", name: "pilots" }], + last_seen: "2026-03-06T10:00:00Z", + dns_label: "pilot-hawk-1", + login_expiration_enabled: false, + ssh_enabled: false, + inactivity_expiration_enabled: false, + }], + peersByName: new Map(), + peersById: new Map(), + policies: [ + { + id: "pol1", + name: "pilots-to-gs", + description: "Allow pilots to reach GS", + enabled: true, + rules: [{ + name: "pilots-to-gs", + description: "", + enabled: true, + action: "accept", + bidirectional: true, + protocol: "all", + sources: [{ id: "g1", name: "pilots" }], + destinations: [{ id: "g2", name: "ground-stations" }], + }], + }, + ], + policiesByName: new Map(), + routes: [], + routesByNetworkId: new Map(), + dns: [], + dnsByName: new Map(), + }; + + const exported = exportState(actual); + + // Groups: "All" and system groups should be excluded + assertEquals(Object.keys(exported.groups).sort(), [ + "ground-stations", + "pilots", + ]); + assertEquals(exported.groups["pilots"].peers, ["Pilot-Hawk-1"]); + assertEquals(exported.groups["ground-stations"].peers, []); + + // Setup keys + assertEquals(Object.keys(exported.setup_keys).sort(), [ + "GS-Hawk-1", + "Pilot-Hawk-1", + ]); + assertEquals(exported.setup_keys["Pilot-Hawk-1"].enrolled, true); // used_times >= usage_limit + assertEquals(exported.setup_keys["GS-Hawk-1"].enrolled, false); // not yet used + + // Policies + assertEquals(Object.keys(exported.policies), ["pilots-to-gs"]); + assertEquals(exported.policies["pilots-to-gs"].sources, ["pilots"]); + assertEquals(exported.policies["pilots-to-gs"].destinations, [ + "ground-stations", + ]); + assertEquals(exported.policies["pilots-to-gs"].bidirectional, true); + + // Routes and DNS should be empty + assertEquals(exported.routes, {}); + assertEquals(exported.dns.nameserver_groups, {}); +}); + +Deno.test("exportState handles empty state", () => { + const actual: ActualState = { + groups: [{ + id: "g-all", + name: "All", + peers_count: 0, + peers: [], + issued: "integration", + }], + groupsByName: new Map(), + groupsById: new Map(), + setupKeys: [], + setupKeysByName: new Map(), + peers: [], + peersByName: new Map(), + peersById: new Map(), + policies: [], + policiesByName: new Map(), + routes: [], + routesByNetworkId: new Map(), + dns: [], + dnsByName: new Map(), + }; + + const exported = exportState(actual); + assertEquals(exported.groups, {}); + assertEquals(exported.setup_keys, {}); + assertEquals(exported.policies, {}); + assertEquals(exported.routes, {}); +}); + +Deno.test("exportState maps auto_groups IDs to group names", () => { + const actual: ActualState = { + groups: [ + { id: "g1", name: "pilots", peers_count: 0, peers: [], issued: "api" }, + ], + groupsByName: new Map([["pilots", { + id: "g1", + name: "pilots", + peers_count: 0, + peers: [], + issued: "api" as const, + }]]), + groupsById: new Map([["g1", { + id: "g1", + name: "pilots", + peers_count: 0, + peers: [], + issued: "api" as const, + }]]), + setupKeys: [ + { + id: 1, + name: "Test-Key", + type: "one-off" as const, + key: "k", + expires: "", + valid: true, + revoked: false, + used_times: 0, + state: "valid" as const, + auto_groups: ["g1"], + usage_limit: 1, + }, + ], + setupKeysByName: new Map(), + peers: [], + peersByName: new Map(), + peersById: new Map(), + policies: [], + policiesByName: new Map(), + routes: [], + routesByNetworkId: new Map(), + dns: [], + dnsByName: new Map(), + }; + + const exported = exportState(actual); + // auto_groups should contain group names, not IDs + assertEquals(exported.setup_keys["Test-Key"].auto_groups, ["pilots"]); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `deno test src/export.test.ts` Expected: FAIL — `exportState` not found. + +**Step 3: Implement `src/export.ts`** + +```typescript +import type { ActualState } from "./state/actual.ts"; +import type { DesiredState } from "./state/schema.ts"; + +/** Groups that are auto-created by NetBird and should not be exported. */ +const SYSTEM_GROUP_NAMES = new Set(["All"]); +const SYSTEM_GROUP_ISSUERS = new Set(["integration", "jwt"]); + +/** + * Transforms live ActualState (fetched from NetBird API) into a valid + * DesiredState object (netbird.json format). Maps all IDs to names. + * + * Skips system-generated groups (All, JWT-issued, integration-issued). + * Setup keys with used_times >= usage_limit are marked enrolled:true. + */ +export function exportState(actual: ActualState): DesiredState { + // Build ID->name lookup for groups (needed for auto_groups, policies, routes, DNS) + const groupIdToName = new Map(); + for (const g of actual.groups) { + groupIdToName.set(g.id, g.name); + } + + // Build setup key name set for peer->key name mapping in groups + const setupKeyNames = new Set(actual.setupKeys.map((k) => k.name)); + + // --- Groups --- + const groups: DesiredState["groups"] = {}; + for (const g of actual.groups) { + if (SYSTEM_GROUP_NAMES.has(g.name) || SYSTEM_GROUP_ISSUERS.has(g.issued)) { + continue; + } + // Map peer names — only include peers whose name matches a setup key + // (the reconciler convention is that peer names equal setup key names) + const peers = g.peers + .map((p) => p.name) + .filter((name) => setupKeyNames.has(name)); + groups[g.name] = { peers }; + } + + // --- Setup Keys --- + const setup_keys: DesiredState["setup_keys"] = {}; + for (const k of actual.setupKeys) { + // Map auto_groups from IDs to names + const autoGroupNames = k.auto_groups + .map((id) => groupIdToName.get(id)) + .filter((name): name is string => name !== undefined); + + setup_keys[k.name] = { + type: k.type, + expires_in: 604800, // Default 7 days — NetBird API doesn't return the original expires_in + usage_limit: k.usage_limit, + auto_groups: autoGroupNames, + enrolled: k.used_times >= k.usage_limit && k.usage_limit > 0, + }; + } + + // --- Policies --- + const policies: DesiredState["policies"] = {}; + for (const p of actual.policies) { + if (p.rules.length === 0) continue; + const rule = p.rules[0]; // Reconciler creates single-rule policies + + const sources = rule.sources.map((s) => + typeof s === "string" ? (groupIdToName.get(s) ?? s) : s.name + ); + const destinations = rule.destinations.map((d) => + typeof d === "string" ? (groupIdToName.get(d) ?? d) : d.name + ); + + policies[p.name] = { + description: p.description, + enabled: p.enabled, + sources, + destinations, + bidirectional: rule.bidirectional, + protocol: rule.protocol, + action: rule.action, + ...(rule.ports && rule.ports.length > 0 ? { ports: rule.ports } : {}), + }; + } + + // --- Routes --- + const routes: DesiredState["routes"] = {}; + for (const r of actual.routes) { + const peerGroups = (r.peer_groups ?? []) + .map((id) => groupIdToName.get(id)) + .filter((name): name is string => name !== undefined); + const distributionGroups = r.groups + .map((id) => groupIdToName.get(id)) + .filter((name): name is string => name !== undefined); + + routes[r.network_id] = { + description: r.description, + ...(r.network ? { network: r.network } : {}), + ...(r.domains && r.domains.length > 0 ? { domains: r.domains } : {}), + peer_groups: peerGroups, + metric: r.metric, + masquerade: r.masquerade, + distribution_groups: distributionGroups, + enabled: r.enabled, + keep_route: r.keep_route, + }; + } + + // --- DNS --- + const nameserver_groups: DesiredState["dns"]["nameserver_groups"] = {}; + for (const ns of actual.dns) { + const nsGroups = ns.groups + .map((id) => groupIdToName.get(id)) + .filter((name): name is string => name !== undefined); + + nameserver_groups[ns.name] = { + description: ns.description, + nameservers: ns.nameservers.map((n) => ({ + ip: n.ip, + ns_type: n.ns_type, + port: n.port, + })), + enabled: ns.enabled, + groups: nsGroups, + primary: ns.primary, + domains: ns.domains, + search_domains_enabled: ns.search_domains_enabled, + }; + } + + return { + groups, + setup_keys, + policies, + routes, + dns: { nameserver_groups }, + }; +} +``` + +**Step 4: Run tests** + +Run: `deno test src/export.test.ts` Expected: All 3 tests pass. + +**Step 5: Commit** + +``` +feat: add state export module (ActualState -> netbird.json) +``` + +--- + +## Task 5: State Export — HTTP Endpoint and CLI Flag + +Wire the export function into the server (`GET /export`) and add `--export` CLI +mode. + +**Files:** + +- Modify: `src/server.ts` +- Modify: `src/main.ts` +- Modify: `src/integration.test.ts` + +**Step 1: Add `GET /export` to `src/server.ts`** + +In `createHandler()`, add before the 404 fallback: + +```typescript +if (url.pathname === "/export" && req.method === "GET") { + return handleExport(ctx); +} +``` + +Add the handler function: + +```typescript +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 }, + ); + } +} +``` + +Add import at top of `server.ts`: + +```typescript +import { exportState } from "./export.ts"; +``` + +The `/export` endpoint requires bearer auth (same as other endpoints — it +exposes internal state). + +**Step 2: Add `--export` CLI mode to `src/main.ts`** + +Before the config loading block, check for CLI args: + +```typescript +// CLI mode: --export dumps state to stdout and exits +if (Deno.args.includes("--export")) { + const apiUrl = getCliArg("--netbird-api-url") ?? + Deno.env.get("NETBIRD_API_URL"); + const apiToken = getCliArg("--netbird-api-token") ?? + Deno.env.get("NETBIRD_API_TOKEN"); + + if (!apiUrl || !apiToken) { + console.error( + "Usage: --export --netbird-api-url --netbird-api-token ", + ); + console.error("Or set NETBIRD_API_URL and NETBIRD_API_TOKEN env vars."); + Deno.exit(1); + } + + const client = new NetbirdClient(apiUrl, apiToken); + const actual = await fetchActualState(client); + const state = exportState(actual); + console.log(JSON.stringify(state, null, 2)); + Deno.exit(0); +} + +function getCliArg(name: string): string | undefined { + const idx = Deno.args.indexOf(name); + if (idx === -1 || idx + 1 >= Deno.args.length) return undefined; + return Deno.args[idx + 1]; +} +``` + +Add imports to `main.ts`: + +```typescript +import { fetchActualState } from "./state/actual.ts"; +import { exportState } from "./export.ts"; +``` + +This block must come BEFORE the `loadConfig()` call, since `--export` mode +doesn't need the full config. + +**Step 3: Add integration test for `/export`** + +```typescript +Deno.test("GET /export returns exported state", async () => { + // ... create handler with mock netbird that returns test data + const resp = await handler( + new Request("http://localhost/export", { + method: "GET", + headers: { "Authorization": `Bearer ${baseConfig.reconcilerToken}` }, + }), + ); + + assertEquals(resp.status, 200); + const body = await resp.json(); + assertEquals(body.status, "ok"); + assertEquals(typeof body.state, "object"); + assertEquals(typeof body.meta.exported_at, "string"); +}); +``` + +**Step 4: Run tests** + +Run: `deno task check && deno task test` Expected: All pass. + +**Step 5: Commit** + +``` +feat: add GET /export endpoint and --export CLI mode for state export +``` + +--- + +## Task 6: Update `.env.example` and Dockerfile + +Add `GITEA_ENABLED` to env example and ensure Dockerfile passes CLI args. + +**Files:** + +- Modify: `deploy/.env.example` +- Modify: `Dockerfile` (no change needed — already passes env vars) + +**Step 1: Update `deploy/.env.example`** + +Add after `NETBIRD_API_TOKEN`: + +``` +# Set to false to run without Gitea integration (standalone mode) +GITEA_ENABLED=true +``` + +**Step 2: Add a `deno.json` task for export** + +Add to `deno.json` tasks: + +```json +"export": "deno run --allow-net --allow-env src/main.ts --export" +``` + +**Step 3: Commit** + +``` +chore: add GITEA_ENABLED to .env.example, add export task to deno.json +``` + +--- + +## Task 7: PoC Ansible — Inventory and Variables + +Create the Ansible structure for deploying the full stack to VPS-A. + +**Files:** + +- Create: `poc/ansible/inventory.yml` +- Create: `poc/ansible/group_vars/all/vars.yml` +- Create: `poc/ansible/group_vars/all/vault.yml.example` +- Create: `poc/ansible/.gitignore` + +**Step 1: Create inventory** + +```yaml +--- +all: + children: + poc_servers: + hosts: + netbird-poc-a: + ansible_host: 46.225.220.61 + ansible_ssh_private_key_file: ~/.ssh/id_ed25519 + ansible_user: root +``` + +**Step 2: Create `vars.yml`** + +```yaml +--- +# PoC Reconciler Validation — Non-secret configuration + +# Domain +netbird_domain: vps-a.networkmonitor.cc + +# Versions +netbird_version: "0.63.0" +dashboard_version: "v2.27.1" +caddy_version: "2.10.2" +coturn_version: "4.8.0-r0" + +# Reconciler +reconciler_image: "ghcr.io/blastpilot/netbird-reconciler:latest" +reconciler_port: 8080 +gitea_enabled: false # Standalone mode for initial testing + +# Gitea +gitea_version: "1.23" +gitea_http_port: 3000 +gitea_ssh_port: 2222 +gitea_admin_user: "blastpilot" +gitea_org_name: "BlastPilot" +gitea_repo_name: "netbird-gitops" + +# Directories +base_dir: /opt/netbird-poc +``` + +**Step 3: Create `vault.yml.example`** + +```yaml +--- +# Copy to vault.yml and fill in values. +# This file is gitignored — do NOT commit real secrets. + +# Auto-generated at deploy time (leave empty, playbook generates them): +vault_encryption_key: "" +vault_turn_password: "" +vault_relay_secret: "" + +# Reconciler auth token (generate with: openssl rand -hex 32) +vault_reconciler_token: "" + +# Gitea admin password (for initial setup) +vault_gitea_admin_password: "" +``` + +**Step 4: Create `.gitignore`** + +``` +group_vars/all/vault.yml +``` + +**Step 5: Commit** + +``` +feat: add PoC ansible inventory and variable files +``` + +--- + +## Task 8: PoC Ansible — Templates + +Create all Docker Compose and config templates. Adapt from the existing PoC +templates in `PoC/netbird-routing-docs-poc/ansible/netbird/templates/`. + +**Files:** + +- Create: `poc/ansible/templates/docker-compose.yml.j2` +- Create: `poc/ansible/templates/management.json.j2` +- Create: `poc/ansible/templates/Caddyfile.j2` +- Create: `poc/ansible/templates/dashboard.env.j2` +- Create: `poc/ansible/templates/relay.env.j2` +- Create: `poc/ansible/templates/turnserver.conf.j2` +- Create: `poc/ansible/templates/reconciler.env.j2` + +**Step 1: Create `docker-compose.yml.j2`** + +Extend the existing PoC template with Gitea and Reconciler services. All on a +shared `netbird` network. + +Services: + +- `caddy` — reverse proxy (same as existing PoC) +- `dashboard` — NetBird dashboard +- `signal` — NetBird signal server +- `relay` — NetBird relay +- `management` — NetBird management API +- `coturn` — STUN/TURN +- `gitea` — Gitea (SQLite, minimal config) +- `reconciler` — our reconciler service + +The reconciler image: for the PoC, build locally or use a pre-built image. Since +there's no release yet, the playbook should build the Docker image on VPS-A from +the repo. Alternative: copy the compiled binary. Simplest: mount the Deno binary +from a pre-compiled artifact. + +Actually — simplest for a PoC: build the Docker image on VPS-A as part of the +playbook. Clone the repo, `docker build`, done. The Dockerfile already works. + +**Step 2: Create all other templates** + +Copy from existing PoC (`management.json.j2`, `Caddyfile.j2`, +`dashboard.env.j2`, `relay.env.j2`, `turnserver.conf.j2`) and adapt variable +names. + +Add to `Caddyfile.j2` — route for reconciler: + +``` +# Reconciler API +handle_path /reconciler/* { + reverse_proxy reconciler:{{ reconciler_port }} +} +``` + +Add to `Caddyfile.j2` — route for Gitea: + +``` +# Gitea +handle_path /gitea/* { + reverse_proxy gitea:{{ gitea_http_port }} +} +``` + +Or better — serve Gitea on a subpath or a separate port. Gitea behind a subpath +requires `ROOT_URL` config. Simplest: expose Gitea on port 3000 directly (not +through Caddy) since it's a PoC. + +Create `reconciler.env.j2`: + +``` +NETBIRD_API_URL=https://{{ netbird_domain }}/api +NETBIRD_API_TOKEN={{ vault_netbird_api_token }} +RECONCILER_TOKEN={{ vault_reconciler_token }} +GITEA_ENABLED={{ gitea_enabled }} +{% if gitea_enabled | bool %} +GITEA_URL=http://gitea:{{ gitea_http_port }} +GITEA_TOKEN={{ vault_gitea_token }} +GITEA_REPO={{ gitea_org_name }}/{{ gitea_repo_name }} +{% endif %} +POLL_INTERVAL_SECONDS=30 +PORT={{ reconciler_port }} +DATA_DIR=/data +``` + +**Step 3: Commit** + +``` +feat: add PoC ansible templates for full stack deployment +``` + +--- + +## Task 9: PoC Ansible — Playbook + +The main playbook that orchestrates deployment. + +**Files:** + +- Create: `poc/ansible/playbook.yml` +- Create: `poc/ansible/files/netbird-seed.json` + +**Step 1: Create seed state file** + +`poc/ansible/files/netbird-seed.json`: + +```json +{ + "groups": { + "ground-stations": { "peers": [] }, + "pilots": { "peers": [] } + }, + "setup_keys": { + "GS-TestHawk-1": { + "type": "one-off", + "expires_in": 604800, + "usage_limit": 1, + "auto_groups": ["ground-stations"], + "enrolled": false + }, + "Pilot-TestHawk-1": { + "type": "one-off", + "expires_in": 604800, + "usage_limit": 1, + "auto_groups": ["pilots"], + "enrolled": false + } + }, + "policies": { + "pilots-to-gs": { + "enabled": true, + "sources": ["pilots"], + "destinations": ["ground-stations"], + "bidirectional": true + } + }, + "routes": {}, + "dns": { "nameserver_groups": {} } +} +``` + +**Step 2: Create playbook** + +`poc/ansible/playbook.yml` — Task groups: + +1. **Generate secrets** — encryption key, TURN password, relay secret, + reconciler token (if not in vault.yml) +2. **Install Docker** — apt repo, docker-ce, docker-compose-plugin +3. **Create directories** — + `{{ base_dir }}/{config,data,reconciler-data,gitea-data}` +4. **Template configs** — all `.j2` templates → `{{ base_dir }}/config/` +5. **Build reconciler image** — clone repo (or copy Dockerfile + src), + `docker build` +6. **Start stack** — `docker compose up -d` +7. **Wait for health** — poll management + reconciler health endpoints +8. **Create NetBird admin** — first login to embedded IdP creates admin +9. **Generate NetBird API token** — via management API (may need manual step) +10. **(If Gitea enabled)** — Create admin user, org, repo, seed `netbird.json` +11. **Print summary** — URLs, credentials, next steps + +Note: generating a NetBird API token programmatically requires authenticating +via the embedded IdP OAuth2 flow, which is non-trivial to automate. For the PoC, +this will likely be a manual step: + +1. Open dashboard, create admin account +2. Go to Settings → API Tokens → generate token +3. Put token in `vault.yml` as `vault_netbird_api_token` +4. Re-run playbook (or just restart reconciler container) + +The playbook should handle this gracefully — start the reconciler last, after +the token is known. + +**Step 3: Commit** + +``` +feat: add PoC ansible playbook and seed state file +``` + +--- + +## Task 10: PoC Ansible — README + +Document how to use the PoC. + +**Files:** + +- Create: `poc/README.md` + +**Step 1: Write README** + +Cover: + +- Prerequisites (SSH access to VPS-A, DNS records) +- Setup steps (copy vault.yml.example, fill secrets) +- Deploy command +- Post-deploy steps (create NetBird admin, generate API token) +- Testing steps (dry-run reconcile, apply, enrollment) +- Teardown + +**Step 2: Commit** + +``` +docs: add PoC README with setup and testing instructions +``` + +--- + +## Task 11: Run All Tests and Type Check + +Final verification before deployment. + +**Files:** None (verification only) + +**Step 1: Run full test suite** + +Run: `deno task check && deno task test && deno task lint` Expected: All pass. + +**Step 2: Run format check** + +Run: `deno task fmt --check` Expected: No formatting issues (or fix them). + +**Step 3: Commit any fixes** + +``` +chore: fix lint/format issues +``` + +--- + +## Summary of Tasks + +| # | Task | Type | Est. | +| -- | ------------------------------------- | ----------- | ------ | +| 1 | `GITEA_ENABLED` feature flag — config | Code | 10 min | +| 2 | Wire flag into main, server, poller | Code | 20 min | +| 3 | Update integration tests | Test | 10 min | +| 4 | State export module | Code + Test | 20 min | +| 5 | Export endpoint + CLI flag | Code + Test | 15 min | +| 6 | Update .env.example and deno.json | Config | 5 min | +| 7 | PoC Ansible — inventory + vars | Ansible | 10 min | +| 8 | PoC Ansible — templates | Ansible | 30 min | +| 9 | PoC Ansible — playbook + seed | Ansible | 30 min | +| 10 | PoC README | Docs | 10 min | +| 11 | Final verification | Test | 5 min | + +**Total estimated: ~2.5 hours** + +Tasks 1-6 are the reconciler code changes (feature flag + export). Tasks 7-10 +are the Ansible deployment. Task 11 is final verification. + +Tasks 1-6 must be done sequentially (each builds on the previous). Tasks 7-10 +can be done in parallel with each other but after Tasks 1-6 (the Ansible +templates reference the feature flag and export endpoint). diff --git a/docs/plans/2026-03-06-reconciler-poc-validation.md b/docs/plans/2026-03-06-reconciler-poc-validation.md new file mode 100644 index 0000000..5751b52 --- /dev/null +++ b/docs/plans/2026-03-06-reconciler-poc-validation.md @@ -0,0 +1,272 @@ +# Reconciler PoC Validation — Design Document + +> **Status:** Draft **Author:** @prox **Date:** 2026-03-06 **Depends on:** +> [NetBird Reconciler Design](2026-03-03-netbird-reconciler-design.md) + +## Goal + +Validate the reconciler end-to-end on a fresh, isolated NetBird instance before +pointing it at production. Prove that: + +1. Declaring state in `netbird.json` → reconcile → resources appear in NetBird. +2. Event poller detects peer enrollment and renames the peer. +3. State export from a live NetBird instance produces a valid `netbird.json`. + +## Scope + +### In scope + +- Deploy a self-contained stack on VPS-A (`vps-a.networkmonitor.cc`): fresh + NetBird, Caddy, Gitea, and reconciler — all via Docker Compose. +- `GITEA_ENABLED` feature flag so the reconciler works without Gitea + integration. +- State export tool: `GET /export` endpoint + `--export` CLI flag. +- Core reconcile: groups, setup keys, policies created via `/reconcile`. +- Event poller: detect enrollment, rename peer — with or without Gitea + commit-back. + +### Out of scope (deferred) + +- Enrollment pipeline integration (docs site → Gitea PR). +- CI workflows (dry-run on PR, reconcile on merge). +- Production deployment to real NetBird environments. +- Key encryption with `age` / artifact upload. + +## Architecture + +``` +VPS-A (vps-a.networkmonitor.cc) +├── Caddy (reverse proxy, HTTPS, ACME) +│ ├── / → NetBird Dashboard +│ ├── /api → NetBird Management API +│ ├── /signalexchange → Signal (gRPC) +│ ├── /relay → Relay +│ └── /reconciler/* → Reconciler HTTP API +├── NetBird Management (config, IdP, API) +├── NetBird Signal (gRPC peer coordination) +├── NetBird Relay (data relay for NATed peers) +├── Coturn (STUN/TURN) +├── Gitea (hosts netbird-gitops repo) +└── Reconciler (reconcile API + event poller) +``` + +All containers share a single Docker Compose stack with a common network. Caddy +terminates TLS and routes by path prefix. + +## Changes to Reconciler + +### 1. Feature Flag: `GITEA_ENABLED` + +New environment variable. Default: `true` (backward compatible). + +**When `GITEA_ENABLED=false`:** + +| Component | Behavior | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Config validation | Skip `GITEA_*` env var requirements | +| Startup | Don't create Gitea client | +| `POST /reconcile` | Works normally — accepts `netbird.json` from request body, applies to NetBird API | +| Event poller | Still runs. Detects `peer.setupkey.add` events, renames peers. Skips commit-back of `enrolled: true`. Logs enrollment instead. | +| `GET /export` | Works normally — no Gitea dependency | + +**When `GITEA_ENABLED=true`:** Current behavior, unchanged. + +**Affected files:** + +- `src/config.ts` — conditional Gitea env var validation +- `src/main.ts` — conditional Gitea client creation, pass flag to poller +- `src/poller/loop.ts` — guard commit-back behind flag + +### 2. State Export + +New module: `src/export.ts` + +Transforms `ActualState` (from `src/state/actual.ts`) into a valid +`netbird.json` conforming to `DesiredStateSchema`. + +**Mapping logic:** + +| NetBird resource | Export strategy | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Groups | Map ID → name. Skip auto-generated groups (`All`, `ch-` prefixed). Peer refs mapped to setup key names where possible, otherwise peer hostname. | +| Setup keys | Export with current config. Set `enrolled: true` if `used_times >= usage_limit`, else `false`. | +| Policies | Map source/destination group IDs → names. Include port rules. | +| Routes | Map group IDs → names, include network CIDRs. | +| DNS nameserver groups | Map group refs → names. | + +**Interfaces:** + +``` +GET /export + → 200: { state: , meta: { exported_at, source_url, groups_count, ... } } + +CLI: deno run src/main.ts --export --netbird-api-url --netbird-api-token + → stdout: netbird.json content +``` + +The CLI mode is standalone — it creates a NetBird client, fetches state, +exports, and exits. No HTTP server started. + +**Affected files:** + +- `src/export.ts` — new: transformation logic +- `src/server.ts` — new endpoint: `GET /export` +- `src/main.ts` — new CLI flag: `--export` + +### 3. No Structural Changes + +The reconcile engine (`diff.ts`, `executor.ts`), NetBird client, and state +schema remain unchanged. The export tool and feature flag are additive. + +## Ansible Playbook + +Location: `poc/ansible/` within this repo. + +``` +poc/ + ansible/ + inventory.yml + playbook.yml + group_vars/ + all/ + vars.yml # domain, ports, non-secret config + vault.yml # secrets (gitignored) + vault.yml.example # template for secrets + templates/ + docker-compose.yml.j2 + management.json.j2 # NetBird management config (embedded IdP) + Caddyfile.j2 + dashboard.env.j2 + relay.env.j2 + turnserver.conf.j2 + reconciler.env.j2 + gitea.env.j2 +``` + +**Playbook tasks:** + +1. Install Docker + Docker Compose (if not present) +2. Create working directory structure +3. Template all config files +4. Pull images, `docker compose up -d` +5. Wait for Gitea to be ready +6. Create Gitea admin user + `BlastPilot` org + `netbird-gitops` repo via API +7. Seed `netbird.json` into the repo with initial test state + +**Key config decisions:** + +- **Caddy** for reverse proxy (proven in existing PoC templates). +- **Embedded IdP** for NetBird (no external OAuth — same as existing PoC). +- **Secrets auto-generated** at deploy time (NetBird encryption key, TURN + secret, relay secret). Printed to stdout for operator reference. +- Reconciler env vars templated from `vault.yml` (NetBird API token, Gitea + token). + +**SSH key:** `~/.ssh/hetzner` (same as docs site deployment). + +**Deploy command:** `ansible-playbook -i inventory.yml playbook.yml` + +## Test netbird.json + +The seed state for validation: + +```json +{ + "groups": { + "ground-stations": { "peers": [] }, + "pilots": { "peers": [] } + }, + "setup_keys": { + "GS-TestHawk-1": { + "type": "one-off", + "expires_in": 604800, + "usage_limit": 1, + "auto_groups": ["ground-stations"], + "enrolled": false + }, + "Pilot-TestHawk-1": { + "type": "one-off", + "expires_in": 604800, + "usage_limit": 1, + "auto_groups": ["pilots"], + "enrolled": false + } + }, + "policies": { + "pilots-to-gs": { + "enabled": true, + "sources": ["pilots"], + "destinations": ["ground-stations"], + "bidirectional": true + } + }, + "routes": {}, + "dns": { "nameserver_groups": {} } +} +``` + +This creates two groups, two one-off setup keys, and a bidirectional policy +between pilots and ground stations. Minimal but sufficient to validate the full +reconcile + enrollment flow. + +## Validation Plan + +### Phase 1 — Deploy + +1. Wipe VPS-A (or just `docker compose down -v` if redeploying). +2. Run playbook → full stack up. +3. Access NetBird dashboard at `https://vps-a.networkmonitor.cc` — verify clean + state (only default "All" group). +4. Access Gitea at `https://vps-a.networkmonitor.cc/gitea` (or dedicated port) — + verify `BlastPilot/netbird-gitops` repo exists with seed `netbird.json`. + +### Phase 2 — Reconcile + +5. `curl -X POST https://vps-a.networkmonitor.cc/reconciler/reconcile?dry_run=true -d @netbird.json` + → Verify plan shows: create 2 groups, 2 setup keys, 1 policy. +6. `curl -X POST https://vps-a.networkmonitor.cc/reconciler/reconcile -d @netbird.json` + → Verify response includes `created_keys` with actual key values. +7. Open NetBird dashboard → verify groups, setup keys, and policy exist. +8. `curl https://vps-a.networkmonitor.cc/reconciler/export` → Compare exported + state with input. Verify round-trip consistency. + +### Phase 3 — Enrollment + +9. Copy a setup key value from step 6 response. +10. On a test machine: `netbird up --setup-key `. +11. Check NetBird dashboard: peer appears, gets auto-renamed by poller, placed + in correct group. +12. Check reconciler logs: enrollment event detected, peer renamed, log entry + written (no Gitea commit since `GITEA_ENABLED=false` for initial test). + +### Phase 4 — State Export (against real instance) + +13. Run CLI export against `dev.netbird.achilles-rnd.cc`: + ``` + deno run src/main.ts --export \ + --netbird-api-url https://dev.netbird.achilles-rnd.cc/api \ + --netbird-api-token + ``` +14. Review output — validates we can bootstrap GitOps from existing environment. +15. Optionally: dry-run reconcile the exported state against the same instance — + should produce an empty plan (no changes needed). + +## Success Criteria + +- [ ] Reconcile creates all declared resources in NetBird. +- [ ] Dry-run returns accurate plan without side effects. +- [ ] Export produces valid `netbird.json` from a live instance. +- [ ] Export → dry-run round-trip yields empty plan (idempotent). +- [ ] Poller detects enrollment and renames peer within 30s. +- [ ] Reconciler starts and operates correctly with `GITEA_ENABLED=false`. +- [ ] Reconciler starts and operates correctly with `GITEA_ENABLED=true` + + Gitea. + +## Risks + +| Risk | Mitigation | +| ------------------------------------------------------------- | -------------------------------------------------------------------------- | +| NetBird Management API behavior differs from docs | Testing against real instance; reconciler has comprehensive error handling | +| Export misses edge cases in resource mapping | Validate with dry-run round-trip (export → reconcile → empty plan) | +| Poller misses events during 30s poll interval | Acceptable for PoC; production can tune interval or add webhook trigger | +| VPS-A resources (2 vCPU, 4GB RAM) insufficient for full stack | Monitor; NetBird + Gitea are lightweight individually | diff --git a/poc/README.md b/poc/README.md new file mode 100644 index 0000000..39b477e --- /dev/null +++ b/poc/README.md @@ -0,0 +1,169 @@ +# NetBird GitOps Reconciler — PoC Deployment + +Ansible playbook that deploys a self-contained stack on VPS-A for end-to-end +testing of the NetBird GitOps reconciler. + +## Stack overview + +| Component | Purpose | +| ---------- | ------------------------------------------- | +| Caddy | TLS termination, reverse proxy | +| NetBird | Management, Signal, Relay, Dashboard, TURN | +| Reconciler | Declarative config → NetBird API reconciler | +| Gitea | Local Git server (optional, off by default) | + +All services run as Docker containers on a single VPS, connected via a `netbird` +Docker bridge network. Caddy handles ACME certificates automatically. + +## Prerequisites + +- SSH access to `46.225.220.61` (root, key-based) +- DNS A record: `vps-a.networkmonitor.cc` → `46.225.220.61` +- `rsync` installed locally (used to sync reconciler source) +- Ansible 2.15+ with `community.general` and `ansible.posix` collections + +Install collections if needed: + +```bash +ansible-galaxy collection install community.general ansible.posix +``` + +## Setup + +### 1. Create vault file + +```bash +cd poc/ansible +cp group_vars/all/vault.yml.example group_vars/all/vault.yml +``` + +For the first deploy, leave all values as empty strings — the playbook +auto-generates NetBird secrets and a reconciler token. + +### 2. Deploy + +```bash +cd poc/ansible +ansible-playbook -i inventory.yml playbook.yml +``` + +The playbook will: + +1. Generate secrets (encryption key, TURN password, relay secret, reconciler + token) +2. Install Docker if not present +3. Configure UFW firewall +4. Rsync the reconciler source code to VPS-A +5. Template all config files +6. Build the reconciler Docker image on VPS-A +7. Pull NetBird/Gitea images and start all services +8. Run health checks and print a summary with generated secrets + +**Save the generated secrets** printed at the end into `vault.yml` so subsequent +runs are idempotent. + +### 3. Create NetBird admin + API token + +1. Open `https://vps-a.networkmonitor.cc` in a browser +2. Create the first admin account (embedded IdP — no external OAuth) +3. Go to **Settings → Personal Access Tokens → Generate** +4. Copy the token into `vault.yml` as `vault_netbird_api_token` +5. Re-run the playbook: + +```bash +ansible-playbook -i inventory.yml playbook.yml +``` + +The reconciler will now start successfully with a valid API token. + +### 4. (Optional) Enable Gitea + +To enable Gitea-backed GitOps polling: + +1. Open `http://vps-a.networkmonitor.cc:3000` and complete the install wizard +2. Create an admin account (user: `blastpilot`) +3. Create org `BlastPilot` and repo `netbird-gitops` +4. Push `netbird.json` to the repo +5. Generate a Gitea API token (Settings → Applications) +6. In `vars.yml`, set `gitea_enabled: "true"` +7. In `vault.yml`, fill in `vault_gitea_token` and `vault_gitea_admin_password` +8. Re-run the playbook + +## Testing + +All commands below assume you have the reconciler token. Replace `` with +the value of `vault_reconciler_token`. + +### Health check + +```bash +curl https://vps-a.networkmonitor.cc/reconciler/health +``` + +### Dry-run reconcile + +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d @ansible/files/netbird-seed.json \ + 'https://vps-a.networkmonitor.cc/reconciler/reconcile?dry_run=true' +``` + +### Apply reconcile + +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d @ansible/files/netbird-seed.json \ + 'https://vps-a.networkmonitor.cc/reconciler/reconcile' +``` + +### Export current state + +```bash +curl -H "Authorization: Bearer " \ + 'https://vps-a.networkmonitor.cc/reconciler/export' +``` + +### Enroll a peer + +Use a setup key from the reconcile response (`created_keys` field): + +```bash +sudo netbird up --management-url https://vps-a.networkmonitor.cc --setup-key +``` + +## Teardown + +Remove all containers and volumes: + +```bash +ssh root@46.225.220.61 "cd /opt/netbird-poc && docker compose down -v" +``` + +## File structure + +``` +poc/ + ansible/ + inventory.yml # VPS-A host definition + playbook.yml # Main deployment playbook + .gitignore # Excludes vault.yml + group_vars/ + all/ + vars.yml # Non-secret config (versions, ports, etc.) + vault.yml.example # Secret template — copy to vault.yml + templates/ + docker-compose.yml.j2 # All services (NetBird + Gitea + Reconciler) + management.json.j2 # NetBird management config + Caddyfile.j2 # Caddy reverse proxy with reconciler route + dashboard.env.j2 # NetBird dashboard env + relay.env.j2 # NetBird relay env + turnserver.conf.j2 # TURN server config + reconciler.env.j2 # Reconciler env + files/ + netbird-seed.json # Example desired state for testing + README.md # This file +``` diff --git a/poc/ansible/.gitignore b/poc/ansible/.gitignore new file mode 100644 index 0000000..9d1bcb5 --- /dev/null +++ b/poc/ansible/.gitignore @@ -0,0 +1 @@ +group_vars/all/vault.yml diff --git a/poc/ansible/files/netbird-seed.json b/poc/ansible/files/netbird-seed.json new file mode 100644 index 0000000..91facdf --- /dev/null +++ b/poc/ansible/files/netbird-seed.json @@ -0,0 +1,32 @@ +{ + "groups": { + "ground-stations": { "peers": [] }, + "pilots": { "peers": [] } + }, + "setup_keys": { + "GS-TestHawk-1": { + "type": "one-off", + "expires_in": 604800, + "usage_limit": 1, + "auto_groups": ["ground-stations"], + "enrolled": false + }, + "Pilot-TestHawk-1": { + "type": "one-off", + "expires_in": 604800, + "usage_limit": 1, + "auto_groups": ["pilots"], + "enrolled": false + } + }, + "policies": { + "pilots-to-gs": { + "enabled": true, + "sources": ["pilots"], + "destinations": ["ground-stations"], + "bidirectional": true + } + }, + "routes": {}, + "dns": { "nameserver_groups": {} } +} diff --git a/poc/ansible/group_vars/all/vars.yml b/poc/ansible/group_vars/all/vars.yml new file mode 100644 index 0000000..8837685 --- /dev/null +++ b/poc/ansible/group_vars/all/vars.yml @@ -0,0 +1,26 @@ +--- +# Non-secret configuration for the NetBird + Reconciler PoC stack. + +netbird_domain: vps-a.networkmonitor.cc + +# --- Component versions (match existing PoC) --- +netbird_version: "0.63.0" +dashboard_version: "v2.27.1" +caddy_version: "2.10.2" +coturn_version: "4.8.0-r0" + +# --- Reconciler --- +reconciler_port: 8080 + +# --- Gitea (standalone mode by default) --- +# String "false" because it goes into an env var verbatim. +gitea_enabled: "false" +gitea_version: "1.23" +gitea_http_port: 3000 +gitea_ssh_port: 2222 +gitea_admin_user: "blastpilot" +gitea_org_name: "BlastPilot" +gitea_repo_name: "netbird-gitops" + +# --- Paths --- +base_dir: /opt/netbird-poc diff --git a/poc/ansible/group_vars/all/vault.yml.example b/poc/ansible/group_vars/all/vault.yml.example new file mode 100644 index 0000000..e18f32a --- /dev/null +++ b/poc/ansible/group_vars/all/vault.yml.example @@ -0,0 +1,20 @@ +--- +# Copy to vault.yml and fill in values. +# This file is gitignored — do NOT commit real secrets. + +# Auto-generated by playbook if empty (leave as empty string): +vault_encryption_key: "" +vault_turn_password: "" +vault_relay_secret: "" + +# Reconciler auth token (generate: openssl rand -hex 32): +vault_reconciler_token: "" + +# Gitea admin password: +vault_gitea_admin_password: "" + +# NetBird API token (created via dashboard after first deploy): +vault_netbird_api_token: "" + +# Gitea API token (created via Gitea UI after first deploy): +vault_gitea_token: "" diff --git a/poc/ansible/inventory.yml b/poc/ansible/inventory.yml new file mode 100644 index 0000000..093f764 --- /dev/null +++ b/poc/ansible/inventory.yml @@ -0,0 +1,9 @@ +--- +all: + children: + poc_servers: + hosts: + netbird-poc-a: + ansible_host: 46.225.220.61 + ansible_ssh_private_key_file: ~/.ssh/id_ed25519 + ansible_user: root diff --git a/poc/ansible/playbook.yml b/poc/ansible/playbook.yml new file mode 100644 index 0000000..74ecf56 --- /dev/null +++ b/poc/ansible/playbook.yml @@ -0,0 +1,366 @@ +--- +# ============================================================================= +# NetBird + Reconciler + Gitea — PoC Deployment +# ============================================================================= +# Deploys a self-contained stack on VPS-A for end-to-end reconciler testing. +# +# Prerequisites: +# - SSH access to VPS-A (46.225.220.61) +# - DNS A record: vps-a.networkmonitor.cc -> 46.225.220.61 +# - rsync installed locally and on VPS-A +# - poc/ansible/group_vars/all/vault.yml (copy from vault.yml.example) +# +# Run: +# cd poc/ansible +# ansible-playbook -i inventory.yml playbook.yml +# ============================================================================= + +- name: Deploy NetBird + Reconciler PoC on VPS-A + hosts: poc_servers + become: true + + tasks: + # ========================================================================= + # 1. Generate secrets (if vault values are empty) + # ========================================================================= + # vault_* vars come from group_vars/all/vault.yml. When left as empty + # strings, the playbook auto-generates values. On subsequent runs with + # filled-in vault.yml, the provided values are used instead. + - name: Generate encryption key (if not provided) + ansible.builtin.shell: openssl rand -base64 32 + register: _gen_encryption_key + changed_when: false + when: vault_encryption_key | default('') | length == 0 + + - name: Generate TURN password (if not provided) + ansible.builtin.shell: openssl rand -hex 32 + register: _gen_turn_password + changed_when: false + when: vault_turn_password | default('') | length == 0 + + - name: Generate relay secret (if not provided) + ansible.builtin.shell: openssl rand -hex 32 + register: _gen_relay_secret + changed_when: false + when: vault_relay_secret | default('') | length == 0 + + - name: Generate reconciler token (if not provided) + ansible.builtin.shell: openssl rand -hex 32 + register: _gen_reconciler_token + changed_when: false + when: vault_reconciler_token | default('') | length == 0 + + - name: Set effective secrets + ansible.builtin.set_fact: + vault_encryption_key: "{{ vault_encryption_key if (vault_encryption_key | default('') | length > 0) else _gen_encryption_key.stdout }}" + vault_turn_password: "{{ vault_turn_password if (vault_turn_password | default('') | length > 0) else _gen_turn_password.stdout }}" + vault_relay_secret: "{{ vault_relay_secret if (vault_relay_secret | default('') | length > 0) else _gen_relay_secret.stdout }}" + vault_reconciler_token: "{{ vault_reconciler_token if (vault_reconciler_token | default('') | length > 0) else _gen_reconciler_token.stdout }}" + + # ========================================================================= + # 2. Install Docker + # ========================================================================= + + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install prerequisites + ansible.builtin.apt: + name: + - apt-transport-https + - ca-certificates + - curl + - gnupg + - lsb-release + - jq + - rsync + state: present + + - name: Check if Docker is installed + ansible.builtin.command: docker --version + register: _docker_check + changed_when: false + failed_when: false + + - name: Create keyrings directory + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + when: _docker_check.rc != 0 + + - name: Add Docker GPG key + ansible.builtin.shell: | + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + args: + creates: /etc/apt/keyrings/docker.gpg + when: _docker_check.rc != 0 + + - name: Add Docker repository + ansible.builtin.apt_repository: + repo: >- + deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] + https://download.docker.com/linux/ubuntu + {{ ansible_distribution_release }} stable + state: present + filename: docker + when: _docker_check.rc != 0 + + - name: Install Docker packages + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-compose-plugin + state: present + update_cache: true + when: _docker_check.rc != 0 + + - name: Start and enable Docker + ansible.builtin.systemd: + name: docker + state: started + enabled: true + + # ========================================================================= + # 3. UFW firewall + # ========================================================================= + + - name: Install UFW + ansible.builtin.apt: + name: ufw + state: present + + - name: Allow SSH + community.general.ufw: + rule: allow + port: "22" + proto: tcp + + - name: Allow HTTP (ACME + Caddy) + community.general.ufw: + rule: allow + port: "80" + proto: tcp + + - name: Allow HTTPS + community.general.ufw: + rule: allow + port: "443" + proto: tcp + + - name: Allow TURN UDP + community.general.ufw: + rule: allow + port: "3478" + proto: udp + + - name: Allow TURN TCP + community.general.ufw: + rule: allow + port: "3478" + proto: tcp + + - name: Allow Gitea HTTP + community.general.ufw: + rule: allow + port: "{{ gitea_http_port | string }}" + proto: tcp + + - name: Enable UFW (default deny incoming) + community.general.ufw: + state: enabled + policy: deny + + # ========================================================================= + # 4. Create directories + # ========================================================================= + + - name: Create base directory + ansible.builtin.file: + path: "{{ base_dir }}" + state: directory + mode: "0755" + + - name: Create config directory + ansible.builtin.file: + path: "{{ base_dir }}/config" + state: directory + mode: "0755" + + - name: Create reconciler source directory + ansible.builtin.file: + path: "{{ base_dir }}/reconciler-src" + state: directory + mode: "0755" + + # ========================================================================= + # 5. Sync reconciler source code + # ========================================================================= + # Uses rsync to copy the project root (minus junk) to VPS-A so that + # `docker compose build` can build the reconciler image on the server. + + - name: Sync reconciler source to VPS-A + ansible.posix.synchronize: + src: "{{ playbook_dir }}/../../" + dest: "{{ base_dir }}/reconciler-src/" + delete: true + rsync_opts: + - "--exclude=.git" + - "--exclude=node_modules" + - "--exclude=poc" + - "--exclude=data" + - "--exclude=deploy" + - "--exclude=.beads" + # synchronize runs as the connecting user, not become. We need to + # set become: false so it uses the SSH user directly for rsync. + become: false + + # ========================================================================= + # 6. Template configs + # ========================================================================= + + - name: Deploy docker-compose.yml + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ base_dir }}/docker-compose.yml" + mode: "0644" + register: _compose_changed + + - name: Deploy management.json + ansible.builtin.template: + src: management.json.j2 + dest: "{{ base_dir }}/config/management.json" + mode: "0644" + register: _management_changed + + - name: Deploy Caddyfile + ansible.builtin.template: + src: Caddyfile.j2 + dest: "{{ base_dir }}/config/Caddyfile" + mode: "0644" + + - name: Deploy dashboard.env + ansible.builtin.template: + src: dashboard.env.j2 + dest: "{{ base_dir }}/config/dashboard.env" + mode: "0640" + + - name: Deploy relay.env + ansible.builtin.template: + src: relay.env.j2 + dest: "{{ base_dir }}/config/relay.env" + mode: "0640" + + - name: Deploy turnserver.conf + ansible.builtin.template: + src: turnserver.conf.j2 + dest: "{{ base_dir }}/config/turnserver.conf" + mode: "0644" + + - name: Deploy reconciler.env + ansible.builtin.template: + src: reconciler.env.j2 + dest: "{{ base_dir }}/config/reconciler.env" + mode: "0640" + register: _reconciler_env_changed + + # ========================================================================= + # 7. Docker Compose — pull, build, up + # ========================================================================= + + - name: Pull Docker images + ansible.builtin.command: + cmd: docker compose pull --ignore-buildable + chdir: "{{ base_dir }}" + changed_when: true + + - name: Build reconciler image + ansible.builtin.command: + cmd: docker compose build reconciler + chdir: "{{ base_dir }}" + changed_when: true + + - name: Start all services + ansible.builtin.command: + cmd: docker compose up -d + chdir: "{{ base_dir }}" + changed_when: true + + # ========================================================================= + # 8. Health checks + # ========================================================================= + + - name: Wait for management container to be running + ansible.builtin.command: + cmd: docker compose ps management --format json + chdir: "{{ base_dir }}" + register: _mgmt_status + until: "'running' in _mgmt_status.stdout and 'restarting' not in _mgmt_status.stdout" + retries: 15 + delay: 5 + changed_when: false + + - name: Wait for Caddy / HTTPS to respond + ansible.builtin.uri: + url: "https://{{ netbird_domain }}" + method: GET + status_code: 200 + validate_certs: false + register: _caddy_check + until: _caddy_check.status == 200 + retries: 12 + delay: 5 + + - name: Check reconciler health (may fail if API token not yet configured) + ansible.builtin.uri: + url: "http://127.0.0.1:{{ reconciler_port }}/health" + method: GET + status_code: 200 + register: _reconciler_check + failed_when: false + changed_when: false + + # ========================================================================= + # 9. Summary + # ========================================================================= + + - name: Note about NetBird API token + ansible.builtin.debug: + msg: >- + The reconciler needs a NetBird API token to function. + If vault_netbird_api_token is empty, the reconciler will crash-loop + until you create an admin account via the dashboard, generate an + API token, add it to vault.yml, and re-run the playbook. + when: vault_netbird_api_token | default('') | length == 0 + + - name: Deployment summary + ansible.builtin.debug: + msg: | + ============================================================ + NetBird + Reconciler PoC deployed on VPS-A + ============================================================ + + Dashboard: https://{{ netbird_domain }} + Gitea: http://{{ netbird_domain }}:{{ gitea_http_port }} + Reconciler: https://{{ netbird_domain }}/reconciler/health + + Reconciler status: {{ 'healthy' if (_reconciler_check.status | default(0)) == 200 else 'NOT YET READY (see note above)' }} + + Generated secrets (save these to vault.yml for idempotent re-runs): + vault_encryption_key: {{ vault_encryption_key }} + vault_turn_password: {{ vault_turn_password }} + vault_relay_secret: {{ vault_relay_secret }} + vault_reconciler_token: {{ vault_reconciler_token }} + + Next steps: + 1. Open the dashboard and create an admin account + 2. Go to Settings > API > generate a Personal Access Token + 3. Put the token in vault.yml as vault_netbird_api_token + 4. Re-run: ansible-playbook -i inventory.yml playbook.yml + ============================================================ diff --git a/poc/ansible/templates/Caddyfile.j2 b/poc/ansible/templates/Caddyfile.j2 new file mode 100644 index 0000000..598443b --- /dev/null +++ b/poc/ansible/templates/Caddyfile.j2 @@ -0,0 +1,46 @@ +{ + servers :80,:443 { + protocols h1 h2c h2 h3 + } + email vlad.stus@gmail.com +} + +(security_headers) { + header * { + Strict-Transport-Security "max-age=3600; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + X-XSS-Protection "1; mode=block" + -Server + Referrer-Policy strict-origin-when-cross-origin + } +} + +{{ netbird_domain }} { + import security_headers + + # Embedded IdP OAuth2 endpoints + reverse_proxy /oauth2/* management:80 + reverse_proxy /.well-known/openid-configuration management:80 + reverse_proxy /.well-known/jwks.json management:80 + + # NetBird Relay + reverse_proxy /relay* relay:80 + + # NetBird Signal (gRPC) + reverse_proxy /signalexchange.SignalExchange/* h2c://signal:10000 + + # NetBird Management API (gRPC) + reverse_proxy /management.ManagementService/* h2c://management:80 + + # NetBird Management REST API + reverse_proxy /api/* management:80 + + # Reconciler API (strip /reconciler prefix before proxying) + handle_path /reconciler/* { + reverse_proxy reconciler:{{ reconciler_port }} + } + + # NetBird Dashboard (catch-all — must be last) + reverse_proxy /* dashboard:80 +} diff --git a/poc/ansible/templates/dashboard.env.j2 b/poc/ansible/templates/dashboard.env.j2 new file mode 100644 index 0000000..d4b128a --- /dev/null +++ b/poc/ansible/templates/dashboard.env.j2 @@ -0,0 +1,12 @@ +NETBIRD_MGMT_API_ENDPOINT=https://{{ netbird_domain }} +NETBIRD_MGMT_GRPC_API_ENDPOINT=https://{{ netbird_domain }} +AUTH_AUDIENCE=netbird-dashboard +AUTH_CLIENT_ID=netbird-dashboard +AUTH_CLIENT_SECRET= +AUTH_AUTHORITY=https://{{ netbird_domain }}/oauth2 +USE_AUTH0=false +AUTH_SUPPORTED_SCOPES=openid profile email groups +AUTH_REDIRECT_URI=/nb-auth +AUTH_SILENT_REDIRECT_URI=/nb-silent-auth +NGINX_SSL_PORT=443 +LETSENCRYPT_DOMAIN=none diff --git a/poc/ansible/templates/docker-compose.yml.j2 b/poc/ansible/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..16eef0b --- /dev/null +++ b/poc/ansible/templates/docker-compose.yml.j2 @@ -0,0 +1,145 @@ +services: + # --------------------------------------------------------------------------- + # NetBird core services (proven config from existing PoC) + # --------------------------------------------------------------------------- + caddy: + image: caddy:{{ caddy_version }} + restart: unless-stopped + networks: + - netbird + ports: + - "80:80" + - "443:443" + volumes: + - ./config/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + logging: + driver: json-file + options: + max-size: "500m" + max-file: "2" + + dashboard: + image: netbirdio/dashboard:{{ dashboard_version }} + restart: unless-stopped + networks: + - netbird + env_file: + - ./config/dashboard.env + logging: + driver: json-file + options: + max-size: "500m" + max-file: "2" + + signal: + image: netbirdio/signal:{{ netbird_version }} + restart: unless-stopped + networks: + - netbird + logging: + driver: json-file + options: + max-size: "500m" + max-file: "2" + + relay: + image: netbirdio/relay:{{ netbird_version }} + restart: unless-stopped + networks: + - netbird + env_file: + - ./config/relay.env + logging: + driver: json-file + options: + max-size: "500m" + max-file: "2" + + management: + image: netbirdio/management:{{ netbird_version }} + restart: unless-stopped + networks: + - netbird + volumes: + - netbird_management:/var/lib/netbird + - ./config/management.json:/etc/netbird/management.json + logging: + driver: json-file + options: + max-size: "500m" + max-file: "2" + + coturn: + image: coturn/coturn:{{ coturn_version }} + restart: unless-stopped + networks: + - netbird + ports: + - "3478:3478/udp" + - "3478:3478/tcp" + volumes: + - ./config/turnserver.conf:/etc/coturn/turnserver.conf:ro + logging: + driver: json-file + options: + max-size: "500m" + max-file: "2" + + # --------------------------------------------------------------------------- + # Gitea (local Git server for GitOps source-of-truth) + # --------------------------------------------------------------------------- + gitea: + image: gitea/gitea:{{ gitea_version }} + restart: unless-stopped + networks: + - netbird + environment: + - GITEA__server__DOMAIN={{ netbird_domain }} + - GITEA__server__ROOT_URL=http://{{ netbird_domain }}:{{ gitea_http_port }} + - GITEA__database__DB_TYPE=sqlite3 + volumes: + - gitea_data:/data + ports: + - "{{ gitea_http_port }}:3000" + - "{{ gitea_ssh_port }}:22" + logging: + driver: json-file + options: + max-size: "50m" + max-file: "2" + + # --------------------------------------------------------------------------- + # NetBird GitOps Reconciler + # --------------------------------------------------------------------------- + reconciler: + build: + context: {{ base_dir }}/reconciler-src + dockerfile: Dockerfile + restart: unless-stopped + networks: + - netbird + env_file: + - ./config/reconciler.env + volumes: + - reconciler_data:/data + ports: + - "127.0.0.1:{{ reconciler_port }}:{{ reconciler_port }}" + depends_on: + - management + logging: + driver: json-file + options: + max-size: "50m" + max-file: "2" + +networks: + netbird: + +volumes: + netbird_management: + caddy_data: + caddy_config: + gitea_data: + reconciler_data: diff --git a/poc/ansible/templates/management.json.j2 b/poc/ansible/templates/management.json.j2 new file mode 100644 index 0000000..dddc79f --- /dev/null +++ b/poc/ansible/templates/management.json.j2 @@ -0,0 +1,49 @@ +{ + "Stuns": [ + { + "Proto": "udp", + "URI": "stun:{{ netbird_domain }}:3478" + } + ], + "TURNConfig": { + "Turns": [ + { + "Proto": "udp", + "URI": "turn:{{ netbird_domain }}:3478", + "Username": "netbird", + "Password": "{{ vault_turn_password }}" + } + ], + "TimeBasedCredentials": false + }, + "Relay": { + "Addresses": [ + "rels://{{ netbird_domain }}:443/relay" + ], + "CredentialsTTL": "168h", + "Secret": "{{ vault_relay_secret }}" + }, + "Signal": { + "Proto": "https", + "URI": "{{ netbird_domain }}:443" + }, + "Datadir": "/var/lib/netbird", + "DataStoreEncryptionKey": "{{ vault_encryption_key }}", + "StoreConfig": { + "Engine": "sqlite" + }, + "HttpConfig": { + "Address": "0.0.0.0:80" + }, + "IdpManagerConfig": { + "ManagerType": "none" + }, + "EmbeddedIdP": { + "Enabled": true, + "Issuer": "https://{{ netbird_domain }}/oauth2", + "DashboardRedirectURIs": [ + "https://{{ netbird_domain }}/nb-auth", + "https://{{ netbird_domain }}/nb-silent-auth" + ] + } +} diff --git a/poc/ansible/templates/reconciler.env.j2 b/poc/ansible/templates/reconciler.env.j2 new file mode 100644 index 0000000..561d494 --- /dev/null +++ b/poc/ansible/templates/reconciler.env.j2 @@ -0,0 +1,12 @@ +NETBIRD_API_URL=http://management:80/api +NETBIRD_API_TOKEN={{ vault_netbird_api_token }} +RECONCILER_TOKEN={{ vault_reconciler_token }} +GITEA_ENABLED={{ gitea_enabled }} +{% if gitea_enabled == "true" %} +GITEA_URL=http://gitea:{{ gitea_http_port }} +GITEA_TOKEN={{ vault_gitea_token }} +GITEA_REPO={{ gitea_org_name }}/{{ gitea_repo_name }} +{% endif %} +POLL_INTERVAL_SECONDS=30 +PORT={{ reconciler_port }} +DATA_DIR=/data diff --git a/poc/ansible/templates/relay.env.j2 b/poc/ansible/templates/relay.env.j2 new file mode 100644 index 0000000..7c27e2a --- /dev/null +++ b/poc/ansible/templates/relay.env.j2 @@ -0,0 +1,2 @@ +NB_RELAY_ADDRESS=rels://{{ netbird_domain }}:443/relay +NB_RELAY_SECRET={{ vault_relay_secret }} diff --git a/poc/ansible/templates/turnserver.conf.j2 b/poc/ansible/templates/turnserver.conf.j2 new file mode 100644 index 0000000..ec013b7 --- /dev/null +++ b/poc/ansible/templates/turnserver.conf.j2 @@ -0,0 +1,10 @@ +listening-port=3478 +relay-ip={{ ansible_default_ipv4.address }} +min-port=10000 +max-port=20000 +fingerprint +use-auth-secret +static-auth-secret={{ vault_turn_password }} +realm={{ netbird_domain }} +cert=/data/cert.pem +pkey=/data/key.pem diff --git a/src/config.ts b/src/config.ts index 3696b23..e1f1807 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,23 +1,65 @@ import { z } from "zod"; -const ConfigSchema = z.object({ +/** + * Base fields required regardless of Gitea integration mode. + */ +const BaseSchema = z.object({ netbirdApiUrl: z.string().url(), netbirdApiToken: z.string().min(1), - giteaUrl: z.string().url(), - giteaToken: z.string().min(1), - giteaRepo: z.string().regex(/^[^/]+\/[^/]+$/), // owner/repo + giteaEnabled: z.string().default("true").transform((v) => v === "true"), reconcilerToken: z.string().min(1), pollIntervalSeconds: z.coerce.number().int().positive().default(30), port: z.coerce.number().int().positive().default(8080), dataDir: z.string().default("/data"), }); +const GiteaFieldsSchema = z.object({ + giteaUrl: z.string().url(), + giteaToken: z.string().min(1), + giteaRepo: z.string().regex(/^[^/]+\/[^/]+$/), // owner/repo +}); + +/** + * When giteaEnabled=true, Gitea fields are validated and required. + * When giteaEnabled=false, Gitea fields are ignored. + * + * The output type always includes the optional Gitea fields so + * downstream code can check `config.giteaEnabled` and narrow. + */ +const ConfigSchema = BaseSchema.merge(GiteaFieldsSchema.partial()) + .superRefine((data, ctx) => { + if (!data.giteaEnabled) return; + + if (!data.giteaUrl) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "GITEA_URL is required when GITEA_ENABLED=true", + path: ["giteaUrl"], + }); + } + if (!data.giteaToken) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "GITEA_TOKEN is required when GITEA_ENABLED=true", + path: ["giteaToken"], + }); + } + if (!data.giteaRepo) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "GITEA_REPO is required when GITEA_ENABLED=true", + path: ["giteaRepo"], + }); + } + }); + export type Config = z.infer; export function loadConfig(): Config { return ConfigSchema.parse({ netbirdApiUrl: Deno.env.get("NETBIRD_API_URL"), netbirdApiToken: Deno.env.get("NETBIRD_API_TOKEN"), + giteaEnabled: Deno.env.get("GITEA_ENABLED"), giteaUrl: Deno.env.get("GITEA_URL"), giteaToken: Deno.env.get("GITEA_TOKEN"), giteaRepo: Deno.env.get("GITEA_REPO"), diff --git a/src/export.test.ts b/src/export.test.ts new file mode 100644 index 0000000..76b8ce7 --- /dev/null +++ b/src/export.test.ts @@ -0,0 +1,523 @@ +import { assertEquals } from "@std/assert"; +import { exportState } from "./export.ts"; +import type { ActualState } from "./state/actual.ts"; +import type { + NbDnsNameserverGroup, + NbGroup, + NbPeer, + NbPolicy, + NbRoute, + NbSetupKey, +} from "./netbird/types.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Builds a minimal ActualState with indexed maps from raw arrays. */ +function buildActualState(data: { + groups?: NbGroup[]; + setupKeys?: NbSetupKey[]; + peers?: NbPeer[]; + policies?: NbPolicy[]; + routes?: NbRoute[]; + dns?: NbDnsNameserverGroup[]; +}): ActualState { + const groups = data.groups ?? []; + const setupKeys = data.setupKeys ?? []; + const peers = data.peers ?? []; + const policies = data.policies ?? []; + const routes = data.routes ?? []; + const dns = data.dns ?? []; + + return { + groups, + groupsByName: new Map(groups.map((g) => [g.name, g])), + groupsById: new Map(groups.map((g) => [g.id, g])), + setupKeys, + setupKeysByName: new Map(setupKeys.map((k) => [k.name, k])), + peers, + peersByName: new Map(peers.map((p) => [p.name, p])), + peersById: new Map(peers.map((p) => [p.id, p])), + policies, + policiesByName: new Map(policies.map((p) => [p.name, p])), + routes, + routesByNetworkId: new Map(routes.map((r) => [r.network_id, r])), + dns, + dnsByName: new Map(dns.map((d) => [d.name, d])), + }; +} + +function makeGroup( + overrides: Partial & Pick, +): NbGroup { + return { + peers_count: 0, + peers: [], + issued: "api", + ...overrides, + }; +} + +function makeSetupKey( + overrides: Partial & Pick, +): NbSetupKey { + return { + id: 1, + type: "one-off", + key: "NBSK-masked", + expires: "2027-01-01T00:00:00Z", + valid: true, + revoked: false, + used_times: 0, + state: "valid", + auto_groups: [], + usage_limit: 1, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests: Normal state with groups, keys, policy +// --------------------------------------------------------------------------- + +Deno.test("exportState: normal state with groups, keys, and policy", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ + id: "g-pilots", + name: "pilots", + peers: [{ id: "p1", name: "Pilot-hawk-72" }], + }), + makeGroup({ id: "g-vehicles", name: "vehicles" }), + ], + setupKeys: [ + makeSetupKey({ + name: "Pilot-hawk-72", + auto_groups: ["g-pilots"], + used_times: 1, + usage_limit: 1, + }), + ], + policies: [ + { + id: "pol1", + name: "allow-pilot-vehicle", + description: "pilot to vehicle", + enabled: true, + rules: [ + { + name: "rule1", + description: "", + enabled: true, + action: "accept", + bidirectional: true, + protocol: "all", + sources: ["g-pilots"], + destinations: ["g-vehicles"], + }, + ], + }, + ], + }); + + const exported = exportState(actual); + + // Groups exported with correct peer mapping + assertEquals(Object.keys(exported.groups), ["pilots", "vehicles"]); + assertEquals(exported.groups["pilots"].peers, ["Pilot-hawk-72"]); + assertEquals(exported.groups["vehicles"].peers, []); + + // Setup key with auto_groups resolved to names + assertEquals(Object.keys(exported.setup_keys), ["Pilot-hawk-72"]); + assertEquals(exported.setup_keys["Pilot-hawk-72"].auto_groups, ["pilots"]); + assertEquals(exported.setup_keys["Pilot-hawk-72"].enrolled, true); + assertEquals(exported.setup_keys["Pilot-hawk-72"].type, "one-off"); + assertEquals(exported.setup_keys["Pilot-hawk-72"].expires_in, 604800); + + // Policy with source/destination resolved + assertEquals(Object.keys(exported.policies), ["allow-pilot-vehicle"]); + assertEquals(exported.policies["allow-pilot-vehicle"].sources, ["pilots"]); + assertEquals(exported.policies["allow-pilot-vehicle"].destinations, [ + "vehicles", + ]); + assertEquals(exported.policies["allow-pilot-vehicle"].bidirectional, true); + assertEquals(exported.policies["allow-pilot-vehicle"].protocol, "all"); + assertEquals(exported.policies["allow-pilot-vehicle"].action, "accept"); +}); + +// --------------------------------------------------------------------------- +// Tests: Empty state (only "All" group) +// --------------------------------------------------------------------------- + +Deno.test("exportState: empty state with only All group produces empty export", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ id: "g-all", name: "All", issued: "jwt" }), + ], + }); + + const exported = exportState(actual); + + assertEquals(Object.keys(exported.groups).length, 0); + assertEquals(Object.keys(exported.setup_keys).length, 0); + assertEquals(Object.keys(exported.policies).length, 0); + assertEquals(Object.keys(exported.routes).length, 0); + assertEquals(Object.keys(exported.dns.nameserver_groups).length, 0); +}); + +// --------------------------------------------------------------------------- +// Tests: auto_groups ID-to-name mapping +// --------------------------------------------------------------------------- + +Deno.test("exportState: auto_groups IDs are resolved to group names", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ id: "g-alpha", name: "alpha" }), + makeGroup({ id: "g-beta", name: "beta" }), + ], + setupKeys: [ + makeSetupKey({ + name: "key-1", + auto_groups: ["g-alpha", "g-beta"], + }), + ], + }); + + const exported = exportState(actual); + + assertEquals(exported.setup_keys["key-1"].auto_groups, ["alpha", "beta"]); +}); + +Deno.test("exportState: auto_groups with unresolvable IDs are dropped", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ id: "g-alpha", name: "alpha" }), + ], + setupKeys: [ + makeSetupKey({ + name: "key-1", + auto_groups: ["g-alpha", "g-nonexistent"], + }), + ], + }); + + const exported = exportState(actual); + + assertEquals(exported.setup_keys["key-1"].auto_groups, ["alpha"]); +}); + +// --------------------------------------------------------------------------- +// Tests: Enrolled detection +// --------------------------------------------------------------------------- + +Deno.test("exportState: enrolled detection — used key is enrolled", () => { + const actual = buildActualState({ + setupKeys: [ + makeSetupKey({ name: "used-key", used_times: 1, usage_limit: 1 }), + ], + }); + + assertEquals(exportState(actual).setup_keys["used-key"].enrolled, true); +}); + +Deno.test("exportState: enrolled detection — unused key is not enrolled", () => { + const actual = buildActualState({ + setupKeys: [ + makeSetupKey({ name: "fresh-key", used_times: 0, usage_limit: 1 }), + ], + }); + + assertEquals(exportState(actual).setup_keys["fresh-key"].enrolled, false); +}); + +Deno.test("exportState: enrolled detection — unlimited reusable is never enrolled", () => { + const actual = buildActualState({ + setupKeys: [ + makeSetupKey({ + name: "reusable-key", + type: "reusable", + used_times: 50, + usage_limit: 0, + }), + ], + }); + + assertEquals( + exportState(actual).setup_keys["reusable-key"].enrolled, + false, + ); +}); + +Deno.test("exportState: enrolled detection — partially used is not enrolled", () => { + const actual = buildActualState({ + setupKeys: [ + makeSetupKey({ + name: "partial-key", + type: "reusable", + used_times: 2, + usage_limit: 5, + }), + ], + }); + + assertEquals( + exportState(actual).setup_keys["partial-key"].enrolled, + false, + ); +}); + +// --------------------------------------------------------------------------- +// Tests: System groups excluded +// --------------------------------------------------------------------------- + +Deno.test("exportState: system groups are excluded", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ id: "g-all", name: "All", issued: "jwt" }), + makeGroup({ id: "g-jwt", name: "jwt-group", issued: "jwt" }), + makeGroup({ + id: "g-int", + name: "integration-group", + issued: "integration", + }), + makeGroup({ id: "g-api", name: "user-group", issued: "api" }), + ], + }); + + const exported = exportState(actual); + const groupNames = Object.keys(exported.groups); + + assertEquals(groupNames, ["user-group"]); +}); + +Deno.test("exportState: All group with api issued is still excluded", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ id: "g-all", name: "All", issued: "api" }), + makeGroup({ id: "g-user", name: "my-group", issued: "api" }), + ], + }); + + const exported = exportState(actual); + + assertEquals(Object.keys(exported.groups), ["my-group"]); +}); + +// --------------------------------------------------------------------------- +// Tests: Group peers filter by setup key name +// --------------------------------------------------------------------------- + +Deno.test("exportState: group peers only include names matching setup keys", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ + id: "g1", + name: "ops", + peers: [ + { id: "p1", name: "Pilot-hawk-72" }, + { id: "p2", name: "random-peer-no-key" }, + ], + }), + ], + setupKeys: [ + makeSetupKey({ name: "Pilot-hawk-72" }), + ], + }); + + const exported = exportState(actual); + + assertEquals(exported.groups["ops"].peers, ["Pilot-hawk-72"]); +}); + +// --------------------------------------------------------------------------- +// Tests: Policies +// --------------------------------------------------------------------------- + +Deno.test("exportState: policies with empty rules are skipped", () => { + const actual = buildActualState({ + policies: [ + { + id: "pol1", + name: "empty-policy", + description: "no rules", + enabled: true, + rules: [], + }, + ], + }); + + assertEquals(Object.keys(exportState(actual).policies).length, 0); +}); + +Deno.test("exportState: policy sources/destinations as {id,name} objects are resolved", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ id: "g-src", name: "source-group" }), + makeGroup({ id: "g-dst", name: "dest-group" }), + ], + policies: [ + { + id: "pol1", + name: "object-refs", + description: "", + enabled: true, + rules: [ + { + name: "r1", + description: "", + enabled: true, + action: "accept", + bidirectional: false, + protocol: "tcp", + ports: ["443", "8080"], + sources: [{ id: "g-src", name: "source-group" }], + destinations: [{ id: "g-dst", name: "dest-group" }], + }, + ], + }, + ], + }); + + const exported = exportState(actual); + + assertEquals(exported.policies["object-refs"].sources, ["source-group"]); + assertEquals(exported.policies["object-refs"].destinations, ["dest-group"]); + assertEquals(exported.policies["object-refs"].protocol, "tcp"); + assertEquals(exported.policies["object-refs"].ports, ["443", "8080"]); + assertEquals(exported.policies["object-refs"].bidirectional, false); +}); + +Deno.test("exportState: policy without ports omits the ports field", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ id: "g1", name: "g" }), + ], + policies: [ + { + id: "pol1", + name: "no-ports", + description: "", + enabled: true, + rules: [ + { + name: "r1", + description: "", + enabled: true, + action: "accept", + bidirectional: true, + protocol: "all", + sources: ["g1"], + destinations: ["g1"], + }, + ], + }, + ], + }); + + const exported = exportState(actual); + + assertEquals(exported.policies["no-ports"].ports, undefined); +}); + +// --------------------------------------------------------------------------- +// Tests: Routes +// --------------------------------------------------------------------------- + +Deno.test("exportState: routes keyed by network_id with IDs resolved", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ id: "g-pg", name: "peer-group" }), + makeGroup({ id: "g-dist", name: "dist-group" }), + ], + routes: [ + { + id: "r1", + description: "LAN route", + network_id: "lan-net", + enabled: true, + peer_groups: ["g-pg"], + network: "10.0.0.0/24", + metric: 100, + masquerade: true, + groups: ["g-dist"], + keep_route: false, + }, + ], + }); + + const exported = exportState(actual); + + assertEquals(Object.keys(exported.routes), ["lan-net"]); + assertEquals(exported.routes["lan-net"].peer_groups, ["peer-group"]); + assertEquals(exported.routes["lan-net"].distribution_groups, ["dist-group"]); + assertEquals(exported.routes["lan-net"].network, "10.0.0.0/24"); + assertEquals(exported.routes["lan-net"].metric, 100); + assertEquals(exported.routes["lan-net"].masquerade, true); + assertEquals(exported.routes["lan-net"].enabled, true); + assertEquals(exported.routes["lan-net"].keep_route, false); +}); + +Deno.test("exportState: route with domains and no network", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ id: "g1", name: "grp" }), + ], + routes: [ + { + id: "r2", + description: "DNS route", + network_id: "dns-route", + enabled: true, + peer_groups: ["g1"], + domains: ["example.com"], + metric: 9999, + masquerade: false, + groups: ["g1"], + keep_route: true, + }, + ], + }); + + const exported = exportState(actual); + + assertEquals(exported.routes["dns-route"].domains, ["example.com"]); + assertEquals(exported.routes["dns-route"].network, undefined); +}); + +// --------------------------------------------------------------------------- +// Tests: DNS +// --------------------------------------------------------------------------- + +Deno.test("exportState: DNS nameserver groups with IDs resolved", () => { + const actual = buildActualState({ + groups: [ + makeGroup({ id: "g-dns", name: "dns-group" }), + ], + dns: [ + { + id: "d1", + name: "internal-dns", + description: "internal resolver", + nameservers: [{ ip: "1.1.1.1", ns_type: "udp", port: 53 }], + enabled: true, + groups: ["g-dns"], + primary: true, + domains: ["internal."], + search_domains_enabled: false, + }, + ], + }); + + const exported = exportState(actual); + + assertEquals(Object.keys(exported.dns.nameserver_groups), ["internal-dns"]); + const ns = exported.dns.nameserver_groups["internal-dns"]; + assertEquals(ns.groups, ["dns-group"]); + assertEquals(ns.nameservers, [{ ip: "1.1.1.1", ns_type: "udp", port: 53 }]); + assertEquals(ns.primary, true); + assertEquals(ns.domains, ["internal."]); + assertEquals(ns.search_domains_enabled, false); + assertEquals(ns.enabled, true); + assertEquals(ns.description, "internal resolver"); +}); diff --git a/src/export.ts b/src/export.ts new file mode 100644 index 0000000..1c550a7 --- /dev/null +++ b/src/export.ts @@ -0,0 +1,243 @@ +import type { ActualState } from "./state/actual.ts"; +import type { DesiredState } from "./state/schema.ts"; + +/** + * Default expires_in value (seconds). The NetBird API does not return the + * original `expires_in` that was used to create a setup key — only the + * absolute `expires` timestamp. We fall back to 7 days as a reasonable + * default so the exported config round-trips cleanly. + */ +const DEFAULT_EXPIRES_IN = 604800; + +/** + * Transforms live NetBird state (as fetched from the API) into the + * declarative `netbird.json` format (`DesiredState`). + * + * This is the inverse of the reconciliation flow: given what's actually + * deployed, produce a config file that would recreate it. Useful for + * bootstrapping gitops from an existing NetBird account. + * + * Filtering rules: + * - Groups: system-managed groups (issued !== "api" or name "All") are + * excluded. Peer lists only include peers whose name matches a known + * setup key (since the desired-state schema maps peers to setup keys). + * - Setup keys: all exported. `auto_groups` IDs resolved to names. + * `enrolled` derived from usage counters. + * - Policies: empty-rules policies skipped. Source/destination IDs + * resolved to group names via the first rule. + * - Routes: keyed by `network_id`. Peer groups and distribution groups + * resolved from IDs to names. + * - DNS: group IDs resolved to names. + */ +export function exportState(actual: ActualState): DesiredState { + const idToName = buildIdToNameMap(actual); + const setupKeyNames = new Set(actual.setupKeys.map((k) => k.name)); + + return { + groups: exportGroups(actual, setupKeyNames, idToName), + setup_keys: exportSetupKeys(actual, idToName), + policies: exportPolicies(actual, idToName), + routes: exportRoutes(actual, idToName), + dns: { + nameserver_groups: exportDns(actual, idToName), + }, + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Builds a group ID → group name lookup from the full groups list. + * Used throughout to translate opaque IDs in API responses back to + * human-readable names for the config file. + */ +function buildIdToNameMap(actual: ActualState): Map { + return new Map(actual.groups.map((g) => [g.id, g.name])); +} + +/** Resolves an array of group IDs to group names, dropping any unresolvable IDs. */ +function resolveIds(ids: string[], idToName: Map): string[] { + return ids + .map((id) => idToName.get(id)) + .filter((name): name is string => name !== undefined); +} + +/** + * Extracts the group ID from a policy source/destination entry. + * The NetBird API returns these as either plain string IDs or + * `{ id, name }` objects depending on the API version. + */ +function extractGroupId(entry: string | { id: string; name: string }): string { + return typeof entry === "string" ? entry : entry.id; +} + +// --------------------------------------------------------------------------- +// Groups +// --------------------------------------------------------------------------- + +function exportGroups( + actual: ActualState, + setupKeyNames: Set, + _idToName: Map, +): DesiredState["groups"] { + const result: DesiredState["groups"] = {}; + + for (const group of actual.groups) { + if (isSystemGroup(group.name, group.issued)) continue; + + // Only include peers whose name matches a known setup key, since + // the desired-state schema models peers as setup-key references. + const peers = group.peers + .map((p) => p.name) + .filter((name) => setupKeyNames.has(name)); + + result[group.name] = { peers }; + } + + return result; +} + +function isSystemGroup(name: string, issued: string): boolean { + return name === "All" || issued !== "api"; +} + +// --------------------------------------------------------------------------- +// Setup Keys +// --------------------------------------------------------------------------- + +function exportSetupKeys( + actual: ActualState, + idToName: Map, +): DesiredState["setup_keys"] { + const result: DesiredState["setup_keys"] = {}; + + for (const key of actual.setupKeys) { + result[key.name] = { + type: key.type, + expires_in: DEFAULT_EXPIRES_IN, + usage_limit: key.usage_limit, + auto_groups: resolveIds(key.auto_groups, idToName), + enrolled: isEnrolled(key.used_times, key.usage_limit), + }; + } + + return result; +} + +/** + * A setup key is considered "enrolled" when it has been fully consumed: + * `used_times >= usage_limit` with a finite limit. Keys with + * `usage_limit === 0` (unlimited reusable) are never enrolled. + */ +function isEnrolled(usedTimes: number, usageLimit: number): boolean { + if (usageLimit === 0) return false; + return usedTimes >= usageLimit; +} + +// --------------------------------------------------------------------------- +// Policies +// --------------------------------------------------------------------------- + +function exportPolicies( + actual: ActualState, + idToName: Map, +): DesiredState["policies"] { + const result: DesiredState["policies"] = {}; + + for (const policy of actual.policies) { + if (policy.rules.length === 0) continue; + + const rule = policy.rules[0]; + const sources = resolveIds( + rule.sources.map(extractGroupId), + idToName, + ); + const destinations = resolveIds( + rule.destinations.map(extractGroupId), + idToName, + ); + + const entry: DesiredState["policies"][string] = { + description: policy.description, + enabled: policy.enabled, + sources, + destinations, + bidirectional: rule.bidirectional, + protocol: rule.protocol, + action: rule.action, + }; + + if (rule.ports && rule.ports.length > 0) { + entry.ports = rule.ports; + } + + result[policy.name] = entry; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +function exportRoutes( + actual: ActualState, + idToName: Map, +): DesiredState["routes"] { + const result: DesiredState["routes"] = {}; + + for (const route of actual.routes) { + const entry: DesiredState["routes"][string] = { + description: route.description, + peer_groups: resolveIds(route.peer_groups ?? [], idToName), + metric: route.metric, + masquerade: route.masquerade, + distribution_groups: resolveIds(route.groups, idToName), + enabled: route.enabled, + keep_route: route.keep_route, + }; + + if (route.network) { + entry.network = route.network; + } + if (route.domains && route.domains.length > 0) { + entry.domains = route.domains; + } + + result[route.network_id] = entry; + } + + return result; +} + +// --------------------------------------------------------------------------- +// DNS +// --------------------------------------------------------------------------- + +function exportDns( + actual: ActualState, + idToName: Map, +): DesiredState["dns"]["nameserver_groups"] { + const result: DesiredState["dns"]["nameserver_groups"] = {}; + + for (const ns of actual.dns) { + result[ns.name] = { + description: ns.description, + nameservers: ns.nameservers.map((s) => ({ + ip: s.ip, + ns_type: s.ns_type, + port: s.port, + })), + enabled: ns.enabled, + groups: resolveIds(ns.groups, idToName), + primary: ns.primary, + domains: ns.domains, + search_domains_enabled: ns.search_domains_enabled, + }; + } + + return result; +} diff --git a/src/gitea/client.test.ts b/src/gitea/client.test.ts index 2871f0c..857ee96 100644 --- a/src/gitea/client.test.ts +++ b/src/gitea/client.test.ts @@ -8,8 +8,8 @@ function mockFetch( const url = typeof input === "string" ? input : input instanceof URL - ? input.toString() - : input.url; + ? input.toString() + : input.url; const method = init?.method ?? "GET"; const key = `${method} ${url}`; const resp = responses.get(key); diff --git a/src/integration.test.ts b/src/integration.test.ts index 4aebb4b..5812bb3 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -92,6 +92,7 @@ function createMockFetch(calls: ApiCall[]) { const MOCK_CONFIG: Config = { netbirdApiUrl: MOCK_BASE, netbirdApiToken: "nb-test-token", + giteaEnabled: true, giteaUrl: "https://gitea.test", giteaToken: "gitea-test-token", giteaRepo: "org/repo", @@ -143,6 +144,32 @@ function authedRequest(path: string, body?: unknown): Request { }); } +// ----------------------------------------------------------------------------- +// Standalone mode (giteaEnabled: false, gitea: null) +// ----------------------------------------------------------------------------- + +const STANDALONE_CONFIG: Config = { + netbirdApiUrl: MOCK_BASE, + netbirdApiToken: "nb-test-token", + giteaEnabled: false, + reconcilerToken: "secret", + pollIntervalSeconds: 30, + port: 8080, + dataDir: "/data", +}; + +function buildStandaloneHandler(calls: ApiCall[]) { + const mockFetch = createMockFetch(calls); + const netbird = new NetbirdClient(MOCK_BASE, "nb-test-token", mockFetch); + + return createHandler({ + config: STANDALONE_CONFIG, + netbird, + gitea: null, + reconcileInProgress: { value: false }, + }); +} + // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- @@ -223,3 +250,187 @@ Deno.test("GET /health returns ok", async () => { const json = await resp.json(); assertEquals(json.status, "ok"); }); + +// ----------------------------------------------------------------------------- +// Standalone mode tests (giteaEnabled: false, gitea: null) +// ----------------------------------------------------------------------------- + +Deno.test("standalone: POST /reconcile?dry_run=true returns planned operations", async () => { + const calls: ApiCall[] = []; + const handler = buildStandaloneHandler(calls); + + const resp = await handler( + authedRequest("/reconcile?dry_run=true", DESIRED_STATE), + ); + assertEquals(resp.status, 200); + + const json = await resp.json(); + assertEquals(json.status, "planned"); + + const opTypes = json.operations.map((op: { type: string }) => op.type); + assertEquals(opTypes.includes("create_group"), true); + assertEquals(opTypes.includes("create_setup_key"), true); +}); + +Deno.test("standalone: POST /reconcile apply creates resources", async () => { + const calls: ApiCall[] = []; + const handler = buildStandaloneHandler(calls); + + const resp = await handler(authedRequest("/reconcile", DESIRED_STATE)); + assertEquals(resp.status, 200); + + const json = await resp.json(); + assertEquals(json.status, "applied"); + + assertExists(json.created_keys["Pilot-hawk-72"]); + assertEquals(json.created_keys["Pilot-hawk-72"], TEST_KEY_VALUE); +}); + +Deno.test("standalone: GET /health returns ok with gitea=null", async () => { + const calls: ApiCall[] = []; + const handler = buildStandaloneHandler(calls); + + const req = new Request("http://localhost:8080/health", { method: "GET" }); + const resp = await handler(req); + assertEquals(resp.status, 200); + + const json = await resp.json(); + assertEquals(json.status, "ok"); +}); + +Deno.test("standalone: POST /sync-events does not crash with gitea=null", async () => { + const calls: ApiCall[] = []; + const handler = buildStandaloneHandler(calls); + + const resp = await handler(authedRequest("/sync-events")); + assertEquals(resp.status, 200); + + const json = await resp.json(); + assertEquals(json.status, "synced"); +}); + +// ----------------------------------------------------------------------------- +// /export endpoint tests +// ----------------------------------------------------------------------------- + +/** + * Creates a mock fetch that returns pre-seeded data for GET list endpoints. + * This lets us verify the export endpoint transforms API data correctly. + */ +function createExportMockFetch(calls: ApiCall[]) { + return async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + const url = typeof input === "string" ? input : input.toString(); + const method = init?.method ?? "GET"; + const path = url.replace(MOCK_BASE, ""); + + calls.push({ method, path }); + + if (method === "GET" && path === "/groups") { + return Response.json([ + { + id: "g-pilots", + name: "pilots", + peers_count: 1, + peers: [{ id: "p1", name: "Pilot-hawk-72" }], + issued: "api", + }, + { + id: "g-all", + name: "All", + peers_count: 0, + peers: [], + issued: "jwt", + }, + ]); + } + if (method === "GET" && path === "/setup-keys") { + return Response.json([ + { + id: 1, + name: "Pilot-hawk-72", + type: "one-off", + key: "NBSK-masked", + expires: "2027-01-01T00:00:00Z", + valid: true, + revoked: false, + used_times: 1, + state: "overused", + auto_groups: ["g-pilots"], + usage_limit: 1, + }, + ]); + } + if (method === "GET" && path === "/peers") return Response.json([]); + if (method === "GET" && path === "/policies") return Response.json([]); + if (method === "GET" && path === "/routes") return Response.json([]); + if (method === "GET" && path === "/dns/nameservers") { + return Response.json([]); + } + if (method === "GET" && path === "/events/audit") { + return Response.json([]); + } + + return Response.json({ error: "mock: unhandled route" }, { status: 500 }); + }; +} + +function buildExportHandler(calls: ApiCall[]) { + const mockFetch = createExportMockFetch(calls); + const netbird = new NetbirdClient(MOCK_BASE, "nb-test-token", mockFetch); + + return createHandler({ + config: MOCK_CONFIG, + netbird, + gitea: {} as GiteaClient, + reconcileInProgress: { value: false }, + }); +} + +Deno.test("GET /export returns 200 with state and meta", async () => { + const calls: ApiCall[] = []; + const handler = buildExportHandler(calls); + + const req = new Request("http://localhost:8080/export", { + method: "GET", + headers: { "Authorization": "Bearer secret" }, + }); + + const resp = await handler(req); + assertEquals(resp.status, 200); + + const json = await resp.json(); + assertEquals(json.status, "ok"); + + // State should contain exported groups (minus "All") + assertEquals(Object.keys(json.state.groups), ["pilots"]); + assertEquals(json.state.groups["pilots"].peers, ["Pilot-hawk-72"]); + + // Setup key auto_groups should be resolved from IDs to names + assertEquals(json.state.setup_keys["Pilot-hawk-72"].auto_groups, ["pilots"]); + assertEquals(json.state.setup_keys["Pilot-hawk-72"].enrolled, true); + + // Meta should be present + assertExists(json.meta.exported_at); + assertEquals(json.meta.source_url, MOCK_BASE); + assertEquals(json.meta.groups_count, 1); + assertEquals(json.meta.setup_keys_count, 1); + assertEquals(json.meta.policies_count, 0); + assertEquals(json.meta.routes_count, 0); + assertEquals(json.meta.dns_count, 0); +}); + +Deno.test("GET /export requires auth — 401 without bearer", async () => { + const calls: ApiCall[] = []; + const handler = buildExportHandler(calls); + + const req = new Request("http://localhost:8080/export", { method: "GET" }); + + const resp = await handler(req); + assertEquals(resp.status, 401); + + const json = await resp.json(); + assertEquals(json.error, "unauthorized"); +}); diff --git a/src/main.ts b/src/main.ts index d782225..4c2af18 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,42 @@ import { NetbirdClient } from "./netbird/client.ts"; import { GiteaClient } from "./gitea/client.ts"; import { createHandler } from "./server.ts"; import { startPollerLoop } from "./poller/loop.ts"; +import { fetchActualState } from "./state/actual.ts"; +import { exportState } from "./export.ts"; + +// --------------------------------------------------------------------------- +// --export: one-shot state export, prints to stdout and exits +// --------------------------------------------------------------------------- + +function getCliArg(name: string): string | undefined { + const idx = Deno.args.indexOf(name); + if (idx === -1 || idx + 1 >= Deno.args.length) return undefined; + return Deno.args[idx + 1]; +} + +if (Deno.args.includes("--export")) { + const apiUrl = getCliArg("--netbird-api-url") ?? + Deno.env.get("NETBIRD_API_URL"); + const apiToken = getCliArg("--netbird-api-token") ?? + Deno.env.get("NETBIRD_API_TOKEN"); + + if (!apiUrl || !apiToken) { + console.error( + "Usage: --export --netbird-api-url --netbird-api-token ", + ); + Deno.exit(1); + } + + const client = new NetbirdClient(apiUrl, apiToken); + const actual = await fetchActualState(client); + const state = exportState(actual); + console.log(JSON.stringify(state, null, 2)); + Deno.exit(0); +} + +// --------------------------------------------------------------------------- +// Normal server mode +// --------------------------------------------------------------------------- let config; try { @@ -19,11 +55,9 @@ try { } const netbird = new NetbirdClient(config.netbirdApiUrl, config.netbirdApiToken); -const gitea = new GiteaClient( - config.giteaUrl, - config.giteaToken, - config.giteaRepo, -); +const gitea = config.giteaEnabled + ? new GiteaClient(config.giteaUrl!, config.giteaToken!, config.giteaRepo!) + : null; const reconcileInProgress = { value: false }; // Start background poller @@ -36,7 +70,13 @@ const pollerAbort = startPollerLoop({ // Start HTTP server const handler = createHandler({ config, netbird, gitea, reconcileInProgress }); -console.log(JSON.stringify({ msg: "starting", port: config.port })); +console.log( + JSON.stringify({ + msg: "starting", + port: config.port, + gitea_enabled: config.giteaEnabled, + }), +); Deno.serve({ port: config.port, handler }); // Graceful shutdown diff --git a/src/netbird/client.test.ts b/src/netbird/client.test.ts index 5e0f075..497b6d7 100644 --- a/src/netbird/client.test.ts +++ b/src/netbird/client.test.ts @@ -8,8 +8,8 @@ function mockFetch( const url = typeof input === "string" ? input : input instanceof URL - ? input.toString() - : input.url; + ? input.toString() + : input.url; const method = init?.method ?? "GET"; const key = `${method} ${url}`; const resp = responses.get(key); diff --git a/src/poller/loop.ts b/src/poller/loop.ts index b9b072a..45fc456 100644 --- a/src/poller/loop.ts +++ b/src/poller/loop.ts @@ -2,12 +2,10 @@ import type { Config } from "../config.ts"; import type { GiteaClient } from "../gitea/client.ts"; import { GiteaApiError } from "../gitea/client.ts"; import type { NetbirdClient } from "../netbird/client.ts"; +import type { NbSetupKey } from "../netbird/types.ts"; import { DesiredStateSchema } from "../state/schema.ts"; import type { DesiredState, SetupKeyConfig } from "../state/schema.ts"; -import { - type EnrollmentDetection, - processEnrollmentEvents, -} from "./poller.ts"; +import { type EnrollmentDetection, processEnrollmentEvents } from "./poller.ts"; import { loadPollerState, savePollerState } from "./state.ts"; // ----------------------------------------------------------------------------- @@ -17,7 +15,7 @@ import { loadPollerState, savePollerState } from "./state.ts"; export interface PollerContext { config: Config; netbird: NetbirdClient; - gitea: GiteaClient; + gitea: GiteaClient | null; reconcileInProgress: { value: boolean }; } @@ -26,21 +24,42 @@ export interface PollerContext { // ----------------------------------------------------------------------------- /** - * Runs one poll cycle: fetch desired state from Gitea, check for new - * enrollment events in NetBird, rename enrolled peers, and commit - * `enrolled: true` back to the repo. + * Runs one poll cycle. Two modes: + * + * **Gitea mode** (gitea != null): fetch desired state from the repo, detect + * enrollments via events, rename peers, commit `enrolled: true` back. + * + * **Standalone mode** (gitea == null): query NetBird setup keys directly, + * treat unused valid keys as "unenrolled", detect enrollments via events, + * rename peers, log (no commit). * * Skips entirely when a reconcile is in progress to avoid racing with * the webhook-triggered reconciliation path. */ export async function pollOnce(ctx: PollerContext): Promise { - const { config, netbird, gitea, reconcileInProgress } = ctx; - - if (reconcileInProgress.value) { - console.log(JSON.stringify({ msg: "poll_skipped", reason: "reconcile_in_progress" })); + if (ctx.reconcileInProgress.value) { + console.log( + JSON.stringify({ msg: "poll_skipped", reason: "reconcile_in_progress" }), + ); return; } + if (ctx.gitea) { + await pollOnceGitea(ctx, ctx.gitea); + } else { + await pollOnceStandalone(ctx); + } +} + +// ----------------------------------------------------------------------------- +// Gitea mode — full GitOps cycle +// ----------------------------------------------------------------------------- + +async function pollOnceGitea( + ctx: PollerContext, + gitea: GiteaClient, +): Promise { + const { config, netbird } = ctx; const pollerState = await loadPollerState(config.dataDir); // Fetch current desired state from Gitea (main branch) @@ -93,6 +112,7 @@ export async function pollOnce(ctx: PollerContext): Promise { for (const enrollment of enrollments) { await processEnrollment( ctx, + gitea, enrollment, currentDesired, currentSha, @@ -114,7 +134,110 @@ export async function pollOnce(ctx: PollerContext): Promise { } // ----------------------------------------------------------------------------- -// Process a single enrollment +// Standalone mode — no Gitea, keys come from NetBird API directly +// ----------------------------------------------------------------------------- + +/** + * In standalone mode there is no git repo to read from. Instead we query + * NetBird's setup keys API and consider any key that is valid, not revoked, + * and has remaining uses as "unenrolled". Enrollment detection and peer + * renaming still happen, but there is no commit-back step. + * + * Idempotency relies on `lastEventTimestamp` persisted in `dataDir`. + * If poller state is lost (data dir wiped, container restart without a + * persistent volume), historical enrollments will be replayed — peers + * get renamed again, which is harmless but produces noisy logs. + */ +async function pollOnceStandalone(ctx: PollerContext): Promise { + const { config, netbird } = ctx; + const pollerState = await loadPollerState(config.dataDir); + + const setupKeys = await netbird.listSetupKeys(); + const unenrolledKeys = new Set(); + + for (const key of setupKeys) { + if (isUnenrolledKey(key)) { + unenrolledKeys.add(key.name); + } + } + + if (unenrolledKeys.size === 0) { + console.log( + JSON.stringify({ msg: "poll_no_unenrolled_keys", mode: "standalone" }), + ); + return; + } + + const events = await netbird.listEvents(); + const enrollments = processEnrollmentEvents( + events, + unenrolledKeys, + pollerState.lastEventTimestamp, + ); + + if (enrollments.length === 0) { + return; + } + + console.log(JSON.stringify({ + msg: "poll_enrollments_detected", + count: enrollments.length, + mode: "standalone", + })); + + let latestTimestamp = pollerState.lastEventTimestamp; + + for (const enrollment of enrollments) { + await processEnrollmentStandalone(netbird, enrollment); + + if (!latestTimestamp || enrollment.timestamp > latestTimestamp) { + latestTimestamp = enrollment.timestamp; + } + } + + await savePollerState(config.dataDir, { + lastEventTimestamp: latestTimestamp, + }); +} + +/** A setup key is "unenrolled" if it is valid, not revoked, and has remaining uses. */ +function isUnenrolledKey(key: NbSetupKey): boolean { + // usage_limit === 0 means unlimited reusable key + return key.valid && !key.revoked && + (key.usage_limit === 0 || key.used_times < key.usage_limit); +} + +/** + * Standalone enrollment: rename the peer and log. No Gitea commit. + */ +async function processEnrollmentStandalone( + netbird: NetbirdClient, + enrollment: EnrollmentDetection, +): Promise { + const { setupKeyName, peerId, peerHostname } = enrollment; + + try { + await netbird.updatePeer(peerId, { name: setupKeyName }); + console.log(JSON.stringify({ + msg: "peer_renamed", + peer_id: peerId, + from: peerHostname, + to: setupKeyName, + mode: "standalone", + note: "gitea commit skipped in standalone mode", + })); + } catch (err) { + console.error(JSON.stringify({ + msg: "peer_rename_failed", + peer_id: peerId, + error: err instanceof Error ? err.message : String(err), + })); + return; + } +} + +// ----------------------------------------------------------------------------- +// Process a single enrollment (Gitea mode) // ----------------------------------------------------------------------------- /** @@ -126,12 +249,13 @@ export async function pollOnce(ctx: PollerContext): Promise { */ async function processEnrollment( ctx: PollerContext, + gitea: GiteaClient, enrollment: EnrollmentDetection, desired: DesiredState, fileSha: string, onCommit: (newSha: string, newDesired: DesiredState) => void, ): Promise { - const { netbird, gitea } = ctx; + const { netbird } = ctx; const { setupKeyName, peerId, peerHostname } = enrollment; // Rename the peer to match the setup key name diff --git a/src/reconcile/executor.test.ts b/src/reconcile/executor.test.ts index accc8a2..ce5834e 100644 --- a/src/reconcile/executor.test.ts +++ b/src/reconcile/executor.test.ts @@ -51,8 +51,7 @@ Deno.test("executor calls createGroup for create_group op", async () => { Deno.test("executor aborts on first failure", async () => { const mockClient = { createGroup: () => Promise.reject(new Error("API down")), - createSetupKey: () => - Promise.resolve({ id: 1, key: "k", name: "key1" }), + createSetupKey: () => Promise.resolve({ id: 1, key: "k", name: "key1" }), }; const ops: Operation[] = [ { type: "create_group", name: "pilots" }, diff --git a/src/reconcile/executor.ts b/src/reconcile/executor.ts index f080b2b..4fcbbe6 100644 --- a/src/reconcile/executor.ts +++ b/src/reconcile/executor.ts @@ -341,7 +341,9 @@ async function executeSingle( case "update_dns": { const existing = actual.dnsByName.get(op.name); if (!existing) { - throw new Error(`dns nameserver group "${op.name}" not found for update`); + throw new Error( + `dns nameserver group "${op.name}" not found for update`, + ); } const groupIds = d.groups ? ctx.resolveGroupIds(d.groups as string[]) diff --git a/src/server.ts b/src/server.ts index d67a75b..0817334 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,20 +1,18 @@ 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 { 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 { pollOnce, type PollerContext } from "./poller/loop.ts"; +import { type PollerContext, pollOnce } from "./poller/loop.ts"; +import { exportState } from "./export.ts"; export interface ServerContext { config: Config; netbird: NetbirdClient; - gitea: GiteaClient; + gitea: GiteaClient | null; reconcileInProgress: { value: boolean }; } @@ -43,6 +41,10 @@ export function createHandler( return handleSyncEvents(ctx); } + if (url.pathname === "/export" && req.method === "GET") { + return handleExport(ctx); + } + return Response.json({ error: "not found" }, { status: 404 }); }; } @@ -198,6 +200,50 @@ async function handleSyncEvents(ctx: ServerContext): Promise { } } +// ----------------------------------------------------------------------------- +// /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 // ----------------------------------------------------------------------------- diff --git a/src/state/actual.test.ts b/src/state/actual.test.ts index 926fdb6..11902f9 100644 --- a/src/state/actual.test.ts +++ b/src/state/actual.test.ts @@ -71,36 +71,64 @@ Deno.test("fetchActualState indexes all resource types", async () => { const actual = await fetchActualState( mockClient({ groups: [ - { id: "g1", name: "ops", peers_count: 1, peers: [{ id: "p1", name: "drone-1" }], issued: "api" }, + { + id: "g1", + name: "ops", + peers_count: 1, + peers: [{ id: "p1", name: "drone-1" }], + issued: "api", + }, ], peers: [ { - id: "p1", name: "drone-1", ip: "100.64.0.1", connected: true, - hostname: "drone-1", os: "linux", version: "0.28.0", - groups: [{ id: "g1", name: "ops" }], last_seen: "2026-03-01T00:00:00Z", - dns_label: "drone-1", login_expiration_enabled: false, - ssh_enabled: false, inactivity_expiration_enabled: false, + id: "p1", + name: "drone-1", + ip: "100.64.0.1", + connected: true, + hostname: "drone-1", + os: "linux", + version: "0.28.0", + groups: [{ id: "g1", name: "ops" }], + last_seen: "2026-03-01T00:00:00Z", + dns_label: "drone-1", + login_expiration_enabled: false, + ssh_enabled: false, + inactivity_expiration_enabled: false, }, ], policies: [ { - id: "pol1", name: "allow-ops", description: "ops traffic", - enabled: true, rules: [], + id: "pol1", + name: "allow-ops", + description: "ops traffic", + enabled: true, + rules: [], }, ], routes: [ { - id: "r1", description: "lan", network_id: "lan-net", - enabled: true, network: "10.0.0.0/24", metric: 100, - masquerade: true, groups: ["g1"], keep_route: false, + id: "r1", + description: "lan", + network_id: "lan-net", + enabled: true, + network: "10.0.0.0/24", + metric: 100, + masquerade: true, + groups: ["g1"], + keep_route: false, }, ], dns: [ { - id: "d1", name: "internal-dns", description: "internal", + id: "d1", + name: "internal-dns", + description: "internal", nameservers: [{ ip: "1.1.1.1", ns_type: "udp", port: 53 }], - enabled: true, groups: ["g1"], primary: true, - domains: ["internal."], search_domains_enabled: false, + enabled: true, + groups: ["g1"], + primary: true, + domains: ["internal."], + search_domains_enabled: false, }, ], }),