added schema expansion for testing

This commit is contained in:
Prox 2026-03-06 16:28:01 +02:00
parent 11434f667a
commit 5c9c3f33bf
17 changed files with 4128 additions and 110 deletions

View File

@ -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<string, unknown>;
}
/** 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<NbPostureCheck[]>
createPostureCheck(data: Omit<NbPostureCheck, "id">): Promise<NbPostureCheck>
updatePostureCheck(id: string, data: Omit<NbPostureCheck, "id">): Promise<NbPostureCheck>
deletePostureCheck(id: string): Promise<void>
```
**Networks:**
```typescript
listNetworks(): Promise<NbNetwork[]>
createNetwork(data: { name: string; description?: string }): Promise<NbNetwork>
updateNetwork(id: string, data: { name: string; description?: string }): Promise<NbNetwork>
deleteNetwork(id: string): Promise<void>
```
**Network Resources (nested under network):**
```typescript
listNetworkResources(networkId: string): Promise<NbNetworkResource[]>
createNetworkResource(networkId: string, data: { name: string; description?: string; address: string; enabled: boolean; groups: string[] }): Promise<NbNetworkResource>
updateNetworkResource(networkId: string, resourceId: string, data: { name: string; description?: string; address: string; enabled: boolean; groups: string[] }): Promise<NbNetworkResource>
deleteNetworkResource(networkId: string, resourceId: string): Promise<void>
```
**Network Routers:**
```typescript
listNetworkRouters(networkId: string): Promise<NbNetworkRouter[]>
createNetworkRouter(networkId: string, data: Omit<NbNetworkRouter, "id">): Promise<NbNetworkRouter>
updateNetworkRouter(networkId: string, routerId: string, data: Omit<NbNetworkRouter, "id">): Promise<NbNetworkRouter>
deleteNetworkRouter(networkId: string, routerId: string): Promise<void>
```
**Users:**
```typescript
listUsers(): Promise<NbUser[]>
createUser(data: { email: string; name?: string; role: string; auto_groups: string[]; is_service_user: boolean }): Promise<NbUser>
updateUser(id: string, data: { name?: string; role?: string; auto_groups?: string[] }): Promise<NbUser>
deleteUser(id: string): Promise<void>
```
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<string, NbPostureCheck>;
networks: NbNetwork[];
networksByName: Map<string, NbNetwork>;
networkResources: Map<string, NbNetworkResource[]>; // networkId -> resources
networkRouters: Map<string, NbNetworkRouter[]>; // networkId -> routers
users: NbUser[];
usersByEmail: Map<string, NbUser>;
```
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 <DEV_TOKEN> > state/dev.json
deno task export -- --netbird-api-url https://achilles-rnd.cc/api --netbird-api-token <PROD_TOKEN> > state/prod.json
deno task export -- --netbird-api-url https://ext.netbird.achilles-rnd.cc/api --netbird-api-token <EXT_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.

View File

@ -4,10 +4,15 @@ import type { ActualState } from "./state/actual.ts";
import type { import type {
NbDnsNameserverGroup, NbDnsNameserverGroup,
NbGroup, NbGroup,
NbNetwork,
NbNetworkResource,
NbNetworkRouter,
NbPeer, NbPeer,
NbPolicy, NbPolicy,
NbPostureCheck,
NbRoute, NbRoute,
NbSetupKey, NbSetupKey,
NbUser,
} from "./netbird/types.ts"; } from "./netbird/types.ts";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -22,6 +27,11 @@ function buildActualState(data: {
policies?: NbPolicy[]; policies?: NbPolicy[];
routes?: NbRoute[]; routes?: NbRoute[];
dns?: NbDnsNameserverGroup[]; dns?: NbDnsNameserverGroup[];
postureChecks?: NbPostureCheck[];
networks?: NbNetwork[];
networkResources?: Map<string, NbNetworkResource[]>;
networkRouters?: Map<string, NbNetworkRouter[]>;
users?: NbUser[];
}): ActualState { }): ActualState {
const groups = data.groups ?? []; const groups = data.groups ?? [];
const setupKeys = data.setupKeys ?? []; const setupKeys = data.setupKeys ?? [];
@ -29,6 +39,9 @@ function buildActualState(data: {
const policies = data.policies ?? []; const policies = data.policies ?? [];
const routes = data.routes ?? []; const routes = data.routes ?? [];
const dns = data.dns ?? []; const dns = data.dns ?? [];
const postureChecks = data.postureChecks ?? [];
const networks = data.networks ?? [];
const users = data.users ?? [];
return { return {
groups, groups,
@ -45,6 +58,14 @@ function buildActualState(data: {
routesByNetworkId: new Map(routes.map((r) => [r.network_id, r])), routesByNetworkId: new Map(routes.map((r) => [r.network_id, r])),
dns, dns,
dnsByName: new Map(dns.map((d) => [d.name, d])), 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", name: "allow-pilot-vehicle",
description: "pilot to vehicle", description: "pilot to vehicle",
enabled: true, enabled: true,
source_posture_checks: [],
rules: [ rules: [
{ {
name: "rule1", name: "rule1",
@ -342,6 +364,7 @@ Deno.test("exportState: policies with empty rules are skipped", () => {
name: "empty-policy", name: "empty-policy",
description: "no rules", description: "no rules",
enabled: true, enabled: true,
source_posture_checks: [],
rules: [], rules: [],
}, },
], ],
@ -362,6 +385,7 @@ Deno.test("exportState: policy sources/destinations as {id,name} objects are res
name: "object-refs", name: "object-refs",
description: "", description: "",
enabled: true, enabled: true,
source_posture_checks: [],
rules: [ rules: [
{ {
name: "r1", name: "r1",
@ -399,6 +423,7 @@ Deno.test("exportState: policy without ports omits the ports field", () => {
name: "no-ports", name: "no-ports",
description: "", description: "",
enabled: true, enabled: true,
source_posture_checks: [],
rules: [ rules: [
{ {
name: "r1", name: "r1",

View File

@ -28,15 +28,41 @@ const DEFAULT_EXPIRES_IN = 604800;
* - Routes: keyed by `network_id`. Peer groups and distribution groups * - Routes: keyed by `network_id`. Peer groups and distribution groups
* resolved from IDs to names. * resolved from IDs to names.
* - DNS: group IDs resolved 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 { export function exportState(actual: ActualState): DesiredState {
const idToName = buildIdToNameMap(actual); const idToName = buildIdToNameMap(actual);
const setupKeyNames = new Set(actual.setupKeys.map((k) => k.name)); const setupKeyNames = new Set(actual.setupKeys.map((k) => k.name));
// Build resource ID → name map from all network resources
const resourceIdToName = new Map<string, string>();
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<string, string>(
actual.postureChecks.map((pc) => [pc.id, pc.name]),
);
return { return {
groups: exportGroups(actual, setupKeyNames, idToName), groups: exportGroups(actual, setupKeyNames, idToName),
setup_keys: exportSetupKeys(actual, 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), routes: exportRoutes(actual, idToName),
dns: { dns: {
nameserver_groups: exportDns(actual, idToName), nameserver_groups: exportDns(actual, idToName),
@ -89,7 +115,7 @@ function exportGroups(
// Only include peers whose name matches a known setup key, since // Only include peers whose name matches a known setup key, since
// the desired-state schema models peers as setup-key references. // the desired-state schema models peers as setup-key references.
const peers = group.peers const peers = (group.peers ?? [])
.map((p) => p.name) .map((p) => p.name)
.filter((name) => setupKeyNames.has(name)); .filter((name) => setupKeyNames.has(name));
@ -143,6 +169,8 @@ function isEnrolled(usedTimes: number, usageLimit: number): boolean {
function exportPolicies( function exportPolicies(
actual: ActualState, actual: ActualState,
idToName: Map<string, string>, idToName: Map<string, string>,
resourceIdToName: Map<string, string>,
postureCheckIdToName: Map<string, string>,
): DesiredState["policies"] { ): DesiredState["policies"] {
const result: DesiredState["policies"] = {}; const result: DesiredState["policies"] = {};
@ -151,11 +179,7 @@ function exportPolicies(
const rule = policy.rules[0]; const rule = policy.rules[0];
const sources = resolveIds( const sources = resolveIds(
rule.sources.map(extractGroupId), (rule.sources ?? []).map(extractGroupId),
idToName,
);
const destinations = resolveIds(
rule.destinations.map(extractGroupId),
idToName, idToName,
); );
@ -163,12 +187,32 @@ function exportPolicies(
description: policy.description, description: policy.description,
enabled: policy.enabled, enabled: policy.enabled,
sources, sources,
destinations, destinations: [],
bidirectional: rule.bidirectional, bidirectional: rule.bidirectional,
protocol: rule.protocol, protocol: rule.protocol,
action: rule.action, 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) { if (rule.ports && rule.ports.length > 0) {
entry.ports = rule.ports; entry.ports = rule.ports;
} }
@ -179,6 +223,121 @@ function exportPolicies(
return result; 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<string, string>,
): 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<string, string>,
): 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<string, string>,
): 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 // Routes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -369,6 +369,11 @@ function createExportMockFetch(calls: ApiCall[]) {
if (method === "GET" && path === "/dns/nameservers") { if (method === "GET" && path === "/dns/nameservers") {
return Response.json([]); 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") { if (method === "GET" && path === "/events/audit") {
return Response.json([]); return Response.json([]);
} }

View File

@ -2,10 +2,15 @@ import type {
NbDnsNameserverGroup, NbDnsNameserverGroup,
NbEvent, NbEvent,
NbGroup, NbGroup,
NbNetwork,
NbNetworkResource,
NbNetworkRouter,
NbPeer, NbPeer,
NbPolicy, NbPolicy,
NbPostureCheck,
NbRoute, NbRoute,
NbSetupKey, NbSetupKey,
NbUser,
} from "./types.ts"; } from "./types.ts";
/** Narrowed fetch signature used for dependency injection. */ /** Narrowed fetch signature used for dependency injection. */
@ -142,6 +147,7 @@ export class NetbirdClient {
name?: string; name?: string;
ssh_enabled?: boolean; ssh_enabled?: boolean;
login_expiration_enabled?: boolean; login_expiration_enabled?: boolean;
inactivity_expiration_enabled?: boolean;
}, },
): Promise<NbPeer> { ): Promise<NbPeer> {
return this.request("PUT", `/peers/${id}`, data); return this.request("PUT", `/peers/${id}`, data);
@ -223,4 +229,169 @@ export class NetbirdClient {
listEvents(): Promise<NbEvent[]> { listEvents(): Promise<NbEvent[]> {
return this.request("GET", "/events/audit"); return this.request("GET", "/events/audit");
} }
// ---------------------------------------------------------------------------
// Posture Checks
// ---------------------------------------------------------------------------
listPostureChecks(): Promise<NbPostureCheck[]> {
return this.request("GET", "/posture-checks");
}
createPostureCheck(
data: Omit<NbPostureCheck, "id">,
): Promise<NbPostureCheck> {
return this.request("POST", "/posture-checks", data);
}
updatePostureCheck(
id: string,
data: Omit<NbPostureCheck, "id">,
): Promise<NbPostureCheck> {
return this.request("PUT", `/posture-checks/${id}`, data);
}
deletePostureCheck(id: string): Promise<void> {
return this.request("DELETE", `/posture-checks/${id}`);
}
// ---------------------------------------------------------------------------
// Networks
// ---------------------------------------------------------------------------
listNetworks(): Promise<NbNetwork[]> {
return this.request("GET", "/networks");
}
createNetwork(
data: { name: string; description?: string },
): Promise<NbNetwork> {
return this.request("POST", "/networks", data);
}
updateNetwork(
id: string,
data: { name: string; description?: string },
): Promise<NbNetwork> {
return this.request("PUT", `/networks/${id}`, data);
}
deleteNetwork(id: string): Promise<void> {
return this.request("DELETE", `/networks/${id}`);
}
// ---------------------------------------------------------------------------
// Network Resources
// ---------------------------------------------------------------------------
listNetworkResources(networkId: string): Promise<NbNetworkResource[]> {
return this.request("GET", `/networks/${networkId}/resources`);
}
createNetworkResource(
networkId: string,
data: {
name: string;
description?: string;
address: string;
enabled: boolean;
groups: string[];
},
): Promise<NbNetworkResource> {
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<NbNetworkResource> {
return this.request(
"PUT",
`/networks/${networkId}/resources/${resourceId}`,
data,
);
}
deleteNetworkResource(
networkId: string,
resourceId: string,
): Promise<void> {
return this.request(
"DELETE",
`/networks/${networkId}/resources/${resourceId}`,
);
}
// ---------------------------------------------------------------------------
// Network Routers
// ---------------------------------------------------------------------------
listNetworkRouters(networkId: string): Promise<NbNetworkRouter[]> {
return this.request("GET", `/networks/${networkId}/routers`);
}
createNetworkRouter(
networkId: string,
data: Omit<NbNetworkRouter, "id">,
): Promise<NbNetworkRouter> {
return this.request("POST", `/networks/${networkId}/routers`, data);
}
updateNetworkRouter(
networkId: string,
routerId: string,
data: Omit<NbNetworkRouter, "id">,
): Promise<NbNetworkRouter> {
return this.request(
"PUT",
`/networks/${networkId}/routers/${routerId}`,
data,
);
}
deleteNetworkRouter(
networkId: string,
routerId: string,
): Promise<void> {
return this.request(
"DELETE",
`/networks/${networkId}/routers/${routerId}`,
);
}
// ---------------------------------------------------------------------------
// Users
// ---------------------------------------------------------------------------
listUsers(): Promise<NbUser[]> {
return this.request("GET", "/users");
}
createUser(data: {
email: string;
name?: string;
role: string;
auto_groups: string[];
is_service_user: boolean;
}): Promise<NbUser> {
return this.request("POST", "/users", data);
}
updateUser(
id: string,
data: { name?: string; role?: string; auto_groups?: string[] },
): Promise<NbUser> {
return this.request("PUT", `/users/${id}`, data);
}
deleteUser(id: string): Promise<void> {
return this.request("DELETE", `/users/${id}`);
}
} }

View File

@ -3,7 +3,7 @@ export interface NbGroup {
id: string; id: string;
name: string; name: string;
peers_count: number; peers_count: number;
peers: Array<{ id: string; name: string }>; peers: Array<{ id: string; name: string }> | null;
issued: "api" | "jwt" | "integration"; issued: "api" | "jwt" | "integration";
} }
@ -46,6 +46,7 @@ export interface NbPolicy {
description: string; description: string;
enabled: boolean; enabled: boolean;
rules: NbPolicyRule[]; rules: NbPolicyRule[];
source_posture_checks: string[];
} }
export interface NbPolicyRule { export interface NbPolicyRule {
@ -57,8 +58,9 @@ export interface NbPolicyRule {
bidirectional: boolean; bidirectional: boolean;
protocol: "tcp" | "udp" | "icmp" | "all"; protocol: "tcp" | "udp" | "icmp" | "all";
ports?: string[]; ports?: string[];
sources: Array<string | { id: string; name: string }>; sources: Array<string | { id: string; name: string }> | null;
destinations: Array<string | { id: string; name: string }>; destinations: Array<string | { id: string; name: string }> | null;
destinationResource?: { id: string; type: string } | null;
} }
/** Route as returned by GET /api/routes */ /** Route as returned by GET /api/routes */
@ -94,6 +96,62 @@ export interface NbDnsNameserverGroup {
search_domains_enabled: boolean; search_domains_enabled: boolean;
} }
/** Posture check as returned by GET /api/posture-checks */
export interface NbPostureCheck {
id: string;
name: string;
description: string;
checks: Record<string, unknown>;
}
/** 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 */ /** Audit event as returned by GET /api/events/audit */
export interface NbEvent { export interface NbEvent {
id: number; id: number;

View File

@ -20,10 +20,36 @@ function emptyActual(): ActualState {
routesByNetworkId: new Map(), routesByNetworkId: new Map(),
dns: [], dns: [],
dnsByName: new Map(), 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> = {},
): 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"] } }, groups: { pilots: { peers: ["Pilot-hawk-72"] } },
setup_keys: { setup_keys: {
"Pilot-hawk-72": { "Pilot-hawk-72": {
@ -34,10 +60,7 @@ const DESIRED: DesiredState = {
enrolled: false, enrolled: false,
}, },
}, },
policies: {}, });
routes: {},
dns: { nameserver_groups: {} },
};
Deno.test("computeDiff against empty actual produces create ops", () => { Deno.test("computeDiff against empty actual produces create ops", () => {
const ops = computeDiff(DESIRED, emptyActual()); 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", () => { Deno.test("computeDiff does not delete system groups", () => {
const desired: DesiredState = { const desired = desiredState();
groups: {},
setup_keys: {},
policies: {},
routes: {},
dns: { nameserver_groups: {} },
};
const actual = emptyActual(); const actual = emptyActual();
const jwtGroup: NbGroup = { 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", () => { Deno.test("computeDiff deletes api-issued groups not in desired", () => {
const desired: DesiredState = { const desired = desiredState();
groups: {},
setup_keys: {},
policies: {},
routes: {},
dns: { nameserver_groups: {} },
};
const actual = emptyActual(); const actual = emptyActual();
const staleGroup: NbGroup = { const staleGroup: NbGroup = {
@ -143,13 +154,9 @@ Deno.test("computeDiff detects group peer membership change", () => {
actual.groups = [group]; actual.groups = [group];
// Desired has a peer in the group, actual has none // Desired has a peer in the group, actual has none
const desired: DesiredState = { const desired = desiredState({
groups: { pilots: { peers: ["Pilot-hawk-72"] } }, groups: { pilots: { peers: ["Pilot-hawk-72"] } },
setup_keys: {}, });
policies: {},
routes: {},
dns: { nameserver_groups: {} },
};
const ops = computeDiff(desired, actual); const ops = computeDiff(desired, actual);
const updateOps = ops.filter((o) => o.type === "update_group"); const updateOps = ops.filter((o) => o.type === "update_group");
assertEquals(updateOps.length, 1); assertEquals(updateOps.length, 1);
@ -157,8 +164,7 @@ Deno.test("computeDiff detects group peer membership change", () => {
}); });
Deno.test("computeDiff skips enrolled setup keys", () => { Deno.test("computeDiff skips enrolled setup keys", () => {
const desired: DesiredState = { const desired = desiredState({
groups: {},
setup_keys: { setup_keys: {
"Already-enrolled": { "Already-enrolled": {
type: "one-off", type: "one-off",
@ -168,19 +174,14 @@ Deno.test("computeDiff skips enrolled setup keys", () => {
enrolled: true, enrolled: true,
}, },
}, },
policies: {}, });
routes: {},
dns: { nameserver_groups: {} },
};
const ops = computeDiff(desired, emptyActual()); const ops = computeDiff(desired, emptyActual());
const createKeyOps = ops.filter((o) => o.type === "create_setup_key"); const createKeyOps = ops.filter((o) => o.type === "create_setup_key");
assertEquals(createKeyOps.length, 0); assertEquals(createKeyOps.length, 0);
}); });
Deno.test("computeDiff creates policy when not in actual", () => { Deno.test("computeDiff creates policy when not in actual", () => {
const desired: DesiredState = { const desired = desiredState({
groups: {},
setup_keys: {},
policies: { policies: {
"allow-pilots": { "allow-pilots": {
description: "Allow pilot traffic", description: "Allow pilot traffic",
@ -190,11 +191,10 @@ Deno.test("computeDiff creates policy when not in actual", () => {
bidirectional: true, bidirectional: true,
protocol: "all", protocol: "all",
action: "accept", action: "accept",
source_posture_checks: [],
}, },
}, },
routes: {}, });
dns: { nameserver_groups: {} },
};
const ops = computeDiff(desired, emptyActual()); const ops = computeDiff(desired, emptyActual());
const policyOps = ops.filter((o) => o.type === "create_policy"); const policyOps = ops.filter((o) => o.type === "create_policy");
assertEquals(policyOps.length, 1); assertEquals(policyOps.length, 1);
@ -220,6 +220,7 @@ Deno.test("computeDiff detects policy enabled change", () => {
name: "allow-pilots", name: "allow-pilots",
description: "Allow pilot traffic", description: "Allow pilot traffic",
enabled: true, // currently enabled enabled: true, // currently enabled
source_posture_checks: [],
rules: [{ rules: [{
name: "allow-pilots", name: "allow-pilots",
description: "", description: "",
@ -233,9 +234,8 @@ Deno.test("computeDiff detects policy enabled change", () => {
}); });
actual.policies = [actual.policiesByName.get("allow-pilots")!]; actual.policies = [actual.policiesByName.get("allow-pilots")!];
const desired: DesiredState = { const desired = desiredState({
groups: { pilots: { peers: [] } }, groups: { pilots: { peers: [] } },
setup_keys: {},
policies: { policies: {
"allow-pilots": { "allow-pilots": {
description: "Allow pilot traffic", description: "Allow pilot traffic",
@ -245,11 +245,10 @@ Deno.test("computeDiff detects policy enabled change", () => {
bidirectional: true, bidirectional: true,
protocol: "all", protocol: "all",
action: "accept", action: "accept",
source_posture_checks: [],
}, },
}, },
routes: {}, });
dns: { nameserver_groups: {} },
};
const ops = computeDiff(desired, actual); const ops = computeDiff(desired, actual);
const updateOps = ops.filter((o) => o.type === "update_policy"); const updateOps = ops.filter((o) => o.type === "update_policy");
assertEquals(updateOps.length, 1); assertEquals(updateOps.length, 1);
@ -257,10 +256,7 @@ Deno.test("computeDiff detects policy enabled change", () => {
}); });
Deno.test("computeDiff creates route when not in actual", () => { Deno.test("computeDiff creates route when not in actual", () => {
const desired: DesiredState = { const desired = desiredState({
groups: {},
setup_keys: {},
policies: {},
routes: { routes: {
"vpn-exit": { "vpn-exit": {
description: "VPN exit route", description: "VPN exit route",
@ -273,8 +269,7 @@ Deno.test("computeDiff creates route when not in actual", () => {
keep_route: true, keep_route: true,
}, },
}, },
dns: { nameserver_groups: {} }, });
};
const ops = computeDiff(desired, emptyActual()); const ops = computeDiff(desired, emptyActual());
const routeOps = ops.filter((o) => o.type === "create_route"); const routeOps = ops.filter((o) => o.type === "create_route");
assertEquals(routeOps.length, 1); 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", () => { Deno.test("computeDiff creates dns when not in actual", () => {
const desired: DesiredState = { const desired = desiredState({
groups: {},
setup_keys: {},
policies: {},
routes: {},
dns: { dns: {
nameserver_groups: { nameserver_groups: {
"cloudflare": { "cloudflare": {
@ -300,7 +291,7 @@ Deno.test("computeDiff creates dns when not in actual", () => {
}, },
}, },
}, },
}; });
const ops = computeDiff(desired, emptyActual()); const ops = computeDiff(desired, emptyActual());
const dnsOps = ops.filter((o) => o.type === "create_dns"); const dnsOps = ops.filter((o) => o.type === "create_dns");
assertEquals(dnsOps.length, 1); 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", () => { Deno.test("computeDiff operations are sorted by EXECUTION_ORDER", () => {
// Desired state that produces creates for multiple resource types // Desired state that produces creates for multiple resource types
const desired: DesiredState = { const desired = desiredState({
groups: { pilots: { peers: [] } }, groups: { pilots: { peers: [] } },
setup_keys: { setup_keys: {
"new-key": { "new-key": {
@ -329,11 +320,10 @@ Deno.test("computeDiff operations are sorted by EXECUTION_ORDER", () => {
bidirectional: true, bidirectional: true,
protocol: "all", protocol: "all",
action: "accept", action: "accept",
source_posture_checks: [],
}, },
}, },
routes: {}, });
dns: { nameserver_groups: {} },
};
const ops = computeDiff(desired, emptyActual()); const ops = computeDiff(desired, emptyActual());
// create_group must come before create_setup_key, which must come before // create_group must come before create_setup_key, which must come before

View File

@ -15,8 +15,12 @@ export function computeDiff(
): Operation[] { ): Operation[] {
const ops: Operation[] = []; const ops: Operation[] = [];
diffPostureChecks(desired, actual, ops);
diffGroups(desired, actual, ops); diffGroups(desired, actual, ops);
diffSetupKeys(desired, actual, ops); diffSetupKeys(desired, actual, ops);
diffNetworks(desired, actual, ops);
diffPeers(desired, actual, ops);
diffUsers(desired, actual, ops);
diffPolicies(desired, actual, ops); diffPolicies(desired, actual, ops);
diffRoutes(desired, actual, ops); diffRoutes(desired, actual, ops);
diffDns(desired, actual, ops); diffDns(desired, actual, ops);
@ -24,6 +28,53 @@ export function computeDiff(
return sortByExecutionOrder(ops); 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 // Groups
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -47,7 +98,7 @@ function diffGroups(
} }
// Compare peer membership by name (sorted for stable comparison) // 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(); const desiredPeerNames = [...config.peers].sort();
if (!arraysEqual(actualPeerNames, desiredPeerNames)) { if (!arraysEqual(actualPeerNames, desiredPeerNames)) {
ops.push({ ops.push({
@ -64,7 +115,10 @@ function diffGroups(
// Delete groups that exist in actual but not in desired. // Delete groups that exist in actual but not in desired.
// Only delete API-issued groups — system and JWT groups are managed externally. // Only delete API-issued groups — system and JWT groups are managed externally.
for (const group of actual.groups) { 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 }); 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<string, infer V> ? 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 // Policies
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -127,29 +564,56 @@ function diffPolicies(
enabled: config.enabled, enabled: config.enabled,
sources: config.sources, sources: config.sources,
destinations: config.destinations, destinations: config.destinations,
destination_resource: config.destination_resource,
source_posture_checks: config.source_posture_checks,
}, },
}); });
continue; continue;
} }
// Extract group names from actual rules for comparison. // 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( const actualSources = extractGroupNames(
existing.rules.flatMap((r) => r.sources), existing.rules.flatMap((r) => r.sources ?? []),
actual,
).sort();
const actualDests = extractGroupNames(
existing.rules.flatMap((r) => r.destinations),
actual, actual,
).sort(); ).sort();
const desiredSources = [...config.sources].sort(); const desiredSources = [...config.sources].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(); 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 ( if (
existing.enabled !== config.enabled || existing.enabled !== config.enabled ||
!arraysEqual(actualSources, desiredSources) || !arraysEqual(actualSources, desiredSources) ||
!arraysEqual(actualDests, desiredDests) destsChanged ||
postureChecksChanged
) { ) {
ops.push({ ops.push({
type: "update_policy", type: "update_policy",
@ -158,6 +622,8 @@ function diffPolicies(
enabled: config.enabled, enabled: config.enabled,
sources: config.sources, sources: config.sources,
destinations: config.destinations, 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). * back to the ID if the group is unknown (defensive).
*/ */
function extractGroupNames( function extractGroupNames(
refs: NbPolicyRule["sources"], refs: NonNullable<NbPolicyRule["sources"]>,
actual: ActualState, actual: ActualState,
): string[] { ): string[] {
return refs.map((ref) => { return refs.map((ref) => {

View File

@ -19,6 +19,14 @@ function emptyActual(): ActualState {
routesByNetworkId: new Map(), routesByNetworkId: new Map(),
dns: [], dns: [],
dnsByName: new Map(), dnsByName: new Map(),
postureChecks: [],
postureChecksByName: new Map(),
networks: [],
networksByName: new Map(),
networkResources: new Map(),
networkRouters: new Map(),
users: [],
usersByEmail: new Map(),
}; };
} }

View File

@ -31,6 +31,21 @@ type ExecutorClient = Pick<
| "createDnsNameserverGroup" | "createDnsNameserverGroup"
| "updateDnsNameserverGroup" | "updateDnsNameserverGroup"
| "deleteDnsNameserverGroup" | "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<ExecutionResult> { ): Promise<ExecutionResult> {
const results: OperationResult[] = []; const results: OperationResult[] = [];
const createdGroupIds = new Map<string, string>(); const createdGroupIds = new Map<string, string>();
const createdPostureCheckIds = new Map<string, string>();
const createdNetworkIds = new Map<string, string>();
const createdKeys = new Map<string, string>(); const createdKeys = new Map<string, string>();
function resolveGroupId(name: string): string { 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) { for (const op of ops) {
try { try {
await executeSingle(op, client, actual, { await executeSingle(op, client, actual, {
createdGroupIds, createdGroupIds,
createdPostureCheckIds,
createdNetworkIds,
createdKeys, createdKeys,
resolveGroupId, resolveGroupId,
resolveGroupIds, resolveGroupIds,
resolvePeerIds, resolvePeerIds,
resolvePeerId,
resolvePostureCheckId,
resolveNetworkId,
}); });
results.push({ ...op, status: "success" }); results.push({ ...op, status: "success" });
} catch (err) { } catch (err) {
@ -99,10 +143,15 @@ export async function executeOperations(
interface ExecutorContext { interface ExecutorContext {
createdGroupIds: Map<string, string>; createdGroupIds: Map<string, string>;
createdPostureCheckIds: Map<string, string>;
createdNetworkIds: Map<string, string>;
createdKeys: Map<string, string>; createdKeys: Map<string, string>;
resolveGroupId: (name: string) => string; resolveGroupId: (name: string) => string;
resolveGroupIds: (names: string[]) => string[]; resolveGroupIds: (names: string[]) => string[];
resolvePeerIds: (names: string[]) => string[]; resolvePeerIds: (names: string[]) => string[];
resolvePeerId: (name: string) => string;
resolvePostureCheckId: (name: string) => string;
resolveNetworkId: (name: string) => string;
} }
async function executeSingle( async function executeSingle(
@ -114,6 +163,37 @@ async function executeSingle(
const d = op.details ?? {}; const d = op.details ?? {};
switch (op.type) { 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<string, unknown>) ?? {},
});
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<string, unknown>) ?? 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 ----- // ----- Groups -----
case "create_group": { case "create_group": {
const peerNames = d.peers as string[] | undefined; const peerNames = d.peers as string[] | undefined;
@ -127,7 +207,9 @@ async function executeSingle(
} }
case "update_group": { case "update_group": {
const existing = actual.groupsByName.get(op.name); 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 desiredPeers = d.desired_peers as string[] | undefined;
const peerIds = desiredPeers?.length const peerIds = desiredPeers?.length
? ctx.resolvePeerIds(desiredPeers) ? ctx.resolvePeerIds(desiredPeers)
@ -180,7 +262,6 @@ async function executeSingle(
break; break;
} }
case "update_peer_groups": { case "update_peer_groups": {
// This op type updates peer-level properties; details.id is the peer ID
const peerId = d.id as string; const peerId = d.id as string;
if (!peerId) throw new Error(`update_peer_groups missing details.id`); if (!peerId) throw new Error(`update_peer_groups missing details.id`);
await client.updatePeer(peerId, { await client.updatePeer(peerId, {
@ -192,6 +273,19 @@ async function executeSingle(
}); });
break; 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": { case "delete_peer": {
const peer = actual.peersByName.get(op.name); const peer = actual.peersByName.get(op.name);
if (!peer) throw new Error(`peer "${op.name}" not found for delete`); if (!peer) throw new Error(`peer "${op.name}" not found for delete`);
@ -199,26 +293,213 @@ async function executeSingle(
break; 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 ----- // ----- Policies -----
case "create_policy": { case "create_policy": {
const sourceIds = ctx.resolveGroupIds(d.sources as string[] ?? []); 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<string, unknown> = {
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({ await client.createPolicy({
name: op.name, name: op.name,
description: (d.description as string) ?? "", description: (d.description as string) ?? "",
enabled: (d.enabled as boolean) ?? true, enabled: (d.enabled as boolean) ?? true,
source_posture_checks: postureCheckIds,
rules: [ rules: [
{ rule as unknown as import("../netbird/types.ts").NbPolicyRule,
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,
},
], ],
}); });
break; break;
@ -229,23 +510,35 @@ async function executeSingle(
throw new Error(`policy "${op.name}" not found for update`); throw new Error(`policy "${op.name}" not found for update`);
} }
const sourceIds = ctx.resolveGroupIds(d.sources as string[] ?? []); 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<string, unknown> = {
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, { await client.updatePolicy(existing.id, {
name: op.name, name: op.name,
description: (d.description as string) ?? existing.description, description: (d.description as string) ?? existing.description,
enabled: (d.enabled as boolean) ?? existing.enabled, enabled: (d.enabled as boolean) ?? existing.enabled,
source_posture_checks: postureCheckIds,
rules: [ rules: [
{ rule as unknown as import("../netbird/types.ts").NbPolicyRule,
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,
},
], ],
}); });
break; break;

View File

@ -6,6 +6,7 @@ export type OperationType =
| "delete_setup_key" | "delete_setup_key"
| "rename_peer" | "rename_peer"
| "update_peer_groups" | "update_peer_groups"
| "update_peer"
| "delete_peer" | "delete_peer"
| "create_policy" | "create_policy"
| "update_policy" | "update_policy"
@ -15,7 +16,22 @@ export type OperationType =
| "delete_route" | "delete_route"
| "create_dns" | "create_dns"
| "update_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 { export interface Operation {
type: OperationType; type: OperationType;
@ -30,11 +46,23 @@ export interface OperationResult extends Operation {
/** Order in which operation types must be executed */ /** Order in which operation types must be executed */
export const EXECUTION_ORDER: OperationType[] = [ export const EXECUTION_ORDER: OperationType[] = [
// Creates: dependencies first
"create_posture_check",
"update_posture_check",
"create_group", "create_group",
"update_group", "update_group",
"create_setup_key", "create_setup_key",
"rename_peer", "rename_peer",
"update_peer_groups", "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", "create_policy",
"update_policy", "update_policy",
"create_route", "create_route",
@ -45,7 +73,12 @@ export const EXECUTION_ORDER: OperationType[] = [
"delete_dns", "delete_dns",
"delete_route", "delete_route",
"delete_policy", "delete_policy",
"delete_user",
"delete_network_router",
"delete_network_resource",
"delete_network",
"delete_peer", "delete_peer",
"delete_setup_key", "delete_setup_key",
"delete_posture_check",
"delete_group", "delete_group",
]; ];

View File

@ -3,10 +3,15 @@ import { fetchActualState } from "./actual.ts";
import type { import type {
NbDnsNameserverGroup, NbDnsNameserverGroup,
NbGroup, NbGroup,
NbNetwork,
NbNetworkResource,
NbNetworkRouter,
NbPeer, NbPeer,
NbPolicy, NbPolicy,
NbPostureCheck,
NbRoute, NbRoute,
NbSetupKey, NbSetupKey,
NbUser,
} from "../netbird/types.ts"; } from "../netbird/types.ts";
/** Minimal mock NetBird client that returns predetermined data */ /** Minimal mock NetBird client that returns predetermined data */
@ -17,6 +22,11 @@ function mockClient(data: {
policies?: NbPolicy[]; policies?: NbPolicy[];
routes?: NbRoute[]; routes?: NbRoute[];
dns?: NbDnsNameserverGroup[]; dns?: NbDnsNameserverGroup[];
postureChecks?: NbPostureCheck[];
networks?: NbNetwork[];
networkResources?: Map<string, NbNetworkResource[]>;
networkRouters?: Map<string, NbNetworkRouter[]>;
users?: NbUser[];
}) { }) {
return { return {
listGroups: () => Promise.resolve(data.groups ?? []), listGroups: () => Promise.resolve(data.groups ?? []),
@ -25,6 +35,13 @@ function mockClient(data: {
listPolicies: () => Promise.resolve(data.policies ?? []), listPolicies: () => Promise.resolve(data.policies ?? []),
listRoutes: () => Promise.resolve(data.routes ?? []), listRoutes: () => Promise.resolve(data.routes ?? []),
listDnsNameserverGroups: () => Promise.resolve(data.dns ?? []), 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", name: "allow-ops",
description: "ops traffic", description: "ops traffic",
enabled: true, enabled: true,
source_posture_checks: [],
rules: [], rules: [],
}, },
], ],

View File

@ -2,10 +2,15 @@ import type { NetbirdClient } from "../netbird/client.ts";
import type { import type {
NbDnsNameserverGroup, NbDnsNameserverGroup,
NbGroup, NbGroup,
NbNetwork,
NbNetworkResource,
NbNetworkRouter,
NbPeer, NbPeer,
NbPolicy, NbPolicy,
NbPostureCheck,
NbRoute, NbRoute,
NbSetupKey, NbSetupKey,
NbUser,
} from "../netbird/types.ts"; } from "../netbird/types.ts";
/** Indexed view of all current NetBird state */ /** Indexed view of all current NetBird state */
@ -24,6 +29,14 @@ export interface ActualState {
routesByNetworkId: Map<string, NbRoute>; routesByNetworkId: Map<string, NbRoute>;
dns: NbDnsNameserverGroup[]; dns: NbDnsNameserverGroup[];
dnsByName: Map<string, NbDnsNameserverGroup>; dnsByName: Map<string, NbDnsNameserverGroup>;
postureChecks: NbPostureCheck[];
postureChecksByName: Map<string, NbPostureCheck>;
networks: NbNetwork[];
networksByName: Map<string, NbNetwork>;
networkResources: Map<string, NbNetworkResource[]>; // keyed by network ID
networkRouters: Map<string, NbNetworkRouter[]>; // keyed by network ID
users: NbUser[];
usersByEmail: Map<string, NbUser>;
} }
/** /**
@ -40,6 +53,11 @@ type ClientLike = Pick<
| "listPolicies" | "listPolicies"
| "listRoutes" | "listRoutes"
| "listDnsNameserverGroups" | "listDnsNameserverGroups"
| "listPostureChecks"
| "listNetworks"
| "listNetworkResources"
| "listNetworkRouters"
| "listUsers"
>; >;
/** /**
@ -50,15 +68,51 @@ type ClientLike = Pick<
export async function fetchActualState( export async function fetchActualState(
client: ClientLike, client: ClientLike,
): Promise<ActualState> { ): Promise<ActualState> {
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.listGroups(),
client.listSetupKeys(), client.listSetupKeys(),
client.listPeers(), client.listPeers(),
client.listPolicies(), client.listPolicies(),
client.listRoutes(), client.listRoutes(),
client.listDnsNameserverGroups(), 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<string, NbNetworkResource[]>(
resourcesByNetwork.map((r) => [r.id, r.resources]),
);
const networkRouters = new Map<string, NbNetworkRouter[]>(
routersByNetwork.map((r) => [r.id, r.routers]),
);
return { return {
groups, groups,
groupsByName: new Map(groups.map((g) => [g.name, g])), 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])), routesByNetworkId: new Map(routes.map((r) => [r.network_id, r])),
dns, dns,
dnsByName: new Map(dns.map((d) => [d.name, d])), 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])),
}; };
} }

View File

@ -14,15 +14,22 @@ export const GroupSchema = z.object({
peers: z.array(z.string()), peers: z.array(z.string()),
}); });
export const DestinationResourceSchema = z.object({
id: z.string(),
type: z.string(),
});
export const PolicySchema = z.object({ export const PolicySchema = z.object({
description: z.string().default(""), description: z.string().default(""),
enabled: z.boolean(), enabled: z.boolean(),
sources: z.array(z.string()), sources: z.array(z.string()),
destinations: z.array(z.string()), destinations: z.array(z.string()).default([]),
bidirectional: z.boolean(), bidirectional: z.boolean(),
protocol: z.enum(["tcp", "udp", "icmp", "all"]).default("all"), protocol: z.enum(["tcp", "udp", "icmp", "all"]).default("all"),
action: z.enum(["accept", "drop"]).default("accept"), action: z.enum(["accept", "drop"]).default("accept"),
ports: z.array(z.string()).optional(), ports: z.array(z.string()).optional(),
destination_resource: DestinationResourceSchema.optional(),
source_posture_checks: z.array(z.string()).default([]),
}); });
export const RouteSchema = z.object({ export const RouteSchema = z.object({
@ -53,6 +60,47 @@ export const DnsNameserverGroupSchema = z.object({
search_domains_enabled: z.boolean().default(false), 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 --- // --- Top-level schema ---
export const DesiredStateSchema = z.object({ export const DesiredStateSchema = z.object({
@ -64,6 +112,10 @@ export const DesiredStateSchema = z.object({
nameserver_groups: z.record(z.string(), DnsNameserverGroupSchema) nameserver_groups: z.record(z.string(), DnsNameserverGroupSchema)
.default({}), .default({}),
}).default({ nameserver_groups: {} }), }).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 --- // --- Inferred types ---
@ -74,6 +126,15 @@ export type GroupConfig = z.infer<typeof GroupSchema>;
export type PolicyConfig = z.infer<typeof PolicySchema>; export type PolicyConfig = z.infer<typeof PolicySchema>;
export type RouteConfig = z.infer<typeof RouteSchema>; export type RouteConfig = z.infer<typeof RouteSchema>;
export type DnsNameserverGroupConfig = z.infer<typeof DnsNameserverGroupSchema>; export type DnsNameserverGroupConfig = z.infer<typeof DnsNameserverGroupSchema>;
export type PostureCheckConfig = z.infer<typeof PostureCheckSchema>;
export type NetworkConfig = z.infer<typeof NetworkSchema>;
export type NetworkResourceConfig = z.infer<typeof NetworkResourceSchema>;
export type NetworkRouterConfig = z.infer<typeof NetworkRouterSchema>;
export type PeerConfig = z.infer<typeof PeerSchema>;
export type UserConfig = z.infer<typeof UserSchema>;
export type DestinationResourceConfig = z.infer<
typeof DestinationResourceSchema
>;
// --- Cross-reference validation --- // --- Cross-reference validation ---
@ -89,11 +150,16 @@ export type DnsNameserverGroupConfig = z.infer<typeof DnsNameserverGroupSchema>;
* 4. Every peer_group and distribution_group in a route references an * 4. Every peer_group and distribution_group in a route references an
* existing group. * existing group.
* 5. Every group in a DNS nameserver group 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[] { export function validateCrossReferences(state: DesiredState): string[] {
const errors: string[] = []; const errors: string[] = [];
const groupNames = new Set(Object.keys(state.groups)); const groupNames = new Set(Object.keys(state.groups));
const setupKeyNames = new Set(Object.keys(state.setup_keys)); 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 // 1. Peers in groups must reference existing setup keys
for (const [groupName, group] of Object.entries(state.groups)) { 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; return errors;
} }

414
state/dev.json Normal file
View File

@ -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": {}
}
}

1022
state/ext.json Normal file

File diff suppressed because it is too large Load Diff

627
state/prod.json Normal file
View File

@ -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": {}
}
}