Store the cli version for display and handle auto update

This commit is contained in:
Owen
2026-09-14 11:26:07 -04:00
parent 018ee17304
commit b6c048c805
11 changed files with 93 additions and 46 deletions
+2
View File
@@ -682,6 +682,8 @@ export const newts = pgTable(
secretHash: varchar("secretHash").notNull(), secretHash: varchar("secretHash").notNull(),
dateCreated: varchar("dateCreated").notNull(), dateCreated: varchar("dateCreated").notNull(),
version: varchar("version"), version: varchar("version"),
agent: varchar("agent").default("newt"), // either newt or cli
agentVersion: varchar("agentVersion"),
siteId: integer("siteId").references(() => sites.siteId, { siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade" onDelete: "cascade"
}) })
+2
View File
@@ -703,6 +703,8 @@ export const newts = sqliteTable(
secretHash: text("secretHash").notNull(), secretHash: text("secretHash").notNull(),
dateCreated: text("dateCreated").notNull(), dateCreated: text("dateCreated").notNull(),
version: text("version"), version: text("version"),
agent: text("agent").default("newt"), // either newt or cli
agentVersion: text("agentVersion"),
siteId: integer("siteId").references(() => sites.siteId, { siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade" onDelete: "cascade"
}) })
+50 -25
View File
@@ -13,31 +13,40 @@ import logger from "@server/logger";
import { regionalCache as cache } from "#dynamic/lib/cache"; import { regionalCache as cache } from "#dynamic/lib/cache";
import config from "@server/lib/config"; import config from "@server/lib/config";
// Stale-while-revalidate in-memory fallback for the releases API.
type ReleaseInfo = { type ReleaseInfo = {
version: string; version: string;
// binary filename -> sha256 hex (sourced from asset `digest` field in GitHub API) // binary filename -> sha256 hex (sourced from asset `digest` field in GitHub API)
assetDigests: Record<string, string>; assetDigests: Record<string, string>;
}; };
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 * Fetches the latest stable newt release from GitHub and returns the version
* tag together with a map of asset-name sha256 hex digest. * 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<ReleaseInfo | null> { async function getLatestReleaseInfo(repo: string): Promise<ReleaseInfo | null> {
try { const stored = await cache.get<ReleaseInfo>(RELEASE_INFO_KEY);
const cached = await cache.get<ReleaseInfo>("cache:newtReleaseInfo"); const isFresh = await cache.has(RELEASE_INFO_FRESH_KEY);
if (cached) { if (stored && isFresh) {
return cached; return stored;
} }
try {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); const timeoutId = setTimeout(() => controller.abort(), 5000);
const fetchResponse = await fetch( const fetchResponse = await fetch(
"https://api.github.com/repos/fosrl/newt/releases", `https://api.github.com/repos/fosrl/${repo}/releases`,
{ signal: controller.signal } { signal: controller.signal }
); );
@@ -47,13 +56,13 @@ async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
logger.warn( logger.warn(
`Failed to fetch Newt releases from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}` `Failed to fetch Newt releases from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}`
); );
return staleReleaseInfo; return stored ?? null;
} }
let releases: any[] = await fetchResponse.json(); let releases: any[] = await fetchResponse.json();
if (!Array.isArray(releases) || releases.length === 0) { if (!Array.isArray(releases) || releases.length === 0) {
logger.warn("No releases found for Newt repository"); logger.warn("No releases found for Newt repository");
return staleReleaseInfo; return stored ?? null;
} }
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
@@ -82,7 +91,7 @@ async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
if (releases.length === 0) { if (releases.length === 0) {
logger.warn("No stable releases found for Newt repository"); logger.warn("No stable releases found for Newt repository");
return staleReleaseInfo; return stored ?? null;
} }
const latest = releases[0]; const latest = releases[0];
@@ -106,8 +115,12 @@ async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
} }
const info: ReleaseInfo = { version, assetDigests }; const info: ReleaseInfo = { version, assetDigests };
staleReleaseInfo = info; await cache.set(RELEASE_INFO_KEY, info, 0);
await cache.set("cache:newtReleaseInfo", info, 3600); await cache.set(
RELEASE_INFO_FRESH_KEY,
true,
RELEASE_INFO_REFRESH_SECONDS
);
return info; return info;
} catch (error: any) { } catch (error: any) {
if (error.name === "AbortError") { if (error.name === "AbortError") {
@@ -118,14 +131,15 @@ async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
error.message || error error.message || error
); );
} }
return staleReleaseInfo; return stored ?? null;
} }
} }
const bodySchema = z.object({ const bodySchema = z.object({
newtId: z.string(), newtId: z.string(),
secret: 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<typeof bodySchema>; export type GetNewtVersionBody = z.infer<typeof bodySchema>;
@@ -153,7 +167,7 @@ export async function getNewtVersion(
); );
} }
const { newtId, secret, platform } = parsedBody.data; const { newtId, secret, platform, agent } = parsedBody.data;
try { try {
// Verify newt credentials // Verify newt credentials
@@ -258,9 +272,13 @@ export async function getNewtVersion(
} }
// Fetch latest release info (version + asset digests) in one API call. // 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( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, 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_<platform>[.exe] // Binary name follows the get-newt.sh convention: newt_<platform>[.exe]
const binaryName = platform.includes("windows") const binaryNameNewt = platform.includes("windows")
? `newt_${platform}.exe` ? `newt_${platform}.exe`
: `newt_${platform}`; : `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 // Look up the SHA256 digest for this specific binary from the GitHub
// release asset metadata (the `digest` field, format "sha256:<hex>"). // release asset metadata (the `digest` field, format "sha256:<hex>").
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. // Determine whether the newt that's asking is already up to date.
// We store the current version on the newt row when it registers. // We store the current version on the newt row when it registers.
@@ -300,8 +325,8 @@ export async function getNewtVersion(
return response<GetNewtVersionResponse>(res, { return response<GetNewtVersionResponse>(res, {
data: { data: {
latestVersion, latestVersion, // this will always be the newt version
currentIsLatest, currentIsLatest, // this will always be based on the newt version
downloadUrl, downloadUrl,
sha256 sha256
}, },
@@ -37,6 +37,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
publicKey, publicKey,
pingResults, pingResults,
newtVersion, newtVersion,
agent,
agentVersion,
backwardsCompatible, backwardsCompatible,
chainId chainId
} = message.data; } = message.data;
@@ -174,17 +176,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
await db await db
.update(newts) .update(newts)
.set({ .set({
version: newtVersion as string version: newtVersion as string,
}) agent: agent,
.where(eq(newts.newtId, newt.newtId)); agentVersion:
} !agentVersion && agent == "newt"
? newtVersion
if (newtVersion && newtVersion !== newt.version) { : agentVersion
// update the newt version in the database
await db
.update(newts)
.set({
version: newtVersion as string
}) })
.where(eq(newts.newtId, newt.newtId)); .where(eq(newts.newtId, newt.newtId));
} }
+4
View File
@@ -48,6 +48,8 @@ type SiteQueryRow = NonNullable<Awaited<ReturnType<typeof query>>>;
export type GetSiteResponse = SiteQueryRow["sites"] & { export type GetSiteResponse = SiteQueryRow["sites"] & {
newtId: string | null; newtId: string | null;
newtVersion: string | null; newtVersion: string | null;
agent: string | null;
agentVersion: string | null;
countryCode: string | null; countryCode: string | null;
}; };
@@ -137,6 +139,8 @@ export async function getSite(
...site.sites, ...site.sites,
newtId: site.newt ? site.newt.newtId : null, newtId: site.newt ? site.newt.newtId : null,
newtVersion: site.newt?.version ?? null, newtVersion: site.newt?.version ?? null,
agent: site.newt?.agent ?? null,
agentVersion: site.newt?.agentVersion ?? null,
countryCode: site.sites.endpoint countryCode: site.sites.endpoint
? ((await getCountryCodeForIp(site.sites.endpoint)) ?? null) ? ((await getCountryCodeForIp(site.sites.endpoint)) ?? null)
: null : null
+2
View File
@@ -133,6 +133,8 @@ function querySitesBase() {
online: sites.online, online: sites.online,
address: sites.address, address: sites.address,
newtVersion: newts.version, newtVersion: newts.version,
agent: newts.agent,
agentVersion: newts.agentVersion,
exitNodeId: sites.exitNodeId, exitNodeId: sites.exitNodeId,
exitNodeName: exitNodes.name, exitNodeName: exitNodes.name,
exitNodeEndpoint: exitNodes.endpoint, exitNodeEndpoint: exitNodes.endpoint,
@@ -74,6 +74,8 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) {
type: site.type as any, type: site.type as any,
online: site.online, online: site.online,
newtVersion: site.newtVersion || undefined, newtVersion: site.newtVersion || undefined,
agent: site.agent || undefined,
agentVersion: site.agentVersion || undefined,
newtUpdateAvailable: site.newtUpdateAvailable || false, newtUpdateAvailable: site.newtUpdateAvailable || false,
exitNodeName: site.exitNodeName || undefined, exitNodeName: site.exitNodeName || undefined,
exitNodeEndpoint: site.exitNodeEndpoint || undefined, exitNodeEndpoint: site.exitNodeEndpoint || undefined,
+2
View File
@@ -70,6 +70,8 @@ export default async function SitesPage(props: SitesPageProps) {
type: site.type as any, type: site.type as any,
online: site.online, online: site.online,
newtVersion: site.newtVersion || undefined, newtVersion: site.newtVersion || undefined,
agent: site.agent || undefined,
agentVersion: site.agentVersion || undefined,
newtUpdateAvailable: site.newtUpdateAvailable || false, newtUpdateAvailable: site.newtUpdateAvailable || false,
exitNodeName: site.exitNodeName || undefined, exitNodeName: site.exitNodeName || undefined,
exitNodeEndpoint: site.exitNodeEndpoint || undefined, exitNodeEndpoint: site.exitNodeEndpoint || undefined,
+6 -3
View File
@@ -319,13 +319,16 @@ export default function PendingSitesTable({
const originalRow = row.original; const originalRow = row.original;
if (originalRow.type === "newt") { if (originalRow.type === "newt") {
const isCli = originalRow.agent === "cli";
return ( return (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Badge variant="secondary"> <Badge variant="secondary">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<span>Newt</span> <span>{isCli ? "CLI" : "Newt"}</span>
{originalRow.newtVersion && ( {originalRow.agentVersion && (
<span>v{originalRow.newtVersion}</span> <span>
v{originalRow.agentVersion}
</span>
)} )}
</div> </div>
</Badge> </Badge>
+6 -3
View File
@@ -63,6 +63,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
) : null; ) : null;
if (site.type === "newt") { if (site.type === "newt") {
const isCli = site.agent === "cli";
return ( return (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
@@ -72,15 +73,17 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSectionTitle> <InfoSectionTitle>
{t("connectionType")} {t("connectionType")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent>Newt</InfoSectionContent> <InfoSectionContent>
{isCli ? "CLI" : "Newt"}
</InfoSectionContent>
</InfoSection> </InfoSection>
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("newtVersion")} {t("newtVersion")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{site.newtVersion {site.agentVersion
? `v${site.newtVersion}` ? `v${site.agentVersion}`
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
+8 -3
View File
@@ -67,6 +67,8 @@ export type SiteRow = {
orgId: string; orgId: string;
type: "newt" | "wireguard" | "local"; type: "newt" | "wireguard" | "local";
newtVersion?: string; newtVersion?: string;
agent?: string;
agentVersion?: string;
newtUpdateAvailable?: boolean; newtUpdateAvailable?: boolean;
online?: boolean | null; online?: boolean | null;
address?: string; address?: string;
@@ -384,14 +386,17 @@ export default function SitesTable({
); );
if (originalRow.type === "newt") { if (originalRow.type === "newt") {
const isCli = originalRow.agent === "cli";
return ( return (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Badge variant="secondary"> <Badge variant="secondary">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<span>Newt</span>
{originalRow.newtVersion && (
<span> <span>
v{originalRow.newtVersion} {isCli ? "CLI" : "Newt"}
</span>
{originalRow.agentVersion && (
<span>
v{originalRow.agentVersion}
</span> </span>
)} )}
</div> </div>