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(); for (const resources of actual.networkResources.values()) { for (const res of resources) { resourceIdToName.set(res.id, res.name); } } // Build posture check ID → name map const postureCheckIdToName = new Map( actual.postureChecks.map((pc) => [pc.id, pc.name]), ); return { groups: exportGroups(actual, setupKeyNames, idToName), setup_keys: exportSetupKeys(actual, idToName), policies: exportPolicies( actual, idToName, 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 { 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[] { 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, _idToName: Map, ): 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, ): 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, resourceIdToName: Map, postureCheckIdToName: Map, ): 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, ): DesiredState["networks"] { const result: DesiredState["networks"] = {}; for (const network of actual.networks) { const resources = actual.networkResources.get(network.id) ?? []; const routers = actual.networkRouters.get(network.id) ?? []; result[network.name] = { description: network.description, resources: resources.map((res) => ({ name: res.name, description: res.description, type: res.type, address: res.address, enabled: res.enabled, groups: res.groups.map((g) => { // Resource groups are objects with id/name — use the idToName map // for consistency, falling back to the embedded name. return idToName.get(g.id) ?? g.name; }), })), routers: routers.map((router) => { const entry: DesiredState["networks"][string]["routers"][number] = { metric: router.metric, masquerade: router.masquerade, enabled: router.enabled, }; if (router.peer) { const peer = actual.peersById.get(router.peer); entry.peer = peer ? peer.name : router.peer; } if (router.peer_groups && router.peer_groups.length > 0) { entry.peer_groups = resolveIds(router.peer_groups, idToName); } return entry; }), }; } return result; } // --------------------------------------------------------------------------- // Peers // --------------------------------------------------------------------------- function exportPeers( actual: ActualState, idToName: Map, ): DesiredState["peers"] { const result: DesiredState["peers"] = {}; for (const peer of actual.peers) { const groups = peer.groups .filter((g) => g.name !== "All") .map((g) => idToName.get(g.id) ?? g.name); result[peer.name] = { groups, login_expiration_enabled: peer.login_expiration_enabled, inactivity_expiration_enabled: peer.inactivity_expiration_enabled, ssh_enabled: peer.ssh_enabled, }; } return result; } // --------------------------------------------------------------------------- // Users // --------------------------------------------------------------------------- function exportUsers( actual: ActualState, idToName: Map, ): DesiredState["users"] { const result: DesiredState["users"] = {}; for (const user of actual.users) { result[user.email] = { name: user.name, role: user.role, auto_groups: resolveIds(user.auto_groups, idToName), }; } return result; } // --------------------------------------------------------------------------- // Routes // --------------------------------------------------------------------------- function exportRoutes( actual: ActualState, idToName: Map, ): 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, ): 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; }