mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 17:00:54 +01:00
feat: migrate to unifi package (#2894)
This commit is contained in:
@@ -1,103 +1,78 @@
|
||||
import type z from "zod";
|
||||
import type { SiteStats } from "node-unifi";
|
||||
import { Controller } from "node-unifi";
|
||||
|
||||
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 { Integration } from "../base/integration";
|
||||
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";
|
||||
import type { HealthSubsystem } from "./unifi-controller-types";
|
||||
|
||||
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);
|
||||
}
|
||||
const client = await this.createControllerClientAsync();
|
||||
const stats = await client.getSitesStats();
|
||||
|
||||
return {
|
||||
wanStatus: this.getStatusValueOverAllSites(result.data, "wan", (site) => site.status === "ok"),
|
||||
wanStatus: this.getStatusValueOverAllSites(stats, "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"),
|
||||
status: this.getStatusValueOverAllSites(stats, "wan", (site) => site.status === "ok"),
|
||||
latency: this.getNumericValueOverAllSites(stats, "www", (site) => site.latency, "max"),
|
||||
ping: this.getNumericValueOverAllSites(stats, "www", (site) => site.speedtest_ping, "max"),
|
||||
uptime: this.getNumericValueOverAllSites(stats, "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"),
|
||||
status: this.getStatusValueOverAllSites(stats, "wlan", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(stats, "wlan", (site) => site.num_user, "sum"),
|
||||
guests: this.getNumericValueOverAllSites(stats, "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"),
|
||||
status: this.getStatusValueOverAllSites(stats, "lan", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(stats, "lan", (site) => site.num_user, "sum"),
|
||||
guests: this.getNumericValueOverAllSites(stats, "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"),
|
||||
status: this.getStatusValueOverAllSites(stats, "vpn", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(stats, "vpn", (site) => site.remote_user_num_active, "sum"),
|
||||
},
|
||||
} satisfies NetworkControllerSummary;
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await this.authenticateAndConstructSessionInHeaderAsync();
|
||||
const client = await this.createControllerClientAsync();
|
||||
await client.getSitesStats();
|
||||
}
|
||||
|
||||
private getStatusValueOverAllSites(
|
||||
data: z.infer<typeof unifiSummaryResponseSchema>,
|
||||
subsystem: Subsystem,
|
||||
selectCallback: (obj: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"][number]) => boolean,
|
||||
private async createControllerClientAsync() {
|
||||
const portString = new URL(this.integration.url).port;
|
||||
const port = Number.isInteger(portString) ? Number(portString) : undefined;
|
||||
const hostname = new URL(this.integration.url).hostname;
|
||||
|
||||
const client = new Controller({
|
||||
host: hostname,
|
||||
// @ts-expect-error the URL construction is incorrect and does not append the required / at the end: https://github.com/jens-maus/node-unifi/blob/05665e8f82a900a15a9ea8b1071750b29825b3bc/unifi.js#L56, https://github.com/jens-maus/node-unifi/blob/05665e8f82a900a15a9ea8b1071750b29825b3bc/unifi.js#L95
|
||||
port: port === undefined ? "/" : `${port}/`,
|
||||
sslverify: false, // TODO: implement a "ignore certificate toggle", see https://github.com/homarr-labs/homarr/issues/2553
|
||||
username: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
});
|
||||
|
||||
// Object.defineProperty(client, '_baseurl', { value: url });
|
||||
await client.login(this.getSecretValue("username"), this.getSecretValue("password"), null);
|
||||
return client;
|
||||
}
|
||||
|
||||
private getStatusValueOverAllSites<S extends HealthSubsystem>(
|
||||
data: SiteStats[],
|
||||
subsystem: S,
|
||||
selectCallback: (obj: SiteStats["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));
|
||||
S extends HealthSubsystem,
|
||||
T extends Extract<SiteStats["health"][number], { subsystem: S }>,
|
||||
>(data: SiteStats[], subsystem: S, selectCallback: (obj: T) => number, strategy: "average" | "sum" | "max"): number {
|
||||
const values = data.map((site) => selectCallback(this.getSubsystem(site.health, subsystem) as T));
|
||||
|
||||
if (strategy === "sum") {
|
||||
return values.reduce((first, second) => first + second, 0);
|
||||
@@ -111,118 +86,18 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo
|
||||
}
|
||||
|
||||
private getBooleanValueOverAllSites(
|
||||
data: z.infer<typeof unifiSummaryResponseSchema>,
|
||||
subsystem: Subsystem,
|
||||
selectCallback: (obj: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"][number]) => boolean,
|
||||
data: SiteStats[],
|
||||
subsystem: HealthSubsystem,
|
||||
selectCallback: (obj: SiteStats["health"][number]) => boolean,
|
||||
): boolean {
|
||||
return data.data.every((site) => selectCallback(this.getSubsystem(site.health, subsystem)));
|
||||
return data.every((site) => selectCallback(this.getSubsystem(site.health, subsystem)));
|
||||
}
|
||||
|
||||
private getSubsystem(
|
||||
health: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"],
|
||||
subsystem: Subsystem,
|
||||
) {
|
||||
private getSubsystem(health: SiteStats["health"], subsystem: HealthSubsystem) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +1,3 @@
|
||||
import { z } from "zod";
|
||||
import type { SiteStats } from "node-unifi";
|
||||
|
||||
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),
|
||||
});
|
||||
export type HealthSubsystem = SiteStats["health"][number]["subsystem"];
|
||||
|
||||
Reference in New Issue
Block a user