feat: add NetBird API client with types and tests
This commit is contained in:
parent
50f83df903
commit
0e2d828bd4
@ -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
15
deno.lock
generated
@ -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
147
src/netbird/client.test.ts
Normal 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
225
src/netbird/client.ts
Normal 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
107
src/netbird/types.ts
Normal 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>;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user