fix(unifi): count active remote VPN sessions (#7013)

This commit is contained in:
Thomas authored and GitHub committed 2026-10-03 23:14:37 +02:00
1 parent 83ae98847a
commit c1dd2b3bca
6 files changed
+190 -12

No files matched your search

@@ -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
<AddingIntegration />
+128
View File
@@ -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": {
@@ -22,6 +22,6 @@ export interface NetworkControllerSummary {
vpn: {
status: "enabled" | "disabled";
users: number;
users: number | null;
};
}
@@ -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<NetworkControllerSummary> {
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;
}
@@ -66,6 +66,10 @@ export default function NetworkControllerSummaryWidget({
</Group>
);
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({
/>
}
>
<Text
title={t("card.vpn.countConnected", { count: summary.vpn.users })}
truncate="end"
style={{ minWidth: 0 }}
>
<Text title={formatVpnCount(summary.vpn.users)} truncate="end" style={{ minWidth: 0 }}>
{t("card.vpn.label")}
{showSecondary && (
<Text c="dimmed" size={secondaryTextSize} ms="xs" span>
{t("card.vpn.countConnected", { count: summary.vpn.users })}
{formatVpnCount(summary.vpn.users)}
</Text>
)}
</Text>
@@ -234,7 +234,7 @@ export default function NetworkControllerSummaryWidget({
{getMatrixMetricLabel(metric.key, t)}
</Text>
<Text size="xs" fw={500}>
{formatMatrixMetric(metric.key, metric.value)}
{formatMatrixMetric(metric.key, metric.value, statusLabels.unknown)}
</Text>
</Stack>
))}
@@ -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);
@@ -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[] => [