Files
mesh-catalog/modules/unifi/client.ts

261 lines
8.4 KiB
TypeScript

// 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/<site>/...), 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<T> {
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<string>;
json: () => Promise<unknown>;
}
interface UniInit {
method?: string;
headers?: Record<string, string>;
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<UniReply> {
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<string, string> {
if (!file) return {};
try {
return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>;
} 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<void> {
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<T>(method: string, path: string, body?: unknown): Promise<T[]> {
if (!this.cookie) await this.login();
const doRequest = async (): Promise<UniReply> => {
const headers: Record<string, string> = {
"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<T>;
if (data.meta.rc !== "ok") throw new Error(`UniFi API error: ${data.meta.msg}`);
return data.data;
}
// --- Port forwarding ---
async listPortForwards(): Promise<UnifiPortForward[]> {
return this.request<UnifiPortForward>("GET", `/api/s/${this.site}/rest/portforward`);
}
async createPortForward(rule: Omit<UnifiPortForward, "_id" | "site_id">): Promise<UnifiPortForward> {
const result = await this.request<UnifiPortForward>("POST", `/api/s/${this.site}/rest/portforward`, rule);
return result[0];
}
async updatePortForward(id: string, rule: Partial<UnifiPortForward>): Promise<UnifiPortForward> {
const result = await this.request<UnifiPortForward>("PUT", `/api/s/${this.site}/rest/portforward/${id}`, rule);
return result[0];
}
async deletePortForward(id: string): Promise<void> {
await this.request<unknown>("DELETE", `/api/s/${this.site}/rest/portforward/${id}`);
}
// --- Devices ---
async listDevices(): Promise<UnifiDevice[]> {
return this.request<UnifiDevice>("GET", `/api/s/${this.site}/stat/device`);
}
// --- Clients ---
async listClients(): Promise<UnifiClientDevice[]> {
return this.request<UnifiClientDevice>("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) };
}
}
}