Control updates from the ui

This commit is contained in:
Owen
2026-05-21 15:43:31 -07:00
parent dee0ca6864
commit 6d4afd0953
9 changed files with 333 additions and 44 deletions

View File

@@ -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")

View File

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

View File

@@ -25,7 +25,8 @@ export enum TierFeature {
StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain",
Labels = "labels"
Labels = "labels",
NewtAutoUpdate = "newtAutoUpdate"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -68,5 +69,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[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"]
};

View File

@@ -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();

View File

@@ -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();

View File

@@ -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