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(