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'; + +
+ VPN widget showing a single integration +
+ +### 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 + + ); +}