From c1dd2b3bcad32e2a7fc67168bd45d06f43b47a3e Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 3 Oct 2026 23:14:37 +0200 Subject: [PATCH] fix(unifi): count active remote VPN sessions (#7013) --- .../integrations/unifi-controller/index.mdx | 6 + apps/docs/public/api/open-api-schema.json | 128 ++++++++++++++++++ .../network-controller-summary-types.ts | 2 +- .../unifi-controller-integration.ts | 47 ++++++- .../network-controller/summary/component.tsx | 17 +-- .../src/network-controller/summary/display.ts | 2 +- 6 files changed, 190 insertions(+), 12 deletions(-) diff --git a/apps/docs/docs/integrations/unifi-controller/index.mdx b/apps/docs/docs/integrations/unifi-controller/index.mdx index 7d9666cb5..7a1a8869b 100644 --- a/apps/docs/docs/integrations/unifi-controller/index.mdx +++ b/apps/docs/docs/integrations/unifi-controller/index.mdx @@ -24,6 +24,12 @@ Therefore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro items={[{ widget: networkControllerSummaryWidget }, { widget: networkControllerStatusWidget }]} /> +VPN users are counted from active remote VPN sessions across all sites. Controllers that return HTTP 404 for +`stat/remoteuservpn` use the legacy health counter instead; a successful empty session list counts as zero. +This avoids the stale L2TP health counter on UniFi Network 10.6.106. VPN types absent from that endpoint cannot be +counted. If a site's session request fails or returns an invalid response, the VPN user total is shown as unknown +while the other network metrics remain available. VPN status still uses the controller's health subsystem. + ### Adding the integration diff --git a/apps/docs/public/api/open-api-schema.json b/apps/docs/public/api/open-api-schema.json index d6d5ce027..30129299f 100644 --- a/apps/docs/public/api/open-api-schema.json +++ b/apps/docs/public/api/open-api-schema.json @@ -5923,6 +5923,134 @@ } } } + }, + "/api/integrations/{id}/test-connection": { + "get": { + "operationId": "integration-testConnection", + "summary": "Test a saved integration using stored credentials", + "description": "Requires full integration access. Runs the native connection check without changing saved configuration. Upstream authentication may establish a session. Success is a connection check, not widget or service acceptance. Failures expose only a safe category, not upstream errors or credentials.", + "tags": [ + "integrations" + ], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": true + } + }, + "required": [ + "success" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": false + }, + "error": { + "type": "string", + "enum": [ + "missingSecret", + "authorization", + "certificate", + "request", + "statusCode", + "parse", + "unknown" + ] + } + }, + "required": [ + "success", + "error" + ], + "additionalProperties": false + } + ], + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } } }, "components": { diff --git a/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts b/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts index 587b5470d..fb2a30ac9 100644 --- a/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts +++ b/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts @@ -22,6 +22,6 @@ export interface NetworkControllerSummary { vpn: { status: "enabled" | "disabled"; - users: number; + users: number | null; }; } diff --git a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts index 2a5be3b8c..20c23ca0d 100644 --- a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts +++ b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts @@ -1,6 +1,7 @@ import type tls from "node:tls"; -import axios from "axios"; +import axios, { isAxiosError } from "axios"; import { HttpCookieAgent, HttpsCookieAgent } from "http-cookie-agent/http"; +import { z } from "zod/v4"; import { getAllTrustedCertificatesAsync, @@ -11,6 +12,7 @@ import { fetchWithTrustedCertificatesAsync, getHttpRequestSignal, } from "@homarr/core/infrastructure/http"; +import { createLogger } from "@homarr/core/infrastructure/logs"; import type { SiteStats } from "@homarr/node-unifi"; import Unifi from "@homarr/node-unifi"; @@ -25,6 +27,10 @@ import type { NetworkControllerSummaryIntegration } from "../interfaces/network- import type { NetworkControllerSummary } from "../interfaces/network-controller-summary/network-controller-summary-types"; import type { HealthSubsystem } from "./unifi-controller-types"; +const logger = createLogger({ module: "unifi-controller-integration" }); + +const remoteVpnSessionsSchema = z.array(z.object({ up: z.boolean() })); + @HandleIntegrationErrors([integrationAxiosHttpErrorHandler]) export class UnifiControllerIntegration extends Integration implements NetworkControllerSummaryIntegration { private readonly httpSessionStore = createSessionStore<{ cookie: string; csrfToken?: string }>(this.integration); @@ -45,6 +51,43 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo public async getNetworkSummaryAsync(): Promise { const client = await this.createControllerClientAsync(); const stats = await client.getSitesStats(); + const vpnUserCounts = await Promise.all( + stats.map(async (site) => { + try { + const sessions: unknown = await client.customApiRequest( + `/api/s/${encodeURIComponent(site.name)}/stat/remoteuservpn`, + ); + return remoteVpnSessionsSchema.parse(sessions).filter((session) => session.up).length; + } catch (error) { + // Older controllers may not expose remote sessions. An empty successful + // response is authoritative; only an unsupported endpoint uses health. + if (isAxiosError(error) && error.response?.status === 404) { + return this.getNumericValueOverAllSites([site], "vpn", (vpn) => vpn.remote_user_num_active, "sum"); + } + let status: number | undefined; + if (isAxiosError(error)) status = error.response?.status; + let reason = "request-failed"; + if (error instanceof z.ZodError) reason = "invalid-response"; + logger.warn("UniFi remote VPN session count unavailable", { + integrationId: this.integration.id, + site: site.name, + status, + reason, + }); + return null; + } + }), + ); + + // A partial site total cannot describe the controller's connected users. + let vpnUsers: number | null = 0; + for (const count of vpnUserCounts) { + if (count === null) { + vpnUsers = null; + break; + } + vpnUsers += count; + } return { wanStatus: this.getStatusValueOverAllSites(stats, "wan", (site) => site.status === "ok"), @@ -66,7 +109,7 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo }, vpn: { status: this.getStatusValueOverAllSites(stats, "vpn", (site) => site.status === "ok"), - users: this.getNumericValueOverAllSites(stats, "vpn", (site) => site.remote_user_num_active, "sum"), + users: vpnUsers, }, } satisfies NetworkControllerSummary; } diff --git a/packages/widgets/src/network-controller/summary/component.tsx b/packages/widgets/src/network-controller/summary/component.tsx index d0e8b3a3d..377508e49 100644 --- a/packages/widgets/src/network-controller/summary/component.tsx +++ b/packages/widgets/src/network-controller/summary/component.tsx @@ -66,6 +66,10 @@ export default function NetworkControllerSummaryWidget({ ); const firstSummary = summaries[0]; + const formatVpnCount = (count: number | null) => { + if (count === null) return statusLabels.unknown; + return t("card.vpn.countConnected", { count }); + }; let responsiveWidth = width; let responsiveHeight = height; @@ -154,15 +158,11 @@ export default function NetworkControllerSummaryWidget({ /> } > - + {t("card.vpn.label")} {showSecondary && ( - {t("card.vpn.countConnected", { count: summary.vpn.users })} + {formatVpnCount(summary.vpn.users)} )} @@ -234,7 +234,7 @@ export default function NetworkControllerSummaryWidget({ {getMatrixMetricLabel(metric.key, t)} - {formatMatrixMetric(metric.key, metric.value)} + {formatMatrixMetric(metric.key, metric.value, statusLabels.unknown)} ))} @@ -257,7 +257,8 @@ const getMatrixMetricLabel = ( t: ScopedTranslationFunction<"widget.networkControllerSummary">, ) => t(`advanced.metric.${key}` as never); -const formatMatrixMetric = (key: NetworkControllerMatrixMetricKey, value: number) => { +const formatMatrixMetric = (key: NetworkControllerMatrixMetricKey, value: number | null, unknown: string) => { + if (value === null) return unknown; if (key === "latency" || key === "ping") return `${value}ms`; if (key === "uptime") return dayjs.duration(value, "seconds").humanize(); return String(value); diff --git a/packages/widgets/src/network-controller/summary/display.ts b/packages/widgets/src/network-controller/summary/display.ts index 11cb28c29..035490f50 100644 --- a/packages/widgets/src/network-controller/summary/display.ts +++ b/packages/widgets/src/network-controller/summary/display.ts @@ -6,7 +6,7 @@ export type NetworkControllerMatrixMetricKey = "latency" | "ping" | "uptime" | " export interface NetworkControllerMatrixSection { key: NetworkControllerMatrixSectionKey; status: "enabled" | "disabled"; - metrics: { key: NetworkControllerMatrixMetricKey; value: number }[]; + metrics: { key: NetworkControllerMatrixMetricKey; value: number | null }[]; } export const getNetworkControllerMatrix = (summary: NetworkControllerSummary): NetworkControllerMatrixSection[] => [