fix(unifi): count active remote VPN sessions (#7013)
This commit is contained in:
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 />
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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[] => [
|
||||
|
||||
Reference in new issue
Block a user