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
9 changed files with 218 additions and 179 deletions

No files matched your search

@@ -202,9 +202,9 @@ Homarr supports multiple authentication options, from internal userbase (credent
| ``AUTH_OIDC_CLIENT_NAME`` | Display name of provider (in login screen) | OIDC |
| ``AUTH_OIDC_AUTO_LOGIN`` | Automatically redirect to OIDC login | false |
| ``AUTH_OIDC_SCOPE_OVERWRITE`` | Overwrite default scopes (openid, profile, email) | openid email profile groups |
| ``AUTH_OIDC_GROUPS_ATTRIBUTE`` | Attribute used for groups (roles) claim. Dot-separated paths such as `resource_access.homarr.roles` are supported. | groups |
| ``AUTH_OIDC_GROUPS_ATTRIBUTE`` | Attribute used for groups (roles) claim | groups |
| ``AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT`` | Stop syncing group memberships of OIDC users from the groups claim and manage them locally via the group members page instead | false |
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. Dot-separated paths are supported. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
@@ -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
+10 -5
View File
@@ -9,7 +9,7 @@ import { groupMembers, groups, users } from "@homarr/db/schema";
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { env } from "./env";
import { extractProfileName, getProfileValueByPath } from "./providers/oidc/profile";
import { extractProfileName } from "./providers/oidc/oidc-provider";
const logger = createLogger({ module: "authEvents" });
@@ -31,11 +31,16 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
if (!dbUser) throw new Error("User not found");
const groupsKey = env.AUTH_OIDC_GROUPS_ATTRIBUTE;
const profileGroups = profile ? getProfileValueByPath(profile, groupsKey) : undefined;
// Groups from oidc provider are provided from the profile, it's not typed.
if (!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT && dbUser.provider === "oidc" && Array.isArray(profileGroups)) {
logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profileGroups)}`);
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profileGroups as string[]);
if (
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
dbUser.provider === "oidc" &&
profile &&
groupsKey in profile &&
Array.isArray(profile[groupsKey])
) {
logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profile[groupsKey])}`);
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]);
}
// In ldap-authroization we return the groups from ldap, it's not typed.
@@ -7,7 +7,6 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { env } from "../../env";
import { createRedirectUri } from "../../redirect";
import { extractProfileName } from "./profile";
import { assertVerifiedEmailForCredentialsLinking } from "./verified-email";
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
@@ -77,3 +76,12 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
// @ts-expect-error `undici` has a `duplex` option
[customFetch]: fetchWithTrustedCertificatesAsync,
});
export const extractProfileName = (profile: Profile) => {
if (!env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE) {
// Use the name as the username if the preferred_username is an email address
return profile.preferred_username?.includes("@") ? profile.name : profile.preferred_username;
}
return profile[env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE as keyof typeof profile] as string;
};
-23
View File
@@ -1,23 +0,0 @@
import type { Profile } from "@auth/core/types";
import { env } from "../../env";
export const getProfileValueByPath = (profile: Profile, path: string): unknown => {
return path.split(".").reduce<unknown>((value, key) => {
if (typeof value !== "object" || value === null || !Object.hasOwn(value, key)) {
return undefined;
}
return (value as Record<string, unknown>)[key];
}, profile);
};
export const extractProfileName = (profile: Profile): string | undefined => {
if (!env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE) {
// Use the name as the username if the preferred_username is an email address
return (profile.preferred_username?.includes("@") ? profile.name : profile.preferred_username) ?? undefined;
}
const profileName = getProfileValueByPath(profile, env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE);
return typeof profileName === "string" ? profileName : undefined;
};
@@ -1,96 +0,0 @@
import type { Profile } from "@auth/core/types";
import { afterEach, describe, expect, test, vi } from "vitest";
const mockEnv = vi.hoisted(() => ({
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: undefined as string | undefined,
}));
vi.mock("../../../env", () => ({ env: mockEnv }));
import { extractProfileName, getProfileValueByPath } from "../profile";
afterEach(() => {
mockEnv.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE = undefined;
});
describe("getProfileValueByPath", () => {
const profile = {
sub: "user-id",
resource_access: {
homarr: {
roles: ["admins"],
},
},
} satisfies Profile;
test("returns a top-level claim", () => {
expect(getProfileValueByPath(profile, "sub")).toBe("user-id");
});
test("returns a nested claim", () => {
expect(getProfileValueByPath(profile, "resource_access.homarr.roles")).toEqual(["admins"]);
});
test("returns undefined when the path does not exist", () => {
expect(getProfileValueByPath(profile, "resource_access.missing.roles")).toBeUndefined();
});
});
describe("extractProfileName", () => {
test("uses preferred_username when overwrite is not set", () => {
const profile = {
sub: "user-id",
preferred_username: "johndoe",
} satisfies Profile;
expect(extractProfileName(profile)).toBe("johndoe");
});
test("uses name when preferred_username is an email", () => {
const profile = {
sub: "user-id",
preferred_username: "john@example.com",
name: "John Doe",
} satisfies Profile;
expect(extractProfileName(profile)).toBe("John Doe");
});
test("returns undefined when the fallback name is null", () => {
const profile = {
sub: "user-id",
preferred_username: "john@example.com",
name: null,
} satisfies Profile;
expect(extractProfileName(profile)).toBeUndefined();
});
test("returns undefined when preferred_username is missing", () => {
const profile = {
sub: "user-id",
} satisfies Profile;
expect(extractProfileName(profile)).toBeUndefined();
});
test("uses a nested name claim when configured", () => {
mockEnv.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE = "user.profile.display_name";
const profile = {
sub: "user-id",
user: { profile: { display_name: "Nested User" } },
} satisfies Profile;
expect(extractProfileName(profile)).toBe("Nested User");
});
test("returns undefined when the configured claim is not a string", () => {
mockEnv.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE = "user.profile";
const profile = {
sub: "user-id",
user: { profile: { display_name: "Nested User" } },
} satisfies Profile;
expect(extractProfileName(profile)).toBeUndefined();
});
});
+1 -31
View File
@@ -1,7 +1,7 @@
import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies";
import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
import { cookies } from "next/headers";
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
@@ -21,12 +21,6 @@ vi.mock("../env", () => {
env: mockEnv,
};
});
afterEach(() => {
mockEnv.AUTH_OIDC_GROUPS_ATTRIBUTE = "someRandomGroupsKey";
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = false;
});
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
type HeadersExport = typeof import("next/headers");
vi.mock("next/headers", async (importOriginal) => {
@@ -158,30 +152,6 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
expect(dbGroupMembers?.groupId).toBe("1");
});
test("should add missing group membership from a nested claim", async () => {
// Arrange
mockEnv.AUTH_OIDC_GROUPS_ATTRIBUTE = "resource_access.homarr.roles";
const db = createDb();
await createUserAsync(db, "oidc");
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: {
preferred_username: "test",
resource_access: { homarr: { roles: ["test"] } },
},
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
test("should remove group membership", async () => {
// Arrange
const db = createDb();
@@ -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>(