From 6d4afd0953e4df3266a29b219b275caa9a004d9c Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 15:43:31 -0700 Subject: [PATCH] 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" + ) + } + ) + )} + + + + ); + }} + /> + )}