// The UniFi controller API client — unifi's own code, living in the module (novox/hq ADR 0039). // Moved out of the shared hal sdk, where a change to the UniFi API rebuilt everything; here it // rebuilds only unifi. This module's tools import it, and nothing outside unifi does. // // The controller speaks its classic self-managed API (/api/login, /api/s//...), authenticated // with a username and password and a session cookie. It presents a self-signed certificate, so the // requests deliberately skip TLS verification — see uniFetch below. import { readFileSync } from "node:fs"; import { request as httpsRequest } from "node:https"; export interface UnifiPortForward { _id?: string; name: string; enabled: boolean; pfwd_interface: string; src: string; dst_port: string; fwd: string; fwd_port: string; proto: string; log: boolean; site_id?: string; } export interface UnifiDevice { _id: string; name: string; model: string; type: string; ip: string; mac: string; version: string; adopted: boolean; state: number; uptime: number; } export interface UnifiClientDevice { _id: string; name?: string; hostname?: string; ip: string; mac: string; oui: string; is_wired: boolean; network?: string; last_seen: number; uptime?: number; } interface UnifiResponse { meta: { rc: string; msg?: string }; data: T[]; } /** The minimal response shape uniFetch returns — enough for this client, without pretending to be * the whole DOM `Response`. */ interface UniReply { ok: boolean; status: number; statusText: string; setCookies: string[]; text: () => Promise; json: () => Promise; } interface UniInit { method?: string; headers?: Record; body?: string; } /** * Fetch wrapper that disables TLS verification for UniFi's self-signed certificate. Uses node:https * directly rather than the built-in fetch, because fetch caches NODE_TLS_REJECT_UNAUTHORIZED at * startup and scoped per-request toggling does not work — the reason the hal original reached for * https as well. */ function uniFetch(url: string, init?: UniInit): Promise { const parsed = new URL(url); return new Promise((resolve, reject) => { const req = httpsRequest( parsed, { method: init?.method ?? "GET", headers: init?.headers ?? {}, rejectUnauthorized: false, }, (res) => { const chunks: Buffer[] = []; res.on("data", (chunk: Buffer) => chunks.push(chunk)); res.on("end", () => { const body = Buffer.concat(chunks).toString(); const status = res.statusCode ?? 0; const rawCookies = res.headers["set-cookie"]; const setCookies = Array.isArray(rawCookies) ? rawCookies : rawCookies ? [rawCookies] : []; resolve({ ok: status >= 200 && status < 300, status, statusText: res.statusMessage ?? "", setCookies, text: async () => body, json: async () => JSON.parse(body) as unknown, }); }); }, ); req.on("error", reject); if (init?.body) req.write(init.body); req.end(); }); } /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, username, password, * site }. Read from MESH_UNIFI_CONFIG_FILE; absent or unreadable is an empty config, not a throw. */ function meshConfig(file?: string): Record { if (!file) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } catch { return {}; } } export class UnifiApiClient { readonly baseUrl: string; private cookie: string | null = null; private csrfToken: string | null = null; constructor( url: string, private readonly username: string, private readonly password: string, private readonly site: string = "default", ) { this.baseUrl = url.replace(/\/+$/, ""); } /** * Build from the module's resolved environment. URL, credentials and site come from the * settings-merged config file, falling back to MESH_UNIFI_* env vars and finally the local * controller port. Throws when no username/password is configured, so a misconfigured module * exposes nothing rather than calling the controller unauthenticated. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): UnifiApiClient { const cfg = meshConfig(env.MESH_UNIFI_CONFIG_FILE); const url = cfg.url ?? env.MESH_UNIFI_URL ?? `https://127.0.0.1:${env.UNIFI_HTTPS_PORT ?? "8443"}`; const username = cfg.username ?? env.MESH_UNIFI_USERNAME; const password = cfg.password ?? env.MESH_UNIFI_PASSWORD; const site = cfg.site ?? env.MESH_UNIFI_SITE ?? "default"; if (!username || !password) { throw new Error("no UniFi credentials — set MESH_UNIFI_USERNAME and MESH_UNIFI_PASSWORD"); } return new UnifiApiClient(url, username, password, site); } private async login(): Promise { const res = await uniFetch(`${this.baseUrl}/api/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: this.username, password: this.password }), }); if (!res.ok && res.status !== 302) { throw new Error(`UniFi auth failed: ${res.status} ${await res.text()}`); } // Extract the session cookie and CSRF token from the response. const cookies: string[] = []; for (const c of res.setCookies) { const name = c.split("=")[0]; const value = c.split(";")[0]; if (name === "TOKEN" || name === "unifises" || name === "csrf_token") { cookies.push(value); } if (name === "csrf_token") { this.csrfToken = value.split("=")[1]; } } this.cookie = cookies.join("; "); if (!this.cookie) { throw new Error("UniFi auth: no session cookie returned"); } } private async request(method: string, path: string, body?: unknown): Promise { if (!this.cookie) await this.login(); const doRequest = async (): Promise => { const headers: Record = { "Content-Type": "application/json", Cookie: this.cookie!, }; if (this.csrfToken) headers["X-Csrf-Token"] = this.csrfToken; return uniFetch(`${this.baseUrl}${path}`, { method, headers, ...(body ? { body: JSON.stringify(body) } : {}), }); }; let res = await doRequest(); if (res.status === 401) { this.cookie = null; await this.login(); res = await doRequest(); } if (!res.ok) throw new Error(`UniFi ${method} ${path}: ${res.status} ${await res.text()}`); const data = (await res.json()) as UnifiResponse; if (data.meta.rc !== "ok") throw new Error(`UniFi API error: ${data.meta.msg}`); return data.data; } // --- Port forwarding --- async listPortForwards(): Promise { return this.request("GET", `/api/s/${this.site}/rest/portforward`); } async createPortForward(rule: Omit): Promise { const result = await this.request("POST", `/api/s/${this.site}/rest/portforward`, rule); return result[0]; } async updatePortForward(id: string, rule: Partial): Promise { const result = await this.request("PUT", `/api/s/${this.site}/rest/portforward/${id}`, rule); return result[0]; } async deletePortForward(id: string): Promise { await this.request("DELETE", `/api/s/${this.site}/rest/portforward/${id}`); } // --- Devices --- async listDevices(): Promise { return this.request("GET", `/api/s/${this.site}/stat/device`); } // --- Clients --- async listClients(): Promise { return this.request("GET", `/api/s/${this.site}/stat/sta`); } /** * A health probe that never throws: report whether the controller answers and can be logged into. * Every other call assumes the controller is up and authenticated; this is the one that tells the * mesh whether it is, so a diagnosis does not start from a stack trace. */ async reachable(): Promise<{ reachable: boolean; url: string; error?: string }> { try { await this.listDevices(); return { reachable: true, url: this.baseUrl }; } catch (err) { return { reachable: false, url: this.baseUrl, error: err instanceof Error ? err.message : String(err) }; } } }