Files
homarr/packages/integrations/src/unifi-controller/unifi-controller-integration.ts
T
Thomasandajnart ca7be8f3ad fix(integrations): support UniFi controller default ports (#6787)
* fix(integrations): use Unifi default port 8443 when no port is given

The node-unifi library hardcodes https:// in its base URL and the
Unifi controller API only speaks HTTPS. When a user adds the
integration with a bare http:// or https:// URL, getPortFromUrl
returned 80 or 443, and the library attempted a TLS handshake on
the wrong port. The server's plain HTTP response caused
'EPROTO ... wrong version number'.

Derive the port from the user's explicit port, or fall back to 8443
(the integration's defaultPort). Closes #6207.

* style: auto-fix lint and format

* fix(integrations): support UniFi controller default ports

* docs(unifi): resolve review leftovers

---------

Co-authored-by: ajnart <ajnart@users.noreply.github.com>
2026-09-06 15:32:45 +02:00

159 lines
6.6 KiB
TypeScript

import type tls from "node:tls";
import axios from "axios";
import { HttpCookieAgent, HttpsCookieAgent } from "http-cookie-agent/http";
import {
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/core/infrastructure/certificates";
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
import type { SiteStats } from "@homarr/node-unifi";
import Unifi from "@homarr/node-unifi";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import { integrationAxiosHttpErrorHandler } from "../base/errors/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
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 type { HealthSubsystem } from "./unifi-controller-types";
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class UnifiControllerIntegration extends Integration implements NetworkControllerSummaryIntegration {
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
const client = await this.createControllerClientAsync();
const stats = await client.getSitesStats();
return {
wanStatus: this.getStatusValueOverAllSites(stats, "wan", (site) => site.status === "ok"),
www: {
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(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(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(stats, "vpn", (site) => site.status === "ok"),
users: this.getNumericValueOverAllSites(stats, "vpn", (site) => site.remote_user_num_active, "sum"),
},
} satisfies NetworkControllerSummary;
}
protected async testingAsync({ options }: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.createControllerClientAsync(options);
await client.getSitesStats();
return { success: true };
}
private async createControllerClientAsync(options?: {
ca: string | string[];
checkServerIdentity: typeof tls.checkServerIdentity;
}) {
const url = new URL(this.integration.url);
const certificateOptions = options ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
};
// node-unifi always connects over HTTPS, regardless of the protocol in the
// integration URL. UniFi OS consoles use port 443 while self-hosted controllers normally
// use 8443. If no port was provided, try both without hiding authentication errors.
const ports = url.port ? [Number(url.port)] : [443, 8443];
const createClientForPortAsync = async (port: number) => {
const client = new Unifi.Controller({
host: url.hostname,
port,
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
createAxiosInstance({ cookies }) {
return axios.create({
adapter: "http",
httpAgent: new HttpCookieAgent({ cookies }),
httpsAgent: new HttpsCookieAgent({
cookies,
requestCert: true,
...certificateOptions,
}),
});
},
});
await client.login(this.getSecretValue("username"), this.getSecretValue("password"), null);
return client;
};
for (const [index, port] of ports.entries()) {
try {
return await createClientForPortAsync(port);
} catch (error) {
const isLastPort = index === ports.length - 1;
if (isLastPort || !this.isPortFallbackError(error)) {
throw error;
}
}
}
throw new Error("Unable to connect to the UniFi controller");
}
private isPortFallbackError(error: unknown) {
if (typeof error === "object" && error !== null && "isAxiosError" in error && error.isAxiosError === true) {
return !("response" in error) || error.response === undefined;
}
return error instanceof Error && error.message === "failed to detect UniFiOS status";
}
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 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);
}
if (strategy === "average") {
return values.reduce((first, second, _, array) => first + second / array.length, 0);
}
return Math.max(...values);
}
private getBooleanValueOverAllSites(
data: SiteStats[],
subsystem: HealthSubsystem,
selectCallback: (obj: SiteStats["health"][number]) => boolean,
): boolean {
return data.every((site) => selectCallback(this.getSubsystem(site.health, 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;
}
}