diff --git a/apps/docs/docs/integrations/unifi-controller/index.mdx b/apps/docs/docs/integrations/unifi-controller/index.mdx index f25cecaac..7d9666cb5 100644 --- a/apps/docs/docs/integrations/unifi-controller/index.mdx +++ b/apps/docs/docs/integrations/unifi-controller/index.mdx @@ -16,7 +16,7 @@ import { networkControllerStatusWidget } from "@site/docs/widgets/network-contro 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. +Therefore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro controllers as well as self-hosted UniFi controllers. ### Widgets & Capabilities @@ -28,6 +28,14 @@ Therfore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro c +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 { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; + process.env.ENABLE_DNS_CACHING = "false"; +}); + +const controllerCtor = vi.fn(); +const controllerLogin = vi.fn().mockResolvedValue(true); + +vi.mock("@homarr/redis", () => ({ + createGetSetChannel: () => ({ + getAsync: vi.fn().mockResolvedValue(null), + setAsync: vi.fn().mockResolvedValue(undefined), + removeAsync: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("@homarr/core/infrastructure/logs", () => ({ + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + ErrorWithMetadata: class extends Error {}, +})); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + createCustomCheckServerIdentity: () => (() => undefined) as never, + fetchWithTrustedCertificatesAsync: vi.fn(), + createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}), + createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@homarr/core/infrastructure/certificates", () => ({ + getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]), + getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]), +})); + +vi.mock("axios", () => ({ + AxiosError: Error, + default: { + create: vi.fn().mockReturnValue({}), + }, +})); + +vi.mock("http-cookie-agent/http", () => ({ + HttpCookieAgent: class {}, + HttpsCookieAgent: class {}, +})); + +vi.mock("@homarr/node-unifi", () => { + class FakeController { + public options: Record; + constructor(options: Record) { + this.options = options; + controllerCtor(options); + } + public async login(): Promise { + return await controllerLogin(this.options.port); + } + public async getSitesStats(): Promise { + return []; + } + } + return { default: { Controller: FakeController } }; +}); + +import { UnifiControllerIntegration } from "../unifi-controller-integration"; + +const createIntegration = (url: string) => + new UnifiControllerIntegration({ + id: "test-unifi", + name: "Test Unifi", + url, + externalUrl: null, + decryptedSecrets: [ + { kind: "username", value: "admin" }, + { kind: "password", value: "secret" }, + ], + }); + +describe("UnifiControllerIntegration port resolution", () => { + 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).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 })); + }); + + 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 })); + }); + + 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 bee66c956..86bdd245e 100644 --- a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts +++ b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts @@ -2,7 +2,6 @@ import type tls from "node:tls"; import axios from "axios"; import { HttpCookieAgent, HttpsCookieAgent } from "http-cookie-agent/http"; -import { getPortFromUrl } from "@homarr/common"; import { getAllTrustedCertificatesAsync, getTrustedCertificateHostnamesAsync, @@ -67,26 +66,53 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), }; - const client = new Unifi.Controller({ - host: url.hostname, - port: getPortFromUrl(url), - 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, - }), - }); - }, - }); + // 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(