From e3cf680cbde13b3ac0a16f1e7697d72660cdde06 Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 21 Jul 2026 10:07:25 +0200 Subject: [PATCH] fix(integrations): support UniFi controller default ports --- .../integrations/unifi-controller/index.mdx | 50 ++++++++------ .../test/unifi-controller-integration.spec.ts | 41 ++++++++++- .../unifi-controller-integration.ts | 69 ++++++++++++------- 3 files changed, 113 insertions(+), 47 deletions(-) diff --git a/apps/docs/docs/integrations/unifi-controller/index.mdx b/apps/docs/docs/integrations/unifi-controller/index.mdx index 134ce91b8..62e1ca393 100644 --- a/apps/docs/docs/integrations/unifi-controller/index.mdx +++ b/apps/docs/docs/integrations/unifi-controller/index.mdx @@ -1,40 +1,48 @@ --- -title: 'Unifi Controller' +title: "Unifi Controller" description: "Unifi Controller is a network management platform for Unifi devices." hide_title: true --- -import { IntegrationHeader } from '@site/src/components/integrations/header'; -import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; -import { AddingIntegration } from '@site/src/components/integrations/adding'; -import {IntegrationSecrets} from '@site/src/components/integrations/secrets'; +import { IntegrationHeader } from "@site/src/components/integrations/header"; +import { IntegrationCapabilites } from "@site/src/components/integrations/widgets"; +import { AddingIntegration } from "@site/src/components/integrations/adding"; +import { IntegrationSecrets } from "@site/src/components/integrations/secrets"; import { IconTopologyFull } from "@tabler/icons-react"; -import { unifiControllerIntegration } from '.'; -import { networkControllerSummaryWidget } from '@site/docs/widgets/network-controller-summary'; -import { networkControllerStatusWidget } from '@site/docs/widgets/network-controller-status'; +import { unifiControllerIntegration } from "."; +import { networkControllerSummaryWidget } from "@site/docs/widgets/network-controller-summary"; +import { networkControllerStatusWidget } from "@site/docs/widgets/network-controller-status"; - - + This integration is using the [node-unifi](https://www.npmjs.com/package/node-unifi) library under the hood to connect to the Unifi Controller. Therfore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro controllers as well as self-hosted UniFi controllers. ### Widgets & Capabilities + ### Adding the integration + +Use the local HTTPS address of your controller: + +- UniFi OS consoles such as Dream Router, Dream Machine, Cloud Gateway, and CloudKey use `https://` (port 443). +- Self-hosted UniFi Network Servers normally use `https://:8443`. +- If you omit the port, Homarr tries port 443 first and then port 8443 when the first port cannot be reached. + +Use a local administrator account. Cloud-only accounts and accounts with multi-factor authentication are not supported by the underlying client. + ### Secrets - \ No newline at end of file + + diff --git a/packages/integrations/src/unifi-controller/test/unifi-controller-integration.spec.ts b/packages/integrations/src/unifi-controller/test/unifi-controller-integration.spec.ts index 83ba28aee..fb0f9c81b 100644 --- a/packages/integrations/src/unifi-controller/test/unifi-controller-integration.spec.ts +++ b/packages/integrations/src/unifi-controller/test/unifi-controller-integration.spec.ts @@ -7,6 +7,7 @@ vi.hoisted(() => { }); const controllerCtor = vi.fn(); +const controllerLogin = vi.fn().mockResolvedValue(true); vi.mock("@homarr/redis", () => ({ createGetSetChannel: () => ({ @@ -34,6 +35,7 @@ vi.mock("@homarr/core/infrastructure/certificates", () => ({ })); vi.mock("axios", () => ({ + AxiosError: Error, default: { create: vi.fn().mockReturnValue({}), }, @@ -52,7 +54,7 @@ vi.mock("@homarr/node-unifi", () => { controllerCtor(options); } public async login(): Promise { - return true; + return await controllerLogin(this.options.port); } public async getSitesStats(): Promise { return []; @@ -76,15 +78,31 @@ const createIntegration = (url: string) => }); describe("UnifiControllerIntegration port resolution", () => { - test("http://192.168.1.1 falls back to the 8443 default instead of 80", async () => { + test("a URL without a port uses the UniFi OS port 443", async () => { controllerCtor.mockClear(); + controllerLogin.mockClear().mockResolvedValue(true); await createIntegration("http://192.168.1.1").getNetworkSummaryAsync(); - expect(controllerCtor).toHaveBeenCalledWith(expect.objectContaining({ host: "192.168.1.1", port: 8443 })); + expect(controllerCtor).toHaveBeenCalledTimes(1); + expect(controllerCtor).toHaveBeenCalledWith(expect.objectContaining({ host: "192.168.1.1", port: 443 })); + }); + + test("a connection failure on port 443 falls back to the self-hosted controller port 8443", async () => { + controllerCtor.mockClear(); + controllerLogin + .mockClear() + .mockRejectedValueOnce(Object.assign(new Error("connect ECONNREFUSED"), { isAxiosError: true })) + .mockResolvedValueOnce(true); + + await createIntegration("http://controller.lan").getNetworkSummaryAsync(); + + expect(controllerCtor).toHaveBeenNthCalledWith(1, expect.objectContaining({ port: 443 })); + expect(controllerCtor).toHaveBeenNthCalledWith(2, expect.objectContaining({ port: 8443 })); }); test("https://controller.lan:8443 keeps the user-specified port", async () => { controllerCtor.mockClear(); + controllerLogin.mockClear().mockResolvedValue(true); await createIntegration("https://controller.lan:8443").getNetworkSummaryAsync(); expect(controllerCtor).toHaveBeenCalledWith(expect.objectContaining({ host: "controller.lan", port: 8443 })); @@ -92,6 +110,7 @@ describe("UnifiControllerIntegration port resolution", () => { test("https://192.168.1.1:8443 keeps the user-specified port", async () => { controllerCtor.mockClear(); + controllerLogin.mockClear().mockResolvedValue(true); await createIntegration("https://192.168.1.1:8443").getNetworkSummaryAsync(); expect(controllerCtor).toHaveBeenCalledWith(expect.objectContaining({ host: "192.168.1.1", port: 8443 })); @@ -99,8 +118,24 @@ describe("UnifiControllerIntegration port resolution", () => { test("an unusual port like 10443 is honored verbatim", async () => { controllerCtor.mockClear(); + controllerLogin.mockClear().mockResolvedValue(true); await createIntegration("https://192.168.1.1:10443").getNetworkSummaryAsync(); expect(controllerCtor).toHaveBeenCalledWith(expect.objectContaining({ host: "192.168.1.1", port: 10443 })); }); + + test("an authentication failure on port 443 does not try another port", async () => { + controllerCtor.mockClear(); + controllerLogin.mockClear().mockRejectedValueOnce( + Object.assign(new Error("Request failed with status code 401"), { + isAxiosError: true, + response: { status: 401 }, + }), + ); + + await expect(createIntegration("https://controller.lan").getNetworkSummaryAsync()).rejects.toThrow(); + + expect(controllerCtor).toHaveBeenCalledTimes(1); + expect(controllerCtor).toHaveBeenCalledWith(expect.objectContaining({ port: 443 })); + }); }); diff --git a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts index 4071fb302..68f4b961d 100644 --- a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts +++ b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts @@ -61,35 +61,58 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo checkServerIdentity: typeof tls.checkServerIdentity; }) { const url = new URL(this.integration.url); - // ponytail: node-unifi hardcodes https:// in its base URL and never serves HTTP. - // Respect the user's explicit port; fall back to 8443 (the integration's defaultPort) - // so a bare "http://192.168.1.1" maps to https://192.168.1.1:8443 instead of 80. - const port = url.port ? Number(url.port) : 8443; const certificateOptions = options ?? { ca: await getAllTrustedCertificatesAsync(), checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), }; - 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, - }), - }); - }, - }); + // ponytail: 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; + 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(