Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baf22aa01e | ||
|
|
6c50005385 | ||
|
|
67d2c7dacd | ||
|
|
59ee4202e5 |
No files matched your search
@@ -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>(
|
||||
|
||||
Generated
+23
-23
@@ -25,11 +25,11 @@ catalogs:
|
||||
specifier: 0.14.9
|
||||
version: 0.14.9
|
||||
'@auth/core':
|
||||
specifier: ^0.41.3
|
||||
version: 0.41.3
|
||||
specifier: ^0.41.2
|
||||
version: 0.41.2
|
||||
'@auth/drizzle-adapter':
|
||||
specifier: ^1.11.3
|
||||
version: 1.11.3
|
||||
specifier: ^1.11.2
|
||||
version: 1.11.2
|
||||
'@babel/parser':
|
||||
specifier: ^7.29.7
|
||||
version: 7.29.7
|
||||
@@ -520,8 +520,8 @@ catalogs:
|
||||
specifier: 16.3.1
|
||||
version: 16.3.1
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.32
|
||||
version: 5.0.0-beta.32
|
||||
specifier: 5.0.0-beta.31
|
||||
version: 5.0.0-beta.31
|
||||
next-intl:
|
||||
specifier: 4.13.1
|
||||
version: 4.13.1
|
||||
@@ -1587,10 +1587,10 @@ importers:
|
||||
dependencies:
|
||||
'@auth/core':
|
||||
specifier: 'catalog:'
|
||||
version: 0.41.3
|
||||
version: 0.41.2
|
||||
'@auth/drizzle-adapter':
|
||||
specifier: 'catalog:'
|
||||
version: 1.11.3
|
||||
version: 1.11.2
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
@@ -1620,7 +1620,7 @@ importers:
|
||||
version: 16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
|
||||
next-auth:
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.0-beta.32(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
|
||||
version: 5.0.0-beta.31(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.7
|
||||
@@ -1989,7 +1989,7 @@ importers:
|
||||
dependencies:
|
||||
'@auth/core':
|
||||
specifier: 'catalog:'
|
||||
version: 0.41.3
|
||||
version: 0.41.2
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
@@ -3346,12 +3346,12 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@auth/core@0.41.3':
|
||||
resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==}
|
||||
'@auth/core@0.41.2':
|
||||
resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==}
|
||||
peerDependencies:
|
||||
'@simplewebauthn/browser': ^9.0.1
|
||||
'@simplewebauthn/server': ^9.0.2
|
||||
nodemailer: ^7.0.7 || ^8.0.5
|
||||
nodemailer: ^7.0.7
|
||||
peerDependenciesMeta:
|
||||
'@simplewebauthn/browser':
|
||||
optional: true
|
||||
@@ -3360,8 +3360,8 @@ packages:
|
||||
nodemailer:
|
||||
optional: true
|
||||
|
||||
'@auth/drizzle-adapter@1.11.3':
|
||||
resolution: {integrity: sha512-TxqVasPVuf7LDAT1Yuu6bftpuet8o0tjdXW+mXpnXWdSRPQsomdzMoz9RsXkFN/JfdK+/DwdgOEmOTv1gbiFNw==}
|
||||
'@auth/drizzle-adapter@1.11.2':
|
||||
resolution: {integrity: sha512-VOuj7REI8jfJjpSbsYwDM/Zrn55T6lS9Yc+29V+EXMcel8eqG7x+7LodNfd1WHjakfkLYi+qsbYUHB3E6aDA4w==}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
@@ -13685,13 +13685,13 @@ packages:
|
||||
neverpanic@0.0.8:
|
||||
resolution: {integrity: sha512-vVdkelrLxaow/fdWDumzNBO+jwm6X8bxeLJc34THtpj70u0C5QBkcV6CRCu2X726km7XD45N0A3QtYCla4RvKw==}
|
||||
|
||||
next-auth@5.0.0-beta.32:
|
||||
resolution: {integrity: sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==}
|
||||
next-auth@5.0.0-beta.31:
|
||||
resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
|
||||
peerDependencies:
|
||||
'@simplewebauthn/browser': ^9.0.1
|
||||
'@simplewebauthn/server': ^9.0.2
|
||||
next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
|
||||
nodemailer: ^7.0.7 || ^8.0.5
|
||||
nodemailer: ^7.0.7
|
||||
react: ^18.2.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@simplewebauthn/browser':
|
||||
@@ -17439,7 +17439,7 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
react: 19.2.7
|
||||
|
||||
'@auth/core@0.41.3':
|
||||
'@auth/core@0.41.2':
|
||||
dependencies:
|
||||
'@panva/hkdf': 1.2.1
|
||||
jose: 6.2.3
|
||||
@@ -17447,9 +17447,9 @@ snapshots:
|
||||
preact: 10.24.3
|
||||
preact-render-to-string: 6.5.11(preact@10.24.3)
|
||||
|
||||
'@auth/drizzle-adapter@1.11.3':
|
||||
'@auth/drizzle-adapter@1.11.2':
|
||||
dependencies:
|
||||
'@auth/core': 0.41.3
|
||||
'@auth/core': 0.41.2
|
||||
transitivePeerDependencies:
|
||||
- '@simplewebauthn/browser'
|
||||
- '@simplewebauthn/server'
|
||||
@@ -30001,9 +30001,9 @@ snapshots:
|
||||
|
||||
neverpanic@0.0.8: {}
|
||||
|
||||
next-auth@5.0.0-beta.32(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7):
|
||||
next-auth@5.0.0-beta.31(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7):
|
||||
dependencies:
|
||||
'@auth/core': 0.41.3
|
||||
'@auth/core': 0.41.2
|
||||
next: 16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
|
||||
react: 19.2.7
|
||||
|
||||
|
||||
+3
-3
@@ -14,8 +14,8 @@ catalog:
|
||||
"@codemirror/state": ^6.6.0
|
||||
"@codemirror/theme-one-dark": ^6.1.3
|
||||
"@codemirror/view": ^6.43.0
|
||||
"@auth/core": ^0.41.3
|
||||
"@auth/drizzle-adapter": ^1.11.3
|
||||
"@auth/core": ^0.41.2
|
||||
"@auth/drizzle-adapter": ^1.11.2
|
||||
"@ctrl/deluge": ^8.0.0
|
||||
"@ctrl/qbittorrent": ^9.14.0
|
||||
"@ctrl/transmission": ^7.4.0
|
||||
@@ -174,7 +174,7 @@ catalog:
|
||||
maria2: ^0.4.1
|
||||
mysql2: 3.22.6
|
||||
next: 16.3.1
|
||||
next-auth: 5.0.0-beta.32
|
||||
next-auth: 5.0.0-beta.31
|
||||
next-intl: 4.13.1
|
||||
next-themes: ^0.4.6
|
||||
node-loader: ^2.1.0
|
||||
|
||||
Reference in new issue
Block a user