mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-25 02:03:03 +00:00
Compare commits
21 Commits
exit-node-
...
auto-updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3539b9ddb4 | ||
|
|
4530aac4f3 | ||
|
|
6d4afd0953 | ||
|
|
dee0ca6864 | ||
|
|
ed73d089d0 | ||
|
|
3b89104a59 | ||
|
|
e4e8b33e9f | ||
|
|
af13790c93 | ||
|
|
87bcd8ec1b | ||
|
|
b3cfe82dff | ||
|
|
d65128671c | ||
|
|
41fdd5de74 | ||
|
|
2704202ba9 | ||
|
|
72ef0ae020 | ||
|
|
1442faa740 | ||
|
|
6aa589e612 | ||
|
|
4b1a8e14c4 | ||
|
|
1a0db10b1a | ||
|
|
b7634086db | ||
|
|
1ba75092f9 | ||
|
|
82745c701a |
@@ -1601,7 +1601,17 @@
|
|||||||
"contents": "Contents",
|
"contents": "Contents",
|
||||||
"parsedContents": "Parsed Contents (Read Only)",
|
"parsedContents": "Parsed Contents (Read Only)",
|
||||||
"enableDockerSocket": "Enable Docker Blueprint",
|
"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 <docsLink>the documentation</docsLink>.",
|
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to the site connector. Read about how this works in <docsLink>the documentation</docsLink>.",
|
||||||
|
"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 connector automatically downloads the latest version.",
|
||||||
|
"siteAutoUpdateOrgDefault": "Organization default: {state}",
|
||||||
|
"siteAutoUpdateOverriding": "Overriding organization setting",
|
||||||
|
"siteAutoUpdateResetToOrg": "Reset to Organization Default",
|
||||||
|
"siteAutoUpdateEnabled": "enabled",
|
||||||
|
"siteAutoUpdateDisabled": "disabled",
|
||||||
"viewDockerContainers": "View Docker Containers",
|
"viewDockerContainers": "View Docker Containers",
|
||||||
"containersIn": "Containers in {siteName}",
|
"containersIn": "Containers in {siteName}",
|
||||||
"selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.",
|
"selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.",
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ export const orgs = pgTable("orgs", {
|
|||||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||||
isBillingOrg: boolean("isBillingOrg"),
|
isBillingOrg: boolean("isBillingOrg"),
|
||||||
billingOrgId: varchar("billingOrgId")
|
billingOrgId: varchar("billingOrgId"),
|
||||||
|
settingsEnableGlobalNewtAutoUpdate: boolean(
|
||||||
|
"settingsEnableGlobalNewtAutoUpdate"
|
||||||
|
)
|
||||||
|
.notNull()
|
||||||
|
.default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const orgDomains = pgTable("orgDomains", {
|
export const orgDomains = pgTable("orgDomains", {
|
||||||
@@ -103,6 +108,10 @@ export const sites = pgTable("sites", {
|
|||||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||||
listenPort: integer("listenPort"),
|
listenPort: integer("listenPort"),
|
||||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||||
|
autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false),
|
||||||
|
autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
status: varchar("status")
|
status: varchar("status")
|
||||||
.$type<"pending" | "approved">()
|
.$type<"pending" | "approved">()
|
||||||
.default("approved")
|
.default("approved")
|
||||||
|
|||||||
@@ -62,7 +62,13 @@ export const orgs = sqliteTable("orgs", {
|
|||||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||||
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
||||||
billingOrgId: text("billingOrgId")
|
billingOrgId: text("billingOrgId"),
|
||||||
|
settingsEnableGlobalNewtAutoUpdate: integer(
|
||||||
|
"settingsEnableGlobalNewtAutoUpdate",
|
||||||
|
{ mode: "boolean" }
|
||||||
|
)
|
||||||
|
.notNull()
|
||||||
|
.default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userDomains = sqliteTable("userDomains", {
|
export const userDomains = sqliteTable("userDomains", {
|
||||||
@@ -116,6 +122,14 @@ export const sites = sqliteTable("sites", {
|
|||||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.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")
|
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export enum TierFeature {
|
|||||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||||
AlertingRules = "alertingRules",
|
AlertingRules = "alertingRules",
|
||||||
WildcardSubdomain = "wildcardSubdomain",
|
WildcardSubdomain = "wildcardSubdomain",
|
||||||
Labels = "labels"
|
Labels = "labels",
|
||||||
|
NewtAutoUpdate = "newtAutoUpdate"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
@@ -68,5 +69,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
||||||
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
||||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
|
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
||||||
|
|
||||||
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +39,11 @@ import {
|
|||||||
removePeerData,
|
removePeerData,
|
||||||
removeTargets as removeSubnetProxyTargets
|
removeTargets as removeSubnetProxyTargets
|
||||||
} from "@server/routers/client/targets";
|
} from "@server/routers/client/targets";
|
||||||
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
|
|
||||||
|
// TTL for rebuild-association locks. These functions can fan out into many
|
||||||
|
// peer/proxy updates, so give them a generous window.
|
||||||
|
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
|
||||||
|
|
||||||
export async function getClientSiteResourceAccess(
|
export async function getClientSiteResourceAccess(
|
||||||
siteResource: SiteResource,
|
siteResource: SiteResource,
|
||||||
@@ -161,6 +166,23 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
pubKey: string | null;
|
pubKey: string | null;
|
||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
}> {
|
||||||
|
return await lockManager.withLock(
|
||||||
|
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
||||||
|
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
|
||||||
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildClientAssociationsFromSiteResourceImpl(
|
||||||
|
siteResource: SiteResource,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<{
|
||||||
|
mergedAllClients: {
|
||||||
|
clientId: number;
|
||||||
|
pubKey: string | null;
|
||||||
|
subnet: string | null;
|
||||||
|
}[];
|
||||||
}> {
|
}> {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
||||||
@@ -539,6 +561,29 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get the number of sites on each of these clients so we can log it and make decisions about whether to send messages based on it
|
||||||
|
const clientSiteCounts: Record<number, number> = {};
|
||||||
|
if (clientsToProcess.size > 0) {
|
||||||
|
const clientIdsToProcess = Array.from(clientsToProcess.keys());
|
||||||
|
const siteCounts = await trx
|
||||||
|
.select({
|
||||||
|
clientId: clientSitesAssociationsCache.clientId,
|
||||||
|
siteCount: count(clientSitesAssociationsCache.siteId)
|
||||||
|
})
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
clientSitesAssociationsCache.clientId,
|
||||||
|
clientIdsToProcess
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(clientSitesAssociationsCache.clientId);
|
||||||
|
|
||||||
|
for (const row of siteCounts) {
|
||||||
|
clientSiteCounts[row.clientId] = Number(row.siteCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const client of clientsToProcess.values()) {
|
for (const client of clientsToProcess.values()) {
|
||||||
// UPDATE THE NEWT
|
// UPDATE THE NEWT
|
||||||
if (!client.subnet || !client.pubKey) {
|
if (!client.subnet || !client.pubKey) {
|
||||||
@@ -582,7 +627,14 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAdd) {
|
if (isAdd) {
|
||||||
// TODO: if we are in jit mode here should we really be sending this?
|
if (clientSiteCounts[client.clientId] > 250) {
|
||||||
|
// skip adding the peer if we have more than 250 sites because we are in jit mode anyway
|
||||||
|
logger.info(
|
||||||
|
`rebuildClientAssociations: Client ${client.clientId} has ${clientSiteCounts[client.clientId]} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -600,9 +652,24 @@ async function handleMessagesForSiteClients(
|
|||||||
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(exitNodeJobs);
|
Promise.all(exitNodeJobs).catch((error) => {
|
||||||
await Promise.all(newtJobs); // do the servers first to make sure they are ready?
|
logger.error(
|
||||||
await Promise.all(olmJobs);
|
`rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(newtJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Newt peers for site ${site.siteId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(olmJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Olm peers for site ${site.siteId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PeerDestination {
|
interface PeerDestination {
|
||||||
@@ -885,6 +952,17 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
export async function rebuildClientAssociationsFromClient(
|
export async function rebuildClientAssociationsFromClient(
|
||||||
client: Client,
|
client: Client,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<void> {
|
||||||
|
return await lockManager.withLock(
|
||||||
|
`rebuild-client-associations:client:${client.clientId}`,
|
||||||
|
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
||||||
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildClientAssociationsFromClientImpl(
|
||||||
|
client: Client,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let newSiteResourceIds: number[] = [];
|
let newSiteResourceIds: number[] = [];
|
||||||
|
|
||||||
@@ -1157,6 +1235,12 @@ async function handleMessagesForClientSites(
|
|||||||
const olmJobs: Promise<any>[] = [];
|
const olmJobs: Promise<any>[] = [];
|
||||||
const exitNodeJobs: Promise<any>[] = [];
|
const exitNodeJobs: Promise<any>[] = [];
|
||||||
|
|
||||||
|
const totalSitesOnClient = await trx
|
||||||
|
.select({ count: count(clientSitesAssociationsCache.siteId) })
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId))
|
||||||
|
.then((rows) => Number(rows[0].count));
|
||||||
|
|
||||||
for (const siteData of sitesData) {
|
for (const siteData of sitesData) {
|
||||||
const site = siteData.sites;
|
const site = siteData.sites;
|
||||||
const exitNode = siteData.exitNodes;
|
const exitNode = siteData.exitNodes;
|
||||||
@@ -1217,7 +1301,14 @@ async function handleMessagesForClientSites(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: if we are in jit mode here should we really be sending this?
|
if (totalSitesOnClient > 250) {
|
||||||
|
// skip adding the site if we have more than 250 because we are in jit mode anyway
|
||||||
|
logger.info(
|
||||||
|
`rebuildClientAssociations: Client ${client.clientId} has ${totalSitesOnClient} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -1245,9 +1336,24 @@ async function handleMessagesForClientSites(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(exitNodeJobs);
|
Promise.all(exitNodeJobs).catch((error) => {
|
||||||
await Promise.all(newtJobs);
|
logger.error(
|
||||||
await Promise.all(olmJobs);
|
`rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(newtJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Newt peers for client ${client.clientId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(olmJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Olm peers for client ${client.clientId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMessagesForClientResources(
|
async function handleMessagesForClientResources(
|
||||||
@@ -1528,3 +1634,195 @@ async function handleMessagesForClientResources(
|
|||||||
|
|
||||||
await Promise.all([...proxyJobs, ...olmJobs]);
|
await Promise.all([...proxyJobs, ...olmJobs]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ClientAssociationsCacheVerification = {
|
||||||
|
clientId: number;
|
||||||
|
consistent: boolean;
|
||||||
|
// What permissions say the cache should contain
|
||||||
|
expectedSiteResourceIds: number[];
|
||||||
|
expectedSiteIds: number[];
|
||||||
|
// What the cache currently contains
|
||||||
|
actualSiteResourceIds: number[];
|
||||||
|
actualSiteIds: number[];
|
||||||
|
// Diff
|
||||||
|
missingSiteResourceIds: number[]; // present in expected, missing from cache
|
||||||
|
extraSiteResourceIds: number[]; // present in cache, not in expected
|
||||||
|
missingSiteIds: number[];
|
||||||
|
extraSiteIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// verifyClientAssociationsCache walks the same permission-derivation logic as
|
||||||
|
// rebuildClientAssociationsFromClient but does NOT modify the database. It
|
||||||
|
// returns the expected vs actual cache contents and a boolean indicating
|
||||||
|
// whether the cache is in sync with what permissions imply.
|
||||||
|
export async function verifyClientAssociationsCache(
|
||||||
|
client: Client,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<ClientAssociationsCacheVerification> {
|
||||||
|
let newSiteResourceIds: number[] = [];
|
||||||
|
|
||||||
|
// 1. Direct client associations
|
||||||
|
const directSiteResources = await trx
|
||||||
|
.select({ siteResourceId: clientSiteResources.siteResourceId })
|
||||||
|
.from(clientSiteResources)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(siteResources.siteResourceId, clientSiteResources.siteResourceId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientSiteResources.clientId, client.clientId),
|
||||||
|
eq(siteResources.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
newSiteResourceIds.push(
|
||||||
|
...directSiteResources.map((r) => r.siteResourceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. User-based and role-based access (if client has a userId)
|
||||||
|
if (client.userId) {
|
||||||
|
const userSiteResourceIds = await trx
|
||||||
|
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||||
|
.from(userSiteResources)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
userSiteResources.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userSiteResources.userId, client.userId),
|
||||||
|
eq(siteResources.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
newSiteResourceIds.push(
|
||||||
|
...userSiteResourceIds.map((r) => r.siteResourceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const roleIds = await trx
|
||||||
|
.select({ roleId: userOrgRoles.roleId })
|
||||||
|
.from(userOrgRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgRoles.userId, client.userId),
|
||||||
|
eq(userOrgRoles.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows.map((row) => row.roleId));
|
||||||
|
|
||||||
|
if (roleIds.length > 0) {
|
||||||
|
const roleSiteResourceIds = await trx
|
||||||
|
.select({ siteResourceId: roleSiteResources.siteResourceId })
|
||||||
|
.from(roleSiteResources)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
roleSiteResources.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(roleSiteResources.roleId, roleIds),
|
||||||
|
eq(siteResources.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
newSiteResourceIds.push(
|
||||||
|
...roleSiteResourceIds.map((r) => r.siteResourceId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newSiteResourceIds = Array.from(new Set(newSiteResourceIds));
|
||||||
|
|
||||||
|
const newSiteResources =
|
||||||
|
newSiteResourceIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
inArray(siteResources.siteResourceId, newSiteResourceIds)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const networkIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
newSiteResources
|
||||||
|
.map((sr) => sr.networkId)
|
||||||
|
.filter((id): id is number => id !== null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const newSiteIds =
|
||||||
|
networkIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select({ siteId: siteNetworks.siteId })
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(inArray(siteNetworks.networkId, networkIds))
|
||||||
|
.then((rows) =>
|
||||||
|
Array.from(new Set(rows.map((r) => r.siteId)))
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Read the existing cache state
|
||||||
|
const existingResourceAssociations = await trx
|
||||||
|
.select({
|
||||||
|
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId
|
||||||
|
})
|
||||||
|
.from(clientSiteResourcesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
|
||||||
|
);
|
||||||
|
const existingSiteResourceIds = existingResourceAssociations.map(
|
||||||
|
(r) => r.siteResourceId
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingSiteAssociations = await trx
|
||||||
|
.select({ siteId: clientSitesAssociationsCache.siteId })
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
|
const existingSiteIds = existingSiteAssociations.map((s) => s.siteId);
|
||||||
|
|
||||||
|
const expectedSiteResourceSet = new Set(newSiteResourceIds);
|
||||||
|
const actualSiteResourceSet = new Set(existingSiteResourceIds);
|
||||||
|
const expectedSiteSet = new Set(newSiteIds);
|
||||||
|
const actualSiteSet = new Set(existingSiteIds);
|
||||||
|
|
||||||
|
const missingSiteResourceIds = newSiteResourceIds.filter(
|
||||||
|
(id) => !actualSiteResourceSet.has(id)
|
||||||
|
);
|
||||||
|
const extraSiteResourceIds = existingSiteResourceIds.filter(
|
||||||
|
(id) => !expectedSiteResourceSet.has(id)
|
||||||
|
);
|
||||||
|
const missingSiteIds = newSiteIds.filter((id) => !actualSiteSet.has(id));
|
||||||
|
const extraSiteIds = existingSiteIds.filter(
|
||||||
|
(id) => !expectedSiteSet.has(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const consistent =
|
||||||
|
missingSiteResourceIds.length === 0 &&
|
||||||
|
extraSiteResourceIds.length === 0 &&
|
||||||
|
missingSiteIds.length === 0 &&
|
||||||
|
extraSiteIds.length === 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId: client.clientId,
|
||||||
|
consistent,
|
||||||
|
expectedSiteResourceIds: Array.from(expectedSiteResourceSet).sort(
|
||||||
|
(a, b) => a - b
|
||||||
|
),
|
||||||
|
expectedSiteIds: Array.from(expectedSiteSet).sort((a, b) => a - b),
|
||||||
|
actualSiteResourceIds: Array.from(actualSiteResourceSet).sort(
|
||||||
|
(a, b) => a - b
|
||||||
|
),
|
||||||
|
actualSiteIds: Array.from(actualSiteSet).sort((a, b) => a - b),
|
||||||
|
missingSiteResourceIds: missingSiteResourceIds.sort((a, b) => a - b),
|
||||||
|
extraSiteResourceIds: extraSiteResourceIds.sort((a, b) => a - b),
|
||||||
|
missingSiteIds: missingSiteIds.sort((a, b) => a - b),
|
||||||
|
extraSiteIds: extraSiteIds.sort((a, b) => a - b)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, logsDb, statusHistory } from "@server/db";
|
import { db, logsDb, statusHistory } from "@server/db";
|
||||||
import { and, eq, gte, asc } from "drizzle-orm";
|
import { and, eq, gte, asc } from "drizzle-orm";
|
||||||
import cache from "@server/lib/cache";
|
import { regionalCache as cache } from "@server/private/lib/cache";
|
||||||
|
|
||||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export async function invalidateStatusHistoryCache(
|
|||||||
entityId: number
|
entityId: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
||||||
const keys = cache.keys().filter((k) => k.startsWith(prefix));
|
const keys = await cache.keysWithPrefix(prefix);
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await cache.del(keys);
|
await cache.del(keys);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { redisManager } from "@server/private/lib/redis";
|
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
||||||
|
|
||||||
// Create local cache with maxKeys limit to prevent memory leaks
|
// Create local cache with maxKeys limit to prevent memory leaks
|
||||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||||
@@ -298,3 +298,147 @@ class AdaptiveCache {
|
|||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regional adaptive cache backed by the in-cluster Redis instance.
|
||||||
|
* Falls back to a local NodeCache when the regional Redis is unavailable.
|
||||||
|
* Use this for data that is regional in nature (e.g. status history) so
|
||||||
|
* reads are served from the same cluster the user is hitting.
|
||||||
|
*/
|
||||||
|
const regionalLocalCache = new NodeCache({
|
||||||
|
stdTTL: 3600,
|
||||||
|
checkperiod: 120,
|
||||||
|
maxKeys: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
class RegionalAdaptiveCache {
|
||||||
|
private useRedis(): boolean {
|
||||||
|
return (
|
||||||
|
regionalRedisManager.isRedisEnabled() &&
|
||||||
|
regionalRedisManager.getHealthStatus().isHealthy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: any, ttl?: number): Promise<boolean> {
|
||||||
|
const effectiveTtl = ttl === 0 ? undefined : ttl;
|
||||||
|
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
|
||||||
|
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
const success = await regionalRedisManager.set(
|
||||||
|
key,
|
||||||
|
serialized,
|
||||||
|
redisTtl
|
||||||
|
);
|
||||||
|
if (success) {
|
||||||
|
logger.debug(`[regional] Set key in Redis: ${key}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis set error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
|
||||||
|
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T = any>(key: string): Promise<T | undefined> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const value = await regionalRedisManager.get(key);
|
||||||
|
if (value !== null) {
|
||||||
|
logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
}
|
||||||
|
logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis get error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = regionalLocalCache.get<T>(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
logger.debug(`[regional] Cache hit in local cache: ${key}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`[regional] Cache miss in local cache: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string | string[]): Promise<number> {
|
||||||
|
const keys = Array.isArray(key) ? key : [key];
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
for (const k of keys) {
|
||||||
|
const success = await regionalRedisManager.del(k);
|
||||||
|
if (success) {
|
||||||
|
deletedCount++;
|
||||||
|
logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedCount === keys.length) return deletedCount;
|
||||||
|
deletedCount = 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[regional] Redis del error:`, error);
|
||||||
|
deletedCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
const count = regionalLocalCache.del(k);
|
||||||
|
if (count > 0) {
|
||||||
|
deletedCount++;
|
||||||
|
logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(key: string): Promise<boolean> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const value = await regionalRedisManager.get(key);
|
||||||
|
return value !== null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis has error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return regionalLocalCache.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns keys matching the given prefix from whichever backend is active.
|
||||||
|
* Redis uses a KEYS scan; local cache filters in-memory keys.
|
||||||
|
*/
|
||||||
|
async keysWithPrefix(prefix: string): Promise<string[]> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
return await regionalRedisManager.keys(`${prefix}*`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[regional] Redis keys error:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentBackend(): "redis" | "local" {
|
||||||
|
return this.useRedis() ? "redis" : "local";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regionalCache = new RegionalAdaptiveCache();
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ export const privateConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
rejectUnauthorized: z.boolean().optional().default(true)
|
rejectUnauthorized: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
|
.optional(),
|
||||||
|
regional_redis: z
|
||||||
|
.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: portSchema,
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("REGIONAL_REDIS_PASSWORD")),
|
||||||
|
db: z.int().nonnegative().optional().default(0),
|
||||||
|
tls: z
|
||||||
|
.object({
|
||||||
|
rejectUnauthorized: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(true)
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -109,14 +109,14 @@ class RedisManager {
|
|||||||
password: redisConfig.password,
|
password: redisConfig.password,
|
||||||
db: redisConfig.db
|
db: redisConfig.db
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||||
if (redisConfig.tls) {
|
if (redisConfig.tls) {
|
||||||
opts.tls = {
|
opts.tls = {
|
||||||
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,14 +135,14 @@ class RedisManager {
|
|||||||
password: replica.password,
|
password: replica.password,
|
||||||
db: replica.db || redisConfig.db
|
db: replica.db || redisConfig.db
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||||
if (redisConfig.tls) {
|
if (redisConfig.tls) {
|
||||||
opts.tls = {
|
opts.tls = {
|
||||||
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,3 +855,163 @@ class RedisManager {
|
|||||||
export const redisManager = new RedisManager();
|
export const redisManager = new RedisManager();
|
||||||
export const redis = redisManager.getClient();
|
export const redis = redisManager.getClient();
|
||||||
export default redisManager;
|
export default redisManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
|
||||||
|
* Connects only when `redis.regional_redis` is present in the private config
|
||||||
|
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
|
||||||
|
* caching of regionally-scoped data.
|
||||||
|
*/
|
||||||
|
class RegionalRedisManager {
|
||||||
|
private writeClient: Redis | null = null;
|
||||||
|
private readClient: Redis | null = null;
|
||||||
|
private isEnabled: boolean = false;
|
||||||
|
private isHealthy: boolean = false;
|
||||||
|
private connectionTimeout: number = 5000;
|
||||||
|
private commandTimeout: number = 5000;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (build === "oss") return;
|
||||||
|
|
||||||
|
const cfg = privateConfig.getRawPrivateConfig();
|
||||||
|
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
|
||||||
|
|
||||||
|
this.isEnabled = true;
|
||||||
|
this.initializeClients();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfig(): RedisOptions {
|
||||||
|
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
|
||||||
|
const opts: RedisOptions = {
|
||||||
|
host: r.host,
|
||||||
|
port: r.port,
|
||||||
|
password: r.password,
|
||||||
|
db: r.db
|
||||||
|
};
|
||||||
|
if (r.tls) {
|
||||||
|
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeClients(): void {
|
||||||
|
const cfg = this.getConfig();
|
||||||
|
const baseOpts = {
|
||||||
|
...cfg,
|
||||||
|
enableReadyCheck: false,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
keepAlive: 10000,
|
||||||
|
connectTimeout: this.connectionTimeout,
|
||||||
|
commandTimeout: this.commandTimeout
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.writeClient = new Redis(baseOpts);
|
||||||
|
// redis-1 (replica) handles reads; fall back to primary if not resolvable
|
||||||
|
this.readClient = new Redis({
|
||||||
|
...baseOpts,
|
||||||
|
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
|
||||||
|
// Derive replica hostname from the headless service pattern:
|
||||||
|
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
|
||||||
|
// If it doesn't look like a k8s service, just use the same host
|
||||||
|
return h + rest;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// For simplicity use same host for both; callers can always read from primary
|
||||||
|
// The real replica routing is handled by the StatefulSet headless service
|
||||||
|
this.readClient = this.writeClient;
|
||||||
|
|
||||||
|
this.writeClient.on("ready", () => {
|
||||||
|
logger.info("Regional Redis client ready");
|
||||||
|
this.isHealthy = true;
|
||||||
|
});
|
||||||
|
this.writeClient.on("error", (err) => {
|
||||||
|
logger.error("Regional Redis client error:", err);
|
||||||
|
this.isHealthy = false;
|
||||||
|
});
|
||||||
|
this.writeClient.on("reconnecting", () => {
|
||||||
|
logger.info("Regional Redis client reconnecting...");
|
||||||
|
this.isHealthy = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Regional Redis client initialized");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to initialize regional Redis client:", error);
|
||||||
|
this.isEnabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isRedisEnabled(): boolean {
|
||||||
|
return this.isEnabled && this.writeClient !== null && this.isHealthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHealthStatus() {
|
||||||
|
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
ttl?: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
try {
|
||||||
|
if (ttl) {
|
||||||
|
await this.writeClient.setex(key, ttl, value);
|
||||||
|
} else {
|
||||||
|
await this.writeClient.set(key, value);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis SET error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(key: string): Promise<string | null> {
|
||||||
|
if (!this.isRedisEnabled() || !this.readClient) return null;
|
||||||
|
try {
|
||||||
|
return await this.readClient.get(key);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis GET error:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async del(key: string): Promise<boolean> {
|
||||||
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
try {
|
||||||
|
await this.writeClient.del(key);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis DEL error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async keys(pattern: string): Promise<string[]> {
|
||||||
|
if (!this.isRedisEnabled() || !this.readClient) return [];
|
||||||
|
try {
|
||||||
|
return await this.readClient.keys(pattern);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis KEYS error:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this.writeClient) {
|
||||||
|
await this.writeClient.quit();
|
||||||
|
this.writeClient = null;
|
||||||
|
}
|
||||||
|
this.readClient = null;
|
||||||
|
logger.info("Regional Redis client disconnected");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error disconnecting regional Redis client:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regionalRedisManager = new RegionalRedisManager();
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import * as eventStreamingDestination from "#private/routers/eventStreamingDesti
|
|||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
import * as labels from "#private/routers/labels";
|
import * as labels from "#private/routers/labels";
|
||||||
|
import * as client from "@server/routers/client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -829,3 +830,15 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.getTarget),
|
verifyUserHasAction(ActionsEnum.getTarget),
|
||||||
healthChecks.getHealthCheckStatusHistory
|
healthChecks.getHealthCheckStatusHistory
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/client/:clientId/verify-associations-cache",
|
||||||
|
verifyClientAccess,
|
||||||
|
client.verifyClientAssociationsCache
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/client/:clientId/rebuild-associations-cache",
|
||||||
|
verifyClientAccess,
|
||||||
|
client.rebuildClientAssociationsCacheRoute
|
||||||
|
);
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, InferInsertModel } from "drizzle-orm";
|
import { eq, InferInsertModel } from "drizzle-orm";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
|
||||||
import config from "#private/lib/config";
|
import config from "#private/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -35,78 +34,9 @@ const paramsSchema = z.strictObject({
|
|||||||
|
|
||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
logoUrl: z
|
logoUrl: z
|
||||||
.union([
|
.string()
|
||||||
z.literal(""),
|
.optional()
|
||||||
z
|
.transform((val) => (val === "" ? null : val)),
|
||||||
.string()
|
|
||||||
.superRefine(async (urlOrPath, ctx) => {
|
|
||||||
const parseResult = z.url().safeParse(urlOrPath);
|
|
||||||
if (!parseResult.success) {
|
|
||||||
if (build !== "enterprise") {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Must be a valid URL"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
validateLocalPath(urlOrPath);
|
|
||||||
} catch (error) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(urlOrPath, {
|
|
||||||
method: "HEAD"
|
|
||||||
}).catch(() => {
|
|
||||||
// If HEAD fails (CORS or method not allowed), try GET
|
|
||||||
return fetch(urlOrPath, { method: "GET" });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `Failed to load image. Please check that the URL is accessible.`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType =
|
|
||||||
response.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.startsWith("image/")) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage =
|
|
||||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
|
||||||
|
|
||||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
||||||
errorMessage =
|
|
||||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage = `Error verifying URL: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: errorMessage
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
])
|
|
||||||
.transform((val) => (val === "" ? null : val))
|
|
||||||
.nullish(),
|
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
resourceTitle: z.string(),
|
resourceTitle: z.string(),
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import axios from "axios";
|
|
||||||
import { db, exitNodes, newts, sites } from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import redisManager from "#private/lib/redis";
|
|
||||||
import { sendToClient } from "#private/routers/ws";
|
|
||||||
|
|
||||||
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
|
||||||
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
|
||||||
const MAX_DURATION_MS = 5 * 60 * 1000; // Give up after 5 minutes
|
|
||||||
const REDIS_PENDING_SET = "exit-node-reconnect-pending";
|
|
||||||
const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
|
||||||
|
|
||||||
interface PendingReconnect {
|
|
||||||
startTime: number;
|
|
||||||
reachableAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory tracking for this node
|
|
||||||
const pendingReconnects = new Map<number, PendingReconnect>();
|
|
||||||
|
|
||||||
let schedulerInterval: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedules a reconnect check for newts connected to the given exit node.
|
|
||||||
* Called when an exit node transitions from offline to online.
|
|
||||||
*/
|
|
||||||
export async function scheduleExitNodeReconnect(
|
|
||||||
exitNodeId: number,
|
|
||||||
reachableAt: string
|
|
||||||
): Promise<void> {
|
|
||||||
logger.info(
|
|
||||||
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
|
||||||
);
|
|
||||||
|
|
||||||
const entry: PendingReconnect = {
|
|
||||||
startTime: Date.now(),
|
|
||||||
reachableAt
|
|
||||||
};
|
|
||||||
|
|
||||||
pendingReconnects.set(exitNodeId, entry);
|
|
||||||
|
|
||||||
// Store in Redis if available for cross-node coordination
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
|
||||||
await redisManager.sadd(REDIS_PENDING_SET, exitNodeId.toString());
|
|
||||||
await redisManager.hset(
|
|
||||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
|
||||||
"startTime",
|
|
||||||
entry.startTime.toString()
|
|
||||||
);
|
|
||||||
await redisManager.hset(
|
|
||||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
|
||||||
"reachableAt",
|
|
||||||
reachableAt
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the background interval that checks pending exit node reconnects.
|
|
||||||
*/
|
|
||||||
export function startExitNodeReconnectScheduler(): void {
|
|
||||||
if (schedulerInterval) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
schedulerInterval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
await processPendingReconnects();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error in exit node reconnect scheduler", { error });
|
|
||||||
}
|
|
||||||
}, CHECK_INTERVAL_MS);
|
|
||||||
|
|
||||||
logger.debug("Started exit node reconnect scheduler");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processPendingReconnects(): Promise<void> {
|
|
||||||
// Merge in-memory and Redis-tracked pending reconnects
|
|
||||||
const toProcess = new Map(pendingReconnects);
|
|
||||||
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
|
||||||
const redisIds = await redisManager.smembers(REDIS_PENDING_SET);
|
|
||||||
for (const idStr of redisIds) {
|
|
||||||
const id = parseInt(idStr, 10);
|
|
||||||
if (!toProcess.has(id)) {
|
|
||||||
const startTimeStr = await redisManager.hget(
|
|
||||||
`${REDIS_HASH_PREFIX}${id}`,
|
|
||||||
"startTime"
|
|
||||||
);
|
|
||||||
const reachableAt = await redisManager.hget(
|
|
||||||
`${REDIS_HASH_PREFIX}${id}`,
|
|
||||||
"reachableAt"
|
|
||||||
);
|
|
||||||
if (startTimeStr && reachableAt) {
|
|
||||||
toProcess.set(id, {
|
|
||||||
startTime: parseInt(startTimeStr, 10),
|
|
||||||
reachableAt
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
for (const [exitNodeId, entry] of toProcess) {
|
|
||||||
const elapsed = now - entry.startTime;
|
|
||||||
|
|
||||||
// Give up after max duration
|
|
||||||
if (elapsed >= MAX_DURATION_MS) {
|
|
||||||
logger.warn(
|
|
||||||
`Exit node reconnect check timed out for exit node ${exitNodeId} after 5 minutes`
|
|
||||||
);
|
|
||||||
await removePending(exitNodeId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respect initial delay
|
|
||||||
if (elapsed < INITIAL_DELAY_MS) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the exit node HTTP endpoint is reachable
|
|
||||||
const pingUrl = `${entry.reachableAt}/ping`;
|
|
||||||
try {
|
|
||||||
await axios.get(pingUrl, { timeout: 5000 });
|
|
||||||
} catch {
|
|
||||||
logger.debug(
|
|
||||||
`Exit node ${exitNodeId} not yet reachable at ${pingUrl}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Node is reachable — send reconnect to all connected newts
|
|
||||||
logger.info(
|
|
||||||
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
|
||||||
);
|
|
||||||
|
|
||||||
await sendReconnectToNewts(exitNodeId);
|
|
||||||
await removePending(exitNodeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
const connectedNewts = await db
|
|
||||||
.select({ newtId: newts.newtId })
|
|
||||||
.from(newts)
|
|
||||||
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
|
||||||
.where(eq(sites.exitNodeId, exitNodeId));
|
|
||||||
|
|
||||||
if (connectedNewts.length === 0) {
|
|
||||||
logger.debug(
|
|
||||||
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const reconnectMessage = {
|
|
||||||
type: "newt/wg/reconnect",
|
|
||||||
data: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.allSettled(
|
|
||||||
connectedNewts.map(({ newtId }) =>
|
|
||||||
sendToClient(newtId, reconnectMessage)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
|
||||||
{ error }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removePending(exitNodeId: number): Promise<void> {
|
|
||||||
pendingReconnects.delete(exitNodeId);
|
|
||||||
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
|
||||||
await redisManager.srem(REDIS_PENDING_SET, exitNodeId.toString());
|
|
||||||
await redisManager.del(`${REDIS_HASH_PREFIX}${exitNodeId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import { MessageHandler } from "@server/routers/ws";
|
|||||||
import { RemoteExitNode } from "@server/db";
|
import { RemoteExitNode } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles ping messages from clients and responds with pong
|
* Handles ping messages from clients and responds with pong
|
||||||
@@ -38,13 +37,6 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch the current state before updating so we can detect the offline→online transition
|
|
||||||
const [currentExitNode] = await db
|
|
||||||
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
|
||||||
.from(exitNodes)
|
|
||||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
// Update the exit node's last ping timestamp
|
// Update the exit node's last ping timestamp
|
||||||
await db
|
await db
|
||||||
.update(exitNodes)
|
.update(exitNodes)
|
||||||
@@ -53,16 +45,6 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
online: true
|
online: true
|
||||||
})
|
})
|
||||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
||||||
|
|
||||||
// If the exit node was offline and is now coming online, schedule newt reconnects
|
|
||||||
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
|
||||||
scheduleExitNodeReconnect(
|
|
||||||
remoteExitNode.exitNodeId,
|
|
||||||
currentExitNode.reachableAt
|
|
||||||
).catch((error) => {
|
|
||||||
logger.error("Failed to schedule exit node reconnect", { error });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling ping message", { error });
|
logger.error("Error handling ping message", { error });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,3 @@ export * from "./listRemoteExitNodes";
|
|||||||
export * from "./pickRemoteExitNodeDefaults";
|
export * from "./pickRemoteExitNodeDefaults";
|
||||||
export * from "./quickStartRemoteExitNode";
|
export * from "./quickStartRemoteExitNode";
|
||||||
export * from "./offlineChecker";
|
export * from "./offlineChecker";
|
||||||
export * from "./exitNodeReconnectScheduler";
|
|
||||||
|
|||||||
@@ -14,8 +14,7 @@
|
|||||||
import {
|
import {
|
||||||
handleRemoteExitNodeRegisterMessage,
|
handleRemoteExitNodeRegisterMessage,
|
||||||
handleRemoteExitNodePingMessage,
|
handleRemoteExitNodePingMessage,
|
||||||
startRemoteExitNodeOfflineChecker,
|
startRemoteExitNodeOfflineChecker
|
||||||
startExitNodeReconnectScheduler
|
|
||||||
} from "#private/routers/remoteExitNode";
|
} from "#private/routers/remoteExitNode";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -30,5 +29,4 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
|
|
||||||
if (build != "saas") {
|
if (build != "saas") {
|
||||||
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
||||||
startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -522,13 +522,13 @@ const sendToClientLocal = async (
|
|||||||
|
|
||||||
const messageString = JSON.stringify(messageWithVersion);
|
const messageString = JSON.stringify(messageWithVersion);
|
||||||
if (options.compress) {
|
if (options.compress) {
|
||||||
logger.debug(
|
// logger.debug(
|
||||||
`Message size before compression: ${messageString.length} bytes`
|
// `Message size before compression: ${messageString.length} bytes`
|
||||||
);
|
// );
|
||||||
const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8"));
|
const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8"));
|
||||||
logger.debug(
|
// logger.debug(
|
||||||
`Message size after compression: ${compressed.length} bytes`
|
// `Message size after compression: ${compressed.length} bytes`
|
||||||
);
|
// );
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(compressed);
|
client.send(compressed);
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ export * from "./listUserDevices";
|
|||||||
export * from "./updateClient";
|
export * from "./updateClient";
|
||||||
export * from "./getClient";
|
export * from "./getClient";
|
||||||
export * from "./createUserClient";
|
export * from "./createUserClient";
|
||||||
|
export * from "./verifyClientAssociationsCache";
|
||||||
|
export * from "./rebuildClientAssociationsCacheRoute";
|
||||||
|
|||||||
81
server/routers/client/rebuildClientAssociationsCacheRoute.ts
Normal file
81
server/routers/client/rebuildClientAssociationsCacheRoute.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { clients } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/client/{clientId}/rebuild-associations-cache",
|
||||||
|
description:
|
||||||
|
"Rebuild the client's site/site-resource association cache based on current permissions.",
|
||||||
|
tags: [OpenAPITags.Client],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function rebuildClientAssociationsCacheRoute(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Client with ID ${clientId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromClient(client);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Client association cache rebuilt successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to rebuild client association cache"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
server/routers/client/verifyClientAssociationsCache.ts
Normal file
83
server/routers/client/verifyClientAssociationsCache.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { clients } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { verifyClientAssociationsCache as verifyClientAssociationsCacheLib } from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/client/{clientId}/verify-associations-cache",
|
||||||
|
description:
|
||||||
|
"Read-only check of whether the client's site/site-resource association cache matches what the current permissions imply.",
|
||||||
|
tags: [OpenAPITags.Client],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function verifyClientAssociationsCache(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Client with ID ${clientId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await verifyClientAssociationsCacheLib(client);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: report,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: report.consistent
|
||||||
|
? "Client association cache is consistent"
|
||||||
|
: "Client association cache is INCONSISTENT",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to verify client association cache"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1231,6 +1231,22 @@ authRouter.post(
|
|||||||
newt.getNewtToken
|
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(
|
authRouter.post(
|
||||||
"/newt/register",
|
"/newt/register",
|
||||||
rateLimit({
|
rateLimit({
|
||||||
|
|||||||
317
server/routers/newt/getNewtVersion.ts
Normal file
317
server/routers/newt/getNewtVersion.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { db, orgs, sites } 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 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<string, string>;
|
||||||
|
};
|
||||||
|
let staleReleaseInfo: ReleaseInfo | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ReleaseInfo | null> {
|
||||||
|
try {
|
||||||
|
const cached = await cache.get<ReleaseInfo>("cache:newtReleaseInfo");
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
const fetchResponse = await fetch(
|
||||||
|
"https://api.github.com/repos/fosrl/newt/releases",
|
||||||
|
{ signal: controller.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!fetchResponse.ok) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to fetch Newt releases from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}`
|
||||||
|
);
|
||||||
|
return staleReleaseInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
let releases: any[] = await fetchResponse.json();
|
||||||
|
if (!Array.isArray(releases) || releases.length === 0) {
|
||||||
|
logger.warn("No releases found for Newt repository");
|
||||||
|
return staleReleaseInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.tag_name.includes("v")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (releases.length === 0) {
|
||||||
|
logger.warn("No stable releases found for Newt repository");
|
||||||
|
return staleReleaseInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = releases[0];
|
||||||
|
const version: string = latest.tag_name;
|
||||||
|
|
||||||
|
// Build a map of binary filename → sha256 hex from the asset `digest`
|
||||||
|
// field returned by the GitHub API (format: "sha256:<hex>").
|
||||||
|
const assetDigests: Record<string, string> = {};
|
||||||
|
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 Newt releases timed out (5s)");
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"Error fetching Newt releases:",
|
||||||
|
error.message || error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return staleReleaseInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
newtId: z.string(),
|
||||||
|
secret: z.string(),
|
||||||
|
platform: z.string() // e.g. "linux_amd64", "darwin_arm64"
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GetNewtVersionBody = z.infer<typeof bodySchema>;
|
||||||
|
|
||||||
|
export type GetNewtVersionResponse = {
|
||||||
|
latestVersion: string;
|
||||||
|
currentIsLatest: boolean;
|
||||||
|
downloadUrl: string;
|
||||||
|
sha256: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getNewtVersion(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
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 [existingNewt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.newtId, newtId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingNewt) {
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
if (!releaseInfo) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Unable to determine latest Newt version"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestVersion = releaseInfo.version;
|
||||||
|
|
||||||
|
// Binary name follows the get-newt.sh convention: newt_<platform>[.exe]
|
||||||
|
const binaryName = platform.includes("windows")
|
||||||
|
? `newt_${platform}.exe`
|
||||||
|
: `newt_${platform}`;
|
||||||
|
|
||||||
|
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:<hex>").
|
||||||
|
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;
|
||||||
|
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<GetNewtVersionResponse>(res, {
|
||||||
|
data: {
|
||||||
|
latestVersion,
|
||||||
|
currentIsLatest,
|
||||||
|
downloadUrl,
|
||||||
|
sha256
|
||||||
|
},
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./createNewt";
|
export * from "./createNewt";
|
||||||
export * from "./getNewtToken";
|
export * from "./getNewtToken";
|
||||||
|
export * from "./getNewtVersion";
|
||||||
export * from "./handleNewtRegisterMessage";
|
export * from "./handleNewtRegisterMessage";
|
||||||
export * from "./handleReceiveBandwidthMessage";
|
export * from "./handleReceiveBandwidthMessage";
|
||||||
export * from "./handleNewtGetConfigMessage";
|
export * from "./handleNewtGetConfigMessage";
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ const updateOrgBodySchema = z
|
|||||||
settingsLogRetentionDaysConnection: z
|
settingsLogRetentionDaysConnection: z
|
||||||
.number()
|
.number()
|
||||||
.min(build === "saas" ? 0 : -1)
|
.min(build === "saas" ? 0 : -1)
|
||||||
.optional()
|
.optional(),
|
||||||
|
settingsEnableGlobalNewtAutoUpdate: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
error: "At least one field must be provided for update"
|
error: "At least one field must be provided for update"
|
||||||
@@ -118,6 +119,15 @@ export async function updateOrg(
|
|||||||
if (!hasPasswordExpirationFeature) {
|
if (!hasPasswordExpirationFeature) {
|
||||||
parsedBody.data.passwordExpiryDays = undefined;
|
parsedBody.data.passwordExpiryDays = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasNewtAutoUpdateFeature = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix[TierFeature.NewtAutoUpdate]
|
||||||
|
);
|
||||||
|
if (!hasNewtAutoUpdateFeature) {
|
||||||
|
parsedBody.data.settingsEnableGlobalNewtAutoUpdate = false; // force it off
|
||||||
|
}
|
||||||
|
|
||||||
if (build == "saas") {
|
if (build == "saas") {
|
||||||
const { tier } = await getOrgTierData(orgId);
|
const { tier } = await getOrgTierData(orgId);
|
||||||
|
|
||||||
@@ -136,8 +146,10 @@ export async function updateOrg(
|
|||||||
|
|
||||||
if (maxRetentionDays !== null) {
|
if (maxRetentionDays !== null) {
|
||||||
if (
|
if (
|
||||||
parsedBody.data.settingsLogRetentionDaysRequest !== undefined &&
|
parsedBody.data.settingsLogRetentionDaysRequest !==
|
||||||
parsedBody.data.settingsLogRetentionDaysRequest > maxRetentionDays
|
undefined &&
|
||||||
|
parsedBody.data.settingsLogRetentionDaysRequest >
|
||||||
|
maxRetentionDays
|
||||||
) {
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -147,8 +159,10 @@ export async function updateOrg(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
parsedBody.data.settingsLogRetentionDaysAccess !== undefined &&
|
parsedBody.data.settingsLogRetentionDaysAccess !==
|
||||||
parsedBody.data.settingsLogRetentionDaysAccess > maxRetentionDays
|
undefined &&
|
||||||
|
parsedBody.data.settingsLogRetentionDaysAccess >
|
||||||
|
maxRetentionDays
|
||||||
) {
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -158,8 +172,10 @@ export async function updateOrg(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
parsedBody.data.settingsLogRetentionDaysAction !== undefined &&
|
parsedBody.data.settingsLogRetentionDaysAction !==
|
||||||
parsedBody.data.settingsLogRetentionDaysAction > maxRetentionDays
|
undefined &&
|
||||||
|
parsedBody.data.settingsLogRetentionDaysAction >
|
||||||
|
maxRetentionDays
|
||||||
) {
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -169,8 +185,10 @@ export async function updateOrg(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
parsedBody.data.settingsLogRetentionDaysConnection !== undefined &&
|
parsedBody.data.settingsLogRetentionDaysConnection !==
|
||||||
parsedBody.data.settingsLogRetentionDaysConnection > maxRetentionDays
|
undefined &&
|
||||||
|
parsedBody.data.settingsLogRetentionDaysConnection >
|
||||||
|
maxRetentionDays
|
||||||
) {
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -196,7 +214,9 @@ export async function updateOrg(
|
|||||||
settingsLogRetentionDaysAction:
|
settingsLogRetentionDaysAction:
|
||||||
parsedBody.data.settingsLogRetentionDaysAction,
|
parsedBody.data.settingsLogRetentionDaysAction,
|
||||||
settingsLogRetentionDaysConnection:
|
settingsLogRetentionDaysConnection:
|
||||||
parsedBody.data.settingsLogRetentionDaysConnection
|
parsedBody.data.settingsLogRetentionDaysConnection,
|
||||||
|
settingsEnableGlobalNewtAutoUpdate:
|
||||||
|
parsedBody.data.settingsEnableGlobalNewtAutoUpdate
|
||||||
})
|
})
|
||||||
.where(eq(orgs.orgId, orgId))
|
.where(eq(orgs.orgId, orgId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, Site } from "@server/db";
|
||||||
import { sites } from "@server/db";
|
import { sites } from "@server/db";
|
||||||
import { eq, and, ne } from "drizzle-orm";
|
import { eq, and, ne } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -9,7 +9,8 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
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({
|
const updateSiteParamsSchema = z.strictObject({
|
||||||
siteId: z.string().transform(Number).pipe(z.int().positive())
|
siteId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
@@ -21,18 +22,8 @@ const updateSiteBodySchema = z
|
|||||||
niceId: z.string().min(1).max(255).optional(),
|
niceId: z.string().min(1).max(255).optional(),
|
||||||
dockerSocketEnabled: z.boolean().optional(),
|
dockerSocketEnabled: z.boolean().optional(),
|
||||||
status: z.enum(["pending", "approved"]).optional(),
|
status: z.enum(["pending", "approved"]).optional(),
|
||||||
// remoteSubnets: z.string().optional()
|
autoUpdateEnabled: z.boolean().optional(),
|
||||||
// subdomain: z
|
autoUpdateOverrideOrg: z.boolean().optional()
|
||||||
// .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(),
|
|
||||||
})
|
})
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
error: "At least one field must be provided for update"
|
error: "At least one field must be provided for update"
|
||||||
@@ -85,9 +76,24 @@ export async function updateSite(
|
|||||||
const { siteId } = parsedParams.data;
|
const { siteId } = parsedParams.data;
|
||||||
const updateData = parsedBody.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 niceId is provided, check if it's already in use by another site
|
||||||
if (updateData.niceId) {
|
if (updateData.niceId) {
|
||||||
const [existingSite] = await db
|
const [existingSiteNiceIdOverlap] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(
|
.where(
|
||||||
@@ -99,7 +105,7 @@ export async function updateSite(
|
|||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existingSite) {
|
if (existingSiteNiceIdOverlap) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.CONFLICT,
|
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 remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
|
||||||
// if (updateData.remoteSubnets) {
|
// if (updateData.remoteSubnets) {
|
||||||
// const subnets = updateData.remoteSubnets
|
// const subnets = updateData.remoteSubnets
|
||||||
|
|||||||
@@ -153,6 +153,65 @@ export default function GeneralPage() {
|
|||||||
const [approvalId, setApprovalId] = useState<number | null>(null);
|
const [approvalId, setApprovalId] = useState<number | null>(null);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
const [cacheCheck, setCacheCheck] = useState<null | {
|
||||||
|
consistent: boolean;
|
||||||
|
missingSiteResourceIds: number[];
|
||||||
|
extraSiteResourceIds: number[];
|
||||||
|
missingSiteIds: number[];
|
||||||
|
extraSiteIds: number[];
|
||||||
|
expectedSiteResourceIds: number[];
|
||||||
|
actualSiteResourceIds: number[];
|
||||||
|
expectedSiteIds: number[];
|
||||||
|
actualSiteIds: number[];
|
||||||
|
}>(null);
|
||||||
|
const [isCheckingCache, setIsCheckingCache] = useState(false);
|
||||||
|
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
|
||||||
|
|
||||||
|
const handleRebuildCache = async () => {
|
||||||
|
if (!client.clientId) return;
|
||||||
|
setIsRebuildingCache(true);
|
||||||
|
try {
|
||||||
|
await api.post(
|
||||||
|
`/client/${client.clientId}/rebuild-associations-cache`
|
||||||
|
);
|
||||||
|
// Re-verify after rebuild so the result refreshes
|
||||||
|
const res = await api.get(
|
||||||
|
`/client/${client.clientId}/verify-associations-cache`
|
||||||
|
);
|
||||||
|
setCacheCheck(res.data.data);
|
||||||
|
toast({
|
||||||
|
title: "Cache rebuilt",
|
||||||
|
description: "Association cache rebuilt successfully."
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Rebuild failed",
|
||||||
|
description: formatAxiosError(e, "Failed to rebuild cache")
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRebuildingCache(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyCache = async () => {
|
||||||
|
if (!client.clientId) return;
|
||||||
|
setIsCheckingCache(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(
|
||||||
|
`/client/${client.clientId}/verify-associations-cache`
|
||||||
|
);
|
||||||
|
setCacheCheck(res.data.data);
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Cache check failed",
|
||||||
|
description: formatAxiosError(e, "Failed to verify cache")
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCheckingCache(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const showApprovalFeatures =
|
const showApprovalFeatures =
|
||||||
@@ -844,6 +903,75 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hidden cache verification — subtle button, dev/admin diagnostic */}
|
||||||
|
<div className="mt-8 flex flex-col gap-2 items-start opacity-30 hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleVerifyCache}
|
||||||
|
disabled={isCheckingCache}
|
||||||
|
className="text-xs text-muted-foreground underline disabled:opacity-50"
|
||||||
|
title="Verify the client's site association cache against current permissions (read-only)"
|
||||||
|
>
|
||||||
|
{isCheckingCache
|
||||||
|
? "Checking cache…"
|
||||||
|
: "Verify association cache"}
|
||||||
|
</button>
|
||||||
|
{cacheCheck && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"text-xs rounded border px-2 py-1 " +
|
||||||
|
(cacheCheck.consistent
|
||||||
|
? "border-green-600 text-green-700"
|
||||||
|
: "border-red-600 text-red-700")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cacheCheck.consistent ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
Cache is consistent
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1 font-semibold">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Cache is INCONSISTENT
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Missing site resources: [
|
||||||
|
{cacheCheck.missingSiteResourceIds.join(
|
||||||
|
", "
|
||||||
|
)}
|
||||||
|
]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Extra site resources: [
|
||||||
|
{cacheCheck.extraSiteResourceIds.join(", ")}
|
||||||
|
]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Missing sites: [
|
||||||
|
{cacheCheck.missingSiteIds.join(", ")}]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Extra sites: [
|
||||||
|
{cacheCheck.extraSiteIds.join(", ")}]
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRebuildCache}
|
||||||
|
disabled={isRebuildingCache}
|
||||||
|
className="mt-1 text-xs underline font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRebuildingCache
|
||||||
|
? "Rebuilding…"
|
||||||
|
: "Rebuild cache now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,16 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import type { OrgContextType } from "@app/contexts/orgContext";
|
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
|
// Schema for general organization settings
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
subnet: z.string().optional()
|
subnet: z.string().optional(),
|
||||||
|
settingsEnableGlobalNewtAutoUpdate: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
@@ -163,17 +168,24 @@ function GeneralSectionForm({ org }: SectionFormProps) {
|
|||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
GeneralFormSchema.pick({
|
GeneralFormSchema.pick({
|
||||||
name: true,
|
name: true,
|
||||||
subnet: true
|
subnet: true,
|
||||||
|
settingsEnableGlobalNewtAutoUpdate: true
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: org.name,
|
name: org.name,
|
||||||
subnet: org.subnet || "" // Add default value for subnet
|
subnet: org.subnet || "",
|
||||||
|
settingsEnableGlobalNewtAutoUpdate:
|
||||||
|
org.settingsEnableGlobalNewtAutoUpdate ?? false
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
const hasAutoUpdateFeature = isPaidUser(
|
||||||
|
tierMatrix[TierFeature.NewtAutoUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
const [, formAction, loadingSave] = useActionState(performSave, null);
|
const [, formAction, loadingSave] = useActionState(performSave, null);
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
@@ -186,7 +198,9 @@ function GeneralSectionForm({ org }: SectionFormProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const reqData = {
|
const reqData = {
|
||||||
name: data.name
|
name: data.name,
|
||||||
|
settingsEnableGlobalNewtAutoUpdate:
|
||||||
|
data.settingsEnableGlobalNewtAutoUpdate
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
// Update organization
|
// Update organization
|
||||||
@@ -194,13 +208,16 @@ function GeneralSectionForm({ org }: SectionFormProps) {
|
|||||||
|
|
||||||
// Update the org context to reflect the change in the info card
|
// Update the org context to reflect the change in the info card
|
||||||
updateOrg({
|
updateOrg({
|
||||||
name: data.name
|
name: data.name,
|
||||||
|
settingsEnableGlobalNewtAutoUpdate:
|
||||||
|
data.settingsEnableGlobalNewtAutoUpdate
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("orgUpdated"),
|
title: t("orgUpdated"),
|
||||||
description: t("orgUpdatedDescription")
|
description: t("orgUpdatedDescription")
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
@@ -243,6 +260,31 @@ function GeneralSectionForm({ org }: SectionFormProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PaidFeaturesAlert
|
||||||
|
tiers={tierMatrix.newtAutoUpdate}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="settingsEnableGlobalNewtAutoUpdate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="settings-enable-global-newt-auto-update"
|
||||||
|
label={t("newtAutoUpdate")}
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={!hasAutoUpdateFeature}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("newtAutoUpdateDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
|||||||
@@ -36,35 +36,53 @@ import { useState } from "react";
|
|||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
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";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string().nonempty("Name is required"),
|
name: z.string().nonempty("Name is required"),
|
||||||
niceId: z.string().min(1).max(255).optional(),
|
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<typeof GeneralFormSchema>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const { site, updateSite } = useSiteContext();
|
const { site, updateSite } = useSiteContext();
|
||||||
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
const hasAutoUpdateFeature = isPaidUser(
|
||||||
|
tierMatrix[TierFeature.NewtAutoUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(
|
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const orgAutoUpdate = org.org.settingsEnableGlobalNewtAutoUpdate ?? false;
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(GeneralFormSchema),
|
resolver: zodResolver(GeneralFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: site?.name,
|
name: site?.name,
|
||||||
niceId: site?.niceId || "",
|
niceId: site?.niceId || "",
|
||||||
dockerSocketEnabled: site?.dockerSocketEnabled ?? false
|
dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
|
||||||
|
autoUpdateEnabled: site?.autoUpdateOverrideOrg
|
||||||
|
? (site?.autoUpdateEnabled ?? false)
|
||||||
|
: orgAutoUpdate,
|
||||||
|
autoUpdateOverrideOrg: site?.autoUpdateOverrideOrg ?? false
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
@@ -76,13 +94,17 @@ export default function GeneralPage() {
|
|||||||
await api.post(`/site/${site?.siteId}`, {
|
await api.post(`/site/${site?.siteId}`, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
dockerSocketEnabled: data.dockerSocketEnabled
|
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||||
|
autoUpdateEnabled: data.autoUpdateEnabled,
|
||||||
|
autoUpdateOverrideOrg: data.autoUpdateOverrideOrg
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSite({
|
updateSite({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
dockerSocketEnabled: data.dockerSocketEnabled
|
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||||
|
autoUpdateEnabled: data.autoUpdateEnabled,
|
||||||
|
autoUpdateOverrideOrg: data.autoUpdateOverrideOrg
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.niceId && data.niceId !== site?.niceId) {
|
if (data.niceId && data.niceId !== site?.niceId) {
|
||||||
@@ -199,7 +221,9 @@ export default function GeneralPage() {
|
|||||||
{t.rich(
|
{t.rich(
|
||||||
"enableDockerSocketDescription",
|
"enableDockerSocketDescription",
|
||||||
{
|
{
|
||||||
docsLink: (chunks) => (
|
docsLink: (
|
||||||
|
chunks
|
||||||
|
) => (
|
||||||
<a
|
<a
|
||||||
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
|
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -217,6 +241,80 @@ export default function GeneralPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PaidFeaturesAlert
|
||||||
|
tiers={tierMatrix.newtAutoUpdate}
|
||||||
|
/>
|
||||||
|
{site && site.type === "newt" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="autoUpdateEnabled"
|
||||||
|
render={({ field }) => {
|
||||||
|
const isOverriding = form.watch(
|
||||||
|
"autoUpdateOverrideOrg"
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SwitchInput
|
||||||
|
id="auto-update-enabled"
|
||||||
|
label={t(
|
||||||
|
"siteAutoUpdateLabel"
|
||||||
|
)}
|
||||||
|
checked={
|
||||||
|
field.value
|
||||||
|
}
|
||||||
|
onCheckedChange={(
|
||||||
|
checked
|
||||||
|
) => {
|
||||||
|
field.onChange(
|
||||||
|
checked
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"autoUpdateOverrideOrg",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
!hasAutoUpdateFeature
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{isOverriding && (
|
||||||
|
<ButtonUI
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto p-0 pb-2 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
form.setValue(
|
||||||
|
"autoUpdateOverrideOrg",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"autoUpdateEnabled",
|
||||||
|
orgAutoUpdate
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"siteAutoUpdateResetToOrg"
|
||||||
|
)}
|
||||||
|
</ButtonUI>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"siteAutoUpdateDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import SiteProvider from "@app/providers/SiteProvider";
|
import SiteProvider from "@app/providers/SiteProvider";
|
||||||
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { GetSiteResponse } from "@server/routers/site";
|
import { GetSiteResponse } from "@server/routers/site";
|
||||||
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
@@ -35,6 +37,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
redirect(`/${params.orgId}/settings/sites`);
|
redirect(`/${params.orgId}/settings/sites`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let org = null;
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<GetOrgResponse>>(
|
||||||
|
`/org/${params.orgId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
org = res.data.data;
|
||||||
|
} catch {
|
||||||
|
redirect(`/${params.orgId}/settings/sites`);
|
||||||
|
}
|
||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -64,10 +77,14 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SiteProvider site={site}>
|
<SiteProvider site={site}>
|
||||||
<div className="space-y-4">
|
<OrgProvider org={org}>
|
||||||
<SiteInfoCard />
|
<div className="space-y-4">
|
||||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
<SiteInfoCard />
|
||||||
</div>
|
<HorizontalTabs items={navItems}>
|
||||||
|
{children}
|
||||||
|
</HorizontalTabs>
|
||||||
|
</div>
|
||||||
|
</OrgProvider>
|
||||||
</SiteProvider>
|
</SiteProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,77 +44,11 @@ export type AuthPageCustomizationProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AuthPageFormSchema = z.object({
|
const AuthPageFormSchema = z.object({
|
||||||
logoUrl: z.union([
|
logoUrl: z
|
||||||
z.literal(""),
|
.string()
|
||||||
z.string().superRefine(async (urlOrPath, ctx) => {
|
.optional()
|
||||||
const parseResult = z.url().safeParse(urlOrPath);
|
.transform((val) => (val === "" ? undefined : val)),
|
||||||
if (!parseResult.success) {
|
|
||||||
if (build !== "enterprise") {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Must be a valid URL"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
validateLocalPath(urlOrPath);
|
|
||||||
} catch (error) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message:
|
|
||||||
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(urlOrPath, {
|
|
||||||
method: "HEAD"
|
|
||||||
}).catch(() => {
|
|
||||||
// If HEAD fails (CORS or method not allowed), try GET
|
|
||||||
return fetch(urlOrPath, { method: "GET" });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `Failed to load image. Please check that the URL is accessible.`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.startsWith("image/")) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage =
|
|
||||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
|
||||||
|
|
||||||
if (
|
|
||||||
error instanceof TypeError &&
|
|
||||||
error.message.includes("fetch")
|
|
||||||
) {
|
|
||||||
errorMessage =
|
|
||||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage = `Error verifying URL: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: errorMessage
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
orgTitle: z.string().optional(),
|
orgTitle: z.string().optional(),
|
||||||
|
|||||||
@@ -318,12 +318,28 @@ export default function DeviceLoginForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={9}
|
maxLength={8}
|
||||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value
|
value={field.value
|
||||||
.replace(/-/g, "")
|
.replace(/-/g, "")
|
||||||
.toUpperCase()}
|
.toUpperCase()}
|
||||||
|
onPaste={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const pastedText =
|
||||||
|
event.clipboardData.getData(
|
||||||
|
"text"
|
||||||
|
);
|
||||||
|
const cleanedValue =
|
||||||
|
pastedText
|
||||||
|
.replace(
|
||||||
|
/[^a-zA-Z0-9]/g,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 8);
|
||||||
|
field.onChange(cleanedValue);
|
||||||
|
}}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
// Strip hyphens and convert to uppercase
|
// Strip hyphens and convert to uppercase
|
||||||
const cleanedValue = value
|
const cleanedValue = value
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
|
||||||
|
if (!value?.trim()) return true;
|
||||||
|
|
||||||
|
const commands = value
|
||||||
|
.split(",")
|
||||||
|
.map((command) => command.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return commands.every((command) => {
|
||||||
|
const executable = command.split(/\s+/)[0];
|
||||||
|
return executable.startsWith("/");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type RoleFormValues = {
|
export type RoleFormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -74,19 +88,33 @@ export function RoleForm({
|
|||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z
|
||||||
name: z
|
.object({
|
||||||
.string({ message: t("nameRequired") })
|
name: z
|
||||||
.min(1)
|
.string({ message: t("nameRequired") })
|
||||||
.max(32),
|
.min(1)
|
||||||
description: z.string().max(255).optional(),
|
.max(32),
|
||||||
requireDeviceApproval: z.boolean().optional(),
|
description: z.string().max(255).optional(),
|
||||||
allowSsh: z.boolean().optional(),
|
requireDeviceApproval: z.boolean().optional(),
|
||||||
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
|
allowSsh: z.boolean().optional(),
|
||||||
sshSudoCommands: z.string().optional(),
|
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
|
||||||
sshCreateHomeDir: z.boolean().optional(),
|
sshSudoCommands: z.string().optional(),
|
||||||
sshUnixGroups: z.string().optional()
|
sshCreateHomeDir: z.boolean().optional(),
|
||||||
});
|
sshUnixGroups: z.string().optional()
|
||||||
|
})
|
||||||
|
.superRefine((values, ctx) => {
|
||||||
|
if (
|
||||||
|
values.sshSudoMode === "commands" &&
|
||||||
|
!hasOnlyAbsoluteSudoCommands(values.sshSudoCommands)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["sshSudoCommands"],
|
||||||
|
message:
|
||||||
|
"Each sudo command must start with an absolute path (for example, /usr/bin/systemctl)."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const defaultValues: RoleFormValues = role
|
const defaultValues: RoleFormValues = role
|
||||||
? {
|
? {
|
||||||
@@ -296,7 +324,9 @@ export function RoleForm({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="allowSsh"
|
name="allowSsh"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [
|
const allowSshOptions: OptionSelectOption<
|
||||||
|
"allow" | "disallow"
|
||||||
|
>[] = [
|
||||||
{
|
{
|
||||||
value: "allow",
|
value: "allow",
|
||||||
label: t("roleAllowSshAllow")
|
label: t("roleAllowSshAllow")
|
||||||
@@ -311,7 +341,9 @@ export function RoleForm({
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("roleAllowSsh")}
|
{t("roleAllowSsh")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<OptionSelect<"allow" | "disallow">
|
<OptionSelect<
|
||||||
|
"allow" | "disallow"
|
||||||
|
>
|
||||||
options={allowSshOptions}
|
options={allowSshOptions}
|
||||||
value={
|
value={
|
||||||
sshDisabled
|
sshDisabled
|
||||||
@@ -322,7 +354,9 @@ export function RoleForm({
|
|||||||
}
|
}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
if (sshDisabled) return;
|
if (sshDisabled) return;
|
||||||
field.onChange(v === "allow");
|
field.onChange(
|
||||||
|
v === "allow"
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
cols={2}
|
cols={2}
|
||||||
disabled={sshDisabled}
|
disabled={sshDisabled}
|
||||||
|
|||||||
@@ -45,7 +45,16 @@ export function SwitchInput({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
{label && <Label htmlFor={id}>{label}</Label>}
|
{label && (
|
||||||
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
className={
|
||||||
|
disabled ? "opacity-50 cursor-not-allowed" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
<Switch
|
<Switch
|
||||||
id={id}
|
id={id}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
|||||||
Reference in New Issue
Block a user