diff --git a/docs/plans/2026-03-06-schema-expansion.md b/docs/plans/2026-03-06-schema-expansion.md new file mode 100644 index 0000000..d814070 --- /dev/null +++ b/docs/plans/2026-03-06-schema-expansion.md @@ -0,0 +1,555 @@ +# Schema Expansion: Full NetBird State Coverage + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to +> implement this plan task-by-task. + +**Goal:** Expand the reconciler schema and export to cover all NetBird resource +types: posture checks, networks (with resources and routers), peers, users, and +resource-backed policies. + +**Architecture:** Each new resource type follows the existing pattern: add NB +types → add schema → add to ActualState → add client methods → add diff logic → +add executor handlers → add export → add tests. Policies are extended to support +`destination_resource` as an alternative to `destinations`. The "All" group gets +hardcoded exclusion from deletion. + +**Tech Stack:** Deno 2.x, TypeScript, Zod, injectable fetch for testing. + +--- + +### Task 1: Fix "All" group hardcoded exclusion + policy null-safety + +**Files:** + +- Modify: `src/reconcile/diff.ts:66-70` (add "All" name check) +- Modify: `src/reconcile/diff.ts:138-145` (null-safety for destinations) +- Modify: `src/reconcile/diff.test.ts` (add test for "All" exclusion with + `issued: "api"`) + +The diff already filters `issued === "api"` but "All" has `issued: "api"` in +real environments. Add explicit name exclusion. Also guard against `null` +destinations in policy rules (resource-backed policies). + +**Changes to `src/reconcile/diff.ts`:** + +In `diffGroups`, line 67, change: + +```typescript +if (!desiredNames.has(group.name) && group.issued === "api") { +``` + +to: + +```typescript +if (!desiredNames.has(group.name) && group.issued === "api" && group.name !== "All") { +``` + +In `diffPolicies`, around line 143, wrap destinations extraction: + +```typescript +const actualDests = extractGroupNames( + existing.rules.flatMap((r) => r.destinations ?? []), + actual, +).sort(); +``` + +Add test: `computeDiff does not delete "All" group even when issued is "api"`. + +Run: `deno task test` + +--- + +### Task 2: Add posture check and network types to `src/netbird/types.ts` + +**Files:** + +- Modify: `src/netbird/types.ts` + +Add these interfaces after the existing types: + +```typescript +/** Posture check as returned by GET /api/posture-checks */ +export interface NbPostureCheck { + id: string; + name: string; + description: string; + checks: Record; +} + +/** Network as returned by GET /api/networks */ +export interface NbNetwork { + id: string; + name: string; + description: string; + resources: string[]; + routers: string[]; + policies: string[]; + routing_peers_count: number; +} + +/** Network resource as returned by GET /api/networks/{id}/resources */ +export interface NbNetworkResource { + id: string; + name: string; + description: string; + type: "host" | "subnet" | "domain"; + address: string; + enabled: boolean; + groups: Array< + { id: string; name: string; peers_count: number; resources_count: number } + >; +} + +/** Network router as returned by GET /api/networks/{id}/routers */ +export interface NbNetworkRouter { + id: string; + peer: string | null; + peer_groups: string[] | null; + metric: number; + masquerade: boolean; + enabled: boolean; +} + +/** User as returned by GET /api/users */ +export interface NbUser { + id: string; + name: string; + email: string; + role: "owner" | "admin" | "user"; + status: "active" | "invited" | "blocked"; + auto_groups: string[]; + is_service_user: boolean; +} +``` + +Also add `destinationResource` and `source_posture_checks` to `NbPolicy`: + +```typescript +export interface NbPolicy { + id: string; + name: string; + description: string; + enabled: boolean; + rules: NbPolicyRule[]; + source_posture_checks: string[]; // posture check IDs +} +``` + +And add to `NbPolicyRule`: + +```typescript +export interface NbPolicyRule { + // ... existing fields ... + destinationResource?: { id: string; type: string } | null; +} +``` + +Run: `deno task check` + +--- + +### Task 3: Add client methods for new resource types + +**Files:** + +- Modify: `src/netbird/client.ts` + +Add sections for: + +**Posture Checks:** + +```typescript +listPostureChecks(): Promise +createPostureCheck(data: Omit): Promise +updatePostureCheck(id: string, data: Omit): Promise +deletePostureCheck(id: string): Promise +``` + +**Networks:** + +```typescript +listNetworks(): Promise +createNetwork(data: { name: string; description?: string }): Promise +updateNetwork(id: string, data: { name: string; description?: string }): Promise +deleteNetwork(id: string): Promise +``` + +**Network Resources (nested under network):** + +```typescript +listNetworkResources(networkId: string): Promise +createNetworkResource(networkId: string, data: { name: string; description?: string; address: string; enabled: boolean; groups: string[] }): Promise +updateNetworkResource(networkId: string, resourceId: string, data: { name: string; description?: string; address: string; enabled: boolean; groups: string[] }): Promise +deleteNetworkResource(networkId: string, resourceId: string): Promise +``` + +**Network Routers:** + +```typescript +listNetworkRouters(networkId: string): Promise +createNetworkRouter(networkId: string, data: Omit): Promise +updateNetworkRouter(networkId: string, routerId: string, data: Omit): Promise +deleteNetworkRouter(networkId: string, routerId: string): Promise +``` + +**Users:** + +```typescript +listUsers(): Promise +createUser(data: { email: string; name?: string; role: string; auto_groups: string[]; is_service_user: boolean }): Promise +updateUser(id: string, data: { name?: string; role?: string; auto_groups?: string[] }): Promise +deleteUser(id: string): Promise +``` + +Run: `deno task check` + +--- + +### Task 4: Expand ActualState with new resource collections + +**Files:** + +- Modify: `src/state/actual.ts` + +Add to `ActualState` interface: + +```typescript +postureChecks: NbPostureCheck[]; +postureChecksByName: Map; +networks: NbNetwork[]; +networksByName: Map; +networkResources: Map; // networkId -> resources +networkRouters: Map; // networkId -> routers +users: NbUser[]; +usersByEmail: Map; +``` + +Expand `ClientLike` to include: + +```typescript +| "listPostureChecks" +| "listNetworks" +| "listNetworkResources" +| "listNetworkRouters" +| "listUsers" +``` + +In `fetchActualState`: fetch posture checks, networks, users in the initial +`Promise.all`. Then for each network, fetch its resources and routers in a +second parallel batch. + +Run: `deno task check` + +--- + +### Task 5: Expand the Zod schema with new resource types + +**Files:** + +- Modify: `src/state/schema.ts` + +Add schemas: + +```typescript +export const PostureCheckSchema = z.object({ + description: z.string().default(""), + checks: z.record(z.string(), z.unknown()), +}); + +export const NetworkResourceSchema = z.object({ + name: z.string(), + description: z.string().default(""), + type: z.enum(["host", "subnet", "domain"]), + address: z.string(), + enabled: z.boolean().default(true), + groups: z.array(z.string()), +}); + +export const NetworkRouterSchema = z.object({ + peer: z.string().optional(), + peer_groups: z.array(z.string()).optional(), + metric: z.number().int().min(1).max(9999).default(9999), + masquerade: z.boolean().default(true), + enabled: z.boolean().default(true), +}); + +export const NetworkSchema = z.object({ + description: z.string().default(""), + resources: z.array(NetworkResourceSchema).default([]), + routers: z.array(NetworkRouterSchema).default([]), +}); + +export const PeerSchema = z.object({ + groups: z.array(z.string()), + login_expiration_enabled: z.boolean().default(false), + inactivity_expiration_enabled: z.boolean().default(false), + ssh_enabled: z.boolean().default(false), +}); + +export const UserSchema = z.object({ + name: z.string(), + role: z.enum(["owner", "admin", "user"]), + auto_groups: z.array(z.string()).default([]), +}); +``` + +Extend `PolicySchema` to support `destination_resource`: + +```typescript +export const DestinationResourceSchema = z.object({ + id: z.string(), // resource name, resolved at reconcile time + type: z.string(), +}); + +export const PolicySchema = z.object({ + description: z.string().default(""), + enabled: z.boolean(), + sources: z.array(z.string()), + destinations: z.array(z.string()).default([]), + destination_resource: DestinationResourceSchema.optional(), + bidirectional: z.boolean(), + protocol: z.enum(["tcp", "udp", "icmp", "all"]).default("all"), + action: z.enum(["accept", "drop"]).default("accept"), + ports: z.array(z.string()).optional(), + source_posture_checks: z.array(z.string()).default([]), +}); +``` + +Add to `DesiredStateSchema`: + +```typescript +export const DesiredStateSchema = z.object({ + groups: z.record(z.string(), GroupSchema), + setup_keys: z.record(z.string(), SetupKeySchema), + policies: z.record(z.string(), PolicySchema).default({}), + posture_checks: z.record(z.string(), PostureCheckSchema).default({}), + networks: z.record(z.string(), NetworkSchema).default({}), + peers: z.record(z.string(), PeerSchema).default({}), + users: z.record(z.string(), UserSchema).default({}), + routes: z.record(z.string(), RouteSchema).default({}), + dns: z.object({ + nameserver_groups: z.record(z.string(), DnsNameserverGroupSchema).default( + {}, + ), + }).default({ nameserver_groups: {} }), +}); +``` + +Update `validateCrossReferences` to also check: + +- Peer groups reference existing groups +- User auto_groups reference existing groups +- Network resource groups reference existing groups +- Policy source_posture_checks reference existing posture checks +- Policy destination_resource.id references an existing network resource name + +Run: `deno task check` + +--- + +### Task 6: Add operations for new resource types + +**Files:** + +- Modify: `src/reconcile/operations.ts` + +Add to `OperationType`: + +```typescript +| "create_posture_check" | "update_posture_check" | "delete_posture_check" +| "create_network" | "update_network" | "delete_network" +| "create_network_resource" | "update_network_resource" | "delete_network_resource" +| "create_network_router" | "update_network_router" | "delete_network_router" +| "create_user" | "update_user" | "delete_user" +| "update_peer" +``` + +Update `EXECUTION_ORDER` — networks must be created before resources/routers, +posture checks before policies that reference them: + +```typescript +export const EXECUTION_ORDER: OperationType[] = [ + "create_posture_check", + "update_posture_check", + "create_group", + "update_group", + "create_setup_key", + "rename_peer", + "update_peer_groups", + "update_peer", + "create_network", + "update_network", + "create_network_resource", + "update_network_resource", + "create_network_router", + "update_network_router", + "create_user", + "update_user", + "create_policy", + "update_policy", + "create_route", + "update_route", + "create_dns", + "update_dns", + // Deletions in reverse dependency order + "delete_dns", + "delete_route", + "delete_policy", + "delete_user", + "delete_network_router", + "delete_network_resource", + "delete_network", + "delete_peer", + "delete_setup_key", + "delete_posture_check", + "delete_group", +]; +``` + +Run: `deno task check` + +--- + +### Task 7: Add diff logic for new resource types + +**Files:** + +- Modify: `src/reconcile/diff.ts` + +Add `diffPostureChecks`, `diffNetworks`, `diffPeers`, `diffUsers` functions and +call them from `computeDiff`. + +**Posture checks:** Compare by name. Create if missing. Update if `checks` +object or description changed (deep JSON compare). Delete if not in desired. + +**Networks:** Compare by name. Create network if missing. For each network, diff +resources and routers: + +- Resources: match by name within the network. Create/update/delete. +- Routers: match by peer name (or peer_group). Create/update/delete. + +**Peers:** Compare by name. Only update operations (never create/delete). +Compare `groups` (excluding "All"), `login_expiration_enabled`, +`inactivity_expiration_enabled`, `ssh_enabled`. + +**Users:** Compare by email. Create if missing. Update if role or auto_groups +changed. Delete if not in desired (but never delete "owner" role). + +**Policies update:** Handle `destination_resource` — when present, skip +group-based destination comparison. Handle `source_posture_checks`. + +Run: `deno task check` + +--- + +### Task 8: Add executor handlers for new operations + +**Files:** + +- Modify: `src/reconcile/executor.ts` + +Add `case` handlers in `executeSingle` for all new operation types. Network +operations need special handling: resources and routers reference the network +ID, which may be newly created. Track `createdNetworkIds` similar to +`createdGroupIds`. + +Posture check operations: create/update/delete via client methods. Track +`createdPostureCheckIds`. + +User operations: resolve `auto_groups` names to IDs. + +Network resource operations: resolve `groups` names to IDs. + +Network router operations: resolve `peer` name to peer ID, or `peer_groups` +names to group IDs. + +Update `ExecutorClient` type to include all new client methods. + +Run: `deno task check` + +--- + +### Task 9: Update export to cover new resource types + +**Files:** + +- Modify: `src/export.ts` + +Add `exportPostureChecks`, `exportNetworks`, `exportPeers`, `exportUsers` +functions. + +**Posture checks:** Keyed by name. Pass through `checks` object as-is. Include +`description`. + +**Networks:** Keyed by name. For each network, fetch resources and routers from +ActualState maps. Resources: resolve group IDs to names. Routers: resolve peer +ID to peer name (via `actual.peersById`), resolve peer_group IDs to group names. + +**Peers:** Keyed by peer name. Include groups (resolved to names, excluding +"All"), `login_expiration_enabled`, `inactivity_expiration_enabled`, +`ssh_enabled`. + +**Users:** Keyed by email. Include name, role, auto_groups (resolved to names). + +**Policies:** Handle `destinationResource` — resolve resource ID to resource +name. Include `source_posture_checks` resolved to posture check names. + +Update the `exportState` return to include all new sections. + +Run: `deno task check` + +--- + +### Task 10: Export the three environments to state/*.json + +Run the export against all three production NetBird instances: + +```bash +mkdir -p state +deno task export -- --netbird-api-url https://dev.netbird.achilles-rnd.cc/api --netbird-api-token > state/dev.json +deno task export -- --netbird-api-url https://achilles-rnd.cc/api --netbird-api-token > state/prod.json +deno task export -- --netbird-api-url https://ext.netbird.achilles-rnd.cc/api --netbird-api-token > state/ext.json +``` + +Verify each file parses with the updated schema. Visually inspect for +completeness against dashboards. + +--- + +### Task 11: Update tests + +**Files:** + +- Modify: `src/reconcile/diff.test.ts` — tests for new diff functions +- Modify: `src/reconcile/executor.test.ts` — tests for new executor cases +- Modify: `src/export.test.ts` — tests for new export functions +- Modify: `src/state/schema.test.ts` — tests for new schema validation +- Modify: `src/state/actual.test.ts` — tests for expanded fetchActualState +- Modify: `src/integration.test.ts` — update mock data to include new resource + types + +All existing tests must continue to pass. New tests should cover: + +- Posture check CRUD diff/execute +- Network with resources and routers diff/execute +- Peer update diff (group changes, setting changes) +- User CRUD diff/execute +- Policy with destination_resource (export and diff) +- Policy with source_posture_checks (export and diff) +- Export of all new resource types + +Run: `deno task test` — all tests must pass. + +--- + +### Task 12: Final verification + +Run full quality gate: + +```bash +deno task check # type check +deno fmt --check # formatting +deno task test # all tests +``` + +All must pass. diff --git a/src/export.test.ts b/src/export.test.ts index 76b8ce7..87a4c82 100644 --- a/src/export.test.ts +++ b/src/export.test.ts @@ -4,10 +4,15 @@ import type { ActualState } from "./state/actual.ts"; import type { NbDnsNameserverGroup, NbGroup, + NbNetwork, + NbNetworkResource, + NbNetworkRouter, NbPeer, NbPolicy, + NbPostureCheck, NbRoute, NbSetupKey, + NbUser, } from "./netbird/types.ts"; // --------------------------------------------------------------------------- @@ -22,6 +27,11 @@ function buildActualState(data: { policies?: NbPolicy[]; routes?: NbRoute[]; dns?: NbDnsNameserverGroup[]; + postureChecks?: NbPostureCheck[]; + networks?: NbNetwork[]; + networkResources?: Map; + networkRouters?: Map; + users?: NbUser[]; }): ActualState { const groups = data.groups ?? []; const setupKeys = data.setupKeys ?? []; @@ -29,6 +39,9 @@ function buildActualState(data: { const policies = data.policies ?? []; const routes = data.routes ?? []; const dns = data.dns ?? []; + const postureChecks = data.postureChecks ?? []; + const networks = data.networks ?? []; + const users = data.users ?? []; return { groups, @@ -45,6 +58,14 @@ function buildActualState(data: { routesByNetworkId: new Map(routes.map((r) => [r.network_id, r])), dns, dnsByName: new Map(dns.map((d) => [d.name, d])), + postureChecks, + postureChecksByName: new Map(postureChecks.map((pc) => [pc.name, pc])), + networks, + networksByName: new Map(networks.map((n) => [n.name, n])), + networkResources: data.networkResources ?? new Map(), + networkRouters: data.networkRouters ?? new Map(), + users, + usersByEmail: new Map(users.map((u) => [u.email, u])), }; } @@ -105,6 +126,7 @@ Deno.test("exportState: normal state with groups, keys, and policy", () => { name: "allow-pilot-vehicle", description: "pilot to vehicle", enabled: true, + source_posture_checks: [], rules: [ { name: "rule1", @@ -342,6 +364,7 @@ Deno.test("exportState: policies with empty rules are skipped", () => { name: "empty-policy", description: "no rules", enabled: true, + source_posture_checks: [], rules: [], }, ], @@ -362,6 +385,7 @@ Deno.test("exportState: policy sources/destinations as {id,name} objects are res name: "object-refs", description: "", enabled: true, + source_posture_checks: [], rules: [ { name: "r1", @@ -399,6 +423,7 @@ Deno.test("exportState: policy without ports omits the ports field", () => { name: "no-ports", description: "", enabled: true, + source_posture_checks: [], rules: [ { name: "r1", diff --git a/src/export.ts b/src/export.ts index 1c550a7..69c1ff9 100644 --- a/src/export.ts +++ b/src/export.ts @@ -28,15 +28,41 @@ const DEFAULT_EXPIRES_IN = 604800; * - Routes: keyed by `network_id`. Peer groups and distribution groups * resolved from IDs to names. * - DNS: group IDs resolved to names. + * - Posture checks: keyed by name, checks object passed through. + * - Networks: keyed by name, resources and routers resolved. + * - Peers: keyed by name, groups resolved (excluding "All"). + * - Users: keyed by email, auto_groups resolved. */ export function exportState(actual: ActualState): DesiredState { const idToName = buildIdToNameMap(actual); const setupKeyNames = new Set(actual.setupKeys.map((k) => k.name)); + // Build resource ID → name map from all network resources + const resourceIdToName = new Map(); + for (const resources of actual.networkResources.values()) { + for (const res of resources) { + resourceIdToName.set(res.id, res.name); + } + } + + // Build posture check ID → name map + const postureCheckIdToName = new Map( + actual.postureChecks.map((pc) => [pc.id, pc.name]), + ); + return { groups: exportGroups(actual, setupKeyNames, idToName), setup_keys: exportSetupKeys(actual, idToName), - policies: exportPolicies(actual, idToName), + policies: exportPolicies( + actual, + idToName, + resourceIdToName, + postureCheckIdToName, + ), + posture_checks: exportPostureChecks(actual), + networks: exportNetworks(actual, idToName), + peers: exportPeers(actual, idToName), + users: exportUsers(actual, idToName), routes: exportRoutes(actual, idToName), dns: { nameserver_groups: exportDns(actual, idToName), @@ -89,7 +115,7 @@ function exportGroups( // 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 + const peers = (group.peers ?? []) .map((p) => p.name) .filter((name) => setupKeyNames.has(name)); @@ -143,6 +169,8 @@ function isEnrolled(usedTimes: number, usageLimit: number): boolean { function exportPolicies( actual: ActualState, idToName: Map, + resourceIdToName: Map, + postureCheckIdToName: Map, ): DesiredState["policies"] { const result: DesiredState["policies"] = {}; @@ -151,11 +179,7 @@ function exportPolicies( const rule = policy.rules[0]; const sources = resolveIds( - rule.sources.map(extractGroupId), - idToName, - ); - const destinations = resolveIds( - rule.destinations.map(extractGroupId), + (rule.sources ?? []).map(extractGroupId), idToName, ); @@ -163,12 +187,32 @@ function exportPolicies( description: policy.description, enabled: policy.enabled, sources, - destinations, + destinations: [], bidirectional: rule.bidirectional, protocol: rule.protocol, action: rule.action, + source_posture_checks: resolveIds( + policy.source_posture_checks ?? [], + postureCheckIdToName, + ), }; + // Handle destination_resource vs group-based destinations + if (rule.destinationResource) { + const resourceName = resourceIdToName.get( + rule.destinationResource.id, + ); + entry.destination_resource = { + id: resourceName ?? rule.destinationResource.id, + type: rule.destinationResource.type, + }; + } else { + entry.destinations = resolveIds( + (rule.destinations ?? []).map(extractGroupId), + idToName, + ); + } + if (rule.ports && rule.ports.length > 0) { entry.ports = rule.ports; } @@ -179,6 +223,121 @@ function exportPolicies( return result; } +// --------------------------------------------------------------------------- +// Posture Checks +// --------------------------------------------------------------------------- + +function exportPostureChecks( + actual: ActualState, +): DesiredState["posture_checks"] { + const result: DesiredState["posture_checks"] = {}; + + for (const pc of actual.postureChecks) { + result[pc.name] = { + description: pc.description, + checks: pc.checks, + }; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Networks +// --------------------------------------------------------------------------- + +function exportNetworks( + actual: ActualState, + idToName: Map, +): DesiredState["networks"] { + const result: DesiredState["networks"] = {}; + + for (const network of actual.networks) { + const resources = actual.networkResources.get(network.id) ?? []; + const routers = actual.networkRouters.get(network.id) ?? []; + + result[network.name] = { + description: network.description, + resources: resources.map((res) => ({ + name: res.name, + description: res.description, + type: res.type, + address: res.address, + enabled: res.enabled, + groups: res.groups.map((g) => { + // Resource groups are objects with id/name — use the idToName map + // for consistency, falling back to the embedded name. + return idToName.get(g.id) ?? g.name; + }), + })), + routers: routers.map((router) => { + const entry: DesiredState["networks"][string]["routers"][number] = { + metric: router.metric, + masquerade: router.masquerade, + enabled: router.enabled, + }; + if (router.peer) { + const peer = actual.peersById.get(router.peer); + entry.peer = peer ? peer.name : router.peer; + } + if (router.peer_groups && router.peer_groups.length > 0) { + entry.peer_groups = resolveIds(router.peer_groups, idToName); + } + return entry; + }), + }; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Peers +// --------------------------------------------------------------------------- + +function exportPeers( + actual: ActualState, + idToName: Map, +): DesiredState["peers"] { + const result: DesiredState["peers"] = {}; + + for (const peer of actual.peers) { + const groups = peer.groups + .filter((g) => g.name !== "All") + .map((g) => idToName.get(g.id) ?? g.name); + + result[peer.name] = { + groups, + login_expiration_enabled: peer.login_expiration_enabled, + inactivity_expiration_enabled: peer.inactivity_expiration_enabled, + ssh_enabled: peer.ssh_enabled, + }; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Users +// --------------------------------------------------------------------------- + +function exportUsers( + actual: ActualState, + idToName: Map, +): DesiredState["users"] { + const result: DesiredState["users"] = {}; + + for (const user of actual.users) { + result[user.email] = { + name: user.name, + role: user.role, + auto_groups: resolveIds(user.auto_groups, idToName), + }; + } + + return result; +} + // --------------------------------------------------------------------------- // Routes // --------------------------------------------------------------------------- diff --git a/src/integration.test.ts b/src/integration.test.ts index 5812bb3..a1a3c9b 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -369,6 +369,11 @@ function createExportMockFetch(calls: ApiCall[]) { if (method === "GET" && path === "/dns/nameservers") { return Response.json([]); } + if (method === "GET" && path === "/posture-checks") { + return Response.json([]); + } + if (method === "GET" && path === "/networks") return Response.json([]); + if (method === "GET" && path === "/users") return Response.json([]); if (method === "GET" && path === "/events/audit") { return Response.json([]); } diff --git a/src/netbird/client.ts b/src/netbird/client.ts index e2ccd25..d92993e 100644 --- a/src/netbird/client.ts +++ b/src/netbird/client.ts @@ -2,10 +2,15 @@ import type { NbDnsNameserverGroup, NbEvent, NbGroup, + NbNetwork, + NbNetworkResource, + NbNetworkRouter, NbPeer, NbPolicy, + NbPostureCheck, NbRoute, NbSetupKey, + NbUser, } from "./types.ts"; /** Narrowed fetch signature used for dependency injection. */ @@ -142,6 +147,7 @@ export class NetbirdClient { name?: string; ssh_enabled?: boolean; login_expiration_enabled?: boolean; + inactivity_expiration_enabled?: boolean; }, ): Promise { return this.request("PUT", `/peers/${id}`, data); @@ -223,4 +229,169 @@ export class NetbirdClient { listEvents(): Promise { return this.request("GET", "/events/audit"); } + + // --------------------------------------------------------------------------- + // Posture Checks + // --------------------------------------------------------------------------- + + listPostureChecks(): Promise { + return this.request("GET", "/posture-checks"); + } + + createPostureCheck( + data: Omit, + ): Promise { + return this.request("POST", "/posture-checks", data); + } + + updatePostureCheck( + id: string, + data: Omit, + ): Promise { + return this.request("PUT", `/posture-checks/${id}`, data); + } + + deletePostureCheck(id: string): Promise { + return this.request("DELETE", `/posture-checks/${id}`); + } + + // --------------------------------------------------------------------------- + // Networks + // --------------------------------------------------------------------------- + + listNetworks(): Promise { + return this.request("GET", "/networks"); + } + + createNetwork( + data: { name: string; description?: string }, + ): Promise { + return this.request("POST", "/networks", data); + } + + updateNetwork( + id: string, + data: { name: string; description?: string }, + ): Promise { + return this.request("PUT", `/networks/${id}`, data); + } + + deleteNetwork(id: string): Promise { + return this.request("DELETE", `/networks/${id}`); + } + + // --------------------------------------------------------------------------- + // Network Resources + // --------------------------------------------------------------------------- + + listNetworkResources(networkId: string): Promise { + return this.request("GET", `/networks/${networkId}/resources`); + } + + createNetworkResource( + networkId: string, + data: { + name: string; + description?: string; + address: string; + enabled: boolean; + groups: string[]; + }, + ): Promise { + return this.request("POST", `/networks/${networkId}/resources`, data); + } + + updateNetworkResource( + networkId: string, + resourceId: string, + data: { + name: string; + description?: string; + address: string; + enabled: boolean; + groups: string[]; + }, + ): Promise { + return this.request( + "PUT", + `/networks/${networkId}/resources/${resourceId}`, + data, + ); + } + + deleteNetworkResource( + networkId: string, + resourceId: string, + ): Promise { + return this.request( + "DELETE", + `/networks/${networkId}/resources/${resourceId}`, + ); + } + + // --------------------------------------------------------------------------- + // Network Routers + // --------------------------------------------------------------------------- + + listNetworkRouters(networkId: string): Promise { + return this.request("GET", `/networks/${networkId}/routers`); + } + + createNetworkRouter( + networkId: string, + data: Omit, + ): Promise { + return this.request("POST", `/networks/${networkId}/routers`, data); + } + + updateNetworkRouter( + networkId: string, + routerId: string, + data: Omit, + ): Promise { + return this.request( + "PUT", + `/networks/${networkId}/routers/${routerId}`, + data, + ); + } + + deleteNetworkRouter( + networkId: string, + routerId: string, + ): Promise { + return this.request( + "DELETE", + `/networks/${networkId}/routers/${routerId}`, + ); + } + + // --------------------------------------------------------------------------- + // Users + // --------------------------------------------------------------------------- + + listUsers(): Promise { + return this.request("GET", "/users"); + } + + createUser(data: { + email: string; + name?: string; + role: string; + auto_groups: string[]; + is_service_user: boolean; + }): Promise { + return this.request("POST", "/users", data); + } + + updateUser( + id: string, + data: { name?: string; role?: string; auto_groups?: string[] }, + ): Promise { + return this.request("PUT", `/users/${id}`, data); + } + + deleteUser(id: string): Promise { + return this.request("DELETE", `/users/${id}`); + } } diff --git a/src/netbird/types.ts b/src/netbird/types.ts index 5ccbb59..3e8701b 100644 --- a/src/netbird/types.ts +++ b/src/netbird/types.ts @@ -3,7 +3,7 @@ export interface NbGroup { id: string; name: string; peers_count: number; - peers: Array<{ id: string; name: string }>; + peers: Array<{ id: string; name: string }> | null; issued: "api" | "jwt" | "integration"; } @@ -46,6 +46,7 @@ export interface NbPolicy { description: string; enabled: boolean; rules: NbPolicyRule[]; + source_posture_checks: string[]; } export interface NbPolicyRule { @@ -57,8 +58,9 @@ export interface NbPolicyRule { bidirectional: boolean; protocol: "tcp" | "udp" | "icmp" | "all"; ports?: string[]; - sources: Array; - destinations: Array; + sources: Array | null; + destinations: Array | null; + destinationResource?: { id: string; type: string } | null; } /** Route as returned by GET /api/routes */ @@ -94,6 +96,62 @@ export interface NbDnsNameserverGroup { search_domains_enabled: boolean; } +/** Posture check as returned by GET /api/posture-checks */ +export interface NbPostureCheck { + id: string; + name: string; + description: string; + checks: Record; +} + +/** Network as returned by GET /api/networks */ +export interface NbNetwork { + id: string; + name: string; + description: string; + resources: string[]; + routers: string[]; + policies: string[]; + routing_peers_count: number; +} + +/** Network resource as returned by GET /api/networks/{id}/resources */ +export interface NbNetworkResource { + id: string; + name: string; + description: string; + type: "host" | "subnet" | "domain"; + address: string; + enabled: boolean; + groups: Array<{ + id: string; + name: string; + peers_count: number; + resources_count: number; + }>; +} + +/** Network router as returned by GET /api/networks/{id}/routers */ +export interface NbNetworkRouter { + id: string; + peer: string | null; + peer_groups: string[] | null; + metric: number; + masquerade: boolean; + enabled: boolean; +} + +/** User as returned by GET /api/users */ +export interface NbUser { + id: string; + name: string; + email: string; + role: "owner" | "admin" | "user"; + status: "active" | "invited" | "blocked"; + auto_groups: string[]; + is_service_user: boolean; +} + /** Audit event as returned by GET /api/events/audit */ export interface NbEvent { id: number; diff --git a/src/reconcile/diff.test.ts b/src/reconcile/diff.test.ts index a6b97a3..fb7f4dc 100644 --- a/src/reconcile/diff.test.ts +++ b/src/reconcile/diff.test.ts @@ -20,10 +20,36 @@ function emptyActual(): ActualState { routesByNetworkId: new Map(), dns: [], dnsByName: new Map(), + postureChecks: [], + postureChecksByName: new Map(), + networks: [], + networksByName: new Map(), + networkResources: new Map(), + networkRouters: new Map(), + users: [], + usersByEmail: new Map(), }; } -const DESIRED: DesiredState = { +/** Builds a minimal DesiredState with defaults for all required sections. */ +function desiredState( + overrides: Partial = {}, +): DesiredState { + return { + groups: {}, + setup_keys: {}, + policies: {}, + routes: {}, + dns: { nameserver_groups: {} }, + posture_checks: {}, + networks: {}, + peers: {}, + users: {}, + ...overrides, + }; +} + +const DESIRED: DesiredState = desiredState({ groups: { pilots: { peers: ["Pilot-hawk-72"] } }, setup_keys: { "Pilot-hawk-72": { @@ -34,10 +60,7 @@ const DESIRED: DesiredState = { enrolled: false, }, }, - policies: {}, - routes: {}, - dns: { nameserver_groups: {} }, -}; +}); Deno.test("computeDiff against empty actual produces create ops", () => { const ops = computeDiff(DESIRED, emptyActual()); @@ -80,13 +103,7 @@ Deno.test("computeDiff with matching state produces no ops", () => { }); Deno.test("computeDiff does not delete system groups", () => { - const desired: DesiredState = { - groups: {}, - setup_keys: {}, - policies: {}, - routes: {}, - dns: { nameserver_groups: {} }, - }; + const desired = desiredState(); const actual = emptyActual(); const jwtGroup: NbGroup = { @@ -104,13 +121,7 @@ Deno.test("computeDiff does not delete system groups", () => { }); Deno.test("computeDiff deletes api-issued groups not in desired", () => { - const desired: DesiredState = { - groups: {}, - setup_keys: {}, - policies: {}, - routes: {}, - dns: { nameserver_groups: {} }, - }; + const desired = desiredState(); const actual = emptyActual(); const staleGroup: NbGroup = { @@ -143,13 +154,9 @@ Deno.test("computeDiff detects group peer membership change", () => { actual.groups = [group]; // Desired has a peer in the group, actual has none - const desired: DesiredState = { + const desired = desiredState({ groups: { pilots: { peers: ["Pilot-hawk-72"] } }, - setup_keys: {}, - policies: {}, - routes: {}, - dns: { nameserver_groups: {} }, - }; + }); const ops = computeDiff(desired, actual); const updateOps = ops.filter((o) => o.type === "update_group"); assertEquals(updateOps.length, 1); @@ -157,8 +164,7 @@ Deno.test("computeDiff detects group peer membership change", () => { }); Deno.test("computeDiff skips enrolled setup keys", () => { - const desired: DesiredState = { - groups: {}, + const desired = desiredState({ setup_keys: { "Already-enrolled": { type: "one-off", @@ -168,19 +174,14 @@ Deno.test("computeDiff skips enrolled setup keys", () => { enrolled: true, }, }, - policies: {}, - routes: {}, - dns: { nameserver_groups: {} }, - }; + }); const ops = computeDiff(desired, emptyActual()); const createKeyOps = ops.filter((o) => o.type === "create_setup_key"); assertEquals(createKeyOps.length, 0); }); Deno.test("computeDiff creates policy when not in actual", () => { - const desired: DesiredState = { - groups: {}, - setup_keys: {}, + const desired = desiredState({ policies: { "allow-pilots": { description: "Allow pilot traffic", @@ -190,11 +191,10 @@ Deno.test("computeDiff creates policy when not in actual", () => { bidirectional: true, protocol: "all", action: "accept", + source_posture_checks: [], }, }, - routes: {}, - dns: { nameserver_groups: {} }, - }; + }); const ops = computeDiff(desired, emptyActual()); const policyOps = ops.filter((o) => o.type === "create_policy"); assertEquals(policyOps.length, 1); @@ -220,6 +220,7 @@ Deno.test("computeDiff detects policy enabled change", () => { name: "allow-pilots", description: "Allow pilot traffic", enabled: true, // currently enabled + source_posture_checks: [], rules: [{ name: "allow-pilots", description: "", @@ -233,9 +234,8 @@ Deno.test("computeDiff detects policy enabled change", () => { }); actual.policies = [actual.policiesByName.get("allow-pilots")!]; - const desired: DesiredState = { + const desired = desiredState({ groups: { pilots: { peers: [] } }, - setup_keys: {}, policies: { "allow-pilots": { description: "Allow pilot traffic", @@ -245,11 +245,10 @@ Deno.test("computeDiff detects policy enabled change", () => { bidirectional: true, protocol: "all", action: "accept", + source_posture_checks: [], }, }, - routes: {}, - dns: { nameserver_groups: {} }, - }; + }); const ops = computeDiff(desired, actual); const updateOps = ops.filter((o) => o.type === "update_policy"); assertEquals(updateOps.length, 1); @@ -257,10 +256,7 @@ Deno.test("computeDiff detects policy enabled change", () => { }); Deno.test("computeDiff creates route when not in actual", () => { - const desired: DesiredState = { - groups: {}, - setup_keys: {}, - policies: {}, + const desired = desiredState({ routes: { "vpn-exit": { description: "VPN exit route", @@ -273,8 +269,7 @@ Deno.test("computeDiff creates route when not in actual", () => { keep_route: true, }, }, - dns: { nameserver_groups: {} }, - }; + }); const ops = computeDiff(desired, emptyActual()); const routeOps = ops.filter((o) => o.type === "create_route"); assertEquals(routeOps.length, 1); @@ -282,11 +277,7 @@ Deno.test("computeDiff creates route when not in actual", () => { }); Deno.test("computeDiff creates dns when not in actual", () => { - const desired: DesiredState = { - groups: {}, - setup_keys: {}, - policies: {}, - routes: {}, + const desired = desiredState({ dns: { nameserver_groups: { "cloudflare": { @@ -300,7 +291,7 @@ Deno.test("computeDiff creates dns when not in actual", () => { }, }, }, - }; + }); const ops = computeDiff(desired, emptyActual()); const dnsOps = ops.filter((o) => o.type === "create_dns"); assertEquals(dnsOps.length, 1); @@ -309,7 +300,7 @@ Deno.test("computeDiff creates dns when not in actual", () => { Deno.test("computeDiff operations are sorted by EXECUTION_ORDER", () => { // Desired state that produces creates for multiple resource types - const desired: DesiredState = { + const desired = desiredState({ groups: { pilots: { peers: [] } }, setup_keys: { "new-key": { @@ -329,11 +320,10 @@ Deno.test("computeDiff operations are sorted by EXECUTION_ORDER", () => { bidirectional: true, protocol: "all", action: "accept", + source_posture_checks: [], }, }, - routes: {}, - dns: { nameserver_groups: {} }, - }; + }); const ops = computeDiff(desired, emptyActual()); // create_group must come before create_setup_key, which must come before diff --git a/src/reconcile/diff.ts b/src/reconcile/diff.ts index a866964..e8261d6 100644 --- a/src/reconcile/diff.ts +++ b/src/reconcile/diff.ts @@ -15,8 +15,12 @@ export function computeDiff( ): Operation[] { const ops: Operation[] = []; + diffPostureChecks(desired, actual, ops); diffGroups(desired, actual, ops); diffSetupKeys(desired, actual, ops); + diffNetworks(desired, actual, ops); + diffPeers(desired, actual, ops); + diffUsers(desired, actual, ops); diffPolicies(desired, actual, ops); diffRoutes(desired, actual, ops); diffDns(desired, actual, ops); @@ -24,6 +28,53 @@ export function computeDiff( return sortByExecutionOrder(ops); } +// --------------------------------------------------------------------------- +// Posture Checks +// --------------------------------------------------------------------------- + +function diffPostureChecks( + desired: DesiredState, + actual: ActualState, + ops: Operation[], +): void { + const desiredNames = new Set(Object.keys(desired.posture_checks)); + + for (const [name, config] of Object.entries(desired.posture_checks)) { + const existing = actual.postureChecksByName.get(name); + if (!existing) { + ops.push({ + type: "create_posture_check", + name, + details: { + description: config.description, + checks: config.checks, + }, + }); + continue; + } + + if ( + existing.description !== config.description || + JSON.stringify(existing.checks) !== JSON.stringify(config.checks) + ) { + ops.push({ + type: "update_posture_check", + name, + details: { + description: config.description, + checks: config.checks, + }, + }); + } + } + + for (const pc of actual.postureChecks) { + if (!desiredNames.has(pc.name)) { + ops.push({ type: "delete_posture_check", name: pc.name }); + } + } +} + // --------------------------------------------------------------------------- // Groups // --------------------------------------------------------------------------- @@ -47,7 +98,7 @@ function diffGroups( } // Compare peer membership by name (sorted for stable comparison) - const actualPeerNames = existing.peers.map((p) => p.name).sort(); + const actualPeerNames = (existing.peers ?? []).map((p) => p.name).sort(); const desiredPeerNames = [...config.peers].sort(); if (!arraysEqual(actualPeerNames, desiredPeerNames)) { ops.push({ @@ -64,7 +115,10 @@ function diffGroups( // Delete groups that exist in actual but not in desired. // Only delete API-issued groups — system and JWT groups are managed externally. for (const group of actual.groups) { - if (!desiredNames.has(group.name) && group.issued === "api") { + if ( + !desiredNames.has(group.name) && group.issued === "api" && + group.name !== "All" + ) { ops.push({ type: "delete_group", name: group.name }); } } @@ -106,6 +160,389 @@ function diffSetupKeys( } } +// --------------------------------------------------------------------------- +// Networks (including resources and routers) +// --------------------------------------------------------------------------- + +function diffNetworks( + desired: DesiredState, + actual: ActualState, + ops: Operation[], +): void { + const desiredNames = new Set(Object.keys(desired.networks)); + + for (const [name, config] of Object.entries(desired.networks)) { + const existing = actual.networksByName.get(name); + + if (!existing) { + ops.push({ + type: "create_network", + name, + details: { description: config.description }, + }); + + // All resources and routers under a new network are creates + for (const res of config.resources) { + ops.push({ + type: "create_network_resource", + name: res.name, + details: { + network_name: name, + description: res.description, + type: res.type, + address: res.address, + enabled: res.enabled, + groups: res.groups, + }, + }); + } + for (const router of config.routers) { + ops.push({ + type: "create_network_router", + name: routerKey(router), + details: { + network_name: name, + peer: router.peer, + peer_groups: router.peer_groups, + metric: router.metric, + masquerade: router.masquerade, + enabled: router.enabled, + }, + }); + } + continue; + } + + // Network exists — check for description change + if (existing.description !== config.description) { + ops.push({ + type: "update_network", + name, + details: { description: config.description }, + }); + } + + // Diff resources within this network + const actualResources = actual.networkResources.get(existing.id) ?? []; + diffNetworkResources(name, config.resources, actualResources, actual, ops); + + // Diff routers within this network + const actualRouters = actual.networkRouters.get(existing.id) ?? []; + diffNetworkRouters(name, config.routers, actualRouters, actual, ops); + } + + // Delete networks not in desired (this also implicitly removes their resources/routers) + for (const network of actual.networks) { + if (!desiredNames.has(network.name)) { + // Delete routers and resources first (execution order handles this, + // but we still emit the ops) + const routers = actual.networkRouters.get(network.id) ?? []; + for (const router of routers) { + ops.push({ + type: "delete_network_router", + name: actualRouterKey(router, actual), + details: { network_name: network.name, router_id: router.id }, + }); + } + const resources = actual.networkResources.get(network.id) ?? []; + for (const res of resources) { + ops.push({ + type: "delete_network_resource", + name: res.name, + details: { network_name: network.name, resource_id: res.id }, + }); + } + ops.push({ type: "delete_network", name: network.name }); + } + } +} + +function diffNetworkResources( + networkName: string, + desiredResources: DesiredState["networks"][string]["resources"], + actualResources: ActualState["networkResources"] extends Map< + string, + infer V + > ? V + : never, + actual: ActualState, + ops: Operation[], +): void { + const actualByName = new Map(actualResources.map((r) => [r.name, r])); + const desiredNames = new Set(desiredResources.map((r) => r.name)); + + for (const res of desiredResources) { + const existing = actualByName.get(res.name); + if (!existing) { + ops.push({ + type: "create_network_resource", + name: res.name, + details: { + network_name: networkName, + description: res.description, + type: res.type, + address: res.address, + enabled: res.enabled, + groups: res.groups, + }, + }); + continue; + } + + // Compare fields: resolve actual group names for comparison + const actualGroupNames = existing.groups.map((g) => g.name).sort(); + const desiredGroupNames = [...res.groups].sort(); + + if ( + existing.description !== res.description || + existing.type !== res.type || + existing.address !== res.address || + existing.enabled !== res.enabled || + !arraysEqual(actualGroupNames, desiredGroupNames) + ) { + ops.push({ + type: "update_network_resource", + name: res.name, + details: { + network_name: networkName, + resource_id: existing.id, + description: res.description, + type: res.type, + address: res.address, + enabled: res.enabled, + groups: res.groups, + }, + }); + } + } + + // Delete resources not in desired + for (const res of actualResources) { + if (!desiredNames.has(res.name)) { + ops.push({ + type: "delete_network_resource", + name: res.name, + details: { network_name: networkName, resource_id: res.id }, + }); + } + } +} + +function diffNetworkRouters( + networkName: string, + desiredRouters: DesiredState["networks"][string]["routers"], + actualRouters: ActualState["networkRouters"] extends Map ? V + : never, + actual: ActualState, + ops: Operation[], +): void { + // Match routers by their key (peer name or serialized peer_groups) + const actualByKey = new Map( + actualRouters.map((r) => [actualRouterKey(r, actual), r]), + ); + const desiredKeys = new Set(desiredRouters.map((r) => routerKey(r))); + + for (const router of desiredRouters) { + const key = routerKey(router); + const existing = actualByKey.get(key); + if (!existing) { + ops.push({ + type: "create_network_router", + name: key, + details: { + network_name: networkName, + peer: router.peer, + peer_groups: router.peer_groups, + metric: router.metric, + masquerade: router.masquerade, + enabled: router.enabled, + }, + }); + continue; + } + + // Compare mutable fields + if ( + existing.metric !== router.metric || + existing.masquerade !== router.masquerade || + existing.enabled !== router.enabled + ) { + ops.push({ + type: "update_network_router", + name: key, + details: { + network_name: networkName, + router_id: existing.id, + peer: router.peer, + peer_groups: router.peer_groups, + metric: router.metric, + masquerade: router.masquerade, + enabled: router.enabled, + }, + }); + } + } + + // Delete routers not in desired + for (const router of actualRouters) { + const key = actualRouterKey(router, actual); + if (!desiredKeys.has(key)) { + ops.push({ + type: "delete_network_router", + name: key, + details: { network_name: networkName, router_id: router.id }, + }); + } + } +} + +/** + * Generates a stable key for a desired router config. + * Uses the peer name if set, otherwise serializes peer_groups sorted. + */ +function routerKey( + router: { peer?: string; peer_groups?: string[] }, +): string { + if (router.peer) return `peer:${router.peer}`; + return `groups:${[...(router.peer_groups ?? [])].sort().join(",")}`; +} + +/** + * Generates a stable key for an actual router, resolving peer ID to name. + */ +function actualRouterKey( + router: { peer: string | null; peer_groups: string[] | null }, + actual: ActualState, +): string { + if (router.peer) { + const peer = actual.peersById.get(router.peer); + return `peer:${peer ? peer.name : router.peer}`; + } + // peer_groups on actual routers are group IDs — resolve to names + const groupNames = (router.peer_groups ?? []) + .map((id) => { + const g = actual.groupsById.get(id); + return g ? g.name : id; + }) + .sort(); + return `groups:${groupNames.join(",")}`; +} + +// --------------------------------------------------------------------------- +// Peers +// --------------------------------------------------------------------------- + +function diffPeers( + desired: DesiredState, + actual: ActualState, + ops: Operation[], +): void { + for (const [name, config] of Object.entries(desired.peers)) { + const existing = actual.peersByName.get(name); + if (!existing) continue; // Never create or delete peers + + let changed = false; + + // Compare groups (excluding "All"), resolve actual peer group names + const actualGroupNames = existing.groups + .map((g) => g.name) + .filter((n) => n !== "All") + .sort(); + const desiredGroupNames = [...config.groups].sort(); + if (!arraysEqual(actualGroupNames, desiredGroupNames)) { + changed = true; + } + + if ( + existing.login_expiration_enabled !== config.login_expiration_enabled || + existing.inactivity_expiration_enabled !== + config.inactivity_expiration_enabled || + existing.ssh_enabled !== config.ssh_enabled + ) { + changed = true; + } + + if (changed) { + ops.push({ + type: "update_peer", + name, + details: { + groups: config.groups, + login_expiration_enabled: config.login_expiration_enabled, + inactivity_expiration_enabled: config.inactivity_expiration_enabled, + ssh_enabled: config.ssh_enabled, + }, + }); + } + } +} + +// --------------------------------------------------------------------------- +// Users +// --------------------------------------------------------------------------- + +function diffUsers( + desired: DesiredState, + actual: ActualState, + ops: Operation[], +): void { + const desiredEmails = new Set(Object.keys(desired.users)); + + for (const [email, config] of Object.entries(desired.users)) { + const existing = actual.usersByEmail.get(email); + if (!existing) { + ops.push({ + type: "create_user", + name: email, + details: { + email, + name: config.name, + role: config.role, + auto_groups: config.auto_groups, + }, + }); + continue; + } + + // Compare role and auto_groups + const actualAutoGroupNames = resolveIds( + existing.auto_groups, + actual, + ).sort(); + const desiredAutoGroupNames = [...config.auto_groups].sort(); + + if ( + existing.role !== config.role || + !arraysEqual(actualAutoGroupNames, desiredAutoGroupNames) + ) { + ops.push({ + type: "update_user", + name: email, + details: { + name: config.name, + role: config.role, + auto_groups: config.auto_groups, + }, + }); + } + } + + // Delete users not in desired, but NEVER delete owners + for (const user of actual.users) { + if (!desiredEmails.has(user.email) && user.role !== "owner") { + ops.push({ type: "delete_user", name: user.email }); + } + } +} + +/** Resolves group IDs to group names using actual state. */ +function resolveIds(ids: string[], actual: ActualState): string[] { + return ids.map((id) => { + const group = actual.groupsById.get(id); + return group ? group.name : id; + }); +} + // --------------------------------------------------------------------------- // Policies // --------------------------------------------------------------------------- @@ -127,29 +564,56 @@ function diffPolicies( enabled: config.enabled, sources: config.sources, destinations: config.destinations, + destination_resource: config.destination_resource, + source_posture_checks: config.source_posture_checks, }, }); continue; } // Extract group names from actual rules for comparison. - // A policy may have multiple rules; aggregate sources/destinations - // across all rules for a flat comparison against the desired config. const actualSources = extractGroupNames( - existing.rules.flatMap((r) => r.sources), - actual, - ).sort(); - const actualDests = extractGroupNames( - existing.rules.flatMap((r) => r.destinations), + existing.rules.flatMap((r) => r.sources ?? []), actual, ).sort(); const desiredSources = [...config.sources].sort(); - const desiredDests = [...config.destinations].sort(); + + let destsChanged = false; + if (config.destination_resource) { + // When desired has destination_resource, compare against actual rule's destinationResource + const actualDestRes = existing.rules[0]?.destinationResource; + if ( + !actualDestRes || + actualDestRes.id !== config.destination_resource.id || + actualDestRes.type !== config.destination_resource.type + ) { + destsChanged = true; + } + } else { + // Standard group-based destination comparison + const actualDests = extractGroupNames( + existing.rules.flatMap((r) => r.destinations ?? []), + actual, + ).sort(); + const desiredDests = [...config.destinations].sort(); + destsChanged = !arraysEqual(actualDests, desiredDests); + } + + // Compare source_posture_checks + const actualPostureChecks = [ + ...(existing.source_posture_checks ?? []), + ].sort(); + const desiredPostureChecks = [...config.source_posture_checks].sort(); + const postureChecksChanged = !arraysEqual( + actualPostureChecks, + desiredPostureChecks, + ); if ( existing.enabled !== config.enabled || !arraysEqual(actualSources, desiredSources) || - !arraysEqual(actualDests, desiredDests) + destsChanged || + postureChecksChanged ) { ops.push({ type: "update_policy", @@ -158,6 +622,8 @@ function diffPolicies( enabled: config.enabled, sources: config.sources, destinations: config.destinations, + destination_resource: config.destination_resource, + source_posture_checks: config.source_posture_checks, }, }); } @@ -176,7 +642,7 @@ function diffPolicies( * back to the ID if the group is unknown (defensive). */ function extractGroupNames( - refs: NbPolicyRule["sources"], + refs: NonNullable, actual: ActualState, ): string[] { return refs.map((ref) => { diff --git a/src/reconcile/executor.test.ts b/src/reconcile/executor.test.ts index ce5834e..8d837f8 100644 --- a/src/reconcile/executor.test.ts +++ b/src/reconcile/executor.test.ts @@ -19,6 +19,14 @@ function emptyActual(): ActualState { routesByNetworkId: new Map(), dns: [], dnsByName: new Map(), + postureChecks: [], + postureChecksByName: new Map(), + networks: [], + networksByName: new Map(), + networkResources: new Map(), + networkRouters: new Map(), + users: [], + usersByEmail: new Map(), }; } diff --git a/src/reconcile/executor.ts b/src/reconcile/executor.ts index 4fcbbe6..c760189 100644 --- a/src/reconcile/executor.ts +++ b/src/reconcile/executor.ts @@ -31,6 +31,21 @@ type ExecutorClient = Pick< | "createDnsNameserverGroup" | "updateDnsNameserverGroup" | "deleteDnsNameserverGroup" + | "createPostureCheck" + | "updatePostureCheck" + | "deletePostureCheck" + | "createNetwork" + | "updateNetwork" + | "deleteNetwork" + | "createNetworkResource" + | "updateNetworkResource" + | "deleteNetworkResource" + | "createNetworkRouter" + | "updateNetworkRouter" + | "deleteNetworkRouter" + | "createUser" + | "updateUser" + | "deleteUser" >; /** @@ -48,6 +63,8 @@ export async function executeOperations( ): Promise { const results: OperationResult[] = []; const createdGroupIds = new Map(); + const createdPostureCheckIds = new Map(); + const createdNetworkIds = new Map(); const createdKeys = new Map(); function resolveGroupId(name: string): string { @@ -70,14 +87,41 @@ export async function executeOperations( }); } + function resolvePeerId(name: string): string { + const peer = actual.peersByName.get(name); + if (peer) return peer.id; + throw new Error(`peer "${name}" not found`); + } + + function resolvePostureCheckId(name: string): string { + const created = createdPostureCheckIds.get(name); + if (created) return created; + const existing = actual.postureChecksByName.get(name); + if (existing) return existing.id; + throw new Error(`posture check "${name}" not found`); + } + + function resolveNetworkId(name: string): string { + const created = createdNetworkIds.get(name); + if (created) return created; + const existing = actual.networksByName.get(name); + if (existing) return existing.id; + throw new Error(`network "${name}" not found`); + } + for (const op of ops) { try { await executeSingle(op, client, actual, { createdGroupIds, + createdPostureCheckIds, + createdNetworkIds, createdKeys, resolveGroupId, resolveGroupIds, resolvePeerIds, + resolvePeerId, + resolvePostureCheckId, + resolveNetworkId, }); results.push({ ...op, status: "success" }); } catch (err) { @@ -99,10 +143,15 @@ export async function executeOperations( interface ExecutorContext { createdGroupIds: Map; + createdPostureCheckIds: Map; + createdNetworkIds: Map; createdKeys: Map; resolveGroupId: (name: string) => string; resolveGroupIds: (names: string[]) => string[]; resolvePeerIds: (names: string[]) => string[]; + resolvePeerId: (name: string) => string; + resolvePostureCheckId: (name: string) => string; + resolveNetworkId: (name: string) => string; } async function executeSingle( @@ -114,6 +163,37 @@ async function executeSingle( const d = op.details ?? {}; switch (op.type) { + // ----- Posture Checks ----- + case "create_posture_check": { + const pc = await client.createPostureCheck({ + name: op.name, + description: (d.description as string) ?? "", + checks: (d.checks as Record) ?? {}, + }); + ctx.createdPostureCheckIds.set(op.name, pc.id); + break; + } + case "update_posture_check": { + const existing = actual.postureChecksByName.get(op.name); + if (!existing) { + throw new Error(`posture check "${op.name}" not found for update`); + } + await client.updatePostureCheck(existing.id, { + name: op.name, + description: (d.description as string) ?? existing.description, + checks: (d.checks as Record) ?? existing.checks, + }); + break; + } + case "delete_posture_check": { + const existing = actual.postureChecksByName.get(op.name); + if (!existing) { + throw new Error(`posture check "${op.name}" not found for delete`); + } + await client.deletePostureCheck(existing.id); + break; + } + // ----- Groups ----- case "create_group": { const peerNames = d.peers as string[] | undefined; @@ -127,7 +207,9 @@ async function executeSingle( } case "update_group": { const existing = actual.groupsByName.get(op.name); - if (!existing) throw new Error(`group "${op.name}" not found for update`); + if (!existing) { + throw new Error(`group "${op.name}" not found for update`); + } const desiredPeers = d.desired_peers as string[] | undefined; const peerIds = desiredPeers?.length ? ctx.resolvePeerIds(desiredPeers) @@ -180,7 +262,6 @@ async function executeSingle( break; } case "update_peer_groups": { - // This op type updates peer-level properties; details.id is the peer ID const peerId = d.id as string; if (!peerId) throw new Error(`update_peer_groups missing details.id`); await client.updatePeer(peerId, { @@ -192,6 +273,19 @@ async function executeSingle( }); break; } + case "update_peer": { + const peerId = ctx.resolvePeerId(op.name); + await client.updatePeer(peerId, { + login_expiration_enabled: d.login_expiration_enabled as + | boolean + | undefined, + inactivity_expiration_enabled: d.inactivity_expiration_enabled as + | boolean + | undefined, + ssh_enabled: d.ssh_enabled as boolean | undefined, + }); + break; + } case "delete_peer": { const peer = actual.peersByName.get(op.name); if (!peer) throw new Error(`peer "${op.name}" not found for delete`); @@ -199,26 +293,213 @@ async function executeSingle( break; } + // ----- Networks ----- + case "create_network": { + const network = await client.createNetwork({ + name: op.name, + description: (d.description as string) ?? "", + }); + ctx.createdNetworkIds.set(op.name, network.id); + break; + } + case "update_network": { + const existing = actual.networksByName.get(op.name); + if (!existing) { + throw new Error(`network "${op.name}" not found for update`); + } + await client.updateNetwork(existing.id, { + name: op.name, + description: (d.description as string) ?? existing.description, + }); + break; + } + case "delete_network": { + const existing = actual.networksByName.get(op.name); + if (!existing) { + throw new Error(`network "${op.name}" not found for delete`); + } + await client.deleteNetwork(existing.id); + break; + } + + // ----- Network Resources ----- + case "create_network_resource": { + const networkName = d.network_name as string; + if (!networkName) { + throw new Error("create_network_resource missing network_name"); + } + const networkId = ctx.resolveNetworkId(networkName); + const groupIds = ctx.resolveGroupIds(d.groups as string[] ?? []); + await client.createNetworkResource(networkId, { + name: op.name, + description: (d.description as string) ?? "", + address: d.address as string, + enabled: (d.enabled as boolean) ?? true, + groups: groupIds, + }); + break; + } + case "update_network_resource": { + const networkName = d.network_name as string; + if (!networkName) { + throw new Error("update_network_resource missing network_name"); + } + const networkId = ctx.resolveNetworkId(networkName); + const resourceId = d.resource_id as string; + if (!resourceId) { + throw new Error("update_network_resource missing resource_id"); + } + const groupIds = ctx.resolveGroupIds(d.groups as string[] ?? []); + await client.updateNetworkResource(networkId, resourceId, { + name: op.name, + description: (d.description as string) ?? "", + address: d.address as string, + enabled: (d.enabled as boolean) ?? true, + groups: groupIds, + }); + break; + } + case "delete_network_resource": { + const networkName = d.network_name as string; + if (!networkName) { + throw new Error("delete_network_resource missing network_name"); + } + const networkId = ctx.resolveNetworkId(networkName); + const resourceId = d.resource_id as string; + if (!resourceId) { + throw new Error("delete_network_resource missing resource_id"); + } + await client.deleteNetworkResource(networkId, resourceId); + break; + } + + // ----- Network Routers ----- + case "create_network_router": { + const networkName = d.network_name as string; + if (!networkName) { + throw new Error("create_network_router missing network_name"); + } + const networkId = ctx.resolveNetworkId(networkName); + const peer = d.peer ? ctx.resolvePeerId(d.peer as string) : null; + const peerGroups = d.peer_groups + ? ctx.resolveGroupIds(d.peer_groups as string[]) + : null; + await client.createNetworkRouter(networkId, { + peer, + peer_groups: peerGroups, + metric: (d.metric as number) ?? 9999, + masquerade: (d.masquerade as boolean) ?? true, + enabled: (d.enabled as boolean) ?? true, + }); + break; + } + case "update_network_router": { + const networkName = d.network_name as string; + if (!networkName) { + throw new Error("update_network_router missing network_name"); + } + const networkId = ctx.resolveNetworkId(networkName); + const routerId = d.router_id as string; + if (!routerId) { + throw new Error("update_network_router missing router_id"); + } + const peer = d.peer ? ctx.resolvePeerId(d.peer as string) : null; + const peerGroups = d.peer_groups + ? ctx.resolveGroupIds(d.peer_groups as string[]) + : null; + await client.updateNetworkRouter(networkId, routerId, { + peer, + peer_groups: peerGroups, + metric: (d.metric as number) ?? 9999, + masquerade: (d.masquerade as boolean) ?? true, + enabled: (d.enabled as boolean) ?? true, + }); + break; + } + case "delete_network_router": { + const networkName = d.network_name as string; + if (!networkName) { + throw new Error("delete_network_router missing network_name"); + } + const networkId = ctx.resolveNetworkId(networkName); + const routerId = d.router_id as string; + if (!routerId) { + throw new Error("delete_network_router missing router_id"); + } + await client.deleteNetworkRouter(networkId, routerId); + break; + } + + // ----- Users ----- + case "create_user": { + const autoGroupIds = ctx.resolveGroupIds( + d.auto_groups as string[] ?? [], + ); + await client.createUser({ + email: d.email as string, + name: d.name as string | undefined, + role: d.role as string, + auto_groups: autoGroupIds, + is_service_user: false, + }); + break; + } + case "update_user": { + const existing = actual.usersByEmail.get(op.name); + if (!existing) { + throw new Error(`user "${op.name}" not found for update`); + } + const autoGroupIds = ctx.resolveGroupIds( + d.auto_groups as string[] ?? [], + ); + await client.updateUser(existing.id, { + name: d.name as string | undefined, + role: d.role as string | undefined, + auto_groups: autoGroupIds, + }); + break; + } + case "delete_user": { + const existing = actual.usersByEmail.get(op.name); + if (!existing) { + throw new Error(`user "${op.name}" not found for delete`); + } + await client.deleteUser(existing.id); + break; + } + // ----- Policies ----- case "create_policy": { const sourceIds = ctx.resolveGroupIds(d.sources as string[] ?? []); - const destIds = ctx.resolveGroupIds(d.destinations as string[] ?? []); + const destResource = d.destination_resource as + | { id: string; type: string } + | undefined; + const destIds = destResource + ? [] + : ctx.resolveGroupIds(d.destinations as string[] ?? []); + const postureCheckIds = (d.source_posture_checks as string[] ?? []) + .map((name) => ctx.resolvePostureCheckId(name)); + const rule: Record = { + name: op.name, + description: (d.description as string) ?? "", + enabled: (d.enabled as boolean) ?? true, + action: (d.action as string) ?? "accept", + bidirectional: (d.bidirectional as boolean) ?? true, + protocol: (d.protocol as string) ?? "all", + ports: d.ports as string[] | undefined, + sources: sourceIds, + destinations: destIds, + }; + if (destResource) { + rule.destinationResource = destResource; + } await client.createPolicy({ name: op.name, description: (d.description as string) ?? "", enabled: (d.enabled as boolean) ?? true, + source_posture_checks: postureCheckIds, rules: [ - { - name: op.name, - description: (d.description as string) ?? "", - enabled: (d.enabled as boolean) ?? true, - action: (d.action as "accept" | "drop") ?? "accept", - bidirectional: (d.bidirectional as boolean) ?? true, - protocol: (d.protocol as "tcp" | "udp" | "icmp" | "all") ?? "all", - ports: d.ports as string[] | undefined, - sources: sourceIds, - destinations: destIds, - }, + rule as unknown as import("../netbird/types.ts").NbPolicyRule, ], }); break; @@ -229,23 +510,35 @@ async function executeSingle( throw new Error(`policy "${op.name}" not found for update`); } const sourceIds = ctx.resolveGroupIds(d.sources as string[] ?? []); - const destIds = ctx.resolveGroupIds(d.destinations as string[] ?? []); + const destResource = d.destination_resource as + | { id: string; type: string } + | undefined; + const destIds = destResource + ? [] + : ctx.resolveGroupIds(d.destinations as string[] ?? []); + const postureCheckIds = (d.source_posture_checks as string[] ?? []) + .map((name) => ctx.resolvePostureCheckId(name)); + const rule: Record = { + name: op.name, + description: (d.description as string) ?? existing.description, + enabled: (d.enabled as boolean) ?? existing.enabled, + action: (d.action as string) ?? "accept", + bidirectional: (d.bidirectional as boolean) ?? true, + protocol: (d.protocol as string) ?? "all", + ports: d.ports as string[] | undefined, + sources: sourceIds, + destinations: destIds, + }; + if (destResource) { + rule.destinationResource = destResource; + } await client.updatePolicy(existing.id, { name: op.name, description: (d.description as string) ?? existing.description, enabled: (d.enabled as boolean) ?? existing.enabled, + source_posture_checks: postureCheckIds, rules: [ - { - name: op.name, - description: (d.description as string) ?? existing.description, - enabled: (d.enabled as boolean) ?? existing.enabled, - action: (d.action as "accept" | "drop") ?? "accept", - bidirectional: (d.bidirectional as boolean) ?? true, - protocol: (d.protocol as "tcp" | "udp" | "icmp" | "all") ?? "all", - ports: d.ports as string[] | undefined, - sources: sourceIds, - destinations: destIds, - }, + rule as unknown as import("../netbird/types.ts").NbPolicyRule, ], }); break; diff --git a/src/reconcile/operations.ts b/src/reconcile/operations.ts index 639a70b..54697f5 100644 --- a/src/reconcile/operations.ts +++ b/src/reconcile/operations.ts @@ -6,6 +6,7 @@ export type OperationType = | "delete_setup_key" | "rename_peer" | "update_peer_groups" + | "update_peer" | "delete_peer" | "create_policy" | "update_policy" @@ -15,7 +16,22 @@ export type OperationType = | "delete_route" | "create_dns" | "update_dns" - | "delete_dns"; + | "delete_dns" + | "create_posture_check" + | "update_posture_check" + | "delete_posture_check" + | "create_network" + | "update_network" + | "delete_network" + | "create_network_resource" + | "update_network_resource" + | "delete_network_resource" + | "create_network_router" + | "update_network_router" + | "delete_network_router" + | "create_user" + | "update_user" + | "delete_user"; export interface Operation { type: OperationType; @@ -30,11 +46,23 @@ export interface OperationResult extends Operation { /** Order in which operation types must be executed */ export const EXECUTION_ORDER: OperationType[] = [ + // Creates: dependencies first + "create_posture_check", + "update_posture_check", "create_group", "update_group", "create_setup_key", "rename_peer", "update_peer_groups", + "update_peer", + "create_network", + "update_network", + "create_network_resource", + "update_network_resource", + "create_network_router", + "update_network_router", + "create_user", + "update_user", "create_policy", "update_policy", "create_route", @@ -45,7 +73,12 @@ export const EXECUTION_ORDER: OperationType[] = [ "delete_dns", "delete_route", "delete_policy", + "delete_user", + "delete_network_router", + "delete_network_resource", + "delete_network", "delete_peer", "delete_setup_key", + "delete_posture_check", "delete_group", ]; diff --git a/src/state/actual.test.ts b/src/state/actual.test.ts index 11902f9..0c425a8 100644 --- a/src/state/actual.test.ts +++ b/src/state/actual.test.ts @@ -3,10 +3,15 @@ import { fetchActualState } from "./actual.ts"; import type { NbDnsNameserverGroup, NbGroup, + NbNetwork, + NbNetworkResource, + NbNetworkRouter, NbPeer, NbPolicy, + NbPostureCheck, NbRoute, NbSetupKey, + NbUser, } from "../netbird/types.ts"; /** Minimal mock NetBird client that returns predetermined data */ @@ -17,6 +22,11 @@ function mockClient(data: { policies?: NbPolicy[]; routes?: NbRoute[]; dns?: NbDnsNameserverGroup[]; + postureChecks?: NbPostureCheck[]; + networks?: NbNetwork[]; + networkResources?: Map; + networkRouters?: Map; + users?: NbUser[]; }) { return { listGroups: () => Promise.resolve(data.groups ?? []), @@ -25,6 +35,13 @@ function mockClient(data: { listPolicies: () => Promise.resolve(data.policies ?? []), listRoutes: () => Promise.resolve(data.routes ?? []), listDnsNameserverGroups: () => Promise.resolve(data.dns ?? []), + listPostureChecks: () => Promise.resolve(data.postureChecks ?? []), + listNetworks: () => Promise.resolve(data.networks ?? []), + listNetworkResources: (networkId: string) => + Promise.resolve(data.networkResources?.get(networkId) ?? []), + listNetworkRouters: (networkId: string) => + Promise.resolve(data.networkRouters?.get(networkId) ?? []), + listUsers: () => Promise.resolve(data.users ?? []), }; } @@ -102,6 +119,7 @@ Deno.test("fetchActualState indexes all resource types", async () => { name: "allow-ops", description: "ops traffic", enabled: true, + source_posture_checks: [], rules: [], }, ], diff --git a/src/state/actual.ts b/src/state/actual.ts index d4121a6..b227521 100644 --- a/src/state/actual.ts +++ b/src/state/actual.ts @@ -2,10 +2,15 @@ import type { NetbirdClient } from "../netbird/client.ts"; import type { NbDnsNameserverGroup, NbGroup, + NbNetwork, + NbNetworkResource, + NbNetworkRouter, NbPeer, NbPolicy, + NbPostureCheck, NbRoute, NbSetupKey, + NbUser, } from "../netbird/types.ts"; /** Indexed view of all current NetBird state */ @@ -24,6 +29,14 @@ export interface ActualState { routesByNetworkId: Map; dns: NbDnsNameserverGroup[]; dnsByName: Map; + postureChecks: NbPostureCheck[]; + postureChecksByName: Map; + networks: NbNetwork[]; + networksByName: Map; + networkResources: Map; // keyed by network ID + networkRouters: Map; // keyed by network ID + users: NbUser[]; + usersByEmail: Map; } /** @@ -40,6 +53,11 @@ type ClientLike = Pick< | "listPolicies" | "listRoutes" | "listDnsNameserverGroups" + | "listPostureChecks" + | "listNetworks" + | "listNetworkResources" + | "listNetworkRouters" + | "listUsers" >; /** @@ -50,15 +68,51 @@ type ClientLike = Pick< export async function fetchActualState( client: ClientLike, ): Promise { - const [groups, setupKeys, peers, policies, routes, dns] = await Promise.all([ + const [ + groups, + setupKeys, + peers, + policies, + routes, + dns, + postureChecks, + networks, + users, + ] = await Promise.all([ client.listGroups(), client.listSetupKeys(), client.listPeers(), client.listPolicies(), client.listRoutes(), client.listDnsNameserverGroups(), + client.listPostureChecks(), + client.listNetworks(), + client.listUsers(), ]); + // Fetch sub-resources for each network in parallel + const [resourcesByNetwork, routersByNetwork] = await Promise.all([ + Promise.all( + networks.map(async (n) => ({ + id: n.id, + resources: await client.listNetworkResources(n.id), + })), + ), + Promise.all( + networks.map(async (n) => ({ + id: n.id, + routers: await client.listNetworkRouters(n.id), + })), + ), + ]); + + const networkResources = new Map( + resourcesByNetwork.map((r) => [r.id, r.resources]), + ); + const networkRouters = new Map( + routersByNetwork.map((r) => [r.id, r.routers]), + ); + return { groups, groupsByName: new Map(groups.map((g) => [g.name, g])), @@ -74,5 +128,13 @@ export async function fetchActualState( routesByNetworkId: new Map(routes.map((r) => [r.network_id, r])), dns, dnsByName: new Map(dns.map((d) => [d.name, d])), + postureChecks, + postureChecksByName: new Map(postureChecks.map((pc) => [pc.name, pc])), + networks, + networksByName: new Map(networks.map((n) => [n.name, n])), + networkResources, + networkRouters, + users, + usersByEmail: new Map(users.map((u) => [u.email, u])), }; } diff --git a/src/state/schema.ts b/src/state/schema.ts index 7cade21..b6a4132 100644 --- a/src/state/schema.ts +++ b/src/state/schema.ts @@ -14,15 +14,22 @@ export const GroupSchema = z.object({ peers: z.array(z.string()), }); +export const DestinationResourceSchema = z.object({ + id: z.string(), + type: z.string(), +}); + export const PolicySchema = z.object({ description: z.string().default(""), enabled: z.boolean(), sources: z.array(z.string()), - destinations: z.array(z.string()), + destinations: z.array(z.string()).default([]), bidirectional: z.boolean(), protocol: z.enum(["tcp", "udp", "icmp", "all"]).default("all"), action: z.enum(["accept", "drop"]).default("accept"), ports: z.array(z.string()).optional(), + destination_resource: DestinationResourceSchema.optional(), + source_posture_checks: z.array(z.string()).default([]), }); export const RouteSchema = z.object({ @@ -53,6 +60,47 @@ export const DnsNameserverGroupSchema = z.object({ search_domains_enabled: z.boolean().default(false), }); +export const PostureCheckSchema = z.object({ + description: z.string().default(""), + checks: z.record(z.string(), z.unknown()), +}); + +export const NetworkResourceSchema = z.object({ + name: z.string(), + description: z.string().default(""), + type: z.enum(["host", "subnet", "domain"]), + address: z.string(), + enabled: z.boolean().default(true), + groups: z.array(z.string()), +}); + +export const NetworkRouterSchema = z.object({ + peer: z.string().optional(), + peer_groups: z.array(z.string()).optional(), + metric: z.number().int().min(1).max(9999).default(9999), + masquerade: z.boolean().default(true), + enabled: z.boolean().default(true), +}); + +export const NetworkSchema = z.object({ + description: z.string().default(""), + resources: z.array(NetworkResourceSchema).default([]), + routers: z.array(NetworkRouterSchema).default([]), +}); + +export const PeerSchema = z.object({ + groups: z.array(z.string()), + login_expiration_enabled: z.boolean().default(false), + inactivity_expiration_enabled: z.boolean().default(false), + ssh_enabled: z.boolean().default(false), +}); + +export const UserSchema = z.object({ + name: z.string(), + role: z.enum(["owner", "admin", "user"]), + auto_groups: z.array(z.string()).default([]), +}); + // --- Top-level schema --- export const DesiredStateSchema = z.object({ @@ -64,6 +112,10 @@ export const DesiredStateSchema = z.object({ nameserver_groups: z.record(z.string(), DnsNameserverGroupSchema) .default({}), }).default({ nameserver_groups: {} }), + posture_checks: z.record(z.string(), PostureCheckSchema).default({}), + networks: z.record(z.string(), NetworkSchema).default({}), + peers: z.record(z.string(), PeerSchema).default({}), + users: z.record(z.string(), UserSchema).default({}), }); // --- Inferred types --- @@ -74,6 +126,15 @@ export type GroupConfig = z.infer; export type PolicyConfig = z.infer; export type RouteConfig = z.infer; export type DnsNameserverGroupConfig = z.infer; +export type PostureCheckConfig = z.infer; +export type NetworkConfig = z.infer; +export type NetworkResourceConfig = z.infer; +export type NetworkRouterConfig = z.infer; +export type PeerConfig = z.infer; +export type UserConfig = z.infer; +export type DestinationResourceConfig = z.infer< + typeof DestinationResourceSchema +>; // --- Cross-reference validation --- @@ -89,11 +150,16 @@ export type DnsNameserverGroupConfig = z.infer; * 4. Every peer_group and distribution_group in a route references an * existing group. * 5. Every group in a DNS nameserver group references an existing group. + * 6. Every group in a peer config references an existing group. + * 7. Every auto_group on a user references an existing group. + * 8. Every group on a network resource references an existing group. + * 9. Every source_posture_check in a policy references an existing posture check. */ export function validateCrossReferences(state: DesiredState): string[] { const errors: string[] = []; const groupNames = new Set(Object.keys(state.groups)); const setupKeyNames = new Set(Object.keys(state.setup_keys)); + const postureCheckNames = new Set(Object.keys(state.posture_checks)); // 1. Peers in groups must reference existing setup keys for (const [groupName, group] of Object.entries(state.groups)) { @@ -168,5 +234,51 @@ export function validateCrossReferences(state: DesiredState): string[] { } } + // 6. Peer groups must reference existing groups + for (const [peerName, peer] of Object.entries(state.peers)) { + for (const g of peer.groups) { + if (!groupNames.has(g)) { + errors.push( + `peer "${peerName}": group "${g}" does not match any group`, + ); + } + } + } + + // 7. User auto_groups must reference existing groups + for (const [userName, user] of Object.entries(state.users)) { + for (const ag of user.auto_groups) { + if (!groupNames.has(ag)) { + errors.push( + `user "${userName}": auto_group "${ag}" does not match any group`, + ); + } + } + } + + // 8. Network resource groups must reference existing groups + for (const [networkName, network] of Object.entries(state.networks)) { + for (const resource of network.resources) { + for (const g of resource.groups) { + if (!groupNames.has(g)) { + errors.push( + `network "${networkName}": resource "${resource.name}" group "${g}" does not match any group`, + ); + } + } + } + } + + // 9. Policy source_posture_checks must reference existing posture checks + for (const [policyName, policy] of Object.entries(state.policies)) { + for (const pc of policy.source_posture_checks) { + if (!postureCheckNames.has(pc)) { + errors.push( + `policy "${policyName}": source_posture_check "${pc}" does not match any posture check`, + ); + } + } + } + return errors; } diff --git a/state/dev.json b/state/dev.json new file mode 100644 index 0000000..5dc3c56 --- /dev/null +++ b/state/dev.json @@ -0,0 +1,414 @@ +{ + "groups": { + "dev-team": { + "peers": [] + }, + "dev-services": { + "peers": [] + }, + "fusion": { + "peers": [] + }, + "test-gs": { + "peers": [] + }, + "restricted": { + "peers": [] + } + }, + "setup_keys": { + "public-site": { + "type": "reusable", + "expires_in": 604800, + "usage_limit": 0, + "auto_groups": [ + "dev-services" + ], + "enrolled": false + }, + "docs vps": { + "type": "reusable", + "expires_in": 604800, + "usage_limit": 0, + "auto_groups": [ + "dev-services" + ], + "enrolled": false + } + }, + "policies": { + "Dev to test gs": { + "description": "", + "enabled": true, + "sources": [ + "dev-team" + ], + "destinations": [ + "All" + ], + "bidirectional": false, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "Dev Access to Gitea": { + "description": "", + "enabled": true, + "sources": [ + "dev-team" + ], + "destinations": [ + "dev-services" + ], + "bidirectional": false, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "Fusion Access All": { + "description": "", + "enabled": true, + "sources": [ + "fusion" + ], + "destinations": [ + "dev-team", + "test-gs" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "only fusion": { + "description": "", + "enabled": false, + "sources": [ + "restricted" + ], + "destinations": [ + "fusion" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "Ground Stations to Debian Repository": { + "description": "", + "enabled": true, + "sources": [ + "test-gs" + ], + "destinations": [ + "dev-services" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "dev services can communicate": { + "description": "", + "enabled": true, + "sources": [ + "dev-services" + ], + "destinations": [ + "dev-services" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "Everyone can access docs": { + "description": "", + "enabled": true, + "sources": [ + "All" + ], + "destinations": [], + "bidirectional": false, + "protocol": "all", + "action": "accept", + "source_posture_checks": [], + "destination_resource": { + "id": "docs.blastpilot.achilles-rnd.cc", + "type": "domain" + } + } + }, + "posture_checks": { + "10.112.*.* subnet access": { + "description": "", + "checks": { + "peer_network_range_check": { + "action": "allow", + "ranges": [ + "10.112.0.0/16" + ] + } + } + } + }, + "networks": { + "Internal Services": { + "description": "", + "resources": [ + { + "name": "docs.blastpilot.achilles-rnd.cc", + "description": "docs.blastpilot.achilles-rnd.cc", + "type": "domain", + "address": "docs.blastpilot.achilles-rnd.cc", + "enabled": true, + "groups": [ + "All" + ] + } + ], + "routers": [ + { + "metric": 9999, + "masquerade": true, + "enabled": true, + "peer": "blast-fusion" + } + ] + } + }, + "peers": { + "acarus": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": true, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "blast-fusion": { + "groups": [ + "fusion" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "blastgs-fpv3": { + "groups": [ + "test-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "documentation-site": { + "groups": [ + "dev-services" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "gitea-server": { + "groups": [ + "dev-services" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "grc-1-3bat": { + "groups": [ + "test-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "grc-422-vlad.blast.local": { + "groups": [ + "test-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "ihor-rnd": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "ivan-rnd": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "multik-acer1": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "multik-ptt-test-gs": { + "groups": [ + "dev-team", + "fusion", + "test-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "oleksandr": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": true, + "inactivity_expiration_enabled": true, + "ssh_enabled": false + }, + "prox": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "prox-orangepi": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "prox-pc": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "prox-ubuntu-vm": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "public-website-vps": { + "groups": [ + "dev-services" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-rnd": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "rpitest2": { + "groups": [ + "test-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "seed-asus1": { + "groups": [ + "dev-team", + "fusion" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "seed-linux": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": true, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "seed-macbook1": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "testovyy-nrk-1-rnd-new-arch": { + "groups": [ + "test-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "ubuntu": { + "groups": [], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + } + }, + "users": { + "admin@achilles.local": { + "name": "admin", + "role": "owner", + "auto_groups": [] + }, + "seed@achilles.local": { + "name": "seed", + "role": "admin", + "auto_groups": [ + "dev-team" + ] + }, + "keltir@achilles.local": { + "name": "keltir", + "role": "admin", + "auto_groups": [ + "dev-team" + ] + }, + "eugene@achilles.local": { + "name": "eugene", + "role": "admin", + "auto_groups": [ + "dev-team" + ] + }, + "sava@achilles.local": { + "name": "sava", + "role": "admin", + "auto_groups": [ + "dev-team" + ] + } + }, + "routes": {}, + "dns": { + "nameserver_groups": {} + } +} diff --git a/state/ext.json b/state/ext.json new file mode 100644 index 0000000..d8412fe --- /dev/null +++ b/state/ext.json @@ -0,0 +1,1022 @@ +{ + "groups": { + "Dev Team": { + "peers": [] + }, + "fusion": { + "peers": [] + }, + "210oshp-ground-stations": { + "peers": [] + }, + "210oshp-pilots": { + "peers": [] + }, + "azov-ground-stations": { + "peers": [] + }, + "azov-pilots": { + "peers": [] + }, + "gur-mo-artan-ground-stations": { + "peers": [] + }, + "gur-mo-artan-pilots": { + "peers": [] + }, + "38ombr-pilot": { + "peers": [] + }, + "38ombr-gs": { + "peers": [] + }, + "1oshp-ground-stations": { + "peers": [] + }, + "1oshp-pilots": { + "peers": [] + }, + "bbc129-rugby-pilot": { + "peers": [] + }, + "bbc129-rugby-gs": { + "peers": [] + }, + "k2-pilots": { + "peers": [] + }, + "k2-gs": { + "peers": [] + }, + "425skelya-ground-stations": { + "peers": [] + }, + "425skelya-pilots": { + "peers": [] + }, + "72brig-bulava-ground-stations": { + "peers": [] + }, + "72brig-bulava-pilots": { + "peers": [] + }, + "18-bbc-ngu-pilots": { + "peers": [] + }, + "18-bbc-ngu-gs": { + "peers": [] + }, + "khartia-yasni-ochi-gs": { + "peers": [] + }, + "khartia-yasni-ochi-pilot": { + "peers": [] + }, + "azov-1-bat-rbs-gs": { + "peers": [] + }, + "azov-1-bat-rbs-pilots": { + "peers": [] + }, + "khartia-rubak-recon-bat-pilot": { + "peers": [] + }, + "khartia-rubak-recon-bat-gs": { + "peers": [] + }, + "421-sapsan-gs": { + "peers": [] + }, + "421-sapsan-pilot": { + "peers": [] + } + }, + "setup_keys": { + "khartia-yasni-ochi-multik": { + "type": "reusable", + "expires_in": 604800, + "usage_limit": 7, + "auto_groups": [ + "khartia-yasni-ochi-gs", + "khartia-yasni-ochi-pilot" + ], + "enrolled": false + }, + "azov-1-bat-rbs-multik": { + "type": "reusable", + "expires_in": 604800, + "usage_limit": 8, + "auto_groups": [ + "azov-1-bat-rbs-gs", + "azov-1-bat-rbs-pilots" + ], + "enrolled": false + }, + "khartia-rubak-recon-bat-multik": { + "type": "reusable", + "expires_in": 604800, + "usage_limit": 5, + "auto_groups": [ + "khartia-rubak-recon-bat-gs", + "khartia-rubak-recon-bat-pilot" + ], + "enrolled": false + }, + "421-sapsan-multik": { + "type": "reusable", + "expires_in": 604800, + "usage_limit": 7, + "auto_groups": [ + "421-sapsan-gs", + "421-sapsan-pilot" + ], + "enrolled": false + } + }, + "policies": { + "Fusion Access All": { + "description": "", + "enabled": true, + "sources": [ + "fusion" + ], + "destinations": [ + "1oshp-ground-stations", + "1oshp-pilots", + "210oshp-ground-stations", + "210oshp-pilots", + "38ombr-gs", + "38ombr-pilot", + "425skelya-ground-stations", + "425skelya-pilots", + "72brig-bulava-ground-stations", + "72brig-bulava-pilots", + "azov-ground-stations", + "azov-pilots", + "bbc129-rugby-gs", + "bbc129-rugby-pilot", + "gur-mo-artan-ground-stations", + "gur-mo-artan-pilots", + "k2-gs", + "k2-pilots" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "Dev Team Full Access": { + "description": "", + "enabled": true, + "sources": [ + "Dev Team" + ], + "destinations": [ + "All" + ], + "bidirectional": false, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "210oshp-pilots2gs": { + "description": "", + "enabled": true, + "sources": [ + "210oshp-pilots" + ], + "destinations": [ + "210oshp-ground-stations" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [ + "Admins from Ukraine and Poland (Starlinks)" + ] + }, + "azov-pilots2gs": { + "description": "", + "enabled": true, + "sources": [ + "azov-pilots" + ], + "destinations": [ + "azov-ground-stations" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "gur-mo-artan-pilots2gs": { + "description": "", + "enabled": true, + "sources": [ + "gur-mo-artan-pilots" + ], + "destinations": [ + "gur-mo-artan-ground-stations" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "38ombr": { + "description": "", + "enabled": true, + "sources": [ + "38ombr-pilot" + ], + "destinations": [ + "38ombr-gs" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "1oshp-pilots2gs": { + "description": "", + "enabled": true, + "sources": [ + "1oshp-pilots" + ], + "destinations": [ + "1oshp-ground-stations" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "bbc129-rugby": { + "description": "", + "enabled": true, + "sources": [ + "bbc129-rugby-pilot" + ], + "destinations": [ + "bbc129-rugby-gs" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "k2-pol": { + "description": "", + "enabled": true, + "sources": [ + "k2-pilots" + ], + "destinations": [ + "k2-gs" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "425skelya-pilots2gs": { + "description": "", + "enabled": true, + "sources": [ + "425skelya-pilots" + ], + "destinations": [ + "425skelya-ground-stations" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "72brig-bulava-pilots2gs": { + "description": "", + "enabled": true, + "sources": [ + "72brig-bulava-pilots" + ], + "destinations": [ + "72brig-bulava-ground-stations" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "18-bbc-ngu": { + "description": "", + "enabled": true, + "sources": [ + "18-bbc-ngu-pilots" + ], + "destinations": [ + "18-bbc-ngu-gs", + "fusion" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "khartia-yasni-ochi": { + "description": "", + "enabled": true, + "sources": [ + "khartia-yasni-ochi-pilot" + ], + "destinations": [ + "khartia-yasni-ochi-gs", + "fusion" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "azov-1-bat-rbs": { + "description": "", + "enabled": true, + "sources": [ + "azov-1-bat-rbs-pilots" + ], + "destinations": [ + "azov-1-bat-rbs-gs", + "fusion" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "khartia-rubak-recon-bat": { + "description": "", + "enabled": true, + "sources": [ + "khartia-rubak-recon-bat-pilot" + ], + "destinations": [ + "khartia-rubak-recon-bat-gs", + "fusion" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "Everyone can access docs": { + "description": "", + "enabled": true, + "sources": [ + "All" + ], + "destinations": [], + "bidirectional": false, + "protocol": "all", + "action": "accept", + "source_posture_checks": [], + "destination_resource": { + "id": "docs.blastpilot.achilles-rnd.cc", + "type": "domain" + } + }, + "421-sapsan": { + "description": "", + "enabled": true, + "sources": [ + "421-sapsan-pilot" + ], + "destinations": [ + "421-sapsan-gs", + "fusion" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + } + }, + "posture_checks": { + "Admins from Ukraine and Poland (Starlinks)": { + "description": "", + "checks": { + "geo_location_check": { + "action": "allow", + "locations": [ + { + "country_code": "UA" + }, + { + "country_code": "PL" + } + ] + } + } + } + }, + "networks": { + "Internal Services": { + "description": "", + "resources": [ + { + "name": "docs.blastpilot.achilles-rnd.cc", + "description": "docs.blastpilot.achilles-rnd.cc", + "type": "domain", + "address": "docs.blastpilot.achilles-rnd.cc", + "enabled": true, + "groups": [ + "All" + ] + } + ], + "routers": [ + { + "metric": 9999, + "masquerade": true, + "enabled": true, + "peer": "blast-fusion" + } + ] + } + }, + "peers": { + "129bbc-laptop1": { + "groups": [ + "bbc129-rugby-pilot", + "bbc129-rugby-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "18bbc-ngu-1-laptop": { + "groups": [ + "18-bbc-ngu-pilots", + "18-bbc-ngu-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "18bbc-ngu-2-laptop": { + "groups": [ + "18-bbc-ngu-pilots", + "18-bbc-ngu-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "1oshp-laptop1": { + "groups": [ + "1oshp-ground-stations", + "1oshp-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "1oshp1-laptop": { + "groups": [ + "1oshp-ground-stations", + "1oshp-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "38ombr1-laptop": { + "groups": [ + "38ombr-pilot", + "38ombr-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "38ombr2-laptop": { + "groups": [ + "38ombr-pilot", + "38ombr-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "425skelya1-laptop": { + "groups": [ + "425skelya-ground-stations", + "425skelya-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "72brig-2-laptop": { + "groups": [ + "72brig-bulava-ground-stations", + "72brig-bulava-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "72brig-bulava-1-laptop": { + "groups": [ + "72brig-bulava-ground-stations", + "72brig-bulava-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "azov-1-laptop": { + "groups": [ + "azov-1-bat-rbs-gs", + "azov-1-bat-rbs-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "azov1-laptop": { + "groups": [ + "azov-ground-stations", + "azov-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "azov2-laptop": { + "groups": [ + "azov-ground-stations", + "azov-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "blast-fusion": { + "groups": [ + "fusion" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "DESKTOP-EORD6UD": { + "groups": [ + "421-sapsan-gs", + "421-sapsan-pilot" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "DESKTOP-HICCB5B": { + "groups": [ + "khartia-rubak-recon-bat-pilot", + "khartia-rubak-recon-bat-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "gaazhag": { + "groups": [], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "gur-3-laptop": { + "groups": [ + "gur-mo-artan-ground-stations", + "gur-mo-artan-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "k2-1-laptop": { + "groups": [ + "k2-pilots", + "k2-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "khartia-1-laptop": { + "groups": [ + "khartia-yasni-ochi-gs", + "khartia-yasni-ochi-pilot" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "khartia-3-yasni-ochi-laptop": { + "groups": [ + "khartia-yasni-ochi-gs", + "khartia-yasni-ochi-pilot" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "mavic-rnd-laptop": { + "groups": [ + "Dev Team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "melkiy-210oshp-laptop": { + "groups": [ + "210oshp-ground-stations", + "210oshp-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "multik-acer1": { + "groups": [ + "Dev Team" + ], + "login_expiration_enabled": true, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "No_Defender": { + "groups": [ + "azov-1-bat-rbs-gs", + "azov-1-bat-rbs-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "nuke-1-210oshp-laptop": { + "groups": [ + "210oshp-ground-stations", + "210oshp-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "nuke-2-210oshp-laptop": { + "groups": [ + "210oshp-ground-stations", + "210oshp-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-1oshp": { + "groups": [ + "1oshp-ground-stations", + "1oshp-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-210oshp": { + "groups": [ + "210oshp-ground-stations", + "210oshp-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-421-sapsan": { + "groups": [ + "421-sapsan-gs", + "421-sapsan-pilot" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-425-skelya": { + "groups": [ + "425skelya-ground-stations", + "425skelya-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-azov": { + "groups": [ + "azov-ground-stations", + "azov-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-azov-1-bat-rbs": { + "groups": [ + "azov-1-bat-rbs-gs", + "azov-1-bat-rbs-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-gur-mo-artan": { + "groups": [ + "gur-mo-artan-ground-stations", + "gur-mo-artan-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-k2": { + "groups": [ + "k2-pilots", + "k2-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-khartia-recon-bat-rubak": { + "groups": [ + "khartia-rubak-recon-bat-pilot", + "khartia-rubak-recon-bat-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-khartia-yasni-ochi": { + "groups": [ + "khartia-yasni-ochi-gs", + "khartia-yasni-ochi-pilot" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-2-425-skelya": { + "groups": [ + "425skelya-ground-stations", + "425skelya-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-3-425-skelya": { + "groups": [ + "425skelya-ground-stations", + "425skelya-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-129bbc": { + "groups": [ + "bbc129-rugby-pilot", + "bbc129-rugby-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-18-brig-bbc-ngu": { + "groups": [ + "18-bbc-ngu-pilots", + "18-bbc-ngu-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-1oshp": { + "groups": [ + "1oshp-ground-stations", + "1oshp-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-210oshp": { + "groups": [ + "210oshp-ground-stations", + "210oshp-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-421-sapsan": { + "groups": [ + "421-sapsan-gs", + "421-sapsan-pilot" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-425-skelya": { + "groups": [ + "425skelya-ground-stations", + "425skelya-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-72-brig-bbps-bulava": { + "groups": [ + "72brig-bulava-ground-stations", + "72brig-bulava-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-azov": { + "groups": [ + "azov-ground-stations", + "azov-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-azov-1-bat-rbs": { + "groups": [ + "azov-1-bat-rbs-gs", + "azov-1-bat-rbs-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-gur-mo-artan": { + "groups": [ + "gur-mo-artan-ground-stations", + "gur-mo-artan-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-khartia-recon-bat-rubak": { + "groups": [ + "khartia-rubak-recon-bat-pilot", + "khartia-rubak-recon-bat-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-khartia-yasni-ochi": { + "groups": [ + "khartia-yasni-ochi-gs", + "khartia-yasni-ochi-pilot" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-2-425-skelya": { + "groups": [ + "425skelya-ground-stations", + "425skelya-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-3-425-skelya": { + "groups": [ + "425skelya-ground-stations", + "425skelya-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-mavic-1-38ombr": { + "groups": [ + "38ombr-pilot", + "38ombr-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-mavic-1-azov": { + "groups": [ + "azov-ground-stations", + "azov-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-mavic-1-azov-1-bat-rbs": { + "groups": [ + "azov-1-bat-rbs-gs", + "azov-1-bat-rbs-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-mavic-1-k2": { + "groups": [ + "k2-pilots", + "k2-gs" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-mavic-1-khartia-yasni-ochi": { + "groups": [ + "khartia-yasni-ochi-gs", + "khartia-yasni-ochi-pilot" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "seed-asus1": { + "groups": [ + "Dev Team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "seed-macbook1": { + "groups": [ + "Dev Team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "yohan1-laptop": { + "groups": [ + "gur-mo-artan-ground-stations", + "gur-mo-artan-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + } + }, + "users": { + "admin@achilles.local": { + "name": "admin", + "role": "owner", + "auto_groups": [ + "Dev Team" + ] + }, + "seed@achilles.local": { + "name": "seed", + "role": "admin", + "auto_groups": [ + "Dev Team" + ] + }, + "eugene@achilles.local": { + "name": "eugene", + "role": "admin", + "auto_groups": [ + "Dev Team" + ] + }, + "keltir@achilles.local": { + "name": "keltir", + "role": "admin", + "auto_groups": [ + "Dev Team" + ] + } + }, + "routes": {}, + "dns": { + "nameserver_groups": {} + } +} diff --git a/state/prod.json b/state/prod.json new file mode 100644 index 0000000..cbbc439 --- /dev/null +++ b/state/prod.json @@ -0,0 +1,627 @@ +{ + "groups": { + "battalion-1-pilots": { + "peers": [] + }, + "battalion-2-pilots": { + "peers": [] + }, + "battalion-3-pilots": { + "peers": [] + }, + "battalion-1-ground-stations": { + "peers": [] + }, + "battalion-2-ground-stations": { + "peers": [] + }, + "battalion-3-ground-stations": { + "peers": [] + }, + "dev-team": { + "peers": [] + }, + "fusion": { + "peers": [] + }, + "exp-company-ground-stations": { + "peers": [] + }, + "exp-company-pilots": { + "peers": [] + } + }, + "setup_keys": { + "1bat-multik": { + "type": "reusable", + "expires_in": 604800, + "usage_limit": 10, + "auto_groups": [ + "battalion-1-ground-stations", + "battalion-1-pilots" + ], + "enrolled": false + }, + "boots-laptops": { + "type": "reusable", + "expires_in": 604800, + "usage_limit": 5, + "auto_groups": [ + "battalion-1-ground-stations", + "battalion-1-pilots" + ], + "enrolled": false + } + }, + "policies": { + "1st Battalion - Internal Access": { + "description": "Allow 1st Battalion pilots to access their ground stations", + "enabled": true, + "sources": [ + "battalion-1-pilots", + "fusion" + ], + "destinations": [ + "battalion-1-ground-stations", + "fusion" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "2nd Battalion - Internal Access": { + "description": "Allow 2nd Battalion pilots to access their ground stations", + "enabled": true, + "sources": [ + "battalion-2-pilots", + "fusion" + ], + "destinations": [ + "battalion-2-ground-stations", + "fusion" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "3rd Battalion - Internal Access": { + "description": "Allow 3rd Battalion pilots to access their ground stations", + "enabled": true, + "sources": [ + "battalion-3-pilots", + "fusion" + ], + "destinations": [ + "battalion-3-ground-stations", + "fusion" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "Dev Team - Full Access": { + "description": "Dev team can access all peers for troubleshooting", + "enabled": true, + "sources": [ + "dev-team" + ], + "destinations": [ + "All" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [ + "Restrict admins to Ukraine" + ] + }, + "Fusion Access All Pilots and Ground Stations": { + "description": "", + "enabled": true, + "sources": [ + "fusion" + ], + "destinations": [ + "dev-team", + "exp-company-ground-stations", + "exp-company-pilots", + "battalion-1-ground-stations", + "battalion-2-ground-stations", + "battalion-2-pilots", + "battalion-3-ground-stations", + "battalion-3-pilots", + "battalion-1-pilots" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "exp-company-pilots2gs": { + "description": "", + "enabled": true, + "sources": [ + "exp-company-pilots", + "fusion" + ], + "destinations": [ + "exp-company-ground-stations", + "fusion" + ], + "bidirectional": true, + "protocol": "all", + "action": "accept", + "source_posture_checks": [] + }, + "Everyone can access docs": { + "description": "Internal Services ", + "enabled": false, + "sources": [ + "All" + ], + "destinations": [], + "bidirectional": false, + "protocol": "all", + "action": "accept", + "source_posture_checks": [], + "destination_resource": { + "id": "docs.blastpilot.achilles-rnd.cc", + "type": "domain" + } + } + }, + "posture_checks": { + "Restrict admins to Ukraine": { + "description": "", + "checks": { + "geo_location_check": { + "action": "allow", + "locations": [ + { + "country_code": "UA" + }, + { + "country_code": "PL" + } + ] + } + } + } + }, + "networks": { + "Internal Services": { + "description": "", + "resources": [ + { + "name": "docs.blastpilot.achilles-rnd.cc", + "description": "docs.blastpilot.achilles-rnd.cc", + "type": "domain", + "address": "docs.blastpilot.achilles-rnd.cc", + "enabled": true, + "groups": [ + "All" + ] + } + ], + "routers": [ + { + "metric": 9999, + "masquerade": true, + "enabled": true, + "peer": "blast-fusion" + } + ] + } + }, + "peers": { + "3bat-goggles-laptop": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "3bat-lin-win-laptop": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "3bat-linux-laptop": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "acarus": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": true, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "banya-slackware-laptop": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "banya1-laptop": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "banya2-laptop": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "banya3-laptop": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "banya4-laptop": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "bilozir1-laptop": { + "groups": [ + "battalion-2-pilots", + "battalion-2-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "blast-fusion": { + "groups": [ + "fusion" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "blastgs-agent-dji-goggles1": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "boots1-laptop": { + "groups": [ + "battalion-1-pilots", + "battalion-1-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "boots2-laptop": { + "groups": [ + "battalion-1-pilots", + "battalion-1-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "exp-lenovo-laptop": { + "groups": [ + "exp-company-ground-stations", + "exp-company-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "ihor-rnd-laptop": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "ivan-rnd-laptop": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "kaban-1-laptop": { + "groups": [ + "battalion-1-pilots", + "battalion-1-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "kaban-2-laptop-1bat": { + "groups": [ + "battalion-1-pilots", + "battalion-1-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "kaban-3-laptop-1bat": { + "groups": [ + "battalion-1-pilots", + "battalion-1-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "mango-rexp1-laptop": { + "groups": [ + "exp-company-ground-stations", + "exp-company-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "mavic-rnd-laptop": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "multik-rnd-laptop": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "oleksandr-rnd-laptop": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "prox-rnd-laptop": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-1bat-1rrbpak": { + "groups": [ + "battalion-1-pilots", + "battalion-1-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-3bat-5rrbpak": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-1-rexp": { + "groups": [ + "exp-company-ground-stations", + "exp-company-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-2-1bat-1rrbpak": { + "groups": [ + "battalion-1-pilots", + "battalion-1-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-2-3bat-5rrbpak": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-2-rexp": { + "groups": [ + "battalion-2-pilots", + "battalion-2-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-3-1bat": { + "groups": [ + "battalion-1-pilots", + "battalion-1-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-3-2bat-3rrbpak": { + "groups": [ + "battalion-2-pilots", + "battalion-2-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-3-3bat-5rrbpak": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-4-1bat": { + "groups": [ + "battalion-1-pilots", + "battalion-1-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-autel-4-2bat-3rrbpak": { + "groups": [ + "battalion-2-pilots", + "battalion-2-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "remote-matrice-1-3bat-5rrbpak": { + "groups": [ + "battalion-3-pilots", + "battalion-3-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "rexp-lenovo-laptop": { + "groups": [ + "exp-company-ground-stations", + "exp-company-pilots" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "seed-1-rnd-laptop": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "seed-asus1": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "seed-macbook1": { + "groups": [ + "dev-team" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + }, + "ugv-1-1bat": { + "groups": [ + "battalion-1-pilots", + "battalion-1-ground-stations" + ], + "login_expiration_enabled": false, + "inactivity_expiration_enabled": false, + "ssh_enabled": false + } + }, + "users": { + "seed@achilles.local": { + "name": "seed", + "role": "admin", + "auto_groups": [ + "dev-team" + ] + }, + "keltir@achilles.local": { + "name": "Artem", + "role": "admin", + "auto_groups": [ + "dev-team" + ] + }, + "vlad.stus@gmail.com": { + "name": "admin", + "role": "owner", + "auto_groups": [ + "dev-team" + ] + }, + "": { + "name": "Automation Service", + "role": "admin", + "auto_groups": [] + }, + "eugene@achilles.local": { + "name": "eugene", + "role": "admin", + "auto_groups": [ + "dev-team" + ] + } + }, + "routes": {}, + "dns": { + "nameserver_groups": {} + } +}