diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 7f4c04443..15bbd1992 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -682,6 +682,8 @@ export const newts = pgTable( secretHash: varchar("secretHash").notNull(), dateCreated: varchar("dateCreated").notNull(), version: varchar("version"), + agent: varchar("agent").default("newt"), // either newt or cli + agentVersion: varchar("agentVersion"), siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }) diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 2457785f3..51c1e76d2 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -703,6 +703,8 @@ export const newts = sqliteTable( secretHash: text("secretHash").notNull(), dateCreated: text("dateCreated").notNull(), version: text("version"), + agent: text("agent").default("newt"), // either newt or cli + agentVersion: text("agentVersion"), siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }) diff --git a/server/routers/newt/getNewtVersion.ts b/server/routers/newt/getNewtVersion.ts index b36ec8c16..afc087213 100644 --- a/server/routers/newt/getNewtVersion.ts +++ b/server/routers/newt/getNewtVersion.ts @@ -13,31 +13,40 @@ import logger from "@server/logger"; import { regionalCache as cache } from "#dynamic/lib/cache"; import config from "@server/lib/config"; -// Stale-while-revalidate in-memory fallback for the releases API. type ReleaseInfo = { version: string; // binary filename -> sha256 hex (sourced from asset `digest` field in GitHub API) assetDigests: Record; }; -let staleReleaseInfo: ReleaseInfo | null = null; + +// Cache key holding the last known good release info. It never expires, so +// it keeps serving if GitHub is unreachable, even across restarts/nodes. +const RELEASE_INFO_KEY = "cache:newtReleaseInfo"; +// Short-lived marker controlling how often we re-check GitHub. While it's +// missing (expired, or a previous attempt failed) every request retries. +const RELEASE_INFO_FRESH_KEY = "cache:newtReleaseInfoFresh"; +const RELEASE_INFO_REFRESH_SECONDS = 3600; /** * Fetches the latest stable newt release from GitHub and returns the version * tag together with a map of asset-name → sha256 hex digest. - * Results are cached for one hour; stale data is returned on failure. + * The last successful result is cached indefinitely and re-checked hourly; + * on failure the last known good data keeps being served and every + * subsequent request retries GitHub until it succeeds again. */ -async function getLatestReleaseInfo(): Promise { - try { - const cached = await cache.get("cache:newtReleaseInfo"); - if (cached) { - return cached; - } +async function getLatestReleaseInfo(repo: string): Promise { + const stored = await cache.get(RELEASE_INFO_KEY); + const isFresh = await cache.has(RELEASE_INFO_FRESH_KEY); + if (stored && isFresh) { + return stored; + } + try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); const fetchResponse = await fetch( - "https://api.github.com/repos/fosrl/newt/releases", + `https://api.github.com/repos/fosrl/${repo}/releases`, { signal: controller.signal } ); @@ -47,13 +56,13 @@ async function getLatestReleaseInfo(): Promise { logger.warn( `Failed to fetch Newt releases from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}` ); - return staleReleaseInfo; + return stored ?? null; } let releases: any[] = await fetchResponse.json(); if (!Array.isArray(releases) || releases.length === 0) { logger.warn("No releases found for Newt repository"); - return staleReleaseInfo; + return stored ?? null; } const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); @@ -82,7 +91,7 @@ async function getLatestReleaseInfo(): Promise { if (releases.length === 0) { logger.warn("No stable releases found for Newt repository"); - return staleReleaseInfo; + return stored ?? null; } const latest = releases[0]; @@ -106,8 +115,12 @@ async function getLatestReleaseInfo(): Promise { } const info: ReleaseInfo = { version, assetDigests }; - staleReleaseInfo = info; - await cache.set("cache:newtReleaseInfo", info, 3600); + await cache.set(RELEASE_INFO_KEY, info, 0); + await cache.set( + RELEASE_INFO_FRESH_KEY, + true, + RELEASE_INFO_REFRESH_SECONDS + ); return info; } catch (error: any) { if (error.name === "AbortError") { @@ -118,14 +131,15 @@ async function getLatestReleaseInfo(): Promise { error.message || error ); } - return staleReleaseInfo; + return stored ?? null; } } const bodySchema = z.object({ newtId: z.string(), secret: z.string(), - platform: z.string() // e.g. "linux_amd64", "darwin_arm64" + platform: z.string(), // e.g. "linux_amd64", "darwin_arm64" + agent: z.string().optional().default("newt") }); export type GetNewtVersionBody = z.infer; @@ -153,7 +167,7 @@ export async function getNewtVersion( ); } - const { newtId, secret, platform } = parsedBody.data; + const { newtId, secret, platform, agent } = parsedBody.data; try { // Verify newt credentials @@ -258,9 +272,13 @@ export async function getNewtVersion( } // Fetch latest release info (version + asset digests) in one API call. - const releaseInfo = await getLatestReleaseInfo(); + const releaseInfoNewt = await getLatestReleaseInfo("newt"); + let releaseInfoCli: ReleaseInfo | undefined | null; + if (agent == "cli") { + releaseInfoCli = await getLatestReleaseInfo("cli"); + } - if (!releaseInfo) { + if (!releaseInfoNewt || (agent == "cli" && !releaseInfoCli)) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, @@ -269,18 +287,25 @@ export async function getNewtVersion( ); } - const latestVersion = releaseInfo.version; + const latestVersion = releaseInfoNewt.version; // Binary name follows the get-newt.sh convention: newt_[.exe] - const binaryName = platform.includes("windows") + const binaryNameNewt = platform.includes("windows") ? `newt_${platform}.exe` : `newt_${platform}`; - const downloadUrl = `https://github.com/fosrl/newt/releases/download/${latestVersion}/${binaryName}`; + const binaryNameCli = platform.includes("windows") + ? `pangolin-cli_${platform}.exe` + : `pangolin-cli_${platform}`; + + const downloadUrl = `https://github.com/fosrl/newt/releases/download/${agent == "cli" ? releaseInfoCli?.version : releaseInfoNewt.version}/${agent == "cli" ? binaryNameCli : binaryNameNewt}`; // Look up the SHA256 digest for this specific binary from the GitHub // release asset metadata (the `digest` field, format "sha256:"). - const sha256 = releaseInfo.assetDigests[binaryName] ?? ""; + const sha256 = + releaseInfoNewt.assetDigests[ + agent == "cli" ? binaryNameCli : binaryNameNewt + ] ?? ""; // Determine whether the newt that's asking is already up to date. // We store the current version on the newt row when it registers. @@ -300,8 +325,8 @@ export async function getNewtVersion( return response(res, { data: { - latestVersion, - currentIsLatest, + latestVersion, // this will always be the newt version + currentIsLatest, // this will always be based on the newt version downloadUrl, sha256 }, diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index b04df1c83..1d0d1aef8 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -37,6 +37,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { publicKey, pingResults, newtVersion, + agent, + agentVersion, backwardsCompatible, chainId } = message.data; @@ -174,17 +176,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { await db .update(newts) .set({ - version: newtVersion as string - }) - .where(eq(newts.newtId, newt.newtId)); - } - - if (newtVersion && newtVersion !== newt.version) { - // update the newt version in the database - await db - .update(newts) - .set({ - version: newtVersion as string + version: newtVersion as string, + agent: agent, + agentVersion: + !agentVersion && agent == "newt" + ? newtVersion + : agentVersion }) .where(eq(newts.newtId, newt.newtId)); } diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index 885e3aa7a..d00cad98b 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -48,6 +48,8 @@ type SiteQueryRow = NonNullable>>; export type GetSiteResponse = SiteQueryRow["sites"] & { newtId: string | null; newtVersion: string | null; + agent: string | null; + agentVersion: string | null; countryCode: string | null; }; @@ -137,6 +139,8 @@ export async function getSite( ...site.sites, newtId: site.newt ? site.newt.newtId : null, newtVersion: site.newt?.version ?? null, + agent: site.newt?.agent ?? null, + agentVersion: site.newt?.agentVersion ?? null, countryCode: site.sites.endpoint ? ((await getCountryCodeForIp(site.sites.endpoint)) ?? null) : null diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 0d1a588a1..099981fac 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -133,6 +133,8 @@ function querySitesBase() { online: sites.online, address: sites.address, newtVersion: newts.version, + agent: newts.agent, + agentVersion: newts.agentVersion, exitNodeId: sites.exitNodeId, exitNodeName: exitNodes.name, exitNodeEndpoint: exitNodes.endpoint, diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx index a85b0d7d9..13b0e4560 100644 --- a/src/app/[orgId]/settings/provisioning/pending/page.tsx +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -74,6 +74,8 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) { type: site.type as any, online: site.online, newtVersion: site.newtVersion || undefined, + agent: site.agent || undefined, + agentVersion: site.agentVersion || undefined, newtUpdateAvailable: site.newtUpdateAvailable || false, exitNodeName: site.exitNodeName || undefined, exitNodeEndpoint: site.exitNodeEndpoint || undefined, diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 6542959a3..3bb8f58c2 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -70,6 +70,8 @@ export default async function SitesPage(props: SitesPageProps) { type: site.type as any, online: site.online, newtVersion: site.newtVersion || undefined, + agent: site.agent || undefined, + agentVersion: site.agentVersion || undefined, newtUpdateAvailable: site.newtUpdateAvailable || false, exitNodeName: site.exitNodeName || undefined, exitNodeEndpoint: site.exitNodeEndpoint || undefined, diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index 0c9777c1a..42a0b911d 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -319,13 +319,16 @@ export default function PendingSitesTable({ const originalRow = row.original; if (originalRow.type === "newt") { + const isCli = originalRow.agent === "cli"; return (
- Newt - {originalRow.newtVersion && ( - v{originalRow.newtVersion} + {isCli ? "CLI" : "Newt"} + {originalRow.agentVersion && ( + + v{originalRow.agentVersion} + )}
diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx index 8089e71b2..59743c82c 100644 --- a/src/components/SiteInfoCard.tsx +++ b/src/components/SiteInfoCard.tsx @@ -63,6 +63,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { ) : null; if (site.type === "newt") { + const isCli = site.agent === "cli"; return ( @@ -72,15 +73,17 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { {t("connectionType")} - Newt + + {isCli ? "CLI" : "Newt"} + {t("newtVersion")} - {site.newtVersion - ? `v${site.newtVersion}` + {site.agentVersion + ? `v${site.agentVersion}` : "-"} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 9366fc048..baf43fd24 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -67,6 +67,8 @@ export type SiteRow = { orgId: string; type: "newt" | "wireguard" | "local"; newtVersion?: string; + agent?: string; + agentVersion?: string; newtUpdateAvailable?: boolean; online?: boolean | null; address?: string; @@ -384,14 +386,17 @@ export default function SitesTable({ ); if (originalRow.type === "newt") { + const isCli = originalRow.agent === "cli"; return (
- Newt - {originalRow.newtVersion && ( + + {isCli ? "CLI" : "Newt"} + + {originalRow.agentVersion && ( - v{originalRow.newtVersion} + v{originalRow.agentVersion} )}