feat: add NetBird API client with types and tests

This commit is contained in:
Prox 2026-03-03 23:58:21 +02:00
parent 50f83df903
commit 0e2d828bd4
5 changed files with 495 additions and 0 deletions

View File

@ -10,6 +10,7 @@
"fmt": "deno fmt" "fmt": "deno fmt"
}, },
"imports": { "imports": {
"@std/assert": "jsr:@std/assert@^1.0.0",
"zod": "npm:zod@^3.23.0" "zod": "npm:zod@^3.23.0"
}, },
"compilerOptions": { "compilerOptions": {

15
deno.lock generated
View File

@ -1,8 +1,22 @@
{ {
"version": "5", "version": "5",
"specifiers": { "specifiers": {
"jsr:@std/assert@*": "1.0.19",
"jsr:@std/assert@1": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.12",
"npm:zod@^3.23.0": "3.25.76" "npm:zod@^3.23.0": "3.25.76"
}, },
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
}
},
"npm": { "npm": {
"zod@3.25.76": { "zod@3.25.76": {
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
@ -10,6 +24,7 @@
}, },
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"jsr:@std/assert@1",
"npm:zod@^3.23.0" "npm:zod@^3.23.0"
] ]
} }

147
src/netbird/client.test.ts Normal file
View File

@ -0,0 +1,147 @@
import { assertEquals } from "@std/assert";
import { type FetchFn, NetbirdApiError, NetbirdClient } from "./client.ts";
function mockFetch(
responses: Map<string, { status: number; body: unknown }>,
): FetchFn {
return (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const method = init?.method ?? "GET";
const key = `${method} ${url}`;
const resp = responses.get(key);
if (!resp) throw new Error(`Unmocked request: ${key}`);
return Promise.resolve(
new Response(JSON.stringify(resp.body), {
status: resp.status,
headers: { "Content-Type": "application/json" },
}),
);
};
}
Deno.test("NetbirdClient.listGroups sends auth header and parses response", async () => {
const groups = [{
id: "g1",
name: "pilots",
peers_count: 1,
peers: [],
issued: "api",
}];
const client = new NetbirdClient(
"https://nb.example.com/api",
"test-token",
mockFetch(
new Map([
["GET https://nb.example.com/api/groups", {
status: 200,
body: groups,
}],
]),
),
);
const result = await client.listGroups();
assertEquals(result.length, 1);
assertEquals(result[0].name, "pilots");
});
Deno.test("NetbirdClient throws on non-2xx response", async () => {
const client = new NetbirdClient(
"https://nb.example.com/api",
"test-token",
mockFetch(
new Map([
["GET https://nb.example.com/api/groups", {
status: 401,
body: { message: "unauthorized" },
}],
]),
),
);
try {
await client.listGroups();
throw new Error("Should have thrown");
} catch (e) {
assertEquals(e instanceof NetbirdApiError, true);
assertEquals((e as NetbirdApiError).status, 401);
assertEquals((e as Error).message.includes("401"), true);
}
});
Deno.test("NetbirdClient sends correct auth header", async () => {
let capturedHeaders: Headers | undefined;
const fakeFetch: FetchFn = (
_input: string | URL | Request,
init?: RequestInit,
) => {
capturedHeaders = new Headers(init?.headers);
return Promise.resolve(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
};
const client = new NetbirdClient(
"https://nb.example.com/api",
"my-secret-token",
fakeFetch,
);
await client.listGroups();
assertEquals(capturedHeaders?.get("Authorization"), "Token my-secret-token");
assertEquals(capturedHeaders?.get("Accept"), "application/json");
});
Deno.test("NetbirdClient.deleteGroup handles 204 No Content", async () => {
const fakeFetch: FetchFn = () => {
return Promise.resolve(new Response(null, { status: 204 }));
};
const client = new NetbirdClient(
"https://nb.example.com/api",
"test-token",
fakeFetch,
);
// Should not throw — 204 is a success with no body
await client.deleteGroup("g1");
});
Deno.test("NetbirdClient.createGroup sends POST with body", async () => {
let capturedMethod: string | undefined;
let capturedBody: string | undefined;
const fakeFetch: FetchFn = (
_input: string | URL | Request,
init?: RequestInit,
) => {
capturedMethod = init?.method;
capturedBody = init?.body as string;
return Promise.resolve(
new Response(
JSON.stringify({
id: "g2",
name: "drones",
peers_count: 0,
peers: [],
issued: "api",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
};
const client = new NetbirdClient(
"https://nb.example.com/api",
"test-token",
fakeFetch,
);
const result = await client.createGroup({ name: "drones" });
assertEquals(capturedMethod, "POST");
assertEquals(JSON.parse(capturedBody!), { name: "drones" });
assertEquals(result.name, "drones");
});

225
src/netbird/client.ts Normal file
View File

@ -0,0 +1,225 @@
import type {
NbDnsNameserverGroup,
NbEvent,
NbGroup,
NbPeer,
NbPolicy,
NbRoute,
NbSetupKey,
} from "./types.ts";
/** Narrowed fetch signature used for dependency injection. */
export type FetchFn = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
/** Thrown when the NetBird API returns a non-2xx status. */
export class NetbirdApiError extends Error {
constructor(
public readonly status: number,
public readonly method: string,
public readonly path: string,
public readonly body: unknown,
) {
super(`NetBird API error: ${method} ${path} returned ${status}`);
this.name = "NetbirdApiError";
}
}
/**
* Thin HTTP client for the NetBird Management API.
*
* Accepts an injectable fetch function so callers (and tests) can swap
* the transport without touching the client logic.
*/
export class NetbirdClient {
constructor(
private readonly baseUrl: string,
private readonly token: string,
private readonly fetchFn: FetchFn = fetch,
) {}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
private async request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
"Authorization": `Token ${this.token}`,
"Accept": "application/json",
};
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
const resp = await this.fetchFn(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
let errorBody: unknown;
try {
errorBody = await resp.json();
} catch {
errorBody = await resp.text();
}
throw new NetbirdApiError(resp.status, method, path, errorBody);
}
// 204 No Content — nothing to parse
if (resp.status === 204) {
return undefined as T;
}
return (await resp.json()) as T;
}
// ---------------------------------------------------------------------------
// Groups
// ---------------------------------------------------------------------------
listGroups(): Promise<NbGroup[]> {
return this.request("GET", "/groups");
}
createGroup(data: { name: string; peers?: string[] }): Promise<NbGroup> {
return this.request("POST", "/groups", data);
}
updateGroup(
id: string,
data: { name?: string; peers?: string[] },
): Promise<NbGroup> {
return this.request("PUT", `/groups/${id}`, data);
}
deleteGroup(id: string): Promise<void> {
return this.request("DELETE", `/groups/${id}`);
}
// ---------------------------------------------------------------------------
// Setup Keys
// ---------------------------------------------------------------------------
listSetupKeys(): Promise<NbSetupKey[]> {
return this.request("GET", "/setup-keys");
}
createSetupKey(data: {
name: string;
type: "one-off" | "reusable";
expires_in: number;
auto_groups?: string[];
usage_limit?: number;
}): Promise<NbSetupKey> {
return this.request("POST", "/setup-keys", data);
}
deleteSetupKey(id: number): Promise<void> {
return this.request("DELETE", `/setup-keys/${id}`);
}
// ---------------------------------------------------------------------------
// Peers
// ---------------------------------------------------------------------------
listPeers(): Promise<NbPeer[]> {
return this.request("GET", "/peers");
}
updatePeer(
id: string,
data: {
name?: string;
ssh_enabled?: boolean;
login_expiration_enabled?: boolean;
},
): Promise<NbPeer> {
return this.request("PUT", `/peers/${id}`, data);
}
deletePeer(id: string): Promise<void> {
return this.request("DELETE", `/peers/${id}`);
}
// ---------------------------------------------------------------------------
// Policies
// ---------------------------------------------------------------------------
listPolicies(): Promise<NbPolicy[]> {
return this.request("GET", "/policies");
}
createPolicy(data: Omit<NbPolicy, "id">): Promise<NbPolicy> {
return this.request("POST", "/policies", data);
}
updatePolicy(id: string, data: Omit<NbPolicy, "id">): Promise<NbPolicy> {
return this.request("PUT", `/policies/${id}`, data);
}
deletePolicy(id: string): Promise<void> {
return this.request("DELETE", `/policies/${id}`);
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
listRoutes(): Promise<NbRoute[]> {
return this.request("GET", "/routes");
}
createRoute(data: Omit<NbRoute, "id">): Promise<NbRoute> {
return this.request("POST", "/routes", data);
}
updateRoute(id: string, data: Omit<NbRoute, "id">): Promise<NbRoute> {
return this.request("PUT", `/routes/${id}`, data);
}
deleteRoute(id: string): Promise<void> {
return this.request("DELETE", `/routes/${id}`);
}
// ---------------------------------------------------------------------------
// DNS Nameserver Groups
// ---------------------------------------------------------------------------
listDnsNameserverGroups(): Promise<NbDnsNameserverGroup[]> {
return this.request("GET", "/dns/nameservers");
}
createDnsNameserverGroup(
data: Omit<NbDnsNameserverGroup, "id">,
): Promise<NbDnsNameserverGroup> {
return this.request("POST", "/dns/nameservers", data);
}
updateDnsNameserverGroup(
id: string,
data: Omit<NbDnsNameserverGroup, "id">,
): Promise<NbDnsNameserverGroup> {
return this.request("PUT", `/dns/nameservers/${id}`, data);
}
deleteDnsNameserverGroup(id: string): Promise<void> {
return this.request("DELETE", `/dns/nameservers/${id}`);
}
// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------
listEvents(): Promise<NbEvent[]> {
return this.request("GET", "/events");
}
}

107
src/netbird/types.ts Normal file
View File

@ -0,0 +1,107 @@
/** Group as returned by GET /api/groups */
export interface NbGroup {
id: string;
name: string;
peers_count: number;
peers: Array<{ id: string; name: string }>;
issued: "api" | "jwt" | "integration";
}
/** Setup key as returned by GET /api/setup-keys */
export interface NbSetupKey {
id: number;
name: string;
type: "one-off" | "reusable";
key: string;
expires: string;
valid: boolean;
revoked: boolean;
used_times: number;
state: "valid" | "expired" | "revoked" | "overused";
auto_groups: string[];
usage_limit: number;
}
/** Peer as returned by GET /api/peers */
export interface NbPeer {
id: string;
name: string;
ip: string;
connected: boolean;
hostname: string;
os: string;
version: string;
groups: Array<{ id: string; name: string }>;
last_seen: string;
dns_label: string;
login_expiration_enabled: boolean;
ssh_enabled: boolean;
inactivity_expiration_enabled: boolean;
}
/** Policy as returned by GET /api/policies */
export interface NbPolicy {
id: string;
name: string;
description: string;
enabled: boolean;
rules: NbPolicyRule[];
}
export interface NbPolicyRule {
id?: string;
name: string;
description: string;
enabled: boolean;
action: "accept" | "drop";
bidirectional: boolean;
protocol: "tcp" | "udp" | "icmp" | "all";
ports?: string[];
sources: Array<string | { id: string; name: string }>;
destinations: Array<string | { id: string; name: string }>;
}
/** Route as returned by GET /api/routes */
export interface NbRoute {
id: string;
description: string;
network_id: string;
enabled: boolean;
peer?: string;
peer_groups?: string[];
network?: string;
domains?: string[];
metric: number;
masquerade: boolean;
groups: string[];
keep_route: boolean;
}
/** DNS nameserver group as returned by GET /api/dns/nameservers */
export interface NbDnsNameserverGroup {
id: string;
name: string;
description: string;
nameservers: Array<{
ip: string;
ns_type: string;
port: number;
}>;
enabled: boolean;
groups: string[];
primary: boolean;
domains: string[];
search_domains_enabled: boolean;
}
/** Audit event as returned by GET /api/events/audit */
export interface NbEvent {
id: number;
timestamp: string;
activity: string;
activity_code: string;
initiator_id: string;
initiator_name: string;
target_id: string;
meta: Record<string, string>;
}