netbird-gitops/src/export.ts
2026-03-06 16:28:01 +02:00

403 lines
12 KiB
TypeScript

import type { ActualState } from "./state/actual.ts";
import type { DesiredState } from "./state/schema.ts";
/**
* Default expires_in value (seconds). The NetBird API does not return the
* original `expires_in` that was used to create a setup key — only the
* absolute `expires` timestamp. We fall back to 7 days as a reasonable
* default so the exported config round-trips cleanly.
*/
const DEFAULT_EXPIRES_IN = 604800;
/**
* Transforms live NetBird state (as fetched from the API) into the
* declarative `netbird.json` format (`DesiredState`).
*
* This is the inverse of the reconciliation flow: given what's actually
* deployed, produce a config file that would recreate it. Useful for
* bootstrapping gitops from an existing NetBird account.
*
* Filtering rules:
* - Groups: system-managed groups (issued !== "api" or name "All") are
* excluded. Peer lists only include peers whose name matches a known
* setup key (since the desired-state schema maps peers to setup keys).
* - Setup keys: all exported. `auto_groups` IDs resolved to names.
* `enrolled` derived from usage counters.
* - Policies: empty-rules policies skipped. Source/destination IDs
* resolved to group names via the first rule.
* - Routes: keyed by `network_id`. Peer groups and distribution groups
* resolved from IDs to names.
* - DNS: group IDs resolved to names.
* - 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,
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),
},
};
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Builds a group ID → group name lookup from the full groups list.
* Used throughout to translate opaque IDs in API responses back to
* human-readable names for the config file.
*/
function buildIdToNameMap(actual: ActualState): Map<string, string> {
return new Map(actual.groups.map((g) => [g.id, g.name]));
}
/** Resolves an array of group IDs to group names, dropping any unresolvable IDs. */
function resolveIds(ids: string[], idToName: Map<string, string>): string[] {
return ids
.map((id) => idToName.get(id))
.filter((name): name is string => name !== undefined);
}
/**
* Extracts the group ID from a policy source/destination entry.
* The NetBird API returns these as either plain string IDs or
* `{ id, name }` objects depending on the API version.
*/
function extractGroupId(entry: string | { id: string; name: string }): string {
return typeof entry === "string" ? entry : entry.id;
}
// ---------------------------------------------------------------------------
// Groups
// ---------------------------------------------------------------------------
function exportGroups(
actual: ActualState,
setupKeyNames: Set<string>,
_idToName: Map<string, string>,
): DesiredState["groups"] {
const result: DesiredState["groups"] = {};
for (const group of actual.groups) {
if (isSystemGroup(group.name, group.issued)) continue;
// Only include peers whose name matches a known setup key, since
// the desired-state schema models peers as setup-key references.
const peers = (group.peers ?? [])
.map((p) => p.name)
.filter((name) => setupKeyNames.has(name));
result[group.name] = { peers };
}
return result;
}
function isSystemGroup(name: string, issued: string): boolean {
return name === "All" || issued !== "api";
}
// ---------------------------------------------------------------------------
// Setup Keys
// ---------------------------------------------------------------------------
function exportSetupKeys(
actual: ActualState,
idToName: Map<string, string>,
): DesiredState["setup_keys"] {
const result: DesiredState["setup_keys"] = {};
for (const key of actual.setupKeys) {
result[key.name] = {
type: key.type,
expires_in: DEFAULT_EXPIRES_IN,
usage_limit: key.usage_limit,
auto_groups: resolveIds(key.auto_groups, idToName),
enrolled: isEnrolled(key.used_times, key.usage_limit),
};
}
return result;
}
/**
* A setup key is considered "enrolled" when it has been fully consumed:
* `used_times >= usage_limit` with a finite limit. Keys with
* `usage_limit === 0` (unlimited reusable) are never enrolled.
*/
function isEnrolled(usedTimes: number, usageLimit: number): boolean {
if (usageLimit === 0) return false;
return usedTimes >= usageLimit;
}
// ---------------------------------------------------------------------------
// Policies
// ---------------------------------------------------------------------------
function exportPolicies(
actual: ActualState,
idToName: Map<string, string>,
resourceIdToName: Map<string, string>,
postureCheckIdToName: Map<string, string>,
): DesiredState["policies"] {
const result: DesiredState["policies"] = {};
for (const policy of actual.policies) {
if (policy.rules.length === 0) continue;
const rule = policy.rules[0];
const sources = resolveIds(
(rule.sources ?? []).map(extractGroupId),
idToName,
);
const entry: DesiredState["policies"][string] = {
description: policy.description,
enabled: policy.enabled,
sources,
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;
}
result[policy.name] = entry;
}
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
// ---------------------------------------------------------------------------
function exportRoutes(
actual: ActualState,
idToName: Map<string, string>,
): DesiredState["routes"] {
const result: DesiredState["routes"] = {};
for (const route of actual.routes) {
const entry: DesiredState["routes"][string] = {
description: route.description,
peer_groups: resolveIds(route.peer_groups ?? [], idToName),
metric: route.metric,
masquerade: route.masquerade,
distribution_groups: resolveIds(route.groups, idToName),
enabled: route.enabled,
keep_route: route.keep_route,
};
if (route.network) {
entry.network = route.network;
}
if (route.domains && route.domains.length > 0) {
entry.domains = route.domains;
}
result[route.network_id] = entry;
}
return result;
}
// ---------------------------------------------------------------------------
// DNS
// ---------------------------------------------------------------------------
function exportDns(
actual: ActualState,
idToName: Map<string, string>,
): DesiredState["dns"]["nameserver_groups"] {
const result: DesiredState["dns"]["nameserver_groups"] = {};
for (const ns of actual.dns) {
result[ns.name] = {
description: ns.description,
nameservers: ns.nameservers.map((s) => ({
ip: s.ip,
ns_type: s.ns_type,
port: s.port,
})),
enabled: ns.enabled,
groups: resolveIds(ns.groups, idToName),
primary: ns.primary,
domains: ns.domains,
search_domains_enabled: ns.search_domains_enabled,
};
}
return result;
}