From 8cd9e8c8265f861655e4a5c5e981dac67a2efdcf Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 6 Jul 2026 02:05:44 +0200 Subject: [PATCH] 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. --- .../test/unifi-controller-integration.spec.ts | 114 ++++++++++++++++++ .../unifi-controller-integration.ts | 7 +- 2 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 packages/integrations/src/unifi-controller/test/unifi-controller-integration.spec.ts 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 new file mode 100644 index 000000000..b2a83c7b0 --- /dev/null +++ b/packages/integrations/src/unifi-controller/test/unifi-controller-integration.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; + process.env.ENABLE_DNS_CACHING = "false"; +}); + +const controllerCtor = vi.fn(); + +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", () => ({ + 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 true; + } + 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("http://192.168.1.1 falls back to the 8443 default instead of 80", async () => { + controllerCtor.mockClear(); + await createIntegration("http://192.168.1.1").getNetworkSummaryAsync(); + + expect(controllerCtor).toHaveBeenCalledWith( + expect.objectContaining({ host: "192.168.1.1", port: 8443 }), + ); + }); + + test("https://controller.lan:8443 keeps the user-specified port", async () => { + controllerCtor.mockClear(); + 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(); + 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(); + await createIntegration("https://192.168.1.1:10443").getNetworkSummaryAsync(); + + expect(controllerCtor).toHaveBeenCalledWith( + expect.objectContaining({ host: "192.168.1.1", port: 10443 }), + ); + }); +}); diff --git a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts index bee66c956..4071fb302 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, @@ -62,6 +61,10 @@ 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()), @@ -69,7 +72,7 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo const client = new Unifi.Controller({ host: url.hostname, - port: getPortFromUrl(url), + port, username: this.getSecretValue("username"), password: this.getSecretValue("password"), createAxiosInstance({ cookies }) {