Files
mesh-catalog/modules/unifi/client.ts
T
jschoubben d1f8ab86d1 unifi: list networks and set the DNS their DHCP hands out
Which DNS server the home network's DHCP hands out could be changed only
in the controller's own interface or by hand against its API (novox/hq
issue 198).
2026-10-02 11:53:16 +02:00

285 lines
9.1 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 UnifiNetwork {
_id: string;
name: string;
purpose: string;
ip_subnet?: string;
dhcpd_enabled?: boolean;
dhcpd_dns_enabled?: boolean;
dhcpd_dns_1?: string;
dhcpd_dns_2?: string;
dhcpd_dns_3?: string;
dhcpd_dns_4?: 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}`);
}
// --- Networks ---
async listNetworks(): Promise<UnifiNetwork[]> {
return this.request<UnifiNetwork>("GET", `/api/s/${this.site}/rest/networkconf`);
}
async updateNetwork(id: string, fields: Partial<UnifiNetwork>): Promise<UnifiNetwork> {
const result = await this.request<UnifiNetwork>("PUT", `/api/s/${this.site}/rest/networkconf/${id}`, fields);
return result[0];
}
// --- 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) };
}
}
}