From ed73d089d0f6008f4804c96f38c781100172d193 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 10:26:04 -0700 Subject: [PATCH 1/5] Auto update newt --- server/routers/external.ts | 16 ++ server/routers/newt/getNewtVersion.ts | 228 ++++++++++++++++++++++++++ server/routers/newt/index.ts | 1 + 3 files changed, 245 insertions(+) create mode 100644 server/routers/newt/getNewtVersion.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index a17c88fb1..dfd0c2c7a 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1231,6 +1231,22 @@ authRouter.post( newt.getNewtToken ); +authRouter.post( + "/newt/version", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 60, + keyGenerator: (req) => + `newtVersion:${req.body.newtId || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only check the Newt version ${60} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + newt.getNewtVersion +); + authRouter.post( "/newt/register", rateLimit({ diff --git a/server/routers/newt/getNewtVersion.ts b/server/routers/newt/getNewtVersion.ts new file mode 100644 index 000000000..96511d62a --- /dev/null +++ b/server/routers/newt/getNewtVersion.ts @@ -0,0 +1,228 @@ +import { db } from "@server/db"; +import { newts } from "@server/db"; +import { eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import semver from "semver"; +import { verifyPassword } from "@server/auth/password"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import cache from "#dynamic/lib/cache"; +import config from "@server/lib/config"; + +// Stale-while-revalidate cache for the latest newt version. +let staleNewtVersion: string | null = null; + +async function getLatestNewtVersion(): Promise { + try { + const cachedVersion = await cache.get( + "cache:latestNewtVersion" + ); + if (cachedVersion) { + return cachedVersion; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); + + const fetchResponse = await fetch( + "https://api.github.com/repos/fosrl/newt/tags", + { signal: controller.signal } + ); + + clearTimeout(timeoutId); + + if (!fetchResponse.ok) { + logger.warn( + `Failed to fetch latest Newt version from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}` + ); + return staleNewtVersion; + } + + let tags = await fetchResponse.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for Newt repository"); + return staleNewtVersion; + } + + 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 Newt repository"); + return staleNewtVersion; + } + + const latestVersion = tags[0].name; + staleNewtVersion = latestVersion; + await cache.set("cache:latestNewtVersion", latestVersion, 3600); + + return latestVersion; + } catch (error: any) { + if (error.name === "AbortError") { + logger.warn( + "Request to fetch latest Newt version timed out (1.5s)" + ); + } else { + logger.warn( + "Error fetching latest Newt version:", + error.message || error + ); + } + return staleNewtVersion; + } +} + +const bodySchema = z.object({ + newtId: z.string(), + secret: z.string(), + platform: z.string() // e.g. "linux_amd64", "darwin_arm64" +}); + +export type GetNewtVersionBody = z.infer; + +export type GetNewtVersionResponse = { + latestVersion: string; + currentIsLatest: boolean; + downloadUrl: string; +}; + +export async function getNewtVersion( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { newtId, secret, platform } = parsedBody.data; + + try { + // Verify newt credentials + const existingNewtRes = await db + .select() + .from(newts) + .where(eq(newts.newtId, newtId)); + + if (!existingNewtRes || !existingNewtRes.length) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Newt version check: no newt found with ID ${newtId}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Invalid credentials" + ) + ); + } + + const existingNewt = existingNewtRes[0]; + + const validSecret = await verifyPassword( + secret, + existingNewt.secretHash + ); + if (!validSecret) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Newt version check: invalid secret for newt ID ${newtId}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Invalid credentials" + ) + ); + } + + // Fetch latest version + const latestVersion = await getLatestNewtVersion(); + + if (!latestVersion) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Unable to determine latest Newt version" + ) + ); + } + + // Normalise the tag (strip leading 'v' for the URL, but keep original for comparison) + const tagForUrl = latestVersion.startsWith("v") + ? latestVersion + : `v${latestVersion}`; + + // Binary name follows the get-newt.sh convention: newt_[.exe] + const binaryName = + platform.includes("windows") + ? `newt_${platform}.exe` + : `newt_${platform}`; + + const downloadUrl = `https://github.com/fosrl/newt/releases/download/${tagForUrl}/${binaryName}`; + + // Determine whether the newt that's asking is already up to date. + // We store the current version on the newt row when it registers. + const currentVersion = existingNewt.version ?? null; + let currentIsLatest = false; + if (currentVersion) { + try { + const latest = semver.coerce(latestVersion); + const current = semver.coerce(currentVersion); + if (latest && current) { + currentIsLatest = !semver.lt(current, latest); + } + } catch { + // If we can't compare, assume not latest + } + } + + return response(res, { + data: { + latestVersion, + currentIsLatest, + downloadUrl + }, + success: true, + error: false, + message: "Version info retrieved successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to retrieve version info" + ) + ); + } +} diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index 368cdf636..1f6090358 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -1,5 +1,6 @@ export * from "./createNewt"; export * from "./getNewtToken"; +export * from "./getNewtVersion"; export * from "./handleNewtRegisterMessage"; export * from "./handleReceiveBandwidthMessage"; export * from "./handleNewtGetConfigMessage"; From dee0ca6864afa6fd726ccfe8a222994e0e2d8153 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 14:34:16 -0700 Subject: [PATCH 2/5] Add permissions check, shasum check, & build info --- server/routers/newt/getNewtVersion.ts | 137 +++++++++++++++----------- 1 file changed, 80 insertions(+), 57 deletions(-) diff --git a/server/routers/newt/getNewtVersion.ts b/server/routers/newt/getNewtVersion.ts index 96511d62a..e3be43748 100644 --- a/server/routers/newt/getNewtVersion.ts +++ b/server/routers/newt/getNewtVersion.ts @@ -13,23 +13,31 @@ import logger from "@server/logger"; import cache from "#dynamic/lib/cache"; import config from "@server/lib/config"; -// Stale-while-revalidate cache for the latest newt version. -let staleNewtVersion: string | null = null; +// 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; -async function getLatestNewtVersion(): Promise { +/** + * 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. + */ +async function getLatestReleaseInfo(): Promise { try { - const cachedVersion = await cache.get( - "cache:latestNewtVersion" - ); - if (cachedVersion) { - return cachedVersion; + const cached = await cache.get("cache:newtReleaseInfo"); + if (cached) { + return cached; } const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); + const timeoutId = setTimeout(() => controller.abort(), 5000); const fetchResponse = await fetch( - "https://api.github.com/repos/fosrl/newt/tags", + "https://api.github.com/repos/fosrl/newt/releases", { signal: controller.signal } ); @@ -37,57 +45,71 @@ async function getLatestNewtVersion(): Promise { if (!fetchResponse.ok) { logger.warn( - `Failed to fetch latest Newt version from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}` + `Failed to fetch Newt releases from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}` ); - return staleNewtVersion; + return staleReleaseInfo; } - let tags = await fetchResponse.json(); - if (!Array.isArray(tags) || tags.length === 0) { - logger.warn("No tags found for Newt repository"); - return staleNewtVersion; + let releases: any[] = await fetchResponse.json(); + if (!Array.isArray(releases) || releases.length === 0) { + logger.warn("No releases found for Newt repository"); + return staleReleaseInfo; } - 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); + // Drop drafts, pre-releases, and anything with "rc" in the tag name. + releases = releases.filter( + (r: any) => !r.draft && !r.prerelease && !r.tag_name.includes("rc") + ); + + // Sort descending by semver to find the true latest stable release. + releases.sort((a: any, b: any) => { + const va = semver.coerce(a.tag_name); + const vb = semver.coerce(b.tag_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 Newt repository"); - return staleNewtVersion; + if (releases.length === 0) { + logger.warn("No stable releases found for Newt repository"); + return staleReleaseInfo; } - const latestVersion = tags[0].name; - staleNewtVersion = latestVersion; - await cache.set("cache:latestNewtVersion", latestVersion, 3600); + const latest = releases[0]; + const version: string = latest.tag_name; - return latestVersion; + // Build a map of binary filename → sha256 hex from the asset `digest` + // field returned by the GitHub API (format: "sha256:"). + const assetDigests: Record = {}; + if (Array.isArray(latest.assets)) { + for (const asset of latest.assets) { + if ( + typeof asset.name === "string" && + typeof asset.digest === "string" && + asset.digest.startsWith("sha256:") + ) { + assetDigests[asset.name] = asset.digest.slice( + "sha256:".length + ); + } + } + } + + const info: ReleaseInfo = { version, assetDigests }; + staleReleaseInfo = info; + await cache.set("cache:newtReleaseInfo", info, 3600); + return info; } catch (error: any) { if (error.name === "AbortError") { - logger.warn( - "Request to fetch latest Newt version timed out (1.5s)" - ); + logger.warn("Request to fetch Newt releases timed out (5s)"); } else { logger.warn( - "Error fetching latest Newt version:", + "Error fetching Newt releases:", error.message || error ); } - return staleNewtVersion; + return staleReleaseInfo; } } @@ -103,6 +125,7 @@ export type GetNewtVersionResponse = { latestVersion: string; currentIsLatest: boolean; downloadUrl: string; + sha256: string; }; export async function getNewtVersion( @@ -137,10 +160,7 @@ export async function getNewtVersion( ); } return next( - createHttpError( - HttpCode.UNAUTHORIZED, - "Invalid credentials" - ) + createHttpError(HttpCode.UNAUTHORIZED, "Invalid credentials") ); } @@ -157,17 +177,14 @@ export async function getNewtVersion( ); } return next( - createHttpError( - HttpCode.UNAUTHORIZED, - "Invalid credentials" - ) + createHttpError(HttpCode.UNAUTHORIZED, "Invalid credentials") ); } - // Fetch latest version - const latestVersion = await getLatestNewtVersion(); + // Fetch latest release info (version + asset digests) in one API call. + const releaseInfo = await getLatestReleaseInfo(); - if (!latestVersion) { + if (!releaseInfo) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, @@ -176,19 +193,24 @@ export async function getNewtVersion( ); } - // Normalise the tag (strip leading 'v' for the URL, but keep original for comparison) + const latestVersion = releaseInfo.version; + + // Normalise the tag (ensure leading 'v') for the download URL. const tagForUrl = latestVersion.startsWith("v") ? latestVersion : `v${latestVersion}`; // Binary name follows the get-newt.sh convention: newt_[.exe] - const binaryName = - platform.includes("windows") - ? `newt_${platform}.exe` - : `newt_${platform}`; + const binaryName = platform.includes("windows") + ? `newt_${platform}.exe` + : `newt_${platform}`; const downloadUrl = `https://github.com/fosrl/newt/releases/download/${tagForUrl}/${binaryName}`; + // 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] ?? ""; + // Determine whether the newt that's asking is already up to date. // We store the current version on the newt row when it registers. const currentVersion = existingNewt.version ?? null; @@ -209,7 +231,8 @@ export async function getNewtVersion( data: { latestVersion, currentIsLatest, - downloadUrl + downloadUrl, + sha256 }, success: true, error: false, From 6d4afd0953e4df3266a29b219b275caa9a004d9c Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 15:43:31 -0700 Subject: [PATCH 3/5] Control updates from the ui --- messages/en-US.json | 11 ++ server/db/pg/schema/schema.ts | 11 +- server/db/sqlite/schema/schema.ts | 16 ++- server/lib/billing/tierMatrix.ts | 6 +- server/routers/newt/getNewtVersion.ts | 77 +++++++++++- server/routers/org/updateOrg.ts | 40 ++++-- server/routers/site/updateSite.ts | 47 ++++--- src/app/[orgId]/settings/general/page.tsx | 54 +++++++- .../settings/sites/[niceId]/general/page.tsx | 115 +++++++++++++++++- 9 files changed, 333 insertions(+), 44 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index aa8f902ff..4f826a609 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1602,6 +1602,17 @@ "parsedContents": "Parsed Contents (Read Only)", "enableDockerSocket": "Enable Docker Blueprint", "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt. Read about how this works in the documentation.", + "newtAutoUpdate": "Enable Newt Auto-Update", + "newtAutoUpdateDescription": "When enabled, Newt clients will automatically update to the latest version when a new release is available.", + "newtAutoUpdateDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "siteAutoUpdate": "Newt Auto-Update", + "siteAutoUpdateLabel": "Enable Auto-Update", + "siteAutoUpdateDescription": "Control whether this site's Newt client automatically updates. When not overriding, the organization default is used.", + "siteAutoUpdateOrgDefault": "Organization default: {state}", + "siteAutoUpdateOverriding": "Overriding organization setting", + "siteAutoUpdateResetToOrg": "Reset to organization default", + "siteAutoUpdateEnabled": "enabled", + "siteAutoUpdateDisabled": "disabled", "viewDockerContainers": "View Docker Containers", "containersIn": "Containers in {siteName}", "selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 58e78735c..e2b80f0b3 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -65,7 +65,12 @@ export const orgs = pgTable("orgs", { sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) isBillingOrg: boolean("isBillingOrg"), - billingOrgId: varchar("billingOrgId") + billingOrgId: varchar("billingOrgId"), + settingsEnableGlobalNewtAutoUpdate: boolean( + "settingsEnableGlobalNewtAutoUpdate" + ) + .notNull() + .default(false) }); export const orgDomains = pgTable("orgDomains", { @@ -103,6 +108,10 @@ export const sites = pgTable("sites", { lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), + autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false), + autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg") + .notNull() + .default(false), status: varchar("status") .$type<"pending" | "approved">() .default("approved") diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e3e83d222..d4cafef25 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -62,7 +62,13 @@ export const orgs = sqliteTable("orgs", { sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) isBillingOrg: integer("isBillingOrg", { mode: "boolean" }), - billingOrgId: text("billingOrgId") + billingOrgId: text("billingOrgId"), + settingsEnableGlobalNewtAutoUpdate: integer( + "settingsEnableGlobalNewtAutoUpdate", + { mode: "boolean" } + ) + .notNull() + .default(false) }); export const userDomains = sqliteTable("userDomains", { @@ -116,6 +122,14 @@ export const sites = sqliteTable("sites", { dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() .default(true), + autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" }) + .notNull() + .default(false), + autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", { + mode: "boolean" + }) + .notNull() + .default(false), status: text("status").$type<"pending" | "approved">().default("approved") }); diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index cd6ec59af..307465804 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -25,7 +25,8 @@ export enum TierFeature { StandaloneHealthChecks = "standaloneHealthChecks", AlertingRules = "alertingRules", WildcardSubdomain = "wildcardSubdomain", - Labels = "labels" + Labels = "labels", + NewtAutoUpdate = "newtAutoUpdate" } export const tierMatrix: Record = { @@ -68,5 +69,6 @@ export const tierMatrix: Record = { [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"], [TierFeature.AlertingRules]: ["tier3", "enterprise"], - [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"] + [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"] }; diff --git a/server/routers/newt/getNewtVersion.ts b/server/routers/newt/getNewtVersion.ts index e3be43748..5461e607c 100644 --- a/server/routers/newt/getNewtVersion.ts +++ b/server/routers/newt/getNewtVersion.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, orgs, sites } from "@server/db"; import { newts } from "@server/db"; import { eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; @@ -148,12 +148,13 @@ export async function getNewtVersion( try { // Verify newt credentials - const existingNewtRes = await db + const [existingNewt] = await db .select() .from(newts) - .where(eq(newts.newtId, newtId)); + .where(eq(newts.newtId, newtId)) + .limit(1); - if (!existingNewtRes || !existingNewtRes.length) { + if (!existingNewt) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Newt version check: no newt found with ID ${newtId}. IP: ${req.ip}.` @@ -164,7 +165,15 @@ export async function getNewtVersion( ); } - const existingNewt = existingNewtRes[0]; + if (!existingNewt.siteId) { + logger.warn(`Newt ${newtId} has no associated site`); + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Not associated with a site" + ) + ); + } const validSecret = await verifyPassword( secret, @@ -181,6 +190,64 @@ export async function getNewtVersion( ); } + // check if udpates are enabled for the org or the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, existingNewt.siteId)) + .limit(1); + + if (!site) { + logger.warn(`Site with ID ${existingNewt.siteId} not found`); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Associated site not found" + ) + ); + } + + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, site.orgId)) + .limit(1); + + if (!org) { + logger.warn(`Org with ID ${site.orgId} not found`); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Associated organization not found" + ) + ); + } + + let doUpdate = false; + + if (site.autoUpdateOverrideOrg) { + doUpdate = site.autoUpdateEnabled; + } else { + doUpdate = org.settingsEnableGlobalNewtAutoUpdate; + } + + if (!doUpdate) { + // return no content http code + return response(res, { + data: { + latestVersion: existingNewt.version ?? "", + currentIsLatest: true, + downloadUrl: "", + sha256: "" + }, + success: true, + error: false, + message: + "Auto-updates are disabled for this site and organization", + status: HttpCode.NO_CONTENT + }); + } + // Fetch latest release info (version + asset digests) in one API call. const releaseInfo = await getLatestReleaseInfo(); diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 4eca9a9a6..4f59403f2 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -40,7 +40,8 @@ const updateOrgBodySchema = z settingsLogRetentionDaysConnection: z .number() .min(build === "saas" ? 0 : -1) - .optional() + .optional(), + settingsEnableGlobalNewtAutoUpdate: z.boolean().optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -118,6 +119,15 @@ export async function updateOrg( if (!hasPasswordExpirationFeature) { parsedBody.data.passwordExpiryDays = undefined; } + + const hasNewtAutoUpdateFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.NewtAutoUpdate] + ); + if (!hasNewtAutoUpdateFeature) { + parsedBody.data.settingsEnableGlobalNewtAutoUpdate = false; // force it off + } + if (build == "saas") { const { tier } = await getOrgTierData(orgId); @@ -136,8 +146,10 @@ export async function updateOrg( if (maxRetentionDays !== null) { if ( - parsedBody.data.settingsLogRetentionDaysRequest !== undefined && - parsedBody.data.settingsLogRetentionDaysRequest > maxRetentionDays + parsedBody.data.settingsLogRetentionDaysRequest !== + undefined && + parsedBody.data.settingsLogRetentionDaysRequest > + maxRetentionDays ) { return next( createHttpError( @@ -147,8 +159,10 @@ export async function updateOrg( ); } if ( - parsedBody.data.settingsLogRetentionDaysAccess !== undefined && - parsedBody.data.settingsLogRetentionDaysAccess > maxRetentionDays + parsedBody.data.settingsLogRetentionDaysAccess !== + undefined && + parsedBody.data.settingsLogRetentionDaysAccess > + maxRetentionDays ) { return next( createHttpError( @@ -158,8 +172,10 @@ export async function updateOrg( ); } if ( - parsedBody.data.settingsLogRetentionDaysAction !== undefined && - parsedBody.data.settingsLogRetentionDaysAction > maxRetentionDays + parsedBody.data.settingsLogRetentionDaysAction !== + undefined && + parsedBody.data.settingsLogRetentionDaysAction > + maxRetentionDays ) { return next( createHttpError( @@ -169,8 +185,10 @@ export async function updateOrg( ); } if ( - parsedBody.data.settingsLogRetentionDaysConnection !== undefined && - parsedBody.data.settingsLogRetentionDaysConnection > maxRetentionDays + parsedBody.data.settingsLogRetentionDaysConnection !== + undefined && + parsedBody.data.settingsLogRetentionDaysConnection > + maxRetentionDays ) { return next( createHttpError( @@ -196,7 +214,9 @@ export async function updateOrg( settingsLogRetentionDaysAction: parsedBody.data.settingsLogRetentionDaysAction, settingsLogRetentionDaysConnection: - parsedBody.data.settingsLogRetentionDaysConnection + parsedBody.data.settingsLogRetentionDaysConnection, + settingsEnableGlobalNewtAutoUpdate: + parsedBody.data.settingsEnableGlobalNewtAutoUpdate }) .where(eq(orgs.orgId, orgId)) .returning(); diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index 34d1341d7..7a8021cf1 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, Site } from "@server/db"; import { sites } from "@server/db"; import { eq, and, ne } from "drizzle-orm"; import response from "@server/lib/response"; @@ -9,7 +9,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { isValidCIDR } from "@server/lib/validators"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; const updateSiteParamsSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) @@ -21,18 +22,8 @@ const updateSiteBodySchema = z niceId: z.string().min(1).max(255).optional(), dockerSocketEnabled: z.boolean().optional(), status: z.enum(["pending", "approved"]).optional(), - // remoteSubnets: z.string().optional() - // subdomain: z - // .string() - // .min(1) - // .max(255) - // .transform((val) => val.toLowerCase()) - // .optional() - // pubKey: z.string().optional(), - // subnet: z.string().optional(), - // exitNode: z.number().int().positive().optional(), - // megabytesIn: z.number().int().nonnegative().optional(), - // megabytesOut: z.number().int().nonnegative().optional(), + autoUpdateEnabled: z.boolean().optional(), + autoUpdateOverrideOrg: z.boolean().optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -85,9 +76,24 @@ export async function updateSite( const { siteId } = parsedParams.data; const updateData = parsedBody.data; + const [existingSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!existingSite) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + // if niceId is provided, check if it's already in use by another site if (updateData.niceId) { - const [existingSite] = await db + const [existingSiteNiceIdOverlap] = await db .select() .from(sites) .where( @@ -99,7 +105,7 @@ export async function updateSite( ) .limit(1); - if (existingSite) { + if (existingSiteNiceIdOverlap) { return next( createHttpError( HttpCode.CONFLICT, @@ -109,6 +115,15 @@ export async function updateSite( } } + const hasNewtAutoUpdateFeature = await isLicensedOrSubscribed( + existingSite.orgId, + tierMatrix[TierFeature.NewtAutoUpdate] + ); + if (!hasNewtAutoUpdateFeature) { + parsedBody.data.autoUpdateEnabled = false; // force it off + parsedBody.data.autoUpdateOverrideOrg = false; // force it off + } + // // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs // if (updateData.remoteSubnets) { // const subnets = updateData.remoteSubnets diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 0a2ed39bb..009f2c830 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -38,11 +38,16 @@ import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; import type { OrgContextType } from "@app/contexts/orgContext"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; // Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), - subnet: z.string().optional() + subnet: z.string().optional(), + settingsEnableGlobalNewtAutoUpdate: z.boolean().optional() }); export default function GeneralPage() { @@ -163,17 +168,24 @@ function GeneralSectionForm({ org }: SectionFormProps) { resolver: zodResolver( GeneralFormSchema.pick({ name: true, - subnet: true + subnet: true, + settingsEnableGlobalNewtAutoUpdate: true }) ), defaultValues: { name: org.name, - subnet: org.subnet || "" // Add default value for subnet + subnet: org.subnet || "", + settingsEnableGlobalNewtAutoUpdate: + org.settingsEnableGlobalNewtAutoUpdate ?? false }, mode: "onChange" }); const t = useTranslations(); const router = useRouter(); + const { isPaidUser } = usePaidStatus(); + const hasAutoUpdateFeature = isPaidUser( + tierMatrix[TierFeature.NewtAutoUpdate] + ); const [, formAction, loadingSave] = useActionState(performSave, null); const api = createApiClient(useEnvContext()); @@ -186,7 +198,9 @@ function GeneralSectionForm({ org }: SectionFormProps) { try { const reqData = { - name: data.name + name: data.name, + settingsEnableGlobalNewtAutoUpdate: + data.settingsEnableGlobalNewtAutoUpdate } as any; // Update organization @@ -194,7 +208,9 @@ function GeneralSectionForm({ org }: SectionFormProps) { // Update the org context to reflect the change in the info card updateOrg({ - name: data.name + name: data.name, + settingsEnableGlobalNewtAutoUpdate: + data.settingsEnableGlobalNewtAutoUpdate }); toast({ @@ -243,6 +259,34 @@ function GeneralSectionForm({ org }: SectionFormProps) { )} /> + + ( + + + + + + {hasAutoUpdateFeature + ? t("newtAutoUpdateDescription") + : t( + "newtAutoUpdateDisabledDescription" + )} + + + + )} + /> diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index f8bb6cf5e..9c1bb467d 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -36,35 +36,53 @@ import { useState } from "react"; import { SwitchInput } from "@app/components/SwitchInput"; import { ExternalLink } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; +import { Button as ButtonUI } from "@/components/ui/button"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), niceId: z.string().min(1).max(255).optional(), - dockerSocketEnabled: z.boolean().optional() + dockerSocketEnabled: z.boolean().optional(), + autoUpdateEnabled: z.boolean().optional(), + autoUpdateOverrideOrg: z.boolean().optional() }); type GeneralFormValues = z.infer; export default function GeneralPage() { const { site, updateSite } = useSiteContext(); + const { org } = useOrgContext(); const { env } = useEnvContext(); const api = createApiClient(useEnvContext()); const router = useRouter(); const t = useTranslations(); const { toast } = useToast(); + const { isPaidUser } = usePaidStatus(); + const hasAutoUpdateFeature = isPaidUser( + tierMatrix[TierFeature.NewtAutoUpdate] + ); const [loading, setLoading] = useState(false); const [activeCidrTagIndex, setActiveCidrTagIndex] = useState( null ); + const orgAutoUpdate = + org.org.settingsEnableGlobalNewtAutoUpdate ?? false; + const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { name: site?.name, niceId: site?.niceId || "", - dockerSocketEnabled: site?.dockerSocketEnabled ?? false + dockerSocketEnabled: site?.dockerSocketEnabled ?? false, + autoUpdateEnabled: site?.autoUpdateOverrideOrg + ? (site?.autoUpdateEnabled ?? false) + : orgAutoUpdate, + autoUpdateOverrideOrg: site?.autoUpdateOverrideOrg ?? false }, mode: "onChange" }); @@ -76,13 +94,17 @@ export default function GeneralPage() { await api.post(`/site/${site?.siteId}`, { name: data.name, niceId: data.niceId, - dockerSocketEnabled: data.dockerSocketEnabled + dockerSocketEnabled: data.dockerSocketEnabled, + autoUpdateEnabled: data.autoUpdateEnabled, + autoUpdateOverrideOrg: data.autoUpdateOverrideOrg }); updateSite({ name: data.name, niceId: data.niceId, - dockerSocketEnabled: data.dockerSocketEnabled + dockerSocketEnabled: data.dockerSocketEnabled, + autoUpdateEnabled: data.autoUpdateEnabled, + autoUpdateOverrideOrg: data.autoUpdateOverrideOrg }); if (data.niceId && data.niceId !== site?.niceId) { @@ -217,6 +239,91 @@ export default function GeneralPage() { )} /> )} + + {site && site.type === "newt" && ( + { + const isOverriding = + form.watch( + "autoUpdateOverrideOrg" + ); + return ( + + + { + field.onChange( + checked + ); + form.setValue( + "autoUpdateOverrideOrg", + true + ); + }} + disabled={ + !hasAutoUpdateFeature + } + /> + + + {isOverriding ? ( + + + {t( + "siteAutoUpdateOverriding" + )} + + { + form.setValue( + "autoUpdateOverrideOrg", + false + ); + form.setValue( + "autoUpdateEnabled", + orgAutoUpdate + ); + }} + > + {t( + "siteAutoUpdateResetToOrg" + )} + + + ) : ( + t( + "siteAutoUpdateOrgDefault", + { + state: orgAutoUpdate + ? t( + "siteAutoUpdateEnabled" + ) + : t( + "siteAutoUpdateDisabled" + ) + } + ) + )} + + + + ); + }} + /> + )} From 4530aac4f3272becaf8371a0bf274141c8974859 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 16:21:23 -0700 Subject: [PATCH 4/5] Update setting is working Adjust the ui Adjust description --- messages/en-US.json | 13 ++- src/app/[orgId]/settings/general/page.tsx | 14 ++- .../settings/sites/[niceId]/general/page.tsx | 93 +++++++++---------- .../settings/sites/[niceId]/layout.tsx | 25 ++++- src/components/SwitchInput.tsx | 11 ++- 5 files changed, 85 insertions(+), 71 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 4f826a609..dd8de3773 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1601,16 +1601,15 @@ "contents": "Contents", "parsedContents": "Parsed Contents (Read Only)", "enableDockerSocket": "Enable Docker Blueprint", - "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt. Read about how this works in the documentation.", - "newtAutoUpdate": "Enable Newt Auto-Update", - "newtAutoUpdateDescription": "When enabled, Newt clients will automatically update to the latest version when a new release is available.", - "newtAutoUpdateDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", - "siteAutoUpdate": "Newt Auto-Update", + "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to the site connector. Read about how this works in the documentation.", + "newtAutoUpdate": "Enable Site Auto-Update", + "newtAutoUpdateDescription": "When enabled, site connectors will automatically update to the latest version when a new release is available.", + "siteAutoUpdate": "Site Auto-Update", "siteAutoUpdateLabel": "Enable Auto-Update", - "siteAutoUpdateDescription": "Control whether this site's Newt client automatically updates. When not overriding, the organization default is used.", + "siteAutoUpdateDescription": "Control whether this site's connector automatically downloads the latest version.", "siteAutoUpdateOrgDefault": "Organization default: {state}", "siteAutoUpdateOverriding": "Overriding organization setting", - "siteAutoUpdateResetToOrg": "Reset to organization default", + "siteAutoUpdateResetToOrg": "Reset to Organization Default", "siteAutoUpdateEnabled": "enabled", "siteAutoUpdateDisabled": "disabled", "viewDockerContainers": "View Docker Containers", diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 009f2c830..bef9b0cd7 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -217,6 +217,7 @@ function GeneralSectionForm({ org }: SectionFormProps) { title: t("orgUpdated"), description: t("orgUpdatedDescription") }); + router.refresh(); } catch (e) { toast({ @@ -260,6 +261,9 @@ function GeneralSectionForm({ org }: SectionFormProps) { )} /> + - {hasAutoUpdateFeature - ? t("newtAutoUpdateDescription") - : t( - "newtAutoUpdateDisabledDescription" - )} + {t("newtAutoUpdateDescription")} diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 9c1bb467d..cb3f0706d 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -40,6 +40,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { Button as ButtonUI } from "@/components/ui/button"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), @@ -70,8 +71,7 @@ export default function GeneralPage() { null ); - const orgAutoUpdate = - org.org.settingsEnableGlobalNewtAutoUpdate ?? false; + const orgAutoUpdate = org.org.settingsEnableGlobalNewtAutoUpdate ?? false; const form = useForm({ resolver: zodResolver(GeneralFormSchema), @@ -221,7 +221,9 @@ export default function GeneralPage() { {t.rich( "enableDockerSocketDescription", { - docsLink: (chunks) => ( + docsLink: ( + chunks + ) => ( )} + {site && site.type === "newt" && ( { - const isOverriding = - form.watch( - "autoUpdateOverrideOrg" - ); + const isOverriding = form.watch( + "autoUpdateOverrideOrg" + ); return ( - { - field.onChange( +
+ - - - {isOverriding ? ( - - - {t( - "siteAutoUpdateOverriding" - )} - + ) => { + field.onChange( + checked + ); + form.setValue( + "autoUpdateOverrideOrg", + true + ); + }} + disabled={ + !hasAutoUpdateFeature + } + /> + {isOverriding && ( { form.setValue( "autoUpdateOverrideOrg", @@ -302,20 +301,12 @@ export default function GeneralPage() { "siteAutoUpdateResetToOrg" )} - - ) : ( - t( - "siteAutoUpdateOrgDefault", - { - state: orgAutoUpdate - ? t( - "siteAutoUpdateEnabled" - ) - : t( - "siteAutoUpdateDisabled" - ) - } - ) + )} +
+
+ + {t( + "siteAutoUpdateDescription" )} diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index ba65d06e0..ef79908c3 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -1,6 +1,8 @@ import SiteProvider from "@app/providers/SiteProvider"; +import OrgProvider from "@app/providers/OrgProvider"; import { internal } from "@app/lib/api"; import { GetSiteResponse } from "@server/routers/site"; +import { GetOrgResponse } from "@server/routers/org"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; @@ -35,6 +37,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { redirect(`/${params.orgId}/settings/sites`); } + let org = null; + try { + const res = await internal.get>( + `/org/${params.orgId}`, + await authCookieHeader() + ); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/sites`); + } + const t = await getTranslations(); const navItems = [ @@ -64,10 +77,14 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { /> -
- - {children} -
+ +
+ + + {children} + +
+
); diff --git a/src/components/SwitchInput.tsx b/src/components/SwitchInput.tsx index a33d00ffe..caaf910ba 100644 --- a/src/components/SwitchInput.tsx +++ b/src/components/SwitchInput.tsx @@ -45,7 +45,16 @@ export function SwitchInput({ return (
- {label && } + {label && ( + + )} Date: Thu, 21 May 2026 17:30:06 -0700 Subject: [PATCH 5/5] Working --- server/private/routers/ws/ws.ts | 12 ++++++------ server/routers/newt/getNewtVersion.ts | 13 ++++++------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index c01ebc9eb..a592927cc 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -522,13 +522,13 @@ const sendToClientLocal = async ( const messageString = JSON.stringify(messageWithVersion); if (options.compress) { - logger.debug( - `Message size before compression: ${messageString.length} bytes` - ); + // logger.debug( + // `Message size before compression: ${messageString.length} bytes` + // ); const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8")); - logger.debug( - `Message size after compression: ${compressed.length} bytes` - ); + // logger.debug( + // `Message size after compression: ${compressed.length} bytes` + // ); clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(compressed); diff --git a/server/routers/newt/getNewtVersion.ts b/server/routers/newt/getNewtVersion.ts index 5461e607c..0dd0eff18 100644 --- a/server/routers/newt/getNewtVersion.ts +++ b/server/routers/newt/getNewtVersion.ts @@ -58,7 +58,11 @@ async function getLatestReleaseInfo(): Promise { // Drop drafts, pre-releases, and anything with "rc" in the tag name. releases = releases.filter( - (r: any) => !r.draft && !r.prerelease && !r.tag_name.includes("rc") + (r: any) => + !r.draft && + !r.prerelease && + !r.tag_name.includes("rc") && + !r.tag_name.includes("v") ); // Sort descending by semver to find the true latest stable release. @@ -262,17 +266,12 @@ export async function getNewtVersion( const latestVersion = releaseInfo.version; - // Normalise the tag (ensure leading 'v') for the download URL. - const tagForUrl = latestVersion.startsWith("v") - ? latestVersion - : `v${latestVersion}`; - // Binary name follows the get-newt.sh convention: newt_[.exe] const binaryName = platform.includes("windows") ? `newt_${platform}.exe` : `newt_${platform}`; - const downloadUrl = `https://github.com/fosrl/newt/releases/download/${tagForUrl}/${binaryName}`; + const downloadUrl = `https://github.com/fosrl/newt/releases/download/${latestVersion}/${binaryName}`; // Look up the SHA256 digest for this specific binary from the GitHub // release asset metadata (the `digest` field, format "sha256:").