From 0abde1310140c53c9ace252e6aa26cd67540df42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zachary=20Gu=C3=A9not?= Date: Tue, 8 Sep 2026 15:12:58 +0200 Subject: [PATCH] feat(integrations): add UnifiOsIntegration with optional cloud enrichment Introduces UnifiOsIntegration, orchestrating UnifiOsApi (local) and UnifiSmApi (optional cloud) to build the UnifiOsSummary. Local endpoint calls degrade independently via Promise.allSettled; the cloud client is never instantiated without a configured siteManagerApiKey secret. Registers the unifiOs kind in the integration creator, index, and types barrels, resolving the previously-failing typecheck. Co-Authored-By: Claude Opus 5 --- packages/integrations/src/base/creator.ts | 2 + packages/integrations/src/index.ts | 2 + .../unifi-os-summary-integration.ts | 5 + packages/integrations/src/types.ts | 2 + .../test/unifi-os-integration.spec.ts | 197 ++++++++++++++++++ .../src/unifi-os/unifi-os-integration.ts | 115 ++++++++++ 6 files changed, 323 insertions(+) create mode 100644 packages/integrations/src/interfaces/unifi-os-summary/unifi-os-summary-integration.ts create mode 100644 packages/integrations/src/unifi-os/test/unifi-os-integration.spec.ts create mode 100644 packages/integrations/src/unifi-os/unifi-os-integration.ts diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 11cbd1a90..ae02d884b 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -300,6 +300,8 @@ const integrationCreators = { new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input), unifiController: async (input: IntegrationInput) => new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input), + unifiOs: async (input: IntegrationInput) => + new (await import("../unifi-os/unifi-os-integration")).UnifiOsIntegration(input), opnsense: async (input: IntegrationInput) => new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input), ical: async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input), diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 65dfe2a90..79c759db2 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -106,6 +106,8 @@ export type { LlamacppStats, LlamacppModel } from "./llama-cpp/llamacpp-types"; export { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "./anchor/anchor-types"; export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; +export { UnifiOsIntegration } from "./unifi-os/unifi-os-integration"; + // Helpers export { createIntegrationAsync } from "./base/creator"; export { simulateResponseContractAsync } from "./base/response-contract"; diff --git a/packages/integrations/src/interfaces/unifi-os-summary/unifi-os-summary-integration.ts b/packages/integrations/src/interfaces/unifi-os-summary/unifi-os-summary-integration.ts new file mode 100644 index 000000000..009a706c9 --- /dev/null +++ b/packages/integrations/src/interfaces/unifi-os-summary/unifi-os-summary-integration.ts @@ -0,0 +1,5 @@ +import type { UnifiOsSummary } from "../../unifi-os/unifi-os-types"; + +export interface UnifiOsSummaryIntegration { + getUnifiOsSummaryAsync(): Promise; +} diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 4a7d1429e..98ca7a203 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -13,6 +13,8 @@ export * from "./base/searchable-integration"; export * from "./homeassistant/homeassistant-types"; export * from "./proxmox/proxmox-types"; export * from "./unifi-controller/unifi-controller-types"; +export * from "./unifi-os/unifi-os-types"; +export * from "./interfaces/unifi-os-summary/unifi-os-summary-integration"; export * from "./opnsense/opnsense-types"; export * from "./interfaces/media-releases"; export * from "./coolify/coolify-types"; diff --git a/packages/integrations/src/unifi-os/test/unifi-os-integration.spec.ts b/packages/integrations/src/unifi-os/test/unifi-os-integration.spec.ts new file mode 100644 index 000000000..74587e0dc --- /dev/null +++ b/packages/integrations/src/unifi-os/test/unifi-os-integration.spec.ts @@ -0,0 +1,197 @@ +// @vitest-environment node +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationSecret } from "../../base/types"; +import { UnifiSmApi } from "../unifi-sm-api"; +import { UnifiOsIntegration } from "../unifi-os-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const GATEWAY_MAC = "aa:bb:cc:11:22:33"; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never; + +const page = (data: unknown[]) => ({ offset: 0, limit: 200, count: data.length, totalCount: data.length, data }); + +const localRoutes: Record = { + "/info": { applicationVersion: "10.6.102" }, + "/sites": page([{ id: "site-1", name: "Example site", internalReference: "default" }]), + "/sites/site-1/clients": page([ + { id: "c1", name: "a", type: "WIRED", access: { type: "DEFAULT" } }, + { id: "c2", name: "b", type: "WIRELESS", access: { type: "DEFAULT" } }, + ]), + "/sites/site-1/devices": page([ + { + id: "gw", + name: "Gateway", + model: "UXG Pro", + macAddress: GATEWAY_MAC, + state: "ONLINE", + firmwareUpdatable: true, + features: ["gateway"], + }, + { + id: "sw", + name: "Switch", + model: "USW Lite 8", + macAddress: "aa:bb:cc:99:88:77", + state: "OFFLINE", + firmwareUpdatable: false, + features: ["switching"], + }, + ]), + "/sites/site-1/devices/gw/statistics/latest": { + uptimeSec: 1009049, + cpuUtilizationPct: 19.1, + memoryUtilizationPct: 63.1, + uplink: { txRateBps: 14140912, rxRateBps: 3346784 }, + }, + "/sites/site-1/vpn/servers": page([{ id: "v1", name: "vpn", enabled: true }]), +}; + +const routeLocal = (rawUrl: string): Response | null => { + const path = new URL(rawUrl).pathname.replace("/proxy/network/integration/v1", ""); + const body = localRoutes[path]; + return body === undefined ? null : jsonResponse(body); +}; + +const createIntegration = (decryptedSecrets: IntegrationSecret[]) => + new UnifiOsIntegration({ + id: "test-unifi-os", + name: "Test UniFi OS", + url: "https://console.example.test", + externalUrl: null, + decryptedSecrets, + }); + +const localOnlySecrets: IntegrationSecret[] = [{ kind: "apiKey", value: "local-key" }]; +const hybridSecrets: IntegrationSecret[] = [ + { kind: "apiKey", value: "local-key" }, + { kind: "siteManagerApiKey", value: "cloud-key" }, +]; + +beforeEach(() => { + mockFetch.mockReset(); + UnifiSmApi.clearCache(); +}); + +describe("UnifiOsIntegration getUnifiOsSummaryAsync", () => { + test("builds the summary from the local API alone", async () => { + mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404)); + + const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync(); + + expect(summary.site).toEqual({ id: "site-1", name: "Example site", applicationVersion: "10.6.102" }); + expect(summary.clients).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 0 }); + expect(summary.devices).toEqual({ total: 2, online: 1, offline: 1, firmwareUpdatable: 1 }); + expect(summary.wan.state).toBe("online"); + expect(summary.wan.throughput).toEqual({ txBps: 14140912, rxBps: 3346784 }); + expect(summary.gateway).toMatchObject({ name: "Gateway", uptimeSec: 1009049, cpuPct: 19.1 }); + }); + + test("leaves the ISP block null when no Site Manager key is configured", async () => { + mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404)); + + const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync(); + + expect(summary.wan.isp).toBeNull(); + expect(mockFetch.mock.calls.every(([url]) => !String(url).startsWith("https://api.ui.com"))).toBe(true); + }); + + test("fills the ISP block when a Site Manager key is configured", async () => { + mockFetch.mockImplementation(async (url) => { + const raw = String(url); + if (raw.startsWith("https://api.ui.com/v1/sites")) { + return jsonResponse({ + data: [ + { + siteId: "cloud-1", + hostId: "host-1", + meta: { gatewayMac: GATEWAY_MAC }, + statistics: { wans: { WAN: { externalIp: "203.0.113.10" } } }, + }, + ], + }); + } + if (raw.includes("isp-metrics")) { + return jsonResponse({ + data: { + metrics: [ + { + periods: [ + { + metricTime: "2026-09-08T09:10:00Z", + data: { + wan: { avgLatency: 11, maxLatency: 12, packetLoss: 0, uptime: 100, ispName: "Example ISP" }, + }, + }, + ], + }, + ], + }, + }); + } + return routeLocal(raw) ?? jsonResponse({}, 404); + }); + + const summary = await createIntegration(hybridSecrets).getUnifiOsSummaryAsync(); + + expect(summary.wan.isp).toMatchObject({ name: "Example ISP", latencyMs: 11, externalIp: "203.0.113.10" }); + expect(summary.wan.isp?.measuredAt).toEqual(new Date("2026-09-08T09:10:00Z")); + }); + + test("degrades only the affected block when one local endpoint fails", async () => { + mockFetch.mockImplementation(async (url) => { + const raw = String(url); + if (raw.includes("/vpn/servers")) return jsonResponse({ error: "boom" }, 500); + return routeLocal(raw) ?? jsonResponse({}, 404); + }); + + const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync(); + + expect(summary.clients.total).toBe(2); + expect(summary.devices.total).toBe(2); + }); + + test("keeps clients and devices when the gateway cannot be identified", async () => { + mockFetch.mockImplementation(async (url) => { + const raw = String(url); + if (raw.endsWith("/devices?limit=200")) { + return jsonResponse( + page([ + { + id: "sw", + name: "Switch", + model: "USW Lite 8", + macAddress: "aa:bb:cc:99:88:77", + state: "ONLINE", + firmwareUpdatable: false, + features: ["switching"], + }, + ]), + ); + } + return routeLocal(raw) ?? jsonResponse({}, 404); + }); + + const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync(); + + expect(summary.gateway).toBeNull(); + expect(summary.wan.throughput).toBeNull(); + expect(summary.wan.state).toBe("unknown"); + expect(summary.clients.total).toBe(2); + }); +}); diff --git a/packages/integrations/src/unifi-os/unifi-os-integration.ts b/packages/integrations/src/unifi-os/unifi-os-integration.ts new file mode 100644 index 000000000..5e7b8d857 --- /dev/null +++ b/packages/integrations/src/unifi-os/unifi-os-integration.ts @@ -0,0 +1,115 @@ +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { UnifiOsSummaryIntegration } from "../interfaces/unifi-os-summary/unifi-os-summary-integration"; +import type { UnifiOsFetch } from "./unifi-os-api"; +import { UnifiOsApi } from "./unifi-os-api"; +import { UnifiSmApi } from "./unifi-sm-api"; +import type { UnifiOsDevice, UnifiOsDeviceStatistics, UnifiOsSummary } from "./unifi-os-types"; +import { findGateway, summarizeDevices } from "./unifi-os-types"; + +const EMPTY_CLIENT_COUNTS = { total: 0, wired: 0, wireless: 0, vpn: 0, guests: 0 } as const; +const EMPTY_DEVICE_COUNTS = { total: 0, online: 0, offline: 0, firmwareUpdatable: 0 } as const; + +/** `Promise.allSettled` renvoie `rejected` sans valeur ; on retombe sur un défaut. */ +const settledValue = (result: PromiseSettledResult, fallback: T): T => + result.status === "fulfilled" ? result.value : fallback; + +export class UnifiOsIntegration extends Integration implements UnifiOsSummaryIntegration { + public async getUnifiOsSummaryAsync(): Promise { + return await this.buildSummaryAsync(fetchWithTrustedCertificatesAsync as unknown as UnifiOsFetch); + } + + protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise { + const api = this.createLocalApi(fetchAsync as unknown as UnifiOsFetch); + + try { + await api.getInfoAsync(); + } catch (error) { + return TestConnectionError.UnknownResult(error); + } + + const site = await api.getFirstSiteAsync(); + if (site === null) { + // Une clé valide sur une console sans site est configurable mais inutile : + // mieux vaut le dire au moment du test que laisser un widget vide. + return TestConnectionError.UnknownResult(new Error("The UniFi OS console exposes no site")); + } + + return { success: true }; + } + + private createLocalApi(fetchAsync: UnifiOsFetch) { + return new UnifiOsApi(this.integration.url, this.getSecretValue("apiKey"), fetchAsync); + } + + private async buildSummaryAsync(fetchAsync: UnifiOsFetch): Promise { + const api = this.createLocalApi(fetchAsync); + + const [info, site] = await Promise.all([api.getInfoAsync(), api.getFirstSiteAsync()]); + if (site === null) { + throw new Error("The UniFi OS console exposes no site"); + } + + const [clientsResult, devicesResult] = await Promise.allSettled([ + api.getClientCountsAsync(site.id), + api.getDevicesAsync(site.id), + ]); + + const clients = settledValue(clientsResult, { ...EMPTY_CLIENT_COUNTS }); + const devices = settledValue(devicesResult, []); + const gatewayDevice = findGateway(devices); + + // Les statistiques dépendent du gateway : sans gateway identifié, il n'y a + // rien à demander. Un échec ici ne doit vider que le débit et l'uptime. + let statistics: UnifiOsDeviceStatistics | null = null; + if (gatewayDevice !== null) { + try { + statistics = await api.getDeviceStatisticsAsync(site.id, gatewayDevice.id); + } catch { + statistics = null; + } + } + + const isp = await this.readIspMetricsAsync(fetchAsync, gatewayDevice?.macAddress ?? null); + + return { + site: { id: site.id, name: site.name, applicationVersion: info.applicationVersion }, + wan: { + state: gatewayDevice === null ? "unknown" : gatewayDevice.state === "ONLINE" ? "online" : "offline", + throughput: + statistics?.uplink === undefined + ? null + : { txBps: statistics.uplink.txRateBps, rxBps: statistics.uplink.rxRateBps }, + isp, + }, + clients, + devices: devicesResult.status === "fulfilled" ? summarizeDevices(devices) : { ...EMPTY_DEVICE_COUNTS }, + gateway: + gatewayDevice === null + ? null + : { + name: gatewayDevice.name, + model: gatewayDevice.model, + macAddress: gatewayDevice.macAddress, + uptimeSec: statistics?.uptimeSec ?? null, + cpuPct: statistics?.cpuUtilizationPct ?? null, + memoryPct: statistics?.memoryUtilizationPct ?? null, + }, + }; + } + + /** + * Le cloud n'est jamais interrogé sans clé configurée : le module n'est même + * pas instancié, ce qui rend impossible un appel réseau accidentel. + */ + private async readIspMetricsAsync(fetchAsync: UnifiOsFetch, gatewayMac: string | null) { + if (!this.hasSecretValue("siteManagerApiKey")) return null; + + const smApi = new UnifiSmApi(this.getSecretValue("siteManagerApiKey"), fetchAsync); + return await smApi.getIspMetricsAsync(gatewayMac); + } +}