diff --git a/CODEOWNERS b/CODEOWNERS
index b798cfada..b3e2214ac 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1,6 +1,11 @@
# ── CODEOWNERS ───────────────────────────────────────
* @homarr-labs/maintainers
+# Specific features / integrations
+# Glueten
+apps/docs/docs/integrations/glueten @Lok3sh-kumar
+packages/integrations/src/gluetun @Lok3sh-kumar
+
# Exempt Renovate‑managed files (no owners)
package.json
**/package.json
diff --git a/apps/docs/docs/integrations/gluetun/index.mdx b/apps/docs/docs/integrations/gluetun/index.mdx
new file mode 100644
index 000000000..7c936fc1f
--- /dev/null
+++ b/apps/docs/docs/integrations/gluetun/index.mdx
@@ -0,0 +1,52 @@
+---
+title: 'Gluetun'
+description: 'Connect Gluetun to Homarr as a VPN integration.'
+hide_title: true
+---
+
+import { IntegrationHeader } from '@site/src/components/integrations/header';
+import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
+import { AddingIntegration } from '@site/src/components/integrations/adding';
+import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
+import { gluetunIntegration } from '.';
+import { vpnWidget } from '@site/docs/widgets/vpn';
+
+
+
+[Gluetun](https://github.com/qdm12/gluetun) exposes a control server that Homarr can query to report the VPN tunnel
+status, DNS status, public IP address, and active provider details in the VPN widget.
+
+### Widgets & Capabilities
+
+
+
+### Setting up the Gluetun control server
+
+Refer to the [Gluetun control server documentation](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication) to enable and configure authentication.
+
+### Adding the integration
+
+
+
+### Secrets
+
+Gluetun's control server can be left unprotected or secured with either an API key or HTTP Basic authentication.
+Choose the option that matches how your Gluetun instance integration is set up.
+
+
diff --git a/apps/docs/docs/integrations/gluetun/index.ts b/apps/docs/docs/integrations/gluetun/index.ts
new file mode 100644
index 000000000..6d2489a62
--- /dev/null
+++ b/apps/docs/docs/integrations/gluetun/index.ts
@@ -0,0 +1,8 @@
+import { IntegrationDefinition } from "@site/src/types";
+
+export const gluetunIntegration: IntegrationDefinition = {
+ name: "Gluetun",
+ description: "VPN control server integration for Homarr's VPN widget.",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gluetun.svg",
+ path: "../../integrations/gluetun",
+};
diff --git a/apps/docs/docs/widgets/vpn/img/single.png b/apps/docs/docs/widgets/vpn/img/single.png
new file mode 100644
index 000000000..ea4b423e7
Binary files /dev/null and b/apps/docs/docs/widgets/vpn/img/single.png differ
diff --git a/apps/docs/docs/widgets/vpn/index.mdx b/apps/docs/docs/widgets/vpn/index.mdx
new file mode 100644
index 000000000..0aa6a1a16
--- /dev/null
+++ b/apps/docs/docs/widgets/vpn/index.mdx
@@ -0,0 +1,40 @@
+---
+title: 'VPN'
+description: 'Monitor the status of your VPN integrations.'
+hide_title: true
+---
+
+import { WidgetHeader } from '@site/src/components/widgets/header';
+import { AddingWidget } from '@site/src/components/widgets/adding';
+import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
+import { vpnWidget } from '.';
+import { gluetunIntegration } from '@site/docs/integrations/gluetun';
+
+
+
+The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS
+services are up, the currently exposed public IP address, and the VPN provider and protocol being used.
+
+When multiple VPN integrations are attached, the widget renders one card per integration so you can compare their
+current status at a glance.
+
+### Screenshots
+
+import singleIntegrationImage from './img/single.png';
+
+
+

+
+
+### Supported Integrations
+
+
+
+### Adding the widget
+
+
diff --git a/apps/docs/docs/widgets/vpn/index.ts b/apps/docs/docs/widgets/vpn/index.ts
new file mode 100644
index 000000000..0f1590e5b
--- /dev/null
+++ b/apps/docs/docs/widgets/vpn/index.ts
@@ -0,0 +1,9 @@
+import { WidgetDefinition } from "@site/src/types";
+import { IconShieldLock } from "@tabler/icons-react";
+
+export const vpnWidget: WidgetDefinition = {
+ icon: IconShieldLock,
+ name: "VPN",
+ description: "Monitor the connection status, public IP, and provider details of your VPN integrations.",
+ path: "../../widgets/vpn",
+};
diff --git a/apps/nextjs/src/app/api/backup/test/backup.spec.ts b/apps/nextjs/src/app/api/backup/test/backup.spec.ts
index 0a394abf7..15706ecb8 100644
--- a/apps/nextjs/src/app/api/backup/test/backup.spec.ts
+++ b/apps/nextjs/src/app/api/backup/test/backup.spec.ts
@@ -211,7 +211,17 @@ describe("SQLite backup", () => {
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
expect(decryptedAfter).toBe(plaintext);
- expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
+ // The old key must no longer recover the plaintext. With AES-256-CBC this
+ // usually fails PKCS7 padding validation (throws), but ~1/256 of the time the
+ // wrong key yields valid padding and returns garbage instead of throwing, so
+ // assert on the actual property (plaintext is unrecoverable) rather than throwing.
+ let decryptedWithOldKey: string | null = null;
+ try {
+ decryptedWithOldKey = decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey);
+ } catch {
+ // Expected for most wrong keys: padding validation fails.
+ }
+ expect(decryptedWithOldKey).not.toBe(plaintext);
sqlite.close();
});
diff --git a/apps/nextjs/src/components/backup/use-backup-analysis.ts b/apps/nextjs/src/components/backup/use-backup-analysis.ts
index a59e4335b..ee92a4464 100644
--- a/apps/nextjs/src/components/backup/use-backup-analysis.ts
+++ b/apps/nextjs/src/components/backup/use-backup-analysis.ts
@@ -142,7 +142,8 @@ export const useBackupAnalysis = () => {
phase: "error",
});
throw new Error(
- `Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, { cause: migrationErr },
+ `Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
+ { cause: migrationErr },
);
}
diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts
index 4b43a4299..2fc7228c8 100644
--- a/apps/nextjs/src/components/integration/integration-grid-shared.ts
+++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts
@@ -57,6 +57,7 @@ export const categoryTranslationKeys: Record = {
mediaMonitoring: "integration.category.mediaMonitoring",
speedtest: "integration.category.speedtest",
analytics: "integration.category.analytics",
+ vpn: "integration.category.vpn",
documents: "integration.category.documents",
mediaLibrary: "integration.category.mediaLibrary",
uptimeMonitoring: "integration.category.uptimeMonitoring",
diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts
index 70c1aa997..80776db45 100644
--- a/packages/api/src/router/widgets/index.ts
+++ b/packages/api/src/router/widgets/index.ts
@@ -36,6 +36,7 @@ export const widgetRouter = createTRPCRouter({
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
+ vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
});
diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts
new file mode 100644
index 000000000..f50d65f33
--- /dev/null
+++ b/packages/api/src/router/widgets/vpn.ts
@@ -0,0 +1,59 @@
+import { observable } from "@trpc/server/observable";
+
+import type { Modify } from "@homarr/common/types";
+import type { Integration } from "@homarr/db/schema";
+import type { IntegrationKindByCategory } from "@homarr/definitions";
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import type { VpnSummary } from "@homarr/integrations/types";
+import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
+
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+const createVpnIntegrationMiddleware = () =>
+ createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"));
+
+export const vpnRouter = createTRPCRouter({
+ getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => {
+ return await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = vpnSummaryHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ summary: data,
+ };
+ }),
+ );
+ }),
+ subscribeSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).subscription(({ ctx }) => {
+ return observable<{
+ integration: Modify }>;
+ summary: VpnSummary | null;
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const innerHandler = vpnSummaryHandler.handler(integrationWithSecrets, {});
+ const unsubscribe = innerHandler.subscribe((summary) => {
+ emit.next({
+ integration,
+ summary,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+});
diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts
index 576e001c9..1f0cb8577 100644
--- a/packages/cron-jobs/src/index.ts
+++ b/packages/cron-jobs/src/index.ts
@@ -21,6 +21,7 @@ import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { timetableJob } from "./jobs/integrations/timetable";
import { tracearrJob } from "./jobs/integrations/tracearr";
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
+import { vpnJob } from "./jobs/integrations/vpn";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -57,6 +58,7 @@ const getJobGroup = () => {
timetable: timetableJob,
tracearr: tracearrJob,
umamiActiveVisitors: umamiActiveVisitorsJob,
+ vpn: vpnJob,
});
};
diff --git a/packages/cron-jobs/src/jobs/integrations/vpn.ts b/packages/cron-jobs/src/jobs/integrations/vpn.ts
new file mode 100644
index 000000000..b6495b359
--- /dev/null
+++ b/packages/cron-jobs/src/jobs/integrations/vpn.ts
@@ -0,0 +1,14 @@
+import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
+import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
+import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
+
+import { createCronJob } from "../../lib";
+
+export const vpnJob = createCronJob("vpn", EVERY_30_SECONDS).withCallback(
+ createRequestIntegrationJobHandler(vpnSummaryHandler.handler, {
+ widgetKinds: ["vpn"],
+ getInput: {
+ vpn: () => ({}),
+ },
+ }),
+);
diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts
index 1af579b5f..c49be1f53 100644
--- a/packages/definitions/src/docs/homarr-docs-sitemap.ts
+++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts
@@ -337,6 +337,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/navidrome"
| "/docs/integrations/umami"
+ | "/docs/integrations/gluetun"
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/widgets/clock"
@@ -380,6 +381,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/uptime-kuma"
| "/docs/widgets/audio-stats"
| "/docs/widgets/umami"
+ | "/docs/widgets/vpn"
| "/docs/widgets/ups"
| "/docs/widgets/beszel-system-table"
| "/docs/widgets/beszel-system-grid"
diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts
index 99ec6ebb0..90c3cae8b 100644
--- a/packages/definitions/src/docs/integration-doc-slugs.ts
+++ b/packages/definitions/src/docs/integration-doc-slugs.ts
@@ -55,6 +55,7 @@ export const integrationDocSlugs: Record = {
audiobookshelf: "audiobookshelf",
navidrome: "navidrome",
umami: "umami",
+ gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
archiveTeamWarrior: null,
diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts
index 3ffae6408..2e34a4843 100644
--- a/packages/definitions/src/docs/widget-doc-slugs.ts
+++ b/packages/definitions/src/docs/widget-doc-slugs.ts
@@ -43,6 +43,7 @@ export const widgetDocSlugs: Record = {
uptimeKuma: "uptime-kuma",
audioStats: "audio-stats",
umami: "umami",
+ vpn: "vpn",
ups: "ups",
archiveTeamWarrior: null,
beszelSystemTable: "beszel-system-table",
diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts
index 8efa552e4..abb4ffdfd 100644
--- a/packages/definitions/src/integration.ts
+++ b/packages/definitions/src/integration.ts
@@ -489,6 +489,13 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
defaultPort: 8090,
},
+ gluetun: {
+ name: "Gluetun",
+ secretKinds: [["username", "password"], ["apiKey"], []],
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
+ category: ["vpn"],
+ documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
+ },
archiveTeamWarrior: {
name: "ArchiveTeam Warrior",
secretKinds: [[], ["username", "password"]],
@@ -604,6 +611,7 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
+ "vpn",
"archiving",
"ups",
"documents",
diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts
index 87898a49e..a560f2979 100644
--- a/packages/definitions/src/widget-integration-map.ts
+++ b/packages/definitions/src/widget-integration-map.ts
@@ -38,6 +38,7 @@ export const widgetIntegrationSupport: Partial {
+ const authHeaders = this.getAuthHeaders();
+ const url = this.url("/v1/vpn/status");
+ const response = await input.fetchAsync(url, {
+ headers: authHeaders,
+ });
+
+ if (!response.ok) {
+ return TestConnectionError.StatusResult(response);
+ }
+
+ return { success: true };
+ }
+
+ public async getSummaryAsync(): Promise {
+ const authHeaders = this.getAuthHeaders();
+ const [vpnStatus, dnsStatus, publicIp, vpnSettings] = await Promise.all([
+ this.getVpnStatusAsync(authHeaders),
+ this.getDnsStatusAsync(authHeaders),
+ this.getPublicIpAsync(authHeaders),
+ this.getVpnSettingAsync(authHeaders),
+ ]);
+
+ return {
+ vpnStatus: vpnStatus.status,
+ dnsStatus: dnsStatus.status,
+ publicIp: publicIp.public_ip,
+ country: publicIp.country,
+ city: publicIp.city,
+ vpnProvider: {
+ protocol: vpnSettings.type,
+ provider: vpnSettings.provider.name,
+ },
+ };
+ }
+
+ private async getVpnStatusAsync(authHeaders: Record): Promise {
+ const url = this.url("/v1/vpn/status");
+
+ const response = await fetchWithTrustedCertificatesAsync(url, {
+ headers: authHeaders,
+ });
+
+ if (!response.ok) throw new ResponseError(response);
+ return gluetunVpnStatusSchema.parse(await response.json());
+ }
+
+ private async getDnsStatusAsync(authHeaders: Record): Promise {
+ const url = this.url("/v1/dns/status");
+
+ const response = await fetchWithTrustedCertificatesAsync(url, {
+ headers: authHeaders,
+ });
+
+ if (!response.ok) throw new ResponseError(response);
+ return gluetunDnsStatusSchema.parse(await response.json());
+ }
+
+ private async getPublicIpAsync(authHeaders: Record): Promise {
+ const url = this.url("/v1/publicip/ip");
+
+ const response = await fetchWithTrustedCertificatesAsync(url, {
+ headers: authHeaders,
+ });
+
+ if (!response.ok) throw new ResponseError(response);
+ return gluetunPublicIpSchema.parse(await response.json());
+ }
+
+ private async getVpnSettingAsync(authHeaders: Record): Promise {
+ const url = this.url("/v1/vpn/settings");
+
+ const response = await fetchWithTrustedCertificatesAsync(url, {
+ headers: authHeaders,
+ });
+
+ if (!response.ok) throw new ResponseError(response);
+ return gluetunVpnSettingsSchema.parse(await response.json());
+ }
+
+ private getAuthHeaders(): Record {
+ if (this.hasSecretValue("apiKey")) {
+ return { "X-API-Key": this.getSecretValue("apiKey") };
+ }
+
+ if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
+ const username = this.getSecretValue("username");
+ const password = this.getSecretValue("password");
+
+ const encodedCredentials = btoa(`${username}:${password}`);
+
+ return { Authorization: `Basic ${encodedCredentials}` };
+ }
+
+ return {};
+ }
+}
diff --git a/packages/integrations/src/gluetun/gluetun-types.ts b/packages/integrations/src/gluetun/gluetun-types.ts
new file mode 100644
index 000000000..6886d72a7
--- /dev/null
+++ b/packages/integrations/src/gluetun/gluetun-types.ts
@@ -0,0 +1,116 @@
+import { z } from "zod/v4";
+
+export const gluetunVpnStatusSchema = z.object({
+ status: z.string(),
+});
+
+export type GluetunVpnStatus = z.infer;
+
+export const gluetunDnsStatusSchema = gluetunVpnStatusSchema;
+
+export type GluetunDnsStatus = z.infer;
+
+export const gluetunPublicIpSchema = z.object({
+ public_ip: z.string(),
+ region: z.string(),
+ country: z.string(),
+ city: z.string(),
+ location: z.string(),
+ organization: z.string(),
+ postal_code: z.string(),
+ timezone: z.string(),
+});
+
+export type GluetunPublicIp = z.infer;
+
+const gluetunServerSelectionOpenvpnSchema = z.object({
+ config_file_path: z.string(),
+ protocol: z.string(),
+ endpoint_ip: z.string(),
+ custom_port: z.number(),
+ pia_encryption_preset: z.string(),
+});
+
+const gluetunServerSelectionWireguardSchema = z.object({
+ endpoint_ip: z.string(),
+ endpoint_port: z.number(),
+ public_key: z.string(),
+});
+
+const gluetunServerSelectionSchema = z.object({
+ vpn: z.string(),
+ countries: z.array(z.string()).nullable(),
+ categories: z.array(z.string()).nullable(),
+ regions: z.array(z.string()).nullable(),
+ cities: z.array(z.string()).nullable(),
+ isps: z.array(z.string()).nullable(),
+ names: z.array(z.string()).nullable(),
+ numbers: z.array(z.number()).nullable(),
+ hostnames: z.array(z.string()),
+ owned_only: z.boolean(),
+ free_only: z.boolean(),
+ premium_only: z.boolean(),
+ stream_only: z.boolean(),
+ multi_hop_only: z.boolean(),
+ port_forward_only: z.boolean(),
+ secure_core_only: z.boolean(),
+ tor_only: z.boolean(),
+ openvpn: gluetunServerSelectionOpenvpnSchema,
+ wireguard: gluetunServerSelectionWireguardSchema,
+});
+
+const gluetunPortForwardingSchema = z.object({
+ enabled: z.boolean(),
+ provider: z.string(),
+ status_file_path: z.string(),
+ up_command: z.string(),
+ down_command: z.string(),
+ listening_port: z.number(),
+ username: z.string(),
+ password: z.string(),
+});
+
+const gluetunProviderSchema = z.object({
+ name: z.string(),
+ server_selection: gluetunServerSelectionSchema,
+ port_forwarding: gluetunPortForwardingSchema,
+});
+
+const gluetunOpenvpnSettingsSchema = z.object({
+ version: z.string(),
+ user: z.string(),
+ password: z.string(),
+ config_file_path: z.string(),
+ ciphers: z.array(z.string()).nullable(),
+ auth: z.string(),
+ cert: z.string(),
+ key: z.string(),
+ encrypted_key: z.string(),
+ key_passphrase: z.string(),
+ pia_encryption_preset: z.string(),
+ mssfix: z.number(),
+ interface: z.string(),
+ process_user: z.string(),
+ verbosity: z.number(),
+ flags: z.array(z.string()).nullable(),
+});
+
+const gluetunWireguardSettingsSchema = z.object({
+ private_key: z.string(),
+ pre_shared_key: z.string(),
+ addresses: z.array(z.string()),
+ allowed_ips: z.array(z.string()),
+ interface: z.string(),
+ persistent_keep_alive_interval: z.number(),
+ mtu: z.number(),
+ implementation: z.string(),
+});
+
+export const gluetunVpnSettingsSchema = z.object({
+ type: z.string(),
+ provider: gluetunProviderSchema,
+ openvpn: gluetunOpenvpnSettingsSchema,
+ wireguard: gluetunWireguardSettingsSchema,
+});
+
+export type GluetunVpnSettings = z.infer;
diff --git a/packages/integrations/src/gluetun/test/gluetun-integration.spec.ts b/packages/integrations/src/gluetun/test/gluetun-integration.spec.ts
new file mode 100644
index 000000000..1f84005f3
--- /dev/null
+++ b/packages/integrations/src/gluetun/test/gluetun-integration.spec.ts
@@ -0,0 +1,371 @@
+import { Response } from "undici";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { createDb } from "@homarr/db/test";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationTestingInput } from "../../base/integration";
+import { GluetunIntegration } from "../gluetun-integration";
+import {
+ gluetunDnsStatusSchema,
+ gluetunPublicIpSchema,
+ gluetunVpnSettingsSchema,
+ gluetunVpnStatusSchema,
+} from "../gluetun-types";
+
+vi.mock("@homarr/db", async (importActual) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
+ const actual = await importActual();
+ return {
+ ...actual,
+ db: createDb(),
+ };
+});
+
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ fetchWithTrustedCertificatesAsync: vi.fn(),
+}));
+
+const TEST_URL = "https://gluetun.example.com";
+const TEST_API_KEY = "test-api-key";
+const TEST_USERNAME = "user";
+const TEST_PASSWORD = "p@ss";
+
+const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+
+type FetchResponse = Awaited>;
+
+const VPN_STATUS_PAYLOAD = { status: "running" };
+const DNS_STATUS_PAYLOAD = { status: "running" };
+const PUBLIC_IP_PAYLOAD = {
+ public_ip: "203.0.113.42",
+ region: "Europe",
+ country: "Germany",
+ city: "Berlin",
+ location: "52.5200,13.4050",
+ organization: "Example Org",
+ postal_code: "10115",
+ timezone: "Europe/Berlin",
+};
+const VPN_SETTINGS_PAYLOAD = {
+ type: "wireguard",
+ provider: {
+ name: "mullvad",
+ server_selection: {
+ vpn: "wireguard",
+ countries: ["Germany"],
+ categories: null,
+ regions: null,
+ cities: ["Berlin"],
+ isps: null,
+ names: null,
+ numbers: null,
+ hostnames: ["de-ber-wg-001"],
+ owned_only: false,
+ free_only: false,
+ premium_only: false,
+ stream_only: false,
+ multi_hop_only: false,
+ port_forward_only: false,
+ secure_core_only: false,
+ tor_only: false,
+ openvpn: {
+ config_file_path: "",
+ protocol: "udp",
+ endpoint_ip: "",
+ custom_port: 0,
+ pia_encryption_preset: "",
+ },
+ wireguard: {
+ endpoint_ip: "203.0.113.1",
+ endpoint_port: 51820,
+ public_key: "pubkey",
+ },
+ },
+ port_forwarding: {
+ enabled: false,
+ provider: "mullvad",
+ status_file_path: "",
+ up_command: "",
+ down_command: "",
+ listening_port: 0,
+ username: "",
+ password: "",
+ },
+ },
+ openvpn: {
+ version: "",
+ user: "",
+ password: "",
+ config_file_path: "",
+ ciphers: null,
+ auth: "",
+ cert: "",
+ key: "",
+ encrypted_key: "",
+ key_passphrase: "",
+ pia_encryption_preset: "",
+ mssfix: 0,
+ interface: "tun0",
+ process_user: "",
+ verbosity: 0,
+ flags: null,
+ },
+ wireguard: {
+ private_key: "",
+ pre_shared_key: "",
+ addresses: ["10.0.0.2/32"],
+ allowed_ips: ["0.0.0.0/0"],
+ interface: "wg0",
+ persistent_keep_alive_interval: 25,
+ mtu: 1420,
+ implementation: "auto",
+ },
+};
+
+const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse =>
+ new Response(JSON.stringify(body), {
+ ...init,
+ headers: { "content-type": "application/json" },
+ }) as unknown as FetchResponse;
+
+const setupHappyPath = () => {
+ mockFetch.mockImplementation((url) => {
+ const urlString = typeof url === "string" ? url : url.toString();
+ const { pathname } = new URL(urlString);
+
+ switch (pathname) {
+ case "/v1/vpn/status":
+ return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
+ case "/v1/dns/status":
+ return Promise.resolve(jsonResponse(DNS_STATUS_PAYLOAD));
+ case "/v1/publicip/ip":
+ return Promise.resolve(jsonResponse(PUBLIC_IP_PAYLOAD));
+ case "/v1/vpn/settings":
+ return Promise.resolve(jsonResponse(VPN_SETTINGS_PAYLOAD));
+ default:
+ return Promise.resolve(jsonResponse({ error: "not found" }, { status: 404 }));
+ }
+ });
+};
+
+const createIntegrationWithApiKey = () =>
+ new GluetunIntegration({
+ id: "gluetun-1",
+ name: "Gluetun",
+ url: TEST_URL,
+ externalUrl: null,
+ decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
+ });
+
+const createIntegrationWithBasicAuth = () =>
+ new GluetunIntegration({
+ id: "gluetun-2",
+ name: "Gluetun",
+ url: TEST_URL,
+ externalUrl: null,
+ decryptedSecrets: [
+ { kind: "username", value: TEST_USERNAME },
+ { kind: "password", value: TEST_PASSWORD },
+ ],
+ });
+
+describe("GluetunIntegration.getSummaryAsync", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("maps the four upstream responses into a flat VPN summary", async () => {
+ setupHappyPath();
+
+ const result = await createIntegrationWithApiKey().getSummaryAsync();
+
+ expect(result).toEqual({
+ vpnStatus: "running",
+ dnsStatus: "running",
+ publicIp: "203.0.113.42",
+ country: "Germany",
+ city: "Berlin",
+ vpnProvider: {
+ protocol: "wireguard",
+ provider: "mullvad",
+ },
+ });
+ });
+
+ test("hits each of the four control endpoints exactly once", async () => {
+ setupHappyPath();
+
+ await createIntegrationWithApiKey().getSummaryAsync();
+
+ const calledPaths = mockFetch.mock.calls.map(([url]) => {
+ const urlString = typeof url === "string" ? url : url.toString();
+ return new URL(urlString).pathname;
+ });
+
+ expect(calledPaths).toHaveLength(4);
+ expect(new Set(calledPaths)).toEqual(
+ new Set(["/v1/vpn/status", "/v1/dns/status", "/v1/publicip/ip", "/v1/vpn/settings"]),
+ );
+ });
+
+ test("sends X-API-Key header when an apiKey secret is configured", async () => {
+ setupHappyPath();
+
+ await createIntegrationWithApiKey().getSummaryAsync();
+
+ for (const [, init] of mockFetch.mock.calls) {
+ const headers = (init?.headers ?? {}) as Record;
+ expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
+ expect(headers.Authorization).toBeUndefined();
+ }
+ });
+
+ test("sends Basic auth header when username/password are configured", async () => {
+ setupHappyPath();
+
+ await createIntegrationWithBasicAuth().getSummaryAsync();
+
+ const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
+ for (const [, init] of mockFetch.mock.calls) {
+ const headers = (init?.headers ?? {}) as Record;
+ expect(headers.Authorization).toBe(expected);
+ expect(headers["X-API-Key"]).toBeUndefined();
+ }
+ });
+
+ test("throws when any upstream endpoint returns a non-OK status", async () => {
+ mockFetch.mockImplementation((url) => {
+ const urlString = typeof url === "string" ? url : url.toString();
+ const { pathname } = new URL(urlString);
+
+ if (pathname === "/v1/publicip/ip") {
+ return Promise.resolve(jsonResponse({ error: "unauthorized" }, { status: 401 }));
+ }
+ return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
+ });
+
+ await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
+ });
+
+ test("throws when an upstream payload fails schema validation", async () => {
+ mockFetch.mockImplementation((url) => {
+ const urlString = typeof url === "string" ? url : url.toString();
+ const { pathname } = new URL(urlString);
+
+ if (pathname === "/v1/publicip/ip") {
+ return Promise.resolve(jsonResponse({ public_ip: 123 }));
+ }
+ return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
+ });
+
+ await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
+ });
+});
+
+describe("GluetunIntegration testing endpoint", () => {
+ type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
+
+ const invokeTesting = (integration: GluetunIntegration, input: IntegrationTestingInput) => {
+ const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
+ return testing(input);
+ };
+
+ const makeInput = (response: Response): IntegrationTestingInput =>
+ ({
+ fetchAsync: vi.fn().mockResolvedValue(response),
+ }) as unknown as IntegrationTestingInput;
+
+ test("returns success when /v1/vpn/status responds 200", async () => {
+ const input = makeInput(new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 }));
+
+ const result = await invokeTesting(createIntegrationWithApiKey(), input);
+
+ expect(result.success).toBe(true);
+ });
+
+ test("returns a status-code failure when /v1/vpn/status responds non-OK", async () => {
+ const input = makeInput(new Response("unauthorized", { status: 401 }));
+
+ const result = await invokeTesting(createIntegrationWithApiKey(), input);
+
+ expect(result.success).toBe(false);
+ });
+
+ test("passes the api-key header through to fetchAsync", async () => {
+ const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
+ const input = makeInput(response);
+
+ await invokeTesting(createIntegrationWithApiKey(), input);
+
+ const fetchAsyncMock = vi.mocked(input.fetchAsync);
+ const init = fetchAsyncMock.mock.calls[0]?.[1];
+ const headers = (init?.headers ?? {}) as Record;
+ expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
+ });
+
+ test("passes the Basic auth header through to fetchAsync when username/password are configured", async () => {
+ const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
+ const input = makeInput(response);
+
+ await invokeTesting(createIntegrationWithBasicAuth(), input);
+
+ const fetchAsyncMock = vi.mocked(input.fetchAsync);
+ const init = fetchAsyncMock.mock.calls[0]?.[1];
+ const headers = (init?.headers ?? {}) as Record;
+ const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
+ expect(headers.Authorization).toBe(expected);
+ expect(headers["X-API-Key"]).toBeUndefined();
+ });
+});
+
+describe("gluetun schemas", () => {
+ test("vpn status schema accepts a string status", () => {
+ expect(gluetunVpnStatusSchema.parse({ status: "running" })).toEqual({ status: "running" });
+ });
+
+ test("vpn status schema rejects a missing status", () => {
+ expect(() => gluetunVpnStatusSchema.parse({})).toThrow();
+ });
+
+ test("dns status schema mirrors vpn status schema", () => {
+ expect(gluetunDnsStatusSchema.parse({ status: "stopped" })).toEqual({ status: "stopped" });
+ expect(() => gluetunDnsStatusSchema.parse({ status: 42 })).toThrow();
+ });
+
+ test("public ip schema parses the full payload", () => {
+ expect(gluetunPublicIpSchema.parse(PUBLIC_IP_PAYLOAD)).toEqual(PUBLIC_IP_PAYLOAD);
+ });
+
+ test("public ip schema rejects a missing required field", () => {
+ const { city: _city, ...partial } = PUBLIC_IP_PAYLOAD;
+ expect(() => gluetunPublicIpSchema.parse(partial)).toThrow();
+ });
+
+ test("vpn settings schema parses a wireguard payload", () => {
+ const parsed = gluetunVpnSettingsSchema.parse(VPN_SETTINGS_PAYLOAD);
+ expect(parsed.type).toBe("wireguard");
+ expect(parsed.provider.name).toBe("mullvad");
+ });
+
+ test("vpn settings schema accepts nullable server-selection arrays", () => {
+ const payloadWithNulls = {
+ ...VPN_SETTINGS_PAYLOAD,
+ provider: {
+ ...VPN_SETTINGS_PAYLOAD.provider,
+ server_selection: {
+ ...VPN_SETTINGS_PAYLOAD.provider.server_selection,
+ countries: null,
+ cities: null,
+ },
+ },
+ };
+
+ expect(() => gluetunVpnSettingsSchema.parse(payloadWithNulls)).not.toThrow();
+ });
+
+ test("vpn settings schema rejects a non-string type", () => {
+ expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow();
+ });
+});
diff --git a/packages/integrations/src/gotify/gotify-integration.ts b/packages/integrations/src/gotify/gotify-integration.ts
index b7fd9361e..1d0f5eb8c 100644
--- a/packages/integrations/src/gotify/gotify-integration.ts
+++ b/packages/integrations/src/gotify/gotify-integration.ts
@@ -15,9 +15,9 @@ export class GotifyIntegration extends Integration implements INotificationsInte
}
private getHeaders() {
- const credentials = Buffer.from(
- `${super.getSecretValue("username")}:${super.getSecretValue("password")}`,
- ).toString("base64");
+ const credentials = Buffer.from(`${super.getSecretValue("username")}:${super.getSecretValue("password")}`).toString(
+ "base64",
+ );
return { Authorization: `Basic ${credentials}` };
}
@@ -31,11 +31,13 @@ export class GotifyIntegration extends Integration implements INotificationsInte
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
const parsed = result.data;
- return parsed.messages.map((message): Notification => ({
- id: String(message.id),
- time: new Date(message.date),
- title: message.title,
- body: message.message,
- }));
+ return parsed.messages.map(
+ (message): Notification => ({
+ id: String(message.id),
+ time: new Date(message.date),
+ title: message.title,
+ body: message.message,
+ }),
+ );
}
}
diff --git a/packages/integrations/src/interfaces/vpn-summary/vpn-summary-integration.ts b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-integration.ts
new file mode 100644
index 000000000..39e500d30
--- /dev/null
+++ b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-integration.ts
@@ -0,0 +1,5 @@
+import type { VpnSummary } from "./vpn-summary-types";
+
+export interface VpnSummaryIntegration {
+ getSummaryAsync(): Promise;
+}
diff --git a/packages/integrations/src/interfaces/vpn-summary/vpn-summary-types.ts b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-types.ts
new file mode 100644
index 000000000..91e70454c
--- /dev/null
+++ b/packages/integrations/src/interfaces/vpn-summary/vpn-summary-types.ts
@@ -0,0 +1,13 @@
+export interface VpnProviderDetails {
+ protocol: string;
+ provider: string;
+}
+
+export interface VpnSummary {
+ vpnStatus: string;
+ dnsStatus: string;
+ publicIp: string;
+ country: string;
+ city: string;
+ vpnProvider: VpnProviderDetails;
+}
diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts
index 1ee395dfa..afeaf1db2 100644
--- a/packages/integrations/src/types.ts
+++ b/packages/integrations/src/types.ts
@@ -4,6 +4,7 @@ export * from "./interfaces/network-controller-summary/network-controller-summar
export * from "./interfaces/firewall-summary/firewall-summary-types";
export * from "./interfaces/health-monitoring/health-monitoring-types";
export * from "./interfaces/ups-summary/ups-summary-types";
+export * from "./interfaces/vpn-summary/vpn-summary-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
export * from "./base/searchable-integration";
@@ -22,4 +23,5 @@ export * from "./audiobookshelf/audiobookshelf-types";
export * from "./navidrome/navidrome-types";
export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
+export * from "./gluetun/gluetun-types";
export * from "./beszel/beszel-types";
diff --git a/packages/integrations/test/technitium.spec.ts b/packages/integrations/test/technitium.spec.ts
index 99c9d2610..77189b556 100644
--- a/packages/integrations/test/technitium.spec.ts
+++ b/packages/integrations/test/technitium.spec.ts
@@ -2,15 +2,7 @@
import type { StartedTestContainer } from "testcontainers";
import { GenericContainer, Wait } from "testcontainers";
-import {
- beforeAll,
- beforeEach,
- afterAll,
- describe,
- expect,
- test,
- vi,
-} from "vitest";
+import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
@@ -31,15 +23,10 @@ vi.mock("@homarr/db", async (importActual) => {
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
- const actual =
- await importActual<
- typeof import("@homarr/core/infrastructure/certificates")
- >();
+ const actual = await importActual();
return {
...actual,
- getTrustedCertificateHostnamesAsync: vi
- .fn()
- .mockImplementation(() => Promise.resolve([])),
+ getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
};
});
@@ -90,9 +77,7 @@ const INTEGRATION_ID = "test-technitium";
const makeInput = (
baseUrl: string,
- auth:
- | { kind: "credentials"; password?: string }
- | { kind: "apiKey"; value: string },
+ auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string },
): IntegrationInput => ({
id: INTEGRATION_ID,
name: "Technitium DNS",
@@ -109,10 +94,7 @@ const makeInput = (
// Fetch an API token appropriate for the server version.
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
-async function fetchApiToken(
- baseUrl: string,
- apiVersion: TechnitiumVersion,
-): Promise {
+async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise {
if (apiVersion === "v15") {
// v15 exposes a dedicated endpoint for non-expiring API tokens.
const res = await fetch(
@@ -122,9 +104,7 @@ async function fetchApiToken(
}
// Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session
// token that can be used as a long-lived API key via the ?token= query parameter.
- const res = await fetch(
- `${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`,
- );
+ const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`);
return ((await res.json()) as { token: string }).token;
}
@@ -144,16 +124,12 @@ const pool: Record = {};
beforeAll(async () => {
await Promise.all(
VERSIONS.map(async ({ tag, apiVersion }) => {
- const container = await new GenericContainer(
- `technitium/dns-server:${tag}`,
- )
+ const container = await new GenericContainer(`technitium/dns-server:${tag}`)
.withExposedPorts(5380)
// Explicitly set the admin password so all versions use the same credentials.
// Without this, older versions may generate a random password on first run.
.withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD })
- .withWaitStrategy(
- Wait.forLogMessage("Technitium DNS Server was started successfully."),
- )
+ .withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully."))
.start();
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
const apiToken = await fetchApiToken(baseUrl, apiVersion);
@@ -163,238 +139,209 @@ beforeAll(async () => {
}, 120_000);
afterAll(async () => {
- await Promise.all(
- Object.values(pool).map(({ container }) => container.stop()),
- );
+ await Promise.all(Object.values(pool).map(({ container }) => container.stop()));
});
// ─── version-parameterised suites ────────────────────────────────────────────
-describe.each(VERSIONS)(
- "Technitium DNS $tag ($apiVersion)",
- ({ tag, apiVersion }) => {
- let baseUrl: string;
- let apiToken: string;
+describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => {
+ let baseUrl: string;
+ let apiToken: string;
- beforeAll(() => {
- const entry = pool[tag];
- if (!entry)
- throw new Error(`Container pool entry for ${tag} was not initialized`);
- ({ baseUrl, apiToken } = entry);
- });
+ beforeAll(() => {
+ const entry = pool[tag];
+ if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`);
+ ({ baseUrl, apiToken } = entry);
+ });
- beforeEach(() => {
- sessionState[INTEGRATION_ID] = null;
- });
+ beforeEach(() => {
+ sessionState[INTEGRATION_ID] = null;
+ });
- // ── factory ────────────────────────────────────────────────────────────────
+ // ── factory ────────────────────────────────────────────────────────────────
- describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
- test("factory creates a working integration (auto-detects version)", async () => {
- const integration = await createTechnitiumDnsIntegrationAsync(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
+ test("factory creates a working integration (auto-detects version)", async () => {
+ const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" }));
- const result = await integration.getSummaryAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("enabled");
- expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
- }, 20_000);
- });
+ expect(result.status).toBe("enabled");
+ expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
+ }, 20_000);
+ });
- // ── getSummaryAsync ────────────────────────────────────────────────────────
+ // ── getSummaryAsync ────────────────────────────────────────────────────────
- describe("getSummaryAsync", () => {
- test("returns valid summary with credentials", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ describe("getSummaryAsync", () => {
+ test("returns valid summary with credentials", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- const result = await integration.getSummaryAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
- expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
- expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
- expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
- expect(result.status).toBe("enabled");
- }, 20_000);
+ expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
+ expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
+ expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
+ expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
+ expect(result.status).toBe("enabled");
+ }, 20_000);
- test("returns valid summary with API key", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
- apiVersion,
- );
+ test("returns valid summary with API key", async () => {
+ const integration = new TechnitiumDnsIntegration(
+ makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
+ apiVersion,
+ );
- const result = await integration.getSummaryAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("enabled");
- expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
- }, 20_000);
+ expect(result.status).toBe("enabled");
+ expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
+ }, 20_000);
- test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- const result = await integration.getSummaryAsync();
+ const result = await integration.getSummaryAsync();
- expect(typeof result.domainsBeingBlocked).toBe("number");
- expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
- }, 20_000);
- });
+ expect(typeof result.domainsBeingBlocked).toBe("number");
+ expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
+ }, 20_000);
+ });
- // ── session caching ────────────────────────────────────────────────────────
+ // ── session caching ────────────────────────────────────────────────────────
- describe("session caching", () => {
- test("login is performed once and token is cached for subsequent calls", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ describe("session caching", () => {
+ test("login is performed once and token is cached for subsequent calls", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.getSummaryAsync();
- const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
- expect(tokenAfterFirst).toBeDefined();
+ await integration.getSummaryAsync();
+ const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
+ expect(tokenAfterFirst).toBeDefined();
- await integration.getSummaryAsync();
- const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
+ await integration.getSummaryAsync();
+ const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
- expect(tokenAfterSecond).toBe(tokenAfterFirst);
- }, 20_000);
+ expect(tokenAfterSecond).toBe(tokenAfterFirst);
+ }, 20_000);
- test("recovers from an expired/invalid stored token by re-logging in", async () => {
- // Plant a bad token with the correct version so it hits the right API path before failing
- sessionState[INTEGRATION_ID] = {
- token: "deliberately-invalid-token",
- version: apiVersion,
- };
+ test("recovers from an expired/invalid stored token by re-logging in", async () => {
+ // Plant a bad token with the correct version so it hits the right API path before failing
+ sessionState[INTEGRATION_ID] = {
+ token: "deliberately-invalid-token",
+ version: apiVersion,
+ };
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- apiVersion,
- );
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion);
- const result = await integration.getSummaryAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("enabled");
- expect(sessionState[INTEGRATION_ID]).not.toBeNull();
- expect(sessionState[INTEGRATION_ID]?.token).not.toBe(
- "deliberately-invalid-token",
- );
- }, 20_000);
- });
+ expect(result.status).toBe("enabled");
+ expect(sessionState[INTEGRATION_ID]).not.toBeNull();
+ expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token");
+ }, 20_000);
+ });
- // ── blocking state changes ─────────────────────────────────────────────────
+ // ── blocking state changes ─────────────────────────────────────────────────
- describe("blocking state changes", () => {
- test("disableAsync permanently disables blocking", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ describe("blocking state changes", () => {
+ test("disableAsync permanently disables blocking", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.disableAsync();
- const result = await integration.getSummaryAsync();
+ await integration.disableAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("disabled");
+ expect(result.status).toBe("disabled");
- await integration.enableAsync(); // restore
- }, 20_000);
+ await integration.enableAsync(); // restore
+ }, 20_000);
- test("enableAsync re-enables blocking after permanent disable", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ test("enableAsync re-enables blocking after permanent disable", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.disableAsync();
- await integration.enableAsync();
- const result = await integration.getSummaryAsync();
+ await integration.disableAsync();
+ await integration.enableAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("enabled");
- }, 20_000);
+ expect(result.status).toBe("enabled");
+ }, 20_000);
- test("disableAsync with duration temporarily disables blocking", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ test("disableAsync with duration temporarily disables blocking", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.disableAsync(120); // 120s → 2 min
- const result = await integration.getSummaryAsync();
+ await integration.disableAsync(120); // 120s → 2 min
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("disabled");
+ expect(result.status).toBe("disabled");
- await integration.enableAsync(); // restore
- }, 20_000);
+ await integration.enableAsync(); // restore
+ }, 20_000);
- test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.disableAsync(1);
- const result = await integration.getSummaryAsync();
+ await integration.disableAsync(1);
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("disabled");
+ expect(result.status).toBe("disabled");
- await integration.enableAsync(); // restore
- }, 20_000);
+ await integration.enableAsync(); // restore
+ }, 20_000);
- test("enableAsync cancels an active timed disable immediately", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- );
+ test("enableAsync cancels an active timed disable immediately", async () => {
+ const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
- await integration.disableAsync(600); // 10 minutes
- await integration.enableAsync();
- const result = await integration.getSummaryAsync();
+ await integration.disableAsync(600); // 10 minutes
+ await integration.enableAsync();
+ const result = await integration.getSummaryAsync();
- expect(result.status).toBe("enabled");
- }, 20_000);
- });
+ expect(result.status).toBe("enabled");
+ }, 20_000);
+ });
- // ── testConnectionAsync ────────────────────────────────────────────────────
+ // ── testConnectionAsync ────────────────────────────────────────────────────
- describe("testConnectionAsync", () => {
- test("succeeds with valid credentials", async () => {
- const result = await new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "credentials" }),
- ).testConnectionAsync();
- expect(result.success).toBe(true);
- }, 20_000);
+ describe("testConnectionAsync", () => {
+ test("succeeds with valid credentials", async () => {
+ const result = await new TechnitiumDnsIntegration(
+ makeInput(baseUrl, { kind: "credentials" }),
+ ).testConnectionAsync();
+ expect(result.success).toBe(true);
+ }, 20_000);
- test("succeeds with valid API key", async () => {
- const result = await new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
- apiVersion,
- ).testConnectionAsync();
- expect(result.success).toBe(true);
- }, 20_000);
+ test("succeeds with valid API key", async () => {
+ const result = await new TechnitiumDnsIntegration(
+ makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
+ apiVersion,
+ ).testConnectionAsync();
+ expect(result.success).toBe(true);
+ }, 20_000);
- test("fails with wrong password — returns authorization error", async () => {
- const integration = new TechnitiumDnsIntegration(
- makeInput(baseUrl, {
- kind: "credentials",
- password: "wrong-password",
- }),
- );
+ test("fails with wrong password — returns authorization error", async () => {
+ const integration = new TechnitiumDnsIntegration(
+ makeInput(baseUrl, {
+ kind: "credentials",
+ password: "wrong-password",
+ }),
+ );
- const result = await integration.testConnectionAsync();
+ const result = await integration.testConnectionAsync();
- expect(result.success).toBe(false);
- if (result.success) return;
- expect(result.error).toBeInstanceOf(TestConnectionError);
- expect(result.error.type).toBe("authorization");
- }, 20_000);
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error).toBeInstanceOf(TestConnectionError);
+ expect(result.error.type).toBe("authorization");
+ }, 20_000);
- test("fails with invalid API key — returns authorization error", async () => {
- const result = await new TechnitiumDnsIntegration(
- makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
- apiVersion,
- ).testConnectionAsync();
+ test("fails with invalid API key — returns authorization error", async () => {
+ const result = await new TechnitiumDnsIntegration(
+ makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
+ apiVersion,
+ ).testConnectionAsync();
- expect(result.success).toBe(false);
- if (result.success) return;
- expect(result.error).toBeInstanceOf(TestConnectionError);
- expect(result.error.type).toBe("authorization");
- }, 20_000);
- });
- },
-);
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error).toBeInstanceOf(TestConnectionError);
+ expect(result.error.type).toBe("authorization");
+ }, 20_000);
+ });
+});
diff --git a/packages/request-handler/src/vpn.ts b/packages/request-handler/src/vpn.ts
new file mode 100644
index 000000000..de068938c
--- /dev/null
+++ b/packages/request-handler/src/vpn.ts
@@ -0,0 +1,35 @@
+import dayjs from "dayjs";
+
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import type { IntegrationKindByCategory } from "@homarr/definitions";
+import { createIntegrationAsync } from "@homarr/integrations";
+import type { VpnSummary } from "@homarr/integrations/types";
+
+import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+
+const logger = createLogger({ module: "vpnRequestHandler" });
+
+export const vpnSummaryHandler = createCachedIntegrationRequestHandler<
+ VpnSummary | null,
+ IntegrationKindByCategory<"vpn">,
+ Record
+>({
+ async requestAsync(integration, _input) {
+ try {
+ const integrationInstance = await createIntegrationAsync(integration);
+ return await integrationInstance.getSummaryAsync();
+ } catch (error) {
+ // The VPN control server is unreachable (e.g. the container is down).
+ // Return null so the widget can render an "unavailable" status instead of
+ // surfacing a hard error, and so it recovers automatically once it is back up.
+ logger.warn("Failed to fetch VPN summary, marking as unavailable", {
+ integrationId: integration.id,
+ integrationKind: integration.kind,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ }
+ },
+ cacheDuration: dayjs.duration(5, "minutes"),
+ queryKey: "vpnSummary",
+});
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index c831c6223..50628e908 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -1021,6 +1021,7 @@
"mediaMonitoring": "Monitoring",
"speedtest": "Speed Test",
"analytics": "Analytics",
+ "vpn": "VPN",
"mediaLibrary": "Media Library",
"uptimeMonitoring": "Uptime Monitoring"
},
@@ -2408,6 +2409,12 @@
"title": "Instances"
}
},
+ "vpn": {
+ "name": "VPN",
+ "description": "Overview of your VPN connections",
+ "serviceUnavailable": "VPN unavailable",
+ "option": {}
+ },
"ups": {
"name": "UPS",
"description": "Monitor the status of your UPS devices through a NUT server",
@@ -4588,6 +4595,9 @@
},
"umamiActiveVisitors": {
"label": "Umami Active Visitors"
+ },
+ "vpn": {
+ "label": "VPN Status"
}
},
"interval": {
diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx
index b50139361..d6c1d004d 100644
--- a/packages/widgets/src/docker/component.tsx
+++ b/packages/widgets/src/docker/component.tsx
@@ -207,7 +207,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
acc.memory += safeValue(container.memoryUsage);
return acc;
},
- { cpu: 0, memory: 0 }
+ { cpu: 0, memory: 0 },
);
}, [containers]);
@@ -283,7 +283,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
-
+
{t("table.totalMemory", { memory: humanFileSize(totals.memory) })}
diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx
index 628c67e32..17e9b8eed 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -52,6 +52,7 @@ import * as timetable from "./timetable";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
import * as ups from "./ups";
+import * as vpn from "./vpn";
import * as beszelSystemTable from "./beszel-system-table";
import * as beszelSystemGrid from "./beszel-system-grid";
import * as beszelAlerts from "./beszel-alerts";
@@ -108,6 +109,7 @@ export const widgetImports = {
uptimeKuma,
audioStats,
umami,
+ vpn,
ups,
beszelSystemTable,
beszelSystemGrid,
diff --git a/packages/widgets/src/vpn/component.tsx b/packages/widgets/src/vpn/component.tsx
new file mode 100644
index 000000000..85ee4e3cf
--- /dev/null
+++ b/packages/widgets/src/vpn/component.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import React from "react";
+import { Flex, ScrollArea, Stack } from "@mantine/core";
+
+import { clientApi } from "@homarr/api/client";
+
+import type { WidgetComponentProps } from "../definition";
+import { updateVpnInfoFromSubscription } from "./helpers";
+import { VpnIntegrationCard } from "./vpn-card";
+
+export default function VpnWidget({ options, integrationIds }: WidgetComponentProps<"vpn">) {
+ const [integrations] = clientApi.widget.vpn.getSummaries.useSuspenseQuery(
+ {
+ ...options,
+ integrationIds,
+ },
+ {
+ // Initial load only. Live updates arrive via the subscription below, which is
+ // fed by the VPN cron job publishing to the integration channel.
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ retry: false,
+ },
+ );
+ const utils = clientApi.useUtils();
+
+ clientApi.widget.vpn.subscribeSummaries.useSubscription(
+ {
+ integrationIds,
+ },
+ {
+ onData: (data) => {
+ utils.widget.vpn.getSummaries.setData(
+ {
+ ...options,
+ integrationIds,
+ },
+ (prevData) => updateVpnInfoFromSubscription(prevData, data),
+ );
+ },
+ },
+ );
+
+ if (integrations.length === 1) {
+ // It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const vpn = integrations[0]!;
+
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {integrations.map((vpn) => (
+
+ ))}
+
+
+ );
+}
diff --git a/packages/widgets/src/vpn/helpers.ts b/packages/widgets/src/vpn/helpers.ts
new file mode 100644
index 000000000..24a81720b
--- /dev/null
+++ b/packages/widgets/src/vpn/helpers.ts
@@ -0,0 +1,22 @@
+import type { RouterOutputs } from "@homarr/api";
+
+export const RUNNING_STATUS = "running";
+
+type VpnInfoItem = RouterOutputs["widget"]["vpn"]["getSummaries"][number];
+
+export function getStatusColor(status: string) {
+ return status === RUNNING_STATUS ? "green" : "red";
+}
+
+export function updateVpnInfoFromSubscription(
+ prevData: VpnInfoItem[] | undefined,
+ data: { integration: { id: string }; summary: VpnInfoItem["summary"] },
+): VpnInfoItem[] | undefined {
+ if (!prevData) {
+ return undefined;
+ }
+
+ return prevData.map((item) =>
+ item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
+ );
+}
diff --git a/packages/widgets/src/vpn/index.ts b/packages/widgets/src/vpn/index.ts
new file mode 100644
index 000000000..426366a66
--- /dev/null
+++ b/packages/widgets/src/vpn/index.ts
@@ -0,0 +1,14 @@
+import { IconShieldLock } from "@tabler/icons-react";
+
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+
+import { createWidgetDefinition } from "../definition";
+import { optionsBuilder } from "../options";
+
+export const { componentLoader, definition } = createWidgetDefinition("vpn", {
+ icon: IconShieldLock,
+ createOptions() {
+ return optionsBuilder.from(() => ({}));
+ },
+ supportedIntegrations: getIntegrationKindsByCategory("vpn"),
+}).withDynamicImport(() => import("./component"));
diff --git a/packages/widgets/src/vpn/vpn-card.tsx b/packages/widgets/src/vpn/vpn-card.tsx
new file mode 100644
index 000000000..3cb26bdd5
--- /dev/null
+++ b/packages/widgets/src/vpn/vpn-card.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import React from "react";
+import { Badge, Box, Card, Flex, Group, Text } from "@mantine/core";
+import { IconArrowsExchange, IconMapPin, IconShieldCheck, IconShieldX } from "@tabler/icons-react";
+
+import type { RouterOutputs } from "@homarr/api";
+import { useRequiredBoard } from "@homarr/boards/context";
+import { useScopedI18n } from "@homarr/translation/client";
+
+import { getStatusColor, RUNNING_STATUS } from "./helpers";
+
+export type VpnInfo = RouterOutputs["widget"]["vpn"]["getSummaries"][number]["summary"];
+
+export function VpnIntegrationCard({
+ vpn,
+ integrationName,
+ variant = "single",
+}: {
+ vpn: VpnInfo;
+ integrationName?: string;
+ variant?: "single" | "list";
+}) {
+ const board = useRequiredBoard();
+ const compact = variant === "list";
+
+ const content = vpn ? (
+
+
+
+
+ ) : (
+
+ );
+
+ if (compact) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return {content};
+}
+
+function VpnUnavailableContent({ compact, integrationName }: { compact: boolean; integrationName?: string }) {
+ const t = useScopedI18n("widget.vpn");
+
+ return (
+
+ {/* Empty statuses render the shield and DNS badge in red (see getStatusColor). */}
+
+
+ {integrationName ? (
+
+ {integrationName}
+
+ ) : null}
+
+ {t("serviceUnavailable")}
+
+
+
+ );
+}
+
+function VpnStatusColumn({
+ vpnStatus,
+ dnsStatus,
+ compact,
+}: {
+ vpnStatus: string;
+ dnsStatus: string;
+ compact: boolean;
+}) {
+ // Connected shows a shield with a check; anything else (stopped or unavailable) shows a shield with a cross.
+ const ShieldIcon = vpnStatus === RUNNING_STATUS ? IconShieldCheck : IconShieldX;
+
+ return (
+
+
+
+
+ );
+}
+
+function VpnInfoColumn({
+ integrationName,
+ publicIp,
+ city,
+ country,
+ provider,
+ protocol,
+ compact,
+}: {
+ integrationName?: string;
+ publicIp: string;
+ city: string;
+ country: string;
+ provider: string;
+ protocol: string;
+ compact: boolean;
+}) {
+ return (
+
+ {integrationName ? (
+
+ {integrationName}
+
+ ) : null}
+
+ {publicIp}
+
+
+
+
+ {city}, {country}
+
+
+
+
+ );
+}
+
+function VpnProviderDetails({ provider, protocol }: { provider: string; protocol: string }) {
+ return (
+
+
+ {provider}
+
+
+
+ {protocol}
+
+
+ );
+}
+
+function StatusDot({ status, size = 10 }: { status: string; size?: number }) {
+ return ;
+}
+
+function DnsStatusBadge({ status }: { status: string }) {
+ return (
+ }
+ >
+ DNS
+
+ );
+}