added schema expansion for testing
This commit is contained in:
parent
11434f667a
commit
5c9c3f33bf
555
docs/plans/2026-03-06-schema-expansion.md
Normal file
555
docs/plans/2026-03-06-schema-expansion.md
Normal 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.
|
||||
@ -4,10 +4,15 @@ import type { ActualState } from "./state/actual.ts";
|
||||
import type {
|
||||
NbDnsNameserverGroup,
|
||||
NbGroup,
|
||||
NbNetwork,
|
||||
NbNetworkResource,
|
||||
NbNetworkRouter,
|
||||
NbPeer,
|
||||
NbPolicy,
|
||||
NbPostureCheck,
|
||||
NbRoute,
|
||||
NbSetupKey,
|
||||
NbUser,
|
||||
} from "./netbird/types.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -22,6 +27,11 @@ function buildActualState(data: {
|
||||
policies?: NbPolicy[];
|
||||
routes?: NbRoute[];
|
||||
dns?: NbDnsNameserverGroup[];
|
||||
postureChecks?: NbPostureCheck[];
|
||||
networks?: NbNetwork[];
|
||||
networkResources?: Map<string, NbNetworkResource[]>;
|
||||
networkRouters?: Map<string, NbNetworkRouter[]>;
|
||||
users?: NbUser[];
|
||||
}): ActualState {
|
||||
const groups = data.groups ?? [];
|
||||
const setupKeys = data.setupKeys ?? [];
|
||||
@ -29,6 +39,9 @@ function buildActualState(data: {
|
||||
const policies = data.policies ?? [];
|
||||
const routes = data.routes ?? [];
|
||||
const dns = data.dns ?? [];
|
||||
const postureChecks = data.postureChecks ?? [];
|
||||
const networks = data.networks ?? [];
|
||||
const users = data.users ?? [];
|
||||
|
||||
return {
|
||||
groups,
|
||||
@ -45,6 +58,14 @@ function buildActualState(data: {
|
||||
routesByNetworkId: new Map(routes.map((r) => [r.network_id, r])),
|
||||
dns,
|
||||
dnsByName: new Map(dns.map((d) => [d.name, d])),
|
||||
postureChecks,
|
||||
postureChecksByName: new Map(postureChecks.map((pc) => [pc.name, pc])),
|
||||
networks,
|
||||
networksByName: new Map(networks.map((n) => [n.name, n])),
|
||||
networkResources: data.networkResources ?? new Map(),
|
||||
networkRouters: data.networkRouters ?? new Map(),
|
||||
users,
|
||||
usersByEmail: new Map(users.map((u) => [u.email, u])),
|
||||
};
|
||||
}
|
||||
|
||||
@ -105,6 +126,7 @@ Deno.test("exportState: normal state with groups, keys, and policy", () => {
|
||||
name: "allow-pilot-vehicle",
|
||||
description: "pilot to vehicle",
|
||||
enabled: true,
|
||||
source_posture_checks: [],
|
||||
rules: [
|
||||
{
|
||||
name: "rule1",
|
||||
@ -342,6 +364,7 @@ Deno.test("exportState: policies with empty rules are skipped", () => {
|
||||
name: "empty-policy",
|
||||
description: "no rules",
|
||||
enabled: true,
|
||||
source_posture_checks: [],
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
@ -362,6 +385,7 @@ Deno.test("exportState: policy sources/destinations as {id,name} objects are res
|
||||
name: "object-refs",
|
||||
description: "",
|
||||
enabled: true,
|
||||
source_posture_checks: [],
|
||||
rules: [
|
||||
{
|
||||
name: "r1",
|
||||
@ -399,6 +423,7 @@ Deno.test("exportState: policy without ports omits the ports field", () => {
|
||||
name: "no-ports",
|
||||
description: "",
|
||||
enabled: true,
|
||||
source_posture_checks: [],
|
||||
rules: [
|
||||
{
|
||||
name: "r1",
|
||||
|
||||
175
src/export.ts
175
src/export.ts
@ -28,15 +28,41 @@ const DEFAULT_EXPIRES_IN = 604800;
|
||||
* - Routes: keyed by `network_id`. Peer groups and distribution groups
|
||||
* resolved from IDs to names.
|
||||
* - DNS: group IDs resolved to names.
|
||||
* - Posture checks: keyed by name, checks object passed through.
|
||||
* - Networks: keyed by name, resources and routers resolved.
|
||||
* - Peers: keyed by name, groups resolved (excluding "All").
|
||||
* - Users: keyed by email, auto_groups resolved.
|
||||
*/
|
||||
export function exportState(actual: ActualState): DesiredState {
|
||||
const idToName = buildIdToNameMap(actual);
|
||||
const setupKeyNames = new Set(actual.setupKeys.map((k) => k.name));
|
||||
|
||||
// Build resource ID → name map from all network resources
|
||||
const resourceIdToName = new Map<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 {
|
||||
groups: exportGroups(actual, setupKeyNames, idToName),
|
||||
setup_keys: exportSetupKeys(actual, idToName),
|
||||
policies: exportPolicies(actual, idToName),
|
||||
policies: exportPolicies(
|
||||
actual,
|
||||
idToName,
|
||||
resourceIdToName,
|
||||
postureCheckIdToName,
|
||||
),
|
||||
posture_checks: exportPostureChecks(actual),
|
||||
networks: exportNetworks(actual, idToName),
|
||||
peers: exportPeers(actual, idToName),
|
||||
users: exportUsers(actual, idToName),
|
||||
routes: exportRoutes(actual, idToName),
|
||||
dns: {
|
||||
nameserver_groups: exportDns(actual, idToName),
|
||||
@ -89,7 +115,7 @@ function exportGroups(
|
||||
|
||||
// Only include peers whose name matches a known setup key, since
|
||||
// the desired-state schema models peers as setup-key references.
|
||||
const peers = group.peers
|
||||
const peers = (group.peers ?? [])
|
||||
.map((p) => p.name)
|
||||
.filter((name) => setupKeyNames.has(name));
|
||||
|
||||
@ -143,6 +169,8 @@ function isEnrolled(usedTimes: number, usageLimit: number): boolean {
|
||||
function exportPolicies(
|
||||
actual: ActualState,
|
||||
idToName: Map<string, string>,
|
||||
resourceIdToName: Map<string, string>,
|
||||
postureCheckIdToName: Map<string, string>,
|
||||
): DesiredState["policies"] {
|
||||
const result: DesiredState["policies"] = {};
|
||||
|
||||
@ -151,11 +179,7 @@ function exportPolicies(
|
||||
|
||||
const rule = policy.rules[0];
|
||||
const sources = resolveIds(
|
||||
rule.sources.map(extractGroupId),
|
||||
idToName,
|
||||
);
|
||||
const destinations = resolveIds(
|
||||
rule.destinations.map(extractGroupId),
|
||||
(rule.sources ?? []).map(extractGroupId),
|
||||
idToName,
|
||||
);
|
||||
|
||||
@ -163,12 +187,32 @@ function exportPolicies(
|
||||
description: policy.description,
|
||||
enabled: policy.enabled,
|
||||
sources,
|
||||
destinations,
|
||||
destinations: [],
|
||||
bidirectional: rule.bidirectional,
|
||||
protocol: rule.protocol,
|
||||
action: rule.action,
|
||||
source_posture_checks: resolveIds(
|
||||
policy.source_posture_checks ?? [],
|
||||
postureCheckIdToName,
|
||||
),
|
||||
};
|
||||
|
||||
// Handle destination_resource vs group-based destinations
|
||||
if (rule.destinationResource) {
|
||||
const resourceName = resourceIdToName.get(
|
||||
rule.destinationResource.id,
|
||||
);
|
||||
entry.destination_resource = {
|
||||
id: resourceName ?? rule.destinationResource.id,
|
||||
type: rule.destinationResource.type,
|
||||
};
|
||||
} else {
|
||||
entry.destinations = resolveIds(
|
||||
(rule.destinations ?? []).map(extractGroupId),
|
||||
idToName,
|
||||
);
|
||||
}
|
||||
|
||||
if (rule.ports && rule.ports.length > 0) {
|
||||
entry.ports = rule.ports;
|
||||
}
|
||||
@ -179,6 +223,121 @@ function exportPolicies(
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Posture Checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function exportPostureChecks(
|
||||
actual: ActualState,
|
||||
): DesiredState["posture_checks"] {
|
||||
const result: DesiredState["posture_checks"] = {};
|
||||
|
||||
for (const pc of actual.postureChecks) {
|
||||
result[pc.name] = {
|
||||
description: pc.description,
|
||||
checks: pc.checks,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Networks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function exportNetworks(
|
||||
actual: ActualState,
|
||||
idToName: Map<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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -369,6 +369,11 @@ function createExportMockFetch(calls: ApiCall[]) {
|
||||
if (method === "GET" && path === "/dns/nameservers") {
|
||||
return Response.json([]);
|
||||
}
|
||||
if (method === "GET" && path === "/posture-checks") {
|
||||
return Response.json([]);
|
||||
}
|
||||
if (method === "GET" && path === "/networks") return Response.json([]);
|
||||
if (method === "GET" && path === "/users") return Response.json([]);
|
||||
if (method === "GET" && path === "/events/audit") {
|
||||
return Response.json([]);
|
||||
}
|
||||
|
||||
@ -2,10 +2,15 @@ import type {
|
||||
NbDnsNameserverGroup,
|
||||
NbEvent,
|
||||
NbGroup,
|
||||
NbNetwork,
|
||||
NbNetworkResource,
|
||||
NbNetworkRouter,
|
||||
NbPeer,
|
||||
NbPolicy,
|
||||
NbPostureCheck,
|
||||
NbRoute,
|
||||
NbSetupKey,
|
||||
NbUser,
|
||||
} from "./types.ts";
|
||||
|
||||
/** Narrowed fetch signature used for dependency injection. */
|
||||
@ -142,6 +147,7 @@ export class NetbirdClient {
|
||||
name?: string;
|
||||
ssh_enabled?: boolean;
|
||||
login_expiration_enabled?: boolean;
|
||||
inactivity_expiration_enabled?: boolean;
|
||||
},
|
||||
): Promise<NbPeer> {
|
||||
return this.request("PUT", `/peers/${id}`, data);
|
||||
@ -223,4 +229,169 @@ export class NetbirdClient {
|
||||
listEvents(): Promise<NbEvent[]> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ export interface NbGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
peers_count: number;
|
||||
peers: Array<{ id: string; name: string }>;
|
||||
peers: Array<{ id: string; name: string }> | null;
|
||||
issued: "api" | "jwt" | "integration";
|
||||
}
|
||||
|
||||
@ -46,6 +46,7 @@ export interface NbPolicy {
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
rules: NbPolicyRule[];
|
||||
source_posture_checks: string[];
|
||||
}
|
||||
|
||||
export interface NbPolicyRule {
|
||||
@ -57,8 +58,9 @@ export interface NbPolicyRule {
|
||||
bidirectional: boolean;
|
||||
protocol: "tcp" | "udp" | "icmp" | "all";
|
||||
ports?: string[];
|
||||
sources: Array<string | { id: string; name: string }>;
|
||||
destinations: Array<string | { id: string; name: string }>;
|
||||
sources: Array<string | { id: string; name: string }> | null;
|
||||
destinations: Array<string | { id: string; name: string }> | null;
|
||||
destinationResource?: { id: string; type: string } | null;
|
||||
}
|
||||
|
||||
/** Route as returned by GET /api/routes */
|
||||
@ -94,6 +96,62 @@ export interface NbDnsNameserverGroup {
|
||||
search_domains_enabled: boolean;
|
||||
}
|
||||
|
||||
/** Posture check as returned by GET /api/posture-checks */
|
||||
export interface NbPostureCheck {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
checks: Record<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 */
|
||||
export interface NbEvent {
|
||||
id: number;
|
||||
|
||||
@ -20,10 +20,36 @@ function emptyActual(): ActualState {
|
||||
routesByNetworkId: new Map(),
|
||||
dns: [],
|
||||
dnsByName: new Map(),
|
||||
postureChecks: [],
|
||||
postureChecksByName: new Map(),
|
||||
networks: [],
|
||||
networksByName: new Map(),
|
||||
networkResources: new Map(),
|
||||
networkRouters: new Map(),
|
||||
users: [],
|
||||
usersByEmail: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const DESIRED: DesiredState = {
|
||||
/** Builds a minimal DesiredState with defaults for all required sections. */
|
||||
function desiredState(
|
||||
overrides: Partial<DesiredState> = {},
|
||||
): DesiredState {
|
||||
return {
|
||||
groups: {},
|
||||
setup_keys: {},
|
||||
policies: {},
|
||||
routes: {},
|
||||
dns: { nameserver_groups: {} },
|
||||
posture_checks: {},
|
||||
networks: {},
|
||||
peers: {},
|
||||
users: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const DESIRED: DesiredState = desiredState({
|
||||
groups: { pilots: { peers: ["Pilot-hawk-72"] } },
|
||||
setup_keys: {
|
||||
"Pilot-hawk-72": {
|
||||
@ -34,10 +60,7 @@ const DESIRED: DesiredState = {
|
||||
enrolled: false,
|
||||
},
|
||||
},
|
||||
policies: {},
|
||||
routes: {},
|
||||
dns: { nameserver_groups: {} },
|
||||
};
|
||||
});
|
||||
|
||||
Deno.test("computeDiff against empty actual produces create ops", () => {
|
||||
const ops = computeDiff(DESIRED, emptyActual());
|
||||
@ -80,13 +103,7 @@ Deno.test("computeDiff with matching state produces no ops", () => {
|
||||
});
|
||||
|
||||
Deno.test("computeDiff does not delete system groups", () => {
|
||||
const desired: DesiredState = {
|
||||
groups: {},
|
||||
setup_keys: {},
|
||||
policies: {},
|
||||
routes: {},
|
||||
dns: { nameserver_groups: {} },
|
||||
};
|
||||
const desired = desiredState();
|
||||
const actual = emptyActual();
|
||||
|
||||
const jwtGroup: NbGroup = {
|
||||
@ -104,13 +121,7 @@ Deno.test("computeDiff does not delete system groups", () => {
|
||||
});
|
||||
|
||||
Deno.test("computeDiff deletes api-issued groups not in desired", () => {
|
||||
const desired: DesiredState = {
|
||||
groups: {},
|
||||
setup_keys: {},
|
||||
policies: {},
|
||||
routes: {},
|
||||
dns: { nameserver_groups: {} },
|
||||
};
|
||||
const desired = desiredState();
|
||||
const actual = emptyActual();
|
||||
|
||||
const staleGroup: NbGroup = {
|
||||
@ -143,13 +154,9 @@ Deno.test("computeDiff detects group peer membership change", () => {
|
||||
actual.groups = [group];
|
||||
|
||||
// Desired has a peer in the group, actual has none
|
||||
const desired: DesiredState = {
|
||||
const desired = desiredState({
|
||||
groups: { pilots: { peers: ["Pilot-hawk-72"] } },
|
||||
setup_keys: {},
|
||||
policies: {},
|
||||
routes: {},
|
||||
dns: { nameserver_groups: {} },
|
||||
};
|
||||
});
|
||||
const ops = computeDiff(desired, actual);
|
||||
const updateOps = ops.filter((o) => o.type === "update_group");
|
||||
assertEquals(updateOps.length, 1);
|
||||
@ -157,8 +164,7 @@ Deno.test("computeDiff detects group peer membership change", () => {
|
||||
});
|
||||
|
||||
Deno.test("computeDiff skips enrolled setup keys", () => {
|
||||
const desired: DesiredState = {
|
||||
groups: {},
|
||||
const desired = desiredState({
|
||||
setup_keys: {
|
||||
"Already-enrolled": {
|
||||
type: "one-off",
|
||||
@ -168,19 +174,14 @@ Deno.test("computeDiff skips enrolled setup keys", () => {
|
||||
enrolled: true,
|
||||
},
|
||||
},
|
||||
policies: {},
|
||||
routes: {},
|
||||
dns: { nameserver_groups: {} },
|
||||
};
|
||||
});
|
||||
const ops = computeDiff(desired, emptyActual());
|
||||
const createKeyOps = ops.filter((o) => o.type === "create_setup_key");
|
||||
assertEquals(createKeyOps.length, 0);
|
||||
});
|
||||
|
||||
Deno.test("computeDiff creates policy when not in actual", () => {
|
||||
const desired: DesiredState = {
|
||||
groups: {},
|
||||
setup_keys: {},
|
||||
const desired = desiredState({
|
||||
policies: {
|
||||
"allow-pilots": {
|
||||
description: "Allow pilot traffic",
|
||||
@ -190,11 +191,10 @@ Deno.test("computeDiff creates policy when not in actual", () => {
|
||||
bidirectional: true,
|
||||
protocol: "all",
|
||||
action: "accept",
|
||||
source_posture_checks: [],
|
||||
},
|
||||
},
|
||||
routes: {},
|
||||
dns: { nameserver_groups: {} },
|
||||
};
|
||||
});
|
||||
const ops = computeDiff(desired, emptyActual());
|
||||
const policyOps = ops.filter((o) => o.type === "create_policy");
|
||||
assertEquals(policyOps.length, 1);
|
||||
@ -220,6 +220,7 @@ Deno.test("computeDiff detects policy enabled change", () => {
|
||||
name: "allow-pilots",
|
||||
description: "Allow pilot traffic",
|
||||
enabled: true, // currently enabled
|
||||
source_posture_checks: [],
|
||||
rules: [{
|
||||
name: "allow-pilots",
|
||||
description: "",
|
||||
@ -233,9 +234,8 @@ Deno.test("computeDiff detects policy enabled change", () => {
|
||||
});
|
||||
actual.policies = [actual.policiesByName.get("allow-pilots")!];
|
||||
|
||||
const desired: DesiredState = {
|
||||
const desired = desiredState({
|
||||
groups: { pilots: { peers: [] } },
|
||||
setup_keys: {},
|
||||
policies: {
|
||||
"allow-pilots": {
|
||||
description: "Allow pilot traffic",
|
||||
@ -245,11 +245,10 @@ Deno.test("computeDiff detects policy enabled change", () => {
|
||||
bidirectional: true,
|
||||
protocol: "all",
|
||||
action: "accept",
|
||||
source_posture_checks: [],
|
||||
},
|
||||
},
|
||||
routes: {},
|
||||
dns: { nameserver_groups: {} },
|
||||
};
|
||||
});
|
||||
const ops = computeDiff(desired, actual);
|
||||
const updateOps = ops.filter((o) => o.type === "update_policy");
|
||||
assertEquals(updateOps.length, 1);
|
||||
@ -257,10 +256,7 @@ Deno.test("computeDiff detects policy enabled change", () => {
|
||||
});
|
||||
|
||||
Deno.test("computeDiff creates route when not in actual", () => {
|
||||
const desired: DesiredState = {
|
||||
groups: {},
|
||||
setup_keys: {},
|
||||
policies: {},
|
||||
const desired = desiredState({
|
||||
routes: {
|
||||
"vpn-exit": {
|
||||
description: "VPN exit route",
|
||||
@ -273,8 +269,7 @@ Deno.test("computeDiff creates route when not in actual", () => {
|
||||
keep_route: true,
|
||||
},
|
||||
},
|
||||
dns: { nameserver_groups: {} },
|
||||
};
|
||||
});
|
||||
const ops = computeDiff(desired, emptyActual());
|
||||
const routeOps = ops.filter((o) => o.type === "create_route");
|
||||
assertEquals(routeOps.length, 1);
|
||||
@ -282,11 +277,7 @@ Deno.test("computeDiff creates route when not in actual", () => {
|
||||
});
|
||||
|
||||
Deno.test("computeDiff creates dns when not in actual", () => {
|
||||
const desired: DesiredState = {
|
||||
groups: {},
|
||||
setup_keys: {},
|
||||
policies: {},
|
||||
routes: {},
|
||||
const desired = desiredState({
|
||||
dns: {
|
||||
nameserver_groups: {
|
||||
"cloudflare": {
|
||||
@ -300,7 +291,7 @@ Deno.test("computeDiff creates dns when not in actual", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
const ops = computeDiff(desired, emptyActual());
|
||||
const dnsOps = ops.filter((o) => o.type === "create_dns");
|
||||
assertEquals(dnsOps.length, 1);
|
||||
@ -309,7 +300,7 @@ Deno.test("computeDiff creates dns when not in actual", () => {
|
||||
|
||||
Deno.test("computeDiff operations are sorted by EXECUTION_ORDER", () => {
|
||||
// Desired state that produces creates for multiple resource types
|
||||
const desired: DesiredState = {
|
||||
const desired = desiredState({
|
||||
groups: { pilots: { peers: [] } },
|
||||
setup_keys: {
|
||||
"new-key": {
|
||||
@ -329,11 +320,10 @@ Deno.test("computeDiff operations are sorted by EXECUTION_ORDER", () => {
|
||||
bidirectional: true,
|
||||
protocol: "all",
|
||||
action: "accept",
|
||||
source_posture_checks: [],
|
||||
},
|
||||
},
|
||||
routes: {},
|
||||
dns: { nameserver_groups: {} },
|
||||
};
|
||||
});
|
||||
const ops = computeDiff(desired, emptyActual());
|
||||
|
||||
// create_group must come before create_setup_key, which must come before
|
||||
|
||||
@ -15,8 +15,12 @@ export function computeDiff(
|
||||
): Operation[] {
|
||||
const ops: Operation[] = [];
|
||||
|
||||
diffPostureChecks(desired, actual, ops);
|
||||
diffGroups(desired, actual, ops);
|
||||
diffSetupKeys(desired, actual, ops);
|
||||
diffNetworks(desired, actual, ops);
|
||||
diffPeers(desired, actual, ops);
|
||||
diffUsers(desired, actual, ops);
|
||||
diffPolicies(desired, actual, ops);
|
||||
diffRoutes(desired, actual, ops);
|
||||
diffDns(desired, actual, ops);
|
||||
@ -24,6 +28,53 @@ export function computeDiff(
|
||||
return sortByExecutionOrder(ops);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Posture Checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function diffPostureChecks(
|
||||
desired: DesiredState,
|
||||
actual: ActualState,
|
||||
ops: Operation[],
|
||||
): void {
|
||||
const desiredNames = new Set(Object.keys(desired.posture_checks));
|
||||
|
||||
for (const [name, config] of Object.entries(desired.posture_checks)) {
|
||||
const existing = actual.postureChecksByName.get(name);
|
||||
if (!existing) {
|
||||
ops.push({
|
||||
type: "create_posture_check",
|
||||
name,
|
||||
details: {
|
||||
description: config.description,
|
||||
checks: config.checks,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
existing.description !== config.description ||
|
||||
JSON.stringify(existing.checks) !== JSON.stringify(config.checks)
|
||||
) {
|
||||
ops.push({
|
||||
type: "update_posture_check",
|
||||
name,
|
||||
details: {
|
||||
description: config.description,
|
||||
checks: config.checks,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const pc of actual.postureChecks) {
|
||||
if (!desiredNames.has(pc.name)) {
|
||||
ops.push({ type: "delete_posture_check", name: pc.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Groups
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -47,7 +98,7 @@ function diffGroups(
|
||||
}
|
||||
|
||||
// Compare peer membership by name (sorted for stable comparison)
|
||||
const actualPeerNames = existing.peers.map((p) => p.name).sort();
|
||||
const actualPeerNames = (existing.peers ?? []).map((p) => p.name).sort();
|
||||
const desiredPeerNames = [...config.peers].sort();
|
||||
if (!arraysEqual(actualPeerNames, desiredPeerNames)) {
|
||||
ops.push({
|
||||
@ -64,7 +115,10 @@ function diffGroups(
|
||||
// Delete groups that exist in actual but not in desired.
|
||||
// Only delete API-issued groups — system and JWT groups are managed externally.
|
||||
for (const group of actual.groups) {
|
||||
if (!desiredNames.has(group.name) && group.issued === "api") {
|
||||
if (
|
||||
!desiredNames.has(group.name) && group.issued === "api" &&
|
||||
group.name !== "All"
|
||||
) {
|
||||
ops.push({ type: "delete_group", name: group.name });
|
||||
}
|
||||
}
|
||||
@ -106,6 +160,389 @@ function diffSetupKeys(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Networks (including resources and routers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function diffNetworks(
|
||||
desired: DesiredState,
|
||||
actual: ActualState,
|
||||
ops: Operation[],
|
||||
): void {
|
||||
const desiredNames = new Set(Object.keys(desired.networks));
|
||||
|
||||
for (const [name, config] of Object.entries(desired.networks)) {
|
||||
const existing = actual.networksByName.get(name);
|
||||
|
||||
if (!existing) {
|
||||
ops.push({
|
||||
type: "create_network",
|
||||
name,
|
||||
details: { description: config.description },
|
||||
});
|
||||
|
||||
// All resources and routers under a new network are creates
|
||||
for (const res of config.resources) {
|
||||
ops.push({
|
||||
type: "create_network_resource",
|
||||
name: res.name,
|
||||
details: {
|
||||
network_name: name,
|
||||
description: res.description,
|
||||
type: res.type,
|
||||
address: res.address,
|
||||
enabled: res.enabled,
|
||||
groups: res.groups,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const router of config.routers) {
|
||||
ops.push({
|
||||
type: "create_network_router",
|
||||
name: routerKey(router),
|
||||
details: {
|
||||
network_name: name,
|
||||
peer: router.peer,
|
||||
peer_groups: router.peer_groups,
|
||||
metric: router.metric,
|
||||
masquerade: router.masquerade,
|
||||
enabled: router.enabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Network exists — check for description change
|
||||
if (existing.description !== config.description) {
|
||||
ops.push({
|
||||
type: "update_network",
|
||||
name,
|
||||
details: { description: config.description },
|
||||
});
|
||||
}
|
||||
|
||||
// Diff resources within this network
|
||||
const actualResources = actual.networkResources.get(existing.id) ?? [];
|
||||
diffNetworkResources(name, config.resources, actualResources, actual, ops);
|
||||
|
||||
// Diff routers within this network
|
||||
const actualRouters = actual.networkRouters.get(existing.id) ?? [];
|
||||
diffNetworkRouters(name, config.routers, actualRouters, actual, ops);
|
||||
}
|
||||
|
||||
// Delete networks not in desired (this also implicitly removes their resources/routers)
|
||||
for (const network of actual.networks) {
|
||||
if (!desiredNames.has(network.name)) {
|
||||
// Delete routers and resources first (execution order handles this,
|
||||
// but we still emit the ops)
|
||||
const routers = actual.networkRouters.get(network.id) ?? [];
|
||||
for (const router of routers) {
|
||||
ops.push({
|
||||
type: "delete_network_router",
|
||||
name: actualRouterKey(router, actual),
|
||||
details: { network_name: network.name, router_id: router.id },
|
||||
});
|
||||
}
|
||||
const resources = actual.networkResources.get(network.id) ?? [];
|
||||
for (const res of resources) {
|
||||
ops.push({
|
||||
type: "delete_network_resource",
|
||||
name: res.name,
|
||||
details: { network_name: network.name, resource_id: res.id },
|
||||
});
|
||||
}
|
||||
ops.push({ type: "delete_network", name: network.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function diffNetworkResources(
|
||||
networkName: string,
|
||||
desiredResources: DesiredState["networks"][string]["resources"],
|
||||
actualResources: ActualState["networkResources"] extends Map<
|
||||
string,
|
||||
infer V
|
||||
> ? V
|
||||
: never,
|
||||
actual: ActualState,
|
||||
ops: Operation[],
|
||||
): void {
|
||||
const actualByName = new Map(actualResources.map((r) => [r.name, r]));
|
||||
const desiredNames = new Set(desiredResources.map((r) => r.name));
|
||||
|
||||
for (const res of desiredResources) {
|
||||
const existing = actualByName.get(res.name);
|
||||
if (!existing) {
|
||||
ops.push({
|
||||
type: "create_network_resource",
|
||||
name: res.name,
|
||||
details: {
|
||||
network_name: networkName,
|
||||
description: res.description,
|
||||
type: res.type,
|
||||
address: res.address,
|
||||
enabled: res.enabled,
|
||||
groups: res.groups,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compare fields: resolve actual group names for comparison
|
||||
const actualGroupNames = existing.groups.map((g) => g.name).sort();
|
||||
const desiredGroupNames = [...res.groups].sort();
|
||||
|
||||
if (
|
||||
existing.description !== res.description ||
|
||||
existing.type !== res.type ||
|
||||
existing.address !== res.address ||
|
||||
existing.enabled !== res.enabled ||
|
||||
!arraysEqual(actualGroupNames, desiredGroupNames)
|
||||
) {
|
||||
ops.push({
|
||||
type: "update_network_resource",
|
||||
name: res.name,
|
||||
details: {
|
||||
network_name: networkName,
|
||||
resource_id: existing.id,
|
||||
description: res.description,
|
||||
type: res.type,
|
||||
address: res.address,
|
||||
enabled: res.enabled,
|
||||
groups: res.groups,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete resources not in desired
|
||||
for (const res of actualResources) {
|
||||
if (!desiredNames.has(res.name)) {
|
||||
ops.push({
|
||||
type: "delete_network_resource",
|
||||
name: res.name,
|
||||
details: { network_name: networkName, resource_id: res.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function diffNetworkRouters(
|
||||
networkName: string,
|
||||
desiredRouters: DesiredState["networks"][string]["routers"],
|
||||
actualRouters: ActualState["networkRouters"] extends Map<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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -127,29 +564,56 @@ function diffPolicies(
|
||||
enabled: config.enabled,
|
||||
sources: config.sources,
|
||||
destinations: config.destinations,
|
||||
destination_resource: config.destination_resource,
|
||||
source_posture_checks: config.source_posture_checks,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract group names from actual rules for comparison.
|
||||
// A policy may have multiple rules; aggregate sources/destinations
|
||||
// across all rules for a flat comparison against the desired config.
|
||||
const actualSources = extractGroupNames(
|
||||
existing.rules.flatMap((r) => r.sources),
|
||||
actual,
|
||||
).sort();
|
||||
const actualDests = extractGroupNames(
|
||||
existing.rules.flatMap((r) => r.destinations),
|
||||
existing.rules.flatMap((r) => r.sources ?? []),
|
||||
actual,
|
||||
).sort();
|
||||
const desiredSources = [...config.sources].sort();
|
||||
|
||||
let destsChanged = false;
|
||||
if (config.destination_resource) {
|
||||
// When desired has destination_resource, compare against actual rule's destinationResource
|
||||
const actualDestRes = existing.rules[0]?.destinationResource;
|
||||
if (
|
||||
!actualDestRes ||
|
||||
actualDestRes.id !== config.destination_resource.id ||
|
||||
actualDestRes.type !== config.destination_resource.type
|
||||
) {
|
||||
destsChanged = true;
|
||||
}
|
||||
} else {
|
||||
// Standard group-based destination comparison
|
||||
const actualDests = extractGroupNames(
|
||||
existing.rules.flatMap((r) => r.destinations ?? []),
|
||||
actual,
|
||||
).sort();
|
||||
const desiredDests = [...config.destinations].sort();
|
||||
destsChanged = !arraysEqual(actualDests, desiredDests);
|
||||
}
|
||||
|
||||
// Compare source_posture_checks
|
||||
const actualPostureChecks = [
|
||||
...(existing.source_posture_checks ?? []),
|
||||
].sort();
|
||||
const desiredPostureChecks = [...config.source_posture_checks].sort();
|
||||
const postureChecksChanged = !arraysEqual(
|
||||
actualPostureChecks,
|
||||
desiredPostureChecks,
|
||||
);
|
||||
|
||||
if (
|
||||
existing.enabled !== config.enabled ||
|
||||
!arraysEqual(actualSources, desiredSources) ||
|
||||
!arraysEqual(actualDests, desiredDests)
|
||||
destsChanged ||
|
||||
postureChecksChanged
|
||||
) {
|
||||
ops.push({
|
||||
type: "update_policy",
|
||||
@ -158,6 +622,8 @@ function diffPolicies(
|
||||
enabled: config.enabled,
|
||||
sources: config.sources,
|
||||
destinations: config.destinations,
|
||||
destination_resource: config.destination_resource,
|
||||
source_posture_checks: config.source_posture_checks,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -176,7 +642,7 @@ function diffPolicies(
|
||||
* back to the ID if the group is unknown (defensive).
|
||||
*/
|
||||
function extractGroupNames(
|
||||
refs: NbPolicyRule["sources"],
|
||||
refs: NonNullable<NbPolicyRule["sources"]>,
|
||||
actual: ActualState,
|
||||
): string[] {
|
||||
return refs.map((ref) => {
|
||||
|
||||
@ -19,6 +19,14 @@ function emptyActual(): ActualState {
|
||||
routesByNetworkId: new Map(),
|
||||
dns: [],
|
||||
dnsByName: new Map(),
|
||||
postureChecks: [],
|
||||
postureChecksByName: new Map(),
|
||||
networks: [],
|
||||
networksByName: new Map(),
|
||||
networkResources: new Map(),
|
||||
networkRouters: new Map(),
|
||||
users: [],
|
||||
usersByEmail: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,21 @@ type ExecutorClient = Pick<
|
||||
| "createDnsNameserverGroup"
|
||||
| "updateDnsNameserverGroup"
|
||||
| "deleteDnsNameserverGroup"
|
||||
| "createPostureCheck"
|
||||
| "updatePostureCheck"
|
||||
| "deletePostureCheck"
|
||||
| "createNetwork"
|
||||
| "updateNetwork"
|
||||
| "deleteNetwork"
|
||||
| "createNetworkResource"
|
||||
| "updateNetworkResource"
|
||||
| "deleteNetworkResource"
|
||||
| "createNetworkRouter"
|
||||
| "updateNetworkRouter"
|
||||
| "deleteNetworkRouter"
|
||||
| "createUser"
|
||||
| "updateUser"
|
||||
| "deleteUser"
|
||||
>;
|
||||
|
||||
/**
|
||||
@ -48,6 +63,8 @@ export async function executeOperations(
|
||||
): Promise<ExecutionResult> {
|
||||
const results: OperationResult[] = [];
|
||||
const createdGroupIds = new Map<string, string>();
|
||||
const createdPostureCheckIds = new Map<string, string>();
|
||||
const createdNetworkIds = new Map<string, string>();
|
||||
const createdKeys = new Map<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) {
|
||||
try {
|
||||
await executeSingle(op, client, actual, {
|
||||
createdGroupIds,
|
||||
createdPostureCheckIds,
|
||||
createdNetworkIds,
|
||||
createdKeys,
|
||||
resolveGroupId,
|
||||
resolveGroupIds,
|
||||
resolvePeerIds,
|
||||
resolvePeerId,
|
||||
resolvePostureCheckId,
|
||||
resolveNetworkId,
|
||||
});
|
||||
results.push({ ...op, status: "success" });
|
||||
} catch (err) {
|
||||
@ -99,10 +143,15 @@ export async function executeOperations(
|
||||
|
||||
interface ExecutorContext {
|
||||
createdGroupIds: Map<string, string>;
|
||||
createdPostureCheckIds: Map<string, string>;
|
||||
createdNetworkIds: Map<string, string>;
|
||||
createdKeys: Map<string, string>;
|
||||
resolveGroupId: (name: string) => string;
|
||||
resolveGroupIds: (names: string[]) => string[];
|
||||
resolvePeerIds: (names: string[]) => string[];
|
||||
resolvePeerId: (name: string) => string;
|
||||
resolvePostureCheckId: (name: string) => string;
|
||||
resolveNetworkId: (name: string) => string;
|
||||
}
|
||||
|
||||
async function executeSingle(
|
||||
@ -114,6 +163,37 @@ async function executeSingle(
|
||||
const d = op.details ?? {};
|
||||
|
||||
switch (op.type) {
|
||||
// ----- Posture Checks -----
|
||||
case "create_posture_check": {
|
||||
const pc = await client.createPostureCheck({
|
||||
name: op.name,
|
||||
description: (d.description as string) ?? "",
|
||||
checks: (d.checks as Record<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 -----
|
||||
case "create_group": {
|
||||
const peerNames = d.peers as string[] | undefined;
|
||||
@ -127,7 +207,9 @@ async function executeSingle(
|
||||
}
|
||||
case "update_group": {
|
||||
const existing = actual.groupsByName.get(op.name);
|
||||
if (!existing) throw new Error(`group "${op.name}" not found for update`);
|
||||
if (!existing) {
|
||||
throw new Error(`group "${op.name}" not found for update`);
|
||||
}
|
||||
const desiredPeers = d.desired_peers as string[] | undefined;
|
||||
const peerIds = desiredPeers?.length
|
||||
? ctx.resolvePeerIds(desiredPeers)
|
||||
@ -180,7 +262,6 @@ async function executeSingle(
|
||||
break;
|
||||
}
|
||||
case "update_peer_groups": {
|
||||
// This op type updates peer-level properties; details.id is the peer ID
|
||||
const peerId = d.id as string;
|
||||
if (!peerId) throw new Error(`update_peer_groups missing details.id`);
|
||||
await client.updatePeer(peerId, {
|
||||
@ -192,6 +273,19 @@ async function executeSingle(
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "update_peer": {
|
||||
const peerId = ctx.resolvePeerId(op.name);
|
||||
await client.updatePeer(peerId, {
|
||||
login_expiration_enabled: d.login_expiration_enabled as
|
||||
| boolean
|
||||
| undefined,
|
||||
inactivity_expiration_enabled: d.inactivity_expiration_enabled as
|
||||
| boolean
|
||||
| undefined,
|
||||
ssh_enabled: d.ssh_enabled as boolean | undefined,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "delete_peer": {
|
||||
const peer = actual.peersByName.get(op.name);
|
||||
if (!peer) throw new Error(`peer "${op.name}" not found for delete`);
|
||||
@ -199,26 +293,213 @@ async function executeSingle(
|
||||
break;
|
||||
}
|
||||
|
||||
// ----- Networks -----
|
||||
case "create_network": {
|
||||
const network = await client.createNetwork({
|
||||
name: op.name,
|
||||
description: (d.description as string) ?? "",
|
||||
});
|
||||
ctx.createdNetworkIds.set(op.name, network.id);
|
||||
break;
|
||||
}
|
||||
case "update_network": {
|
||||
const existing = actual.networksByName.get(op.name);
|
||||
if (!existing) {
|
||||
throw new Error(`network "${op.name}" not found for update`);
|
||||
}
|
||||
await client.updateNetwork(existing.id, {
|
||||
name: op.name,
|
||||
description: (d.description as string) ?? existing.description,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "delete_network": {
|
||||
const existing = actual.networksByName.get(op.name);
|
||||
if (!existing) {
|
||||
throw new Error(`network "${op.name}" not found for delete`);
|
||||
}
|
||||
await client.deleteNetwork(existing.id);
|
||||
break;
|
||||
}
|
||||
|
||||
// ----- Network Resources -----
|
||||
case "create_network_resource": {
|
||||
const networkName = d.network_name as string;
|
||||
if (!networkName) {
|
||||
throw new Error("create_network_resource missing network_name");
|
||||
}
|
||||
const networkId = ctx.resolveNetworkId(networkName);
|
||||
const groupIds = ctx.resolveGroupIds(d.groups as string[] ?? []);
|
||||
await client.createNetworkResource(networkId, {
|
||||
name: op.name,
|
||||
description: (d.description as string) ?? "",
|
||||
address: d.address as string,
|
||||
enabled: (d.enabled as boolean) ?? true,
|
||||
groups: groupIds,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "update_network_resource": {
|
||||
const networkName = d.network_name as string;
|
||||
if (!networkName) {
|
||||
throw new Error("update_network_resource missing network_name");
|
||||
}
|
||||
const networkId = ctx.resolveNetworkId(networkName);
|
||||
const resourceId = d.resource_id as string;
|
||||
if (!resourceId) {
|
||||
throw new Error("update_network_resource missing resource_id");
|
||||
}
|
||||
const groupIds = ctx.resolveGroupIds(d.groups as string[] ?? []);
|
||||
await client.updateNetworkResource(networkId, resourceId, {
|
||||
name: op.name,
|
||||
description: (d.description as string) ?? "",
|
||||
address: d.address as string,
|
||||
enabled: (d.enabled as boolean) ?? true,
|
||||
groups: groupIds,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "delete_network_resource": {
|
||||
const networkName = d.network_name as string;
|
||||
if (!networkName) {
|
||||
throw new Error("delete_network_resource missing network_name");
|
||||
}
|
||||
const networkId = ctx.resolveNetworkId(networkName);
|
||||
const resourceId = d.resource_id as string;
|
||||
if (!resourceId) {
|
||||
throw new Error("delete_network_resource missing resource_id");
|
||||
}
|
||||
await client.deleteNetworkResource(networkId, resourceId);
|
||||
break;
|
||||
}
|
||||
|
||||
// ----- Network Routers -----
|
||||
case "create_network_router": {
|
||||
const networkName = d.network_name as string;
|
||||
if (!networkName) {
|
||||
throw new Error("create_network_router missing network_name");
|
||||
}
|
||||
const networkId = ctx.resolveNetworkId(networkName);
|
||||
const peer = d.peer ? ctx.resolvePeerId(d.peer as string) : null;
|
||||
const peerGroups = d.peer_groups
|
||||
? ctx.resolveGroupIds(d.peer_groups as string[])
|
||||
: null;
|
||||
await client.createNetworkRouter(networkId, {
|
||||
peer,
|
||||
peer_groups: peerGroups,
|
||||
metric: (d.metric as number) ?? 9999,
|
||||
masquerade: (d.masquerade as boolean) ?? true,
|
||||
enabled: (d.enabled as boolean) ?? true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "update_network_router": {
|
||||
const networkName = d.network_name as string;
|
||||
if (!networkName) {
|
||||
throw new Error("update_network_router missing network_name");
|
||||
}
|
||||
const networkId = ctx.resolveNetworkId(networkName);
|
||||
const routerId = d.router_id as string;
|
||||
if (!routerId) {
|
||||
throw new Error("update_network_router missing router_id");
|
||||
}
|
||||
const peer = d.peer ? ctx.resolvePeerId(d.peer as string) : null;
|
||||
const peerGroups = d.peer_groups
|
||||
? ctx.resolveGroupIds(d.peer_groups as string[])
|
||||
: null;
|
||||
await client.updateNetworkRouter(networkId, routerId, {
|
||||
peer,
|
||||
peer_groups: peerGroups,
|
||||
metric: (d.metric as number) ?? 9999,
|
||||
masquerade: (d.masquerade as boolean) ?? true,
|
||||
enabled: (d.enabled as boolean) ?? true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "delete_network_router": {
|
||||
const networkName = d.network_name as string;
|
||||
if (!networkName) {
|
||||
throw new Error("delete_network_router missing network_name");
|
||||
}
|
||||
const networkId = ctx.resolveNetworkId(networkName);
|
||||
const routerId = d.router_id as string;
|
||||
if (!routerId) {
|
||||
throw new Error("delete_network_router missing router_id");
|
||||
}
|
||||
await client.deleteNetworkRouter(networkId, routerId);
|
||||
break;
|
||||
}
|
||||
|
||||
// ----- Users -----
|
||||
case "create_user": {
|
||||
const autoGroupIds = ctx.resolveGroupIds(
|
||||
d.auto_groups as string[] ?? [],
|
||||
);
|
||||
await client.createUser({
|
||||
email: d.email as string,
|
||||
name: d.name as string | undefined,
|
||||
role: d.role as string,
|
||||
auto_groups: autoGroupIds,
|
||||
is_service_user: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "update_user": {
|
||||
const existing = actual.usersByEmail.get(op.name);
|
||||
if (!existing) {
|
||||
throw new Error(`user "${op.name}" not found for update`);
|
||||
}
|
||||
const autoGroupIds = ctx.resolveGroupIds(
|
||||
d.auto_groups as string[] ?? [],
|
||||
);
|
||||
await client.updateUser(existing.id, {
|
||||
name: d.name as string | undefined,
|
||||
role: d.role as string | undefined,
|
||||
auto_groups: autoGroupIds,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "delete_user": {
|
||||
const existing = actual.usersByEmail.get(op.name);
|
||||
if (!existing) {
|
||||
throw new Error(`user "${op.name}" not found for delete`);
|
||||
}
|
||||
await client.deleteUser(existing.id);
|
||||
break;
|
||||
}
|
||||
|
||||
// ----- Policies -----
|
||||
case "create_policy": {
|
||||
const sourceIds = ctx.resolveGroupIds(d.sources as string[] ?? []);
|
||||
const destIds = ctx.resolveGroupIds(d.destinations as string[] ?? []);
|
||||
const destResource = d.destination_resource as
|
||||
| { id: string; type: string }
|
||||
| undefined;
|
||||
const destIds = destResource
|
||||
? []
|
||||
: ctx.resolveGroupIds(d.destinations as string[] ?? []);
|
||||
const postureCheckIds = (d.source_posture_checks as string[] ?? [])
|
||||
.map((name) => ctx.resolvePostureCheckId(name));
|
||||
const rule: Record<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({
|
||||
name: op.name,
|
||||
description: (d.description as string) ?? "",
|
||||
enabled: (d.enabled as boolean) ?? true,
|
||||
source_posture_checks: postureCheckIds,
|
||||
rules: [
|
||||
{
|
||||
name: op.name,
|
||||
description: (d.description as string) ?? "",
|
||||
enabled: (d.enabled as boolean) ?? true,
|
||||
action: (d.action as "accept" | "drop") ?? "accept",
|
||||
bidirectional: (d.bidirectional as boolean) ?? true,
|
||||
protocol: (d.protocol as "tcp" | "udp" | "icmp" | "all") ?? "all",
|
||||
ports: d.ports as string[] | undefined,
|
||||
sources: sourceIds,
|
||||
destinations: destIds,
|
||||
},
|
||||
rule as unknown as import("../netbird/types.ts").NbPolicyRule,
|
||||
],
|
||||
});
|
||||
break;
|
||||
@ -229,23 +510,35 @@ async function executeSingle(
|
||||
throw new Error(`policy "${op.name}" not found for update`);
|
||||
}
|
||||
const sourceIds = ctx.resolveGroupIds(d.sources as string[] ?? []);
|
||||
const destIds = ctx.resolveGroupIds(d.destinations as string[] ?? []);
|
||||
const destResource = d.destination_resource as
|
||||
| { id: string; type: string }
|
||||
| undefined;
|
||||
const destIds = destResource
|
||||
? []
|
||||
: ctx.resolveGroupIds(d.destinations as string[] ?? []);
|
||||
const postureCheckIds = (d.source_posture_checks as string[] ?? [])
|
||||
.map((name) => ctx.resolvePostureCheckId(name));
|
||||
const rule: Record<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, {
|
||||
name: op.name,
|
||||
description: (d.description as string) ?? existing.description,
|
||||
enabled: (d.enabled as boolean) ?? existing.enabled,
|
||||
source_posture_checks: postureCheckIds,
|
||||
rules: [
|
||||
{
|
||||
name: op.name,
|
||||
description: (d.description as string) ?? existing.description,
|
||||
enabled: (d.enabled as boolean) ?? existing.enabled,
|
||||
action: (d.action as "accept" | "drop") ?? "accept",
|
||||
bidirectional: (d.bidirectional as boolean) ?? true,
|
||||
protocol: (d.protocol as "tcp" | "udp" | "icmp" | "all") ?? "all",
|
||||
ports: d.ports as string[] | undefined,
|
||||
sources: sourceIds,
|
||||
destinations: destIds,
|
||||
},
|
||||
rule as unknown as import("../netbird/types.ts").NbPolicyRule,
|
||||
],
|
||||
});
|
||||
break;
|
||||
|
||||
@ -6,6 +6,7 @@ export type OperationType =
|
||||
| "delete_setup_key"
|
||||
| "rename_peer"
|
||||
| "update_peer_groups"
|
||||
| "update_peer"
|
||||
| "delete_peer"
|
||||
| "create_policy"
|
||||
| "update_policy"
|
||||
@ -15,7 +16,22 @@ export type OperationType =
|
||||
| "delete_route"
|
||||
| "create_dns"
|
||||
| "update_dns"
|
||||
| "delete_dns";
|
||||
| "delete_dns"
|
||||
| "create_posture_check"
|
||||
| "update_posture_check"
|
||||
| "delete_posture_check"
|
||||
| "create_network"
|
||||
| "update_network"
|
||||
| "delete_network"
|
||||
| "create_network_resource"
|
||||
| "update_network_resource"
|
||||
| "delete_network_resource"
|
||||
| "create_network_router"
|
||||
| "update_network_router"
|
||||
| "delete_network_router"
|
||||
| "create_user"
|
||||
| "update_user"
|
||||
| "delete_user";
|
||||
|
||||
export interface Operation {
|
||||
type: OperationType;
|
||||
@ -30,11 +46,23 @@ export interface OperationResult extends Operation {
|
||||
|
||||
/** Order in which operation types must be executed */
|
||||
export const EXECUTION_ORDER: OperationType[] = [
|
||||
// Creates: dependencies first
|
||||
"create_posture_check",
|
||||
"update_posture_check",
|
||||
"create_group",
|
||||
"update_group",
|
||||
"create_setup_key",
|
||||
"rename_peer",
|
||||
"update_peer_groups",
|
||||
"update_peer",
|
||||
"create_network",
|
||||
"update_network",
|
||||
"create_network_resource",
|
||||
"update_network_resource",
|
||||
"create_network_router",
|
||||
"update_network_router",
|
||||
"create_user",
|
||||
"update_user",
|
||||
"create_policy",
|
||||
"update_policy",
|
||||
"create_route",
|
||||
@ -45,7 +73,12 @@ export const EXECUTION_ORDER: OperationType[] = [
|
||||
"delete_dns",
|
||||
"delete_route",
|
||||
"delete_policy",
|
||||
"delete_user",
|
||||
"delete_network_router",
|
||||
"delete_network_resource",
|
||||
"delete_network",
|
||||
"delete_peer",
|
||||
"delete_setup_key",
|
||||
"delete_posture_check",
|
||||
"delete_group",
|
||||
];
|
||||
|
||||
@ -3,10 +3,15 @@ import { fetchActualState } from "./actual.ts";
|
||||
import type {
|
||||
NbDnsNameserverGroup,
|
||||
NbGroup,
|
||||
NbNetwork,
|
||||
NbNetworkResource,
|
||||
NbNetworkRouter,
|
||||
NbPeer,
|
||||
NbPolicy,
|
||||
NbPostureCheck,
|
||||
NbRoute,
|
||||
NbSetupKey,
|
||||
NbUser,
|
||||
} from "../netbird/types.ts";
|
||||
|
||||
/** Minimal mock NetBird client that returns predetermined data */
|
||||
@ -17,6 +22,11 @@ function mockClient(data: {
|
||||
policies?: NbPolicy[];
|
||||
routes?: NbRoute[];
|
||||
dns?: NbDnsNameserverGroup[];
|
||||
postureChecks?: NbPostureCheck[];
|
||||
networks?: NbNetwork[];
|
||||
networkResources?: Map<string, NbNetworkResource[]>;
|
||||
networkRouters?: Map<string, NbNetworkRouter[]>;
|
||||
users?: NbUser[];
|
||||
}) {
|
||||
return {
|
||||
listGroups: () => Promise.resolve(data.groups ?? []),
|
||||
@ -25,6 +35,13 @@ function mockClient(data: {
|
||||
listPolicies: () => Promise.resolve(data.policies ?? []),
|
||||
listRoutes: () => Promise.resolve(data.routes ?? []),
|
||||
listDnsNameserverGroups: () => Promise.resolve(data.dns ?? []),
|
||||
listPostureChecks: () => Promise.resolve(data.postureChecks ?? []),
|
||||
listNetworks: () => Promise.resolve(data.networks ?? []),
|
||||
listNetworkResources: (networkId: string) =>
|
||||
Promise.resolve(data.networkResources?.get(networkId) ?? []),
|
||||
listNetworkRouters: (networkId: string) =>
|
||||
Promise.resolve(data.networkRouters?.get(networkId) ?? []),
|
||||
listUsers: () => Promise.resolve(data.users ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
@ -102,6 +119,7 @@ Deno.test("fetchActualState indexes all resource types", async () => {
|
||||
name: "allow-ops",
|
||||
description: "ops traffic",
|
||||
enabled: true,
|
||||
source_posture_checks: [],
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
|
||||
@ -2,10 +2,15 @@ import type { NetbirdClient } from "../netbird/client.ts";
|
||||
import type {
|
||||
NbDnsNameserverGroup,
|
||||
NbGroup,
|
||||
NbNetwork,
|
||||
NbNetworkResource,
|
||||
NbNetworkRouter,
|
||||
NbPeer,
|
||||
NbPolicy,
|
||||
NbPostureCheck,
|
||||
NbRoute,
|
||||
NbSetupKey,
|
||||
NbUser,
|
||||
} from "../netbird/types.ts";
|
||||
|
||||
/** Indexed view of all current NetBird state */
|
||||
@ -24,6 +29,14 @@ export interface ActualState {
|
||||
routesByNetworkId: Map<string, NbRoute>;
|
||||
dns: 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"
|
||||
| "listRoutes"
|
||||
| "listDnsNameserverGroups"
|
||||
| "listPostureChecks"
|
||||
| "listNetworks"
|
||||
| "listNetworkResources"
|
||||
| "listNetworkRouters"
|
||||
| "listUsers"
|
||||
>;
|
||||
|
||||
/**
|
||||
@ -50,15 +68,51 @@ type ClientLike = Pick<
|
||||
export async function fetchActualState(
|
||||
client: ClientLike,
|
||||
): 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.listSetupKeys(),
|
||||
client.listPeers(),
|
||||
client.listPolicies(),
|
||||
client.listRoutes(),
|
||||
client.listDnsNameserverGroups(),
|
||||
client.listPostureChecks(),
|
||||
client.listNetworks(),
|
||||
client.listUsers(),
|
||||
]);
|
||||
|
||||
// Fetch sub-resources for each network in parallel
|
||||
const [resourcesByNetwork, routersByNetwork] = await Promise.all([
|
||||
Promise.all(
|
||||
networks.map(async (n) => ({
|
||||
id: n.id,
|
||||
resources: await client.listNetworkResources(n.id),
|
||||
})),
|
||||
),
|
||||
Promise.all(
|
||||
networks.map(async (n) => ({
|
||||
id: n.id,
|
||||
routers: await client.listNetworkRouters(n.id),
|
||||
})),
|
||||
),
|
||||
]);
|
||||
|
||||
const networkResources = new Map<string, NbNetworkResource[]>(
|
||||
resourcesByNetwork.map((r) => [r.id, r.resources]),
|
||||
);
|
||||
const networkRouters = new Map<string, NbNetworkRouter[]>(
|
||||
routersByNetwork.map((r) => [r.id, r.routers]),
|
||||
);
|
||||
|
||||
return {
|
||||
groups,
|
||||
groupsByName: new Map(groups.map((g) => [g.name, g])),
|
||||
@ -74,5 +128,13 @@ export async function fetchActualState(
|
||||
routesByNetworkId: new Map(routes.map((r) => [r.network_id, r])),
|
||||
dns,
|
||||
dnsByName: new Map(dns.map((d) => [d.name, d])),
|
||||
postureChecks,
|
||||
postureChecksByName: new Map(postureChecks.map((pc) => [pc.name, pc])),
|
||||
networks,
|
||||
networksByName: new Map(networks.map((n) => [n.name, n])),
|
||||
networkResources,
|
||||
networkRouters,
|
||||
users,
|
||||
usersByEmail: new Map(users.map((u) => [u.email, u])),
|
||||
};
|
||||
}
|
||||
|
||||
@ -14,15 +14,22 @@ export const GroupSchema = z.object({
|
||||
peers: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const DestinationResourceSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
});
|
||||
|
||||
export const PolicySchema = z.object({
|
||||
description: z.string().default(""),
|
||||
enabled: z.boolean(),
|
||||
sources: z.array(z.string()),
|
||||
destinations: z.array(z.string()),
|
||||
destinations: z.array(z.string()).default([]),
|
||||
bidirectional: z.boolean(),
|
||||
protocol: z.enum(["tcp", "udp", "icmp", "all"]).default("all"),
|
||||
action: z.enum(["accept", "drop"]).default("accept"),
|
||||
ports: z.array(z.string()).optional(),
|
||||
destination_resource: DestinationResourceSchema.optional(),
|
||||
source_posture_checks: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const RouteSchema = z.object({
|
||||
@ -53,6 +60,47 @@ export const DnsNameserverGroupSchema = z.object({
|
||||
search_domains_enabled: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const PostureCheckSchema = z.object({
|
||||
description: z.string().default(""),
|
||||
checks: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export const NetworkResourceSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().default(""),
|
||||
type: z.enum(["host", "subnet", "domain"]),
|
||||
address: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
groups: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const NetworkRouterSchema = z.object({
|
||||
peer: z.string().optional(),
|
||||
peer_groups: z.array(z.string()).optional(),
|
||||
metric: z.number().int().min(1).max(9999).default(9999),
|
||||
masquerade: z.boolean().default(true),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const NetworkSchema = z.object({
|
||||
description: z.string().default(""),
|
||||
resources: z.array(NetworkResourceSchema).default([]),
|
||||
routers: z.array(NetworkRouterSchema).default([]),
|
||||
});
|
||||
|
||||
export const PeerSchema = z.object({
|
||||
groups: z.array(z.string()),
|
||||
login_expiration_enabled: z.boolean().default(false),
|
||||
inactivity_expiration_enabled: z.boolean().default(false),
|
||||
ssh_enabled: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const UserSchema = z.object({
|
||||
name: z.string(),
|
||||
role: z.enum(["owner", "admin", "user"]),
|
||||
auto_groups: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
// --- Top-level schema ---
|
||||
|
||||
export const DesiredStateSchema = z.object({
|
||||
@ -64,6 +112,10 @@ export const DesiredStateSchema = z.object({
|
||||
nameserver_groups: z.record(z.string(), DnsNameserverGroupSchema)
|
||||
.default({}),
|
||||
}).default({ nameserver_groups: {} }),
|
||||
posture_checks: z.record(z.string(), PostureCheckSchema).default({}),
|
||||
networks: z.record(z.string(), NetworkSchema).default({}),
|
||||
peers: z.record(z.string(), PeerSchema).default({}),
|
||||
users: z.record(z.string(), UserSchema).default({}),
|
||||
});
|
||||
|
||||
// --- Inferred types ---
|
||||
@ -74,6 +126,15 @@ export type GroupConfig = z.infer<typeof GroupSchema>;
|
||||
export type PolicyConfig = z.infer<typeof PolicySchema>;
|
||||
export type RouteConfig = z.infer<typeof RouteSchema>;
|
||||
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 ---
|
||||
|
||||
@ -89,11 +150,16 @@ export type DnsNameserverGroupConfig = z.infer<typeof DnsNameserverGroupSchema>;
|
||||
* 4. Every peer_group and distribution_group in a route references an
|
||||
* existing group.
|
||||
* 5. Every group in a DNS nameserver group references an existing group.
|
||||
* 6. Every group in a peer config references an existing group.
|
||||
* 7. Every auto_group on a user references an existing group.
|
||||
* 8. Every group on a network resource references an existing group.
|
||||
* 9. Every source_posture_check in a policy references an existing posture check.
|
||||
*/
|
||||
export function validateCrossReferences(state: DesiredState): string[] {
|
||||
const errors: string[] = [];
|
||||
const groupNames = new Set(Object.keys(state.groups));
|
||||
const setupKeyNames = new Set(Object.keys(state.setup_keys));
|
||||
const postureCheckNames = new Set(Object.keys(state.posture_checks));
|
||||
|
||||
// 1. Peers in groups must reference existing setup keys
|
||||
for (const [groupName, group] of Object.entries(state.groups)) {
|
||||
@ -168,5 +234,51 @@ export function validateCrossReferences(state: DesiredState): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Peer groups must reference existing groups
|
||||
for (const [peerName, peer] of Object.entries(state.peers)) {
|
||||
for (const g of peer.groups) {
|
||||
if (!groupNames.has(g)) {
|
||||
errors.push(
|
||||
`peer "${peerName}": group "${g}" does not match any group`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. User auto_groups must reference existing groups
|
||||
for (const [userName, user] of Object.entries(state.users)) {
|
||||
for (const ag of user.auto_groups) {
|
||||
if (!groupNames.has(ag)) {
|
||||
errors.push(
|
||||
`user "${userName}": auto_group "${ag}" does not match any group`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Network resource groups must reference existing groups
|
||||
for (const [networkName, network] of Object.entries(state.networks)) {
|
||||
for (const resource of network.resources) {
|
||||
for (const g of resource.groups) {
|
||||
if (!groupNames.has(g)) {
|
||||
errors.push(
|
||||
`network "${networkName}": resource "${resource.name}" group "${g}" does not match any group`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Policy source_posture_checks must reference existing posture checks
|
||||
for (const [policyName, policy] of Object.entries(state.policies)) {
|
||||
for (const pc of policy.source_posture_checks) {
|
||||
if (!postureCheckNames.has(pc)) {
|
||||
errors.push(
|
||||
`policy "${policyName}": source_posture_check "${pc}" does not match any posture check`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
414
state/dev.json
Normal file
414
state/dev.json
Normal 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
1022
state/ext.json
Normal file
File diff suppressed because it is too large
Load Diff
627
state/prod.json
Normal file
627
state/prod.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user