Compare commits

..
Author SHA1 Message Date
ajnart 4ab9f25274 fix(integrations): gate mock integration server-side 2026-09-06 09:47:35 +02:00
9 changed files with 65 additions and 208 deletions

No files matched your search

+2 -2
View File
@@ -58,8 +58,8 @@ TURBO_TELEMETRY_DISABLED=1
# Enable kubernetes tool
# ENABLE_KUBERNETES=true
# Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Enable the development-only mock integration. Never enable this in production.
# UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Public URL contract. Values must be absolute HTTP(S) URLs without credentials,
# query strings, or fragments. Trailing slashes are normalized at startup.
@@ -133,9 +133,10 @@ External redis is currently not supported with Proxmox Community Scripts
The advanced deployments environment variables should only be used if you know what you are doing.
:::
| Environment Variable | Description |
| ------------------------ | --------------------------------------------------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
| Environment Variable | Description | Default |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------- |
| `DB_MIGRATIONS_DISABLED` | Disable database migrations. For example, when migrations are managed separately in Helm deployments. | `false` |
| `UNSAFE_ENABLE_MOCK_INTEGRATION` | Enable the development-only Mock integration. This exposes fabricated data and must not be used in production. | `false` |
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts.
@@ -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.
Therefore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro controllers as well as self-hosted UniFi controllers.
Therfore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro controllers as well as self-hosted UniFi controllers.
### Widgets & Capabilities
@@ -28,14 +28,6 @@ Therefore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro
<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
@@ -47,6 +47,9 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
const tCreate = await getI18n("integration.page.create");
const currentKind = result.data;
if (currentKind === "mock" && !env.UNSAFE_ENABLE_MOCK_INTEGRATION) {
notFound();
}
return (
<>
+2
View File
@@ -19,6 +19,7 @@ const publicHttpUrl = (variableName: string) =>
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
@@ -27,6 +28,7 @@ export const env = createEnv({
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
UNSAFE_ENABLE_MOCK_INTEGRATION: process.env.UNSAFE_ENABLE_MOCK_INTEGRATION,
DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
@@ -18,7 +18,12 @@ import {
integrationSecrets,
integrationUserPermissions,
} from "@homarr/db/schema";
import type { GroupPermissionKey, IntegrationPermission, IntegrationSecretKind } from "@homarr/definitions";
import type {
GroupPermissionKey,
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
} from "@homarr/definitions";
import {
getIntegrationKindsByCategory,
getPermissionsWithParents,
@@ -39,6 +44,7 @@ import {
} from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { env } from "../../env";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
@@ -48,6 +54,15 @@ import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
const throwIfMockIntegrationIsDisabled = (kind: IntegrationKind) => {
if (kind !== "mock" || env.UNSAFE_ENABLE_MOCK_INTEGRATION) return;
throw new TRPCError({
code: "FORBIDDEN",
message: "Mock integrations are disabled",
});
};
export const integrationRouter = createTRPCRouter({
getKinds: publicProcedure
.meta({
@@ -58,12 +73,14 @@ export const integrationRouter = createTRPCRouter({
},
})
.query(() => {
return objectEntries(integrationDefs).map(([kind, def]) => ({
kind,
name: def.name,
category: def.category,
requiredSecrets: def.secretKinds,
}));
return objectEntries(integrationDefs)
.filter(([kind]) => kind !== "mock" || env.UNSAFE_ENABLE_MOCK_INTEGRATION)
.map(([kind, def]) => ({
kind,
name: def.name,
category: def.category,
requiredSecrets: def.secretKinds,
}));
}),
all: protectedProcedure
.meta({
@@ -229,6 +246,8 @@ export const integrationRouter = createTRPCRouter({
})
.input(integrationCreateSchema)
.mutation(async ({ ctx, input }) => {
throwIfMockIntegrationIsDisabled(input.kind);
logger.info("Creating integration", {
name: input.name,
kind: input.kind,
@@ -321,6 +321,13 @@ export const onboardRouter = createTRPCRouter({
.requiresStep("setup")
.input(onboardingCompleteSetupSchema)
.mutation(async ({ ctx, input }) => {
if (
!env.UNSAFE_ENABLE_MOCK_INTEGRATION &&
input.integrations.some((integration) => integration.kind === "mock")
) {
throw new TRPCError({ code: "FORBIDDEN", message: "Mock integrations are disabled" });
}
const boardList = await ctx.db.query.boards.findMany({
with: {
sections: { with: { layouts: true } },
@@ -1,141 +0,0 @@
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,6 +2,7 @@ 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,
@@ -66,53 +67,26 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
};
// 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,
}),
});
},
});
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,
}),
});
},
});
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";
await client.login(this.getSecretValue("username"), this.getSecretValue("password"), null);
return client;
}
private getStatusValueOverAllSites<S extends HealthSubsystem>(