Compare commits

..
Author SHA1 Message Date
ajnart baf22aa01e docs(unifi): resolve review leftovers 2026-09-06 11:07:35 +02:00
ajnart 6c50005385 fix(integrations): support UniFi controller default ports 2026-09-06 11:06:59 +02:00
ajnart 67d2c7dacd style: auto-fix lint and format 2026-09-06 11:06:59 +02:00
ajnart 59ee4202e5 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.
2026-09-06 11:06:59 +02:00
4 changed files with 196 additions and 95 deletions

No files matched your search

@@ -1,74 +0,0 @@
---
title: Upgrade from v1 to v2
sidebar_position: 2
tags:
- Getting started
- Upgrade
- Migration
- Backup
---
Homarr v2 upgrades the existing v1 database in place. Keep the same data storage, database configuration, and
`SECRET_ENCRYPTION_KEY`; only change the Homarr image or package version for the first start.
:::danger Back up before starting v2
In Homarr v1, go to **Management → Tools → Backup**, export a backup, and keep the downloaded file somewhere safe
before starting v2. See [Backup and restore](/docs/management/backup) for details.
:::
## Upgrade
1. Pin the Homarr image to `ghcr.io/homarr-labs/homarr:v2.0.0` for the first v2 start.
2. Keep the existing `/appdata` mount, database variables, Redis configuration, and `SECRET_ENCRYPTION_KEY` unchanged.
3. Start one Homarr replica and follow its logs. Startup must reach `Migration complete`; Homarr aborts startup when a
database migration fails.
4. Open Homarr and sign in. Confirm **Management → About** reports v2 before starting additional replicas.
5. After validation, keep the version pinned or return to your normal stable release policy.
For Docker Compose, the change is limited to the image tag before the usual update:
```yaml
services:
homarr:
image: ghcr.io/homarr-labs/homarr:v2.0.0
```
```sh
docker compose pull
docker compose up -d
docker compose logs --follow homarr
```
Do not add `-v` when stopping the Compose deployment; that can remove named volumes.
## What v2 migrates
The startup migration:
- creates the Custom Widget v2, Assistant, header preference, and branding storage;
- adds Base/Mobile layout roles and left/right board gutters;
- converts legacy Categories and dynamic sections into Containers and rails;
- preserves legacy Custom Widget definitions, encrypted secrets, and board references for explicit migration.
Legacy Custom Widgets are not silently rewritten. In **Management → Custom Widgets**, entries marked
**Migration required** provide a credential-free migration prompt. Review and test the resulting v2 definition before
removing the archived v1 version. See [Custom Widgets: Import and Workshop](/docs/management/custom-widgets#import-and-workshop).
## Verify the upgraded instance
- Sign in with each enabled authentication method and verify administrator access.
- Open every important board on desktop and mobile. Check Containers, former Categories, rails, item placement, and
home-board assignments.
- Test at least one read and one permitted action for each critical integration. Recheck integration and board access
for representative Viewer, Editor, and Admin groups.
- Check Docker or Podman endpoints, search engines, uploaded media, trusted certificates, scheduled tasks, WebSockets,
and external Redis when used.
- Review every legacy Custom Widget and migrate it only after its v2 preview and requests work.
- Restart Homarr once more and confirm the logs contain no migration, database, Redis, authentication, or task errors.
## Roll back
Use the backup exported before the upgrade if you need to restore your v1 installation. Do not reuse a database that
v2 has already migrated with v1.
@@ -16,7 +16,7 @@ import { networkControllerStatusWidget } from "@site/docs/widgets/network-contro
<IntegrationHeader integration={unifiControllerIntegration} categories={["Network controller"]} />
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
<AddingIntegration />
Use the local HTTPS address of your controller:
- UniFi OS consoles such as Dream Router, Dream Machine, Cloud Gateway, and CloudKey use `https://<host>` (port 443).
- Self-hosted UniFi Network Servers normally use `https://<host>: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
<IntegrationSecrets
@@ -0,0 +1,141 @@
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();
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<string, unknown>;
constructor(options: Record<string, unknown>) {
this.options = options;
controllerCtor(options);
}
public async login(): Promise<true> {
return await controllerLogin(this.options.port);
}
public async getSitesStats(): Promise<unknown[]> {
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 }));
});
});
@@ -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<S extends HealthSubsystem>(