diff --git a/apps/docs/docs/integrations/unifi-controller/index.mdx b/apps/docs/docs/integrations/unifi-controller/index.mdx
index f25cecaac..62e1ca393 100644
--- a/apps/docs/docs/integrations/unifi-controller/index.mdx
+++ b/apps/docs/docs/integrations/unifi-controller/index.mdx
@@ -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
{
});
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(