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"
|
||||
},
|
||||
"imports": {
|
||||
"@std/assert": "jsr:@std/assert@^1.0.0",
|
||||
"zod": "npm:zod@^3.23.0"
|
||||
},
|
||||
"compilerOptions": {
|
||||
|
||||
15
deno.lock
generated
15
deno.lock
generated
@ -1,8 +1,22 @@
|
||||
{
|
||||
"version": "5",
|
||||
"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"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/assert@1.0.19": {
|
||||
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.12": {
|
||||
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"zod@3.25.76": {
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
|
||||
@ -10,6 +24,7 @@
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@1",
|
||||
"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