- bookshelf: Servarr v1 fork on the radarr template (4 tools). - unifi: portainer-shaped tooled app (7 tools, 9 ports), settings-merged config. - fail2ban: host-level security module mirroring firewall (service + restart-on, no container); ban actions preserved as source ufw/iptables and FLAGGED to be rewritten nftables-native before it actually bans. - marrytts: manifest-only plain container (no tools), like resolv-conf. All typecheck against the built @novox/mesh-sdk; service images digest-pinned. Held from merge pending the hq initialization reconciliation. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
261 lines
8.4 KiB
TypeScript
261 lines
8.4 KiB
TypeScript
// The UniFi controller API client — unifi's own code, living in the module (novox/hq ADR 0044).
|
|
// 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 0051): { 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) };
|
|
}
|
|
}
|
|
}
|