diff --git a/messages/en-US.json b/messages/en-US.json index 6e1947b3e..202444882 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1667,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "View Release Notes", "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", + "pangolinNodeUpdateAvailableInfo": "A new version of Pangolin Node is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Enter the full domain of the resource to see available options.", diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts index 54001432f..3edf2c149 100644 --- a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts +++ b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts @@ -22,6 +22,91 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; +import cache from "#dynamic/lib/cache"; +import semver from "semver"; + +let stalePangolinNodeVersion: string | null = null; + +async function getLatestPangolinNodeVersion(): Promise { + try { + const cachedVersion = await cache.get( + "cache:latestPangolinNodeVersion" + ); + if (cachedVersion) { + return cachedVersion; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); + + const res = await fetch( + "https://api.github.com/repos/fosrl/pangolin-node/tags", + { signal: controller.signal } + ); + + clearTimeout(timeoutId); + + if (!res.ok) { + logger.warn( + `Failed to fetch latest pangolin-node version from GitHub: ${res.status} ${res.statusText}` + ); + return stalePangolinNodeVersion; + } + + let tags = await res.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for pangolin-node repository"); + return stalePangolinNodeVersion; + } + + tags = tags.filter((tag: any) => !tag.name.includes("rc")); + tags.sort((a: any, b: any) => { + const va = semver.coerce(a.name); + const vb = semver.coerce(b.name); + if (!va && !vb) return 0; + if (!va) return 1; + if (!vb) return -1; + return semver.rcompare(va, vb); + }); + + const seen = new Set(); + tags = tags.filter((tag: any) => { + const normalised = semver.coerce(tag.name)?.version; + if (!normalised || seen.has(normalised)) return false; + seen.add(normalised); + return true; + }); + + if (tags.length === 0) { + logger.warn( + "No valid semver tags found for pangolin-node repository" + ); + return stalePangolinNodeVersion; + } + + const latestVersion = tags[0].name; + stalePangolinNodeVersion = latestVersion; + await cache.set("cache:latestPangolinNodeVersion", latestVersion, 3600); + + return latestVersion; + } catch (error: any) { + if (error.name === "AbortError") { + logger.warn( + "Request to fetch latest pangolin-node version timed out (1.5s)" + ); + } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { + logger.warn( + "Connection timeout while fetching latest pangolin-node version" + ); + } else { + logger.warn( + "Error fetching latest pangolin-node version:", + error.message || error + ); + } + return stalePangolinNodeVersion; + } +} const listRemoteExitNodesParamsSchema = z.strictObject({ orgId: z.string() @@ -118,9 +203,41 @@ export async function listRemoteExitNodes( const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; + const latestPangolinNodeVersionPromise = getLatestPangolinNodeVersion(); + + const nodesWithUpdates = remoteExitNodesList.map((node) => ({ + ...node, + updateAvailable: false + })); + + try { + const latestPangolinNodeVersion = + await latestPangolinNodeVersionPromise; + + if (latestPangolinNodeVersion) { + nodesWithUpdates.forEach((node) => { + if (node.version) { + try { + node.updateAvailable = semver.lt( + node.version, + latestPangolinNodeVersion + ); + } catch { + node.updateAvailable = false; + } + } + }); + } + } catch (error) { + logger.warn( + "Failed to check for pangolin-node updates, continuing without update info:", + error + ); + } + return response(res, { data: { - remoteExitNodes: remoteExitNodesList, + remoteExitNodes: nodesWithUpdates, pagination: { total: totalCount, limit, diff --git a/server/routers/remoteExitNode/types.ts b/server/routers/remoteExitNode/types.ts index 25a7d6c53..9984b1b4f 100644 --- a/server/routers/remoteExitNode/types.ts +++ b/server/routers/remoteExitNode/types.ts @@ -21,6 +21,7 @@ export type ListRemoteExitNodesResponse = { remoteExitNodeId: string; dateCreated: string; version: string | null; + updateAvailable?: boolean; exitNodeId: number | null; name: string; address: string; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx index 2c34d92ec..890a14564 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx @@ -45,6 +45,7 @@ export default async function RemoteExitNodesPage( type: node.type, dateCreated: node.dateCreated, version: node.version || undefined, + updateAvailable: node.updateAvailable, orgId: params.orgId }; } diff --git a/src/components/ExitNodesTable.tsx b/src/components/ExitNodesTable.tsx index 67d819a47..73e96a96c 100644 --- a/src/components/ExitNodesTable.tsx +++ b/src/components/ExitNodesTable.tsx @@ -21,6 +21,7 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import { Badge } from "@app/components/ui/badge"; +import { InfoPopup } from "@app/components/ui/info-popup"; export type RemoteExitNodeRow = { id: string; @@ -33,6 +34,7 @@ export type RemoteExitNodeRow = { online: boolean; dateCreated: string; version?: string; + updateAvailable?: boolean; }; type ExitNodesTableProps = { @@ -233,13 +235,18 @@ export default function ExitNodesTable({ const originalRow = row.original; return (
- {originalRow.version && originalRow.version ? ( + {originalRow.version ? ( {"v" + originalRow.version} ) : ( "-" )} + {originalRow.updateAvailable && ( + + )}
); }