feat: unifi controller integration (#2236)

* feat: unifi controller integration

* fix: pr feedback

* fix: pr feedback

* fix: pr feedback

* fix: formatting

* fix: pr feedback

* fix: typecheck

---------

Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
pitschi
2025-04-06 12:17:51 +02:00
committed by GitHub
parent 7caad6fc47
commit c1cd563048
25 changed files with 873 additions and 3 deletions

View File

@@ -0,0 +1,228 @@
import type z from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { ParseError } from "../base/error";
import { Integration, throwErrorByStatusCode } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
import type { NetworkControllerSummary } from "../interfaces/network-controller-summary/network-controller-summary-types";
import { unifiSummaryResponseSchema } from "./unifi-controller-types";
const udmpPrefix = "proxy/network";
type Subsystem = "www" | "wan" | "wlan" | "lan" | "vpn";
export class UnifiControllerIntegration extends Integration implements NetworkControllerSummaryIntegration {
private prefix: string | undefined;
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
if (!this.headers) {
await this.authenticateAndConstructSessionInHeaderAsync();
}
const requestUrl = this.url(`/${this.prefix}/api/stat/sites`);
const requestHeaders: Record<string, string> = {
"Content-Type": "application/json",
...this.headers,
};
if (this.csrfToken) {
requestHeaders["X-CSRF-TOKEN"] = this.csrfToken;
}
const statsResponse = await fetchWithTrustedCertificatesAsync(requestUrl, {
method: "GET",
headers: {
...requestHeaders,
},
}).catch((err: TypeError) => {
const detailMessage = String(err.cause);
throw new IntegrationTestConnectionError("invalidUrl", detailMessage);
});
if (!statsResponse.ok) {
throwErrorByStatusCode(statsResponse.status);
}
const result = unifiSummaryResponseSchema.safeParse(await statsResponse.json());
if (!result.success) {
throw new ParseError("Unifi controller", result.error);
}
return {
wanStatus: this.getStatusValueOverAllSites(result.data, "wan", (site) => site.status === "ok"),
www: {
status: this.getStatusValueOverAllSites(result.data, "wan", (site) => site.status === "ok"),
latency: this.getNumericValueOverAllSites(result.data, "www", (site) => site.latency, "max"),
ping: this.getNumericValueOverAllSites(result.data, "www", (site) => site.speedtest_ping, "max"),
uptime: this.getNumericValueOverAllSites(result.data, "www", (site) => site.uptime, "max"),
},
wifi: {
status: this.getStatusValueOverAllSites(result.data, "wlan", (site) => site.status === "ok"),
users: this.getNumericValueOverAllSites(result.data, "wlan", (site) => site.num_user, "sum"),
guests: this.getNumericValueOverAllSites(result.data, "wlan", (site) => site.num_guest, "sum"),
},
lan: {
status: this.getStatusValueOverAllSites(result.data, "lan", (site) => site.status === "ok"),
users: this.getNumericValueOverAllSites(result.data, "lan", (site) => site.num_user, "sum"),
guests: this.getNumericValueOverAllSites(result.data, "lan", (site) => site.num_guest, "sum"),
},
vpn: {
status: this.getStatusValueOverAllSites(result.data, "vpn", (site) => site.status === "ok"),
users: this.getNumericValueOverAllSites(result.data, "vpn", (site) => site.remote_user_num_active, "sum"),
},
} satisfies NetworkControllerSummary;
}
public async testConnectionAsync(): Promise<void> {
await this.authenticateAndConstructSessionInHeaderAsync();
}
private getStatusValueOverAllSites(
data: z.infer<typeof unifiSummaryResponseSchema>,
subsystem: Subsystem,
selectCallback: (obj: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"][number]) => boolean,
) {
return this.getBooleanValueOverAllSites(data, subsystem, selectCallback) ? "enabled" : "disabled";
}
private getNumericValueOverAllSites<
S extends Subsystem,
T extends Extract<z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"][number], { subsystem: S }>,
>(
data: z.infer<typeof unifiSummaryResponseSchema>,
subsystem: S,
selectCallback: (obj: T) => number,
strategy: "average" | "sum" | "max",
): number {
const values = data.data.map((site) => selectCallback(this.getSubsystem(site.health, subsystem) as T));
if (strategy === "sum") {
return values.reduce((first, second) => first + second, 0);
}
if (strategy === "average") {
return values.reduce((first, second, _, array) => first + second / array.length, 0);
}
return Math.max(...values);
}
private getBooleanValueOverAllSites(
data: z.infer<typeof unifiSummaryResponseSchema>,
subsystem: Subsystem,
selectCallback: (obj: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"][number]) => boolean,
): boolean {
return data.data.every((site) => selectCallback(this.getSubsystem(site.health, subsystem)));
}
private getSubsystem(
health: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"],
subsystem: Subsystem,
) {
const value = health.find((health) => health.subsystem === subsystem);
if (!value) {
throw new Error(`Subsystem ${subsystem} not found!`);
}
return value;
}
private headers: Record<string, string> | undefined = undefined;
private csrfToken: string | undefined;
private async authenticateAndConstructSessionInHeaderAsync(): Promise<void> {
await this.determineUDMVariantAsync();
await this.authenticateAndSetCookieAsync();
}
private async authenticateAndSetCookieAsync(): Promise<void> {
if (this.headers) {
return;
}
const endpoint = this.prefix === udmpPrefix ? "auth/login" : "login";
logger.debug("Authenticating at network console: " + endpoint);
const loginUrl = this.url(`/api/${endpoint}`);
const loginBody = {
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
remember: true,
};
const requestHeaders: Record<string, string> = { "Content-Type": "application/json" };
if (this.csrfToken) {
requestHeaders["X-CSRF-TOKEN"] = this.csrfToken;
}
const loginResponse = await fetchWithTrustedCertificatesAsync(loginUrl, {
method: "POST",
headers: {
...requestHeaders,
},
body: JSON.stringify(loginBody),
}).catch((err: TypeError) => {
const detailMessage = String(err.cause);
throw new IntegrationTestConnectionError("invalidUrl", detailMessage);
});
if (!loginResponse.ok) {
throwErrorByStatusCode(loginResponse.status);
}
const responseHeaders = loginResponse.headers;
const newHeaders: Record<string, string> = {};
const loginToken = UnifiControllerIntegration.extractLoginTokenFromCookies(responseHeaders);
newHeaders.Cookie = `${loginToken};`;
this.headers = newHeaders;
}
private async determineUDMVariantAsync(): Promise<void> {
if (this.prefix) {
return;
}
logger.debug("Prefix for authentication not set; initial connect to determine UDM variant");
const url = this.url("/");
const { status, ok, headers } = await fetchWithTrustedCertificatesAsync(url, { method: "HEAD" })
.then((res) => res)
.catch((err: TypeError) => {
const detailMessage = String(err.cause);
throw new IntegrationTestConnectionError("invalidUrl", detailMessage);
});
if (!ok) {
throw new IntegrationTestConnectionError("invalidUrl", "status code: " + status);
}
let prefix = "";
if (headers.get("x-csrf-token") !== null) {
// Unifi OS < 3.2.5 passes & requires csrf-token
prefix = udmpPrefix;
const headersCSRFToken = headers.get("x-csrf-token");
if (headersCSRFToken) {
this.csrfToken = headersCSRFToken;
}
} else if (headers.get("access-control-expose-headers") !== null) {
// Unifi OS ≥ 3.2.5 doesnt pass csrf token but still uses different endpoint
prefix = udmpPrefix;
}
this.prefix = prefix;
logger.debug("Final prefix: " + this.prefix);
}
private static extractLoginTokenFromCookies(headers: Headers): string {
const cookies = headers.get("set-cookie") ?? "";
const loginToken = cookies.split(";").find((cookie) => cookie.includes("TOKEN"));
if (loginToken) {
return loginToken;
}
throw new Error("Login token not found in cookies");
}
}

View File

@@ -0,0 +1,130 @@
import { z } from "zod";
export const healthSchema = z.discriminatedUnion("subsystem", [
z.object({
subsystem: z.literal("wlan"),
num_user: z.number(),
num_guest: z.number(),
num_iot: z.number(),
"tx_bytes-r": z.number(),
"rx_bytes-r": z.number(),
status: z.string(),
num_ap: z.number(),
num_adopted: z.number(),
num_disabled: z.number(),
num_disconnected: z.number(),
num_pending: z.number(),
}),
z.object({
subsystem: z.literal("wan"),
num_gw: z.number(),
num_adopted: z.number(),
num_disconnected: z.number(),
num_pending: z.number(),
status: z.string(),
wan_ip: z.string().ip(),
gateways: z.array(z.string().ip()),
netmask: z.string().ip(),
nameservers: z.array(z.string().ip()).optional(),
num_sta: z.number(),
"tx_bytes-r": z.number(),
"rx_bytes-r": z.number(),
gw_mac: z.string(),
gw_name: z.string(),
"gw_system-stats": z.object({
cpu: z.string(),
mem: z.string(),
uptime: z.string(),
}),
gw_version: z.string(),
isp_name: z.string(),
isp_organization: z.string(),
uptime_stats: z.object({
WAN: z.object({
alerting_monitors: z.array(
z.object({
availability: z.number(),
latency_average: z.number(),
target: z.string(),
type: z.enum(["icmp", "dns"]),
}),
),
availability: z.number(),
latency_average: z.number(),
monitors: z.array(
z.object({
availability: z.number(),
latency_average: z.number(),
target: z.string(),
type: z.enum(["icmp", "dns"]),
}),
),
time_period: z.number(),
uptime: z.number(),
}),
}),
}),
z.object({
subsystem: z.literal("www"),
status: z.string(),
"tx_bytes-r": z.number(),
"rx_bytes-r": z.number(),
latency: z.number(),
uptime: z.number(),
drops: z.number(),
xput_up: z.number(),
xput_down: z.number(),
speedtest_status: z.string(),
speedtest_lastrun: z.number(),
speedtest_ping: z.number(),
gw_mac: z.string(),
}),
z.object({
subsystem: z.literal("lan"),
lan_ip: z.string().ip().nullish(),
status: z.string(),
num_user: z.number(),
num_guest: z.number(),
num_iot: z.number(),
"tx_bytes-r": z.number(),
"rx_bytes-r": z.number(),
num_sw: z.number(),
num_adopted: z.number(),
num_disconnected: z.number(),
num_pending: z.number(),
}),
z.object({
subsystem: z.literal("vpn"),
status: z.string(),
remote_user_enabled: z.boolean(),
remote_user_num_active: z.number(),
remote_user_num_inactive: z.number(),
remote_user_rx_bytes: z.number(),
remote_user_tx_bytes: z.number(),
remote_user_rx_packets: z.number(),
remote_user_tx_packets: z.number(),
site_to_site_enabled: z.boolean(),
}),
]);
export type Health = z.infer<typeof healthSchema>;
export const siteSchema = z.object({
anonymous_id: z.string().uuid(),
name: z.string(),
external_id: z.string().uuid(),
_id: z.string(),
attr_no_delete: z.boolean(),
attr_hidden_id: z.string(),
desc: z.string(),
health: z.array(healthSchema),
num_new_alarms: z.number(),
});
export type Site = z.infer<typeof siteSchema>;
export const unifiSummaryResponseSchema = z.object({
meta: z.object({
rc: z.enum(["ok"]),
}),
data: z.array(siteSchema),
});