diff --git a/deno.json b/deno.json index 58086cf..f78ff66 100644 --- a/deno.json +++ b/deno.json @@ -10,6 +10,7 @@ "fmt": "deno fmt" }, "imports": { + "@std/assert": "jsr:@std/assert@^1.0.0", "zod": "npm:zod@^3.23.0" }, "compilerOptions": { diff --git a/deno.lock b/deno.lock index 14f9d72..bf67beb 100644 --- a/deno.lock +++ b/deno.lock @@ -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" ] } diff --git a/src/netbird/client.test.ts b/src/netbird/client.test.ts new file mode 100644 index 0000000..5e0f075 --- /dev/null +++ b/src/netbird/client.test.ts @@ -0,0 +1,147 @@ +import { assertEquals } from "@std/assert"; +import { type FetchFn, NetbirdApiError, NetbirdClient } from "./client.ts"; + +function mockFetch( + responses: Map, +): 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"); +}); diff --git a/src/netbird/client.ts b/src/netbird/client.ts new file mode 100644 index 0000000..e6f162e --- /dev/null +++ b/src/netbird/client.ts @@ -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; + +/** 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( + method: string, + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path}`; + const headers: Record = { + "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 { + return this.request("GET", "/groups"); + } + + createGroup(data: { name: string; peers?: string[] }): Promise { + return this.request("POST", "/groups", data); + } + + updateGroup( + id: string, + data: { name?: string; peers?: string[] }, + ): Promise { + return this.request("PUT", `/groups/${id}`, data); + } + + deleteGroup(id: string): Promise { + return this.request("DELETE", `/groups/${id}`); + } + + // --------------------------------------------------------------------------- + // Setup Keys + // --------------------------------------------------------------------------- + + listSetupKeys(): Promise { + return this.request("GET", "/setup-keys"); + } + + createSetupKey(data: { + name: string; + type: "one-off" | "reusable"; + expires_in: number; + auto_groups?: string[]; + usage_limit?: number; + }): Promise { + return this.request("POST", "/setup-keys", data); + } + + deleteSetupKey(id: number): Promise { + return this.request("DELETE", `/setup-keys/${id}`); + } + + // --------------------------------------------------------------------------- + // Peers + // --------------------------------------------------------------------------- + + listPeers(): Promise { + return this.request("GET", "/peers"); + } + + updatePeer( + id: string, + data: { + name?: string; + ssh_enabled?: boolean; + login_expiration_enabled?: boolean; + }, + ): Promise { + return this.request("PUT", `/peers/${id}`, data); + } + + deletePeer(id: string): Promise { + return this.request("DELETE", `/peers/${id}`); + } + + // --------------------------------------------------------------------------- + // Policies + // --------------------------------------------------------------------------- + + listPolicies(): Promise { + return this.request("GET", "/policies"); + } + + createPolicy(data: Omit): Promise { + return this.request("POST", "/policies", data); + } + + updatePolicy(id: string, data: Omit): Promise { + return this.request("PUT", `/policies/${id}`, data); + } + + deletePolicy(id: string): Promise { + return this.request("DELETE", `/policies/${id}`); + } + + // --------------------------------------------------------------------------- + // Routes + // --------------------------------------------------------------------------- + + listRoutes(): Promise { + return this.request("GET", "/routes"); + } + + createRoute(data: Omit): Promise { + return this.request("POST", "/routes", data); + } + + updateRoute(id: string, data: Omit): Promise { + return this.request("PUT", `/routes/${id}`, data); + } + + deleteRoute(id: string): Promise { + return this.request("DELETE", `/routes/${id}`); + } + + // --------------------------------------------------------------------------- + // DNS Nameserver Groups + // --------------------------------------------------------------------------- + + listDnsNameserverGroups(): Promise { + return this.request("GET", "/dns/nameservers"); + } + + createDnsNameserverGroup( + data: Omit, + ): Promise { + return this.request("POST", "/dns/nameservers", data); + } + + updateDnsNameserverGroup( + id: string, + data: Omit, + ): Promise { + return this.request("PUT", `/dns/nameservers/${id}`, data); + } + + deleteDnsNameserverGroup(id: string): Promise { + return this.request("DELETE", `/dns/nameservers/${id}`); + } + + // --------------------------------------------------------------------------- + // Events + // --------------------------------------------------------------------------- + + listEvents(): Promise { + return this.request("GET", "/events"); + } +} diff --git a/src/netbird/types.ts b/src/netbird/types.ts new file mode 100644 index 0000000..5ccbb59 --- /dev/null +++ b/src/netbird/types.ts @@ -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; + destinations: Array; +} + +/** 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; +}