From a2bb53a8b8fd17da3d2e23851b75d4e179f66225 Mon Sep 17 00:00:00 2001 From: Lokesh <110459168+Lok3sh-kumar@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:59:23 +0530 Subject: [PATCH] feat: add VPN status widget (#5812) Co-authored-by: Lokesh-Kumar-Dudi Co-authored-by: Thomas Camlong --- CODEOWNERS | 5 + apps/docs/docs/integrations/gluetun/index.mdx | 52 +++ apps/docs/docs/integrations/gluetun/index.ts | 8 + apps/docs/docs/widgets/vpn/img/single.png | Bin 0 -> 30744 bytes apps/docs/docs/widgets/vpn/index.mdx | 40 ++ apps/docs/docs/widgets/vpn/index.ts | 9 + .../src/app/api/backup/test/backup.spec.ts | 12 +- .../components/backup/use-backup-analysis.ts | 3 +- .../integration/integration-grid-shared.ts | 1 + packages/api/src/router/widgets/index.ts | 1 + packages/api/src/router/widgets/vpn.ts | 59 +++ packages/cron-jobs/src/index.ts | 2 + .../cron-jobs/src/jobs/integrations/vpn.ts | 14 + .../src/docs/homarr-docs-sitemap.ts | 2 + .../src/docs/integration-doc-slugs.ts | 1 + .../definitions/src/docs/widget-doc-slugs.ts | 1 + packages/definitions/src/integration.ts | 8 + .../definitions/src/widget-integration-map.ts | 1 + packages/definitions/src/widget.ts | 1 + packages/integrations/src/base/creator.ts | 2 + .../src/gluetun/gluetun-integration.ts | 115 ++++++ .../integrations/src/gluetun/gluetun-types.ts | 116 ++++++ .../gluetun/test/gluetun-integration.spec.ts | 371 ++++++++++++++++++ .../src/gotify/gotify-integration.ts | 20 +- .../vpn-summary/vpn-summary-integration.ts | 5 + .../vpn-summary/vpn-summary-types.ts | 13 + packages/integrations/src/types.ts | 2 + packages/integrations/test/technitium.spec.ts | 369 ++++++++--------- packages/request-handler/src/vpn.ts | 35 ++ packages/translation/src/lang/en.json | 10 + packages/widgets/src/docker/component.tsx | 4 +- packages/widgets/src/index.tsx | 2 + packages/widgets/src/vpn/component.tsx | 72 ++++ packages/widgets/src/vpn/helpers.ts | 22 ++ packages/widgets/src/vpn/index.ts | 14 + packages/widgets/src/vpn/vpn-card.tsx | 164 ++++++++ 36 files changed, 1332 insertions(+), 224 deletions(-) create mode 100644 apps/docs/docs/integrations/gluetun/index.mdx create mode 100644 apps/docs/docs/integrations/gluetun/index.ts create mode 100644 apps/docs/docs/widgets/vpn/img/single.png create mode 100644 apps/docs/docs/widgets/vpn/index.mdx create mode 100644 apps/docs/docs/widgets/vpn/index.ts create mode 100644 packages/api/src/router/widgets/vpn.ts create mode 100644 packages/cron-jobs/src/jobs/integrations/vpn.ts create mode 100644 packages/integrations/src/gluetun/gluetun-integration.ts create mode 100644 packages/integrations/src/gluetun/gluetun-types.ts create mode 100644 packages/integrations/src/gluetun/test/gluetun-integration.spec.ts create mode 100644 packages/integrations/src/interfaces/vpn-summary/vpn-summary-integration.ts create mode 100644 packages/integrations/src/interfaces/vpn-summary/vpn-summary-types.ts create mode 100644 packages/request-handler/src/vpn.ts create mode 100644 packages/widgets/src/vpn/component.tsx create mode 100644 packages/widgets/src/vpn/helpers.ts create mode 100644 packages/widgets/src/vpn/index.ts create mode 100644 packages/widgets/src/vpn/vpn-card.tsx 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 0000000000000000000000000000000000000000..ea4b423e70e21580000822f773dca05929df8fcd GIT binary patch literal 30744 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

5JJ);@KRAtTa4XRy*y&h>_K6N0 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

^U!O@n~~hT){zP>!YMTx z$eDT`H$q1A7b1-DwzP6x1DlJ92IJ~7ve;;WA5K{h^PRLb40@shrIyLD5}8I$0c#Xo=Ujz%zfT_P{}-Nci23VD_7{)^Xydget3zmai+X`VxBBMCK<6d_wf5|B?73 z6u*JMO--{`WzDTJ$387{yT2di>hGY!!|WC>MGv+M{6uyXQ~q)T0Nf8C)OU_;xK$FV z7JBTwBB$_(w3IBqY@3OmsXp9XIJKt&GOK6klUJ(rH|djvAD+o3u!VyEXp3xAous+H z#4${pQJIuXW2p(<%?fu@?lLt2*4kg5hP+l#iPQpCm4)%zg1A(G?e&6cznc5(Sz_qk zufC~GYyzPjB@Mep+r);-A_ZxHl*!U~RsfEGQ#Rk75`KGtw zhHMWGFp)D5!=^#s;HheJzSQ*r=aR3%QoFnoe|ph`0d6IvL&1@M^_3+K0QYGKS9_`^ zz2E1^M}~A4$ux*tO!_ON=V^?FVt&6~dbTKBd7JhhPNlm!f&K3xWx*K<13pXlT*>Ee zAIQz5Q$T=A6+S051=j!nrvJBw;DI2<4ybA6p_?h{320J%yjS`)4Tb)%DL^R3+=Ayn ze0x7fI|#rI=g|?lHzpq$A?)A3YTmj`)V`yaaLc-}iV#}D@BAGDe+4B-^)XOC(2G&V z7f23+z8z5{aZCf}pYAP;yf>E^%U30mux`hZaQRu@0{*G=FDDC3JO(mUZYna?8XR~G zAo9(CtG$c5x3LoV96%b30cz_T(ni9zD?ST6db4W&ZE{KZF`&Hn1+?Sd0CVXL7+MC5 z_Qv{FC}d%G>^fk5RyX51ki99;U+Aw3eTv5>G3X@5mm@DZnWG ztVjBXPKI#N8vQ!X|K93L?g(^Ce8>UNEvjPci+X;c^#9OFa}WUUS!E^vZ@C~84RCs9 zm6=e?p%)(t@t5stSg6p#jilBf(`p8cJpg;XNsV}i&AkB8a}1#9`r z(}j!w1|Q-UzKJL~@~QSR83#GoP`2>8o6Y2Y6J7j&Z%~Bb^mC*97*kf-emufUgTYI( zI0_!#M=4tr{qI}CoXm%6^E|nYv^WJEEqZ>p8&}@CM;(&Q_0P=Zosm~jT|1igOdFKe z-6;g#mDL}Ji~q713n7ZWO8z%z18L$j=<|D=im-g_im>Ve2JW~9kHrd$=BC&D=1o9F zoMym3jCu&p=KXI)7~aKloviz|kI*krOPg-0MiLE(Bh=YIVEp+1dv5QEvk2hN52p1r zSc!Y0g`a#`{Dd9j)$C9ENAI5%ps36CSjzlX_%XdP&t!;d3{hJ3J+=!tesYeO3r#b} z|HdN$@>x40fSLS?e_4%O6xMBSf>?BD7AW<{&T+`8+Tv9af%#B*2f6;YxzhrrY; zS-~g$BAii!eYH;Cc_{j_%k(D%{~Prmj9C3!@6A4Xoz$|J827}QBW3v`oh?=|ojhSR zq-g!FuPjhe!-3|gq`H3xZY1Fr?2ameoTi++P?1|AAynvng~Wn#5eq*;BcogB+);Fe z4PPf1Dz?r2zm=W>3G=;^A7R?_itMCZ*?8B%nyFIn-J=B?l7jidTU`RUES$G)2SBd- zHh08YzjGfHvT_()L&?iCjQfL3{ z6;?ji{>5sddhTs(!z;Yz0UE4-dtb^3C_|igtUvy@+^B(#&_Ff& zUGcw*zr+_%n3R5s9+m%ho{bQ|_DStiocVA1$(sO_D({Y%jL-iKb3<}KsABXZ!#Vcf zMdz%ph$)%~G})s3r|}1>fSSNi-=UQI|1%G>8!G+I3^lRQVtEVr(^S0=t5AL%@qYkR C$8mK4 literal 0 HcmV?d00001 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 + + ); +}