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"
+ )
+ }
+ )
+ )}
+
+
+
+ );
+ }}
+ />
+ )}