403 lines
12 KiB
TypeScript
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;
|
|
}
|