Merge branch 'fosrl:dev' into dev

This commit is contained in:
Mustafa
2026-05-03 18:53:48 +02:00
committed by GitHub
634 changed files with 38068 additions and 10086 deletions
+126
View File
@@ -0,0 +1,126 @@
export type ListAlertRulesResponse = {
alertRules: {
alertRuleId: number;
orgId: string;
name: string;
eventType: string;
enabled: boolean;
cooldownSeconds: number;
lastTriggeredAt: number | null;
createdAt: number;
updatedAt: number;
siteIds: number[];
healthCheckIds: number[];
resourceIds: number[];
}[];
pagination: {
total: number;
limit: number;
offset: number;
};
};
export type CreateAlertRuleResponse = {
alertRuleId: number;
};
export type GetAlertRuleResponse = {
alertRuleId: number;
orgId: string;
name: string;
eventType:
| "site_online"
| "site_offline"
| "site_toggle"
| "health_check_healthy"
| "health_check_unhealthy"
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
enabled: boolean;
cooldownSeconds: number;
lastTriggeredAt: number | null;
createdAt: number;
updatedAt: number;
siteIds: number[];
healthCheckIds: number[];
resourceIds: number[];
recipients: {
recipientId: number;
userId: string | null;
roleId: number | null;
email: string | null;
}[];
webhookActions: {
webhookActionId: number;
webhookUrl: string;
enabled: boolean;
lastSentAt: number | null;
config: WebhookAlertConfig | null;
}[];
};
/**
* Stored as an encrypted JSON blob in `alertWebhookActions.config`.
*/
export interface WebhookAlertConfig {
/** Authentication strategy for the webhook endpoint */
authType: WebhookAuthType;
/** Bearer token used when authType === "bearer" */
bearerToken?: string;
/** Basic credentials "username:password" used when authType === "basic" */
basicCredentials?: string;
/** Custom header name used when authType === "custom" */
customHeaderName?: string;
/** Custom header value used when authType === "custom" */
customHeaderValue?: string;
/** Extra headers to send with every webhook request */
headers?: Array<{ key: string; value: string }>;
/** HTTP method (default POST) */
method?: string;
/** Whether to use a custom body template */
useBodyTemplate?: boolean;
/** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
bodyTemplate?: string;
}
// ---------------------------------------------------------------------------
// Alert event types
// ---------------------------------------------------------------------------
export type AlertEventType =
| "site_online"
| "site_offline"
| "site_toggle"
| "health_check_healthy"
| "health_check_unhealthy"
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
// ---------------------------------------------------------------------------
// Webhook authentication config (stored as encrypted JSON in the DB)
// ---------------------------------------------------------------------------
export type WebhookAuthType = "none" | "bearer" | "basic" | "custom";
// ---------------------------------------------------------------------------
// Internal alert event passed through the processing pipeline
// ---------------------------------------------------------------------------
export interface AlertContext {
eventType: AlertEventType;
orgId: string;
/** Set for site_online / site_offline events */
siteId?: number;
/** Set for health_check_* events */
healthCheckId?: number;
/** Set for resource_* events */
resourceId?: number;
/** Human-readable context data included in emails and webhook payloads */
data: Record<string, unknown>;
}
+111 -38
View File
@@ -1,8 +1,8 @@
import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db";
import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm";
import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
@@ -92,7 +92,10 @@ function getWhere(data: Q) {
lt(requestAuditLog.timestamp, data.timeEnd),
eq(requestAuditLog.orgId, data.orgId),
data.resourceId
? eq(requestAuditLog.resourceId, data.resourceId)
? or(
eq(requestAuditLog.resourceId, data.resourceId),
eq(requestAuditLog.siteResourceId, data.resourceId)
)
: undefined,
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
data.method ? eq(requestAuditLog.method, data.method) : undefined,
@@ -110,15 +113,16 @@ export function queryRequest(data: Q) {
return primaryLogsDb
.select({
id: requestAuditLog.id,
timestamp: requestAuditLog.timestamp,
orgId: requestAuditLog.orgId,
action: requestAuditLog.action,
reason: requestAuditLog.reason,
actorType: requestAuditLog.actorType,
actor: requestAuditLog.actor,
actorId: requestAuditLog.actorId,
resourceId: requestAuditLog.resourceId,
ip: requestAuditLog.ip,
timestamp: requestAuditLog.timestamp,
orgId: requestAuditLog.orgId,
action: requestAuditLog.action,
reason: requestAuditLog.reason,
actorType: requestAuditLog.actorType,
actor: requestAuditLog.actor,
actorId: requestAuditLog.actorId,
resourceId: requestAuditLog.resourceId,
siteResourceId: requestAuditLog.siteResourceId,
ip: requestAuditLog.ip,
location: requestAuditLog.location,
userAgent: requestAuditLog.userAgent,
metadata: requestAuditLog.metadata,
@@ -137,37 +141,73 @@ export function queryRequest(data: Q) {
}
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
// If logs database is the same as main database, we can do a join
// Otherwise, we need to fetch resource details separately
const resourceIds = logs
.map(log => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0) {
const siteResourceIds = logs
.filter(log => log.resourceId == null && log.siteResourceId != null)
.map(log => log.siteResourceId)
.filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0 && siteResourceIds.length === 0) {
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
}
// Fetch resource details from main database
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name,
niceId: resources.niceId
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>();
// Create a map for quick lookup
const resourceMap = new Map(
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
);
if (resourceIds.length > 0) {
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name,
niceId: resources.niceId
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
for (const r of resourceDetails) {
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId });
}
}
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>();
if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name,
niceId: siteResources.niceId
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
for (const r of siteResourceDetails) {
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId });
}
}
// Enrich logs with resource details
return logs.map(log => ({
...log,
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
}));
return logs.map(log => {
if (log.resourceId != null) {
const details = resourceMap.get(log.resourceId);
return {
...log,
resourceName: details?.name ?? null,
resourceNiceId: details?.niceId ?? null
};
} else if (log.siteResourceId != null) {
const details = siteResourceMap.get(log.siteResourceId);
return {
...log,
resourceId: log.siteResourceId,
resourceName: details?.name ?? null,
resourceNiceId: details?.niceId ?? null
};
}
return { ...log, resourceName: null, resourceNiceId: null };
});
}
export function countRequestQuery(data: Q) {
@@ -211,7 +251,8 @@ async function queryUniqueFilterAttributes(
uniqueLocations,
uniqueHosts,
uniquePaths,
uniqueResources
uniqueResources,
uniqueSiteResources
] = await Promise.all([
primaryLogsDb
.selectDistinct({ actor: requestAuditLog.actor })
@@ -239,6 +280,13 @@ async function queryUniqueFilterAttributes(
})
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
.selectDistinct({
id: requestAuditLog.siteResourceId
})
.from(requestAuditLog)
.where(and(baseConditions, isNull(requestAuditLog.resourceId)))
.limit(DISTINCT_LIMIT + 1)
]);
@@ -259,6 +307,10 @@ async function queryUniqueFilterAttributes(
.map(row => row.id)
.filter((id): id is number => id !== null);
const siteResourceIds = uniqueSiteResources
.map(row => row.id)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
if (resourceIds.length > 0) {
@@ -270,10 +322,31 @@ async function queryUniqueFilterAttributes(
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
resourcesWithNames = resourceDetails.map(r => ({
id: r.resourceId,
name: r.name
}));
resourcesWithNames = [
...resourcesWithNames,
...resourceDetails.map(r => ({
id: r.resourceId,
name: r.name
}))
];
}
if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
resourcesWithNames = [
...resourcesWithNames,
...siteResourceDetails.map(r => ({
id: r.siteResourceId,
name: r.name
}))
];
}
return {
+1
View File
@@ -28,6 +28,7 @@ export type QueryRequestAuditLogResponse = {
actor: string | null;
actorId: string | null;
resourceId: number | null;
siteResourceId: number | null;
resourceNiceId: string | null;
resourceName: string | null;
ip: string | null;
+26 -15
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, orgs, userOrgs, users } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import { eq, and, inArray, not } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -17,11 +17,10 @@ import { verifyTotpCode } from "@server/auth/totp";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { build } from "@server/build";
import { getOrgTierData } from "#dynamic/lib/billing";
import {
deleteOrgById,
sendTerminationMessages
} from "@server/lib/deleteOrg";
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
import { UserType } from "@server/types/UserTypes";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
const deleteMyAccountBody = z.strictObject({
password: z.string().optional(),
@@ -98,15 +97,16 @@ export async function deleteMyAccount(
and(eq(userOrgs.userId, userId), eq(userOrgs.isOwner, true))
);
const orgIds = ownedOrgsRows.map((r) => r.orgId);
const ownedOrgIds = ownedOrgsRows.map((r) => r.orgId);
if (build === "saas" && orgIds.length > 0) {
if (build === "saas" && ownedOrgIds.length > 0) {
const primaryOrgId = ownedOrgsRows.find(
(r) => r.isBillingOrg && r.isOwner
)?.orgId;
if (primaryOrgId) {
const { tier, active } = await getOrgTierData(primaryOrgId);
if (active && tier) {
const { tier, active, isTrial } =
await getOrgTierData(primaryOrgId);
if (active && tier && !isTrial) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -119,14 +119,14 @@ export async function deleteMyAccount(
if (!password) {
const orgsWithNames =
orgIds.length > 0
ownedOrgIds.length > 0
? await db
.select({
orgId: orgs.orgId,
name: orgs.name
})
.from(orgs)
.where(inArray(orgs.orgId, orgIds))
.where(inArray(orgs.orgId, ownedOrgIds))
: [];
return response<DeleteMyAccountPreviewResponse>(res, {
data: {
@@ -206,9 +206,23 @@ export async function deleteMyAccount(
olmsToTerminate: allOlmsToTerminate
});
const otherOrgsTheUserWasIn = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
not(inArray(userOrgs.orgId, ownedOrgIds))
)
);
await db.transaction(async (trx) => {
await trx.delete(users).where(eq(users.userId, userId));
await calculateUserClientsForOrgs(userId, trx);
// loop through the other orgs and decrement the count
for (const userOrg of otherOrgsTheUserWasIn) {
await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx);
}
});
try {
@@ -233,10 +247,7 @@ export async function deleteMyAccount(
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred"
)
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+25 -5
View File
@@ -6,7 +6,7 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { resourceAccessToken, resources, sessions } from "@server/db";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import {
createResourceSession,
serializeResourceSessionCookie,
@@ -65,11 +65,31 @@ export async function exchangeSession(
const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined;
const [resource] = await db
const parts = cleanHost.split(".");
const wildcardCandidates: string[] = [];
for (let i = 1; i < parts.length; i++) {
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
}
const potentialResources = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, cleanHost))
.limit(1);
.where(
or(
eq(resources.fullDomain, cleanHost),
wildcardCandidates.length > 0
? and(
eq(resources.wildcard, true),
inArray(resources.fullDomain, wildcardCandidates)
)
: sql`false`
)
);
const exactMatch = potentialResources.find(
(r) => r.fullDomain === cleanHost
);
const resource = exactMatch ?? potentialResources[0];
if (!resource) {
return next(
@@ -178,7 +198,7 @@ export async function exchangeSession(
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain!,
cleanHost,
token,
!resource.ssl,
expiresAt ? new Date(expiresAt) : undefined
+4
View File
@@ -18,6 +18,7 @@ Reasons:
105 - Valid Password
106 - Valid email
107 - Valid SSO
108 - Connected Client
201 - Resource Not Found
202 - Resource Blocked
@@ -38,6 +39,7 @@ const auditLogBuffer: Array<{
metadata: any;
action: boolean;
resourceId?: number;
siteResourceId?: number;
reason: number;
location?: string;
originalRequestURL: string;
@@ -186,6 +188,7 @@ export async function logRequestAudit(
action: boolean;
reason: number;
resourceId?: number;
siteResourceId?: number;
orgId?: string;
location?: string;
user?: { username: string; userId: string };
@@ -262,6 +265,7 @@ export async function logRequestAudit(
metadata: sanitizeString(metadata),
action: data.action,
resourceId: data.resourceId,
siteResourceId: data.siteResourceId,
reason: data.reason,
location: sanitizeString(data.location),
originalRequestURL: sanitizeString(body.originalRequestURL) ?? "",
+2 -1
View File
@@ -3,6 +3,7 @@ export type GetCertificateResponse = {
domain: string;
domainId: string;
wildcard: boolean;
domainType: string;
status: string; // pending, requested, valid, expired, failed
expiresAt: string | null;
lastRenewalAttempt: Date | null;
@@ -10,4 +11,4 @@ export type GetCertificateResponse = {
updatedAt: number;
errorMessage?: string | null;
renewalCount: number;
};
};
+34 -4
View File
@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, olms, users } from "@server/db";
import { db, idp, idpOidcConfig, olms, users } from "@server/db";
import { clients, currentFingerprint } from "@server/db";
import { eq, and } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -236,6 +236,9 @@ export type GetClientResponse = NonNullable<
lastSeen: number | null;
} | null;
posture: PostureData | null;
userType: string | null;
idpName: string | null;
idpVariant: string | null;
};
registry.registerPath({
@@ -243,7 +246,7 @@ registry.registerPath({
path: "/org/{orgId}/client/{niceId}",
description:
"Get a client by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.",
tags: [OpenAPITags.Site],
tags: [OpenAPITags.Client],
request: {
params: z.object({
orgId: z.string(),
@@ -337,6 +340,30 @@ export async function getClient(
: maskPostureDataWithPlaceholder(rawPosture)
: null;
let userType: string | null = null;
let idpName: string | null = null;
let idpVariant: string | null = null;
if (client.clients.userId) {
const [idpRow] = await db
.select({
userType: users.type,
idpName: idp.name,
idpVariant: idpOidcConfig.variant
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(eq(users.userId, client.clients.userId))
.limit(1);
if (idpRow) {
userType = idpRow.userType;
idpName = idpRow.idpName;
idpVariant = idpRow.idpVariant;
}
}
const data: GetClientResponse = {
...client.clients,
name: clientName,
@@ -347,7 +374,10 @@ export async function getClient(
userName: client.user?.name ?? null,
userUsername: client.user?.username ?? null,
fingerprint: fingerprintData,
posture: postureData
posture: postureData,
userType,
idpName,
idpVariant
};
return response<GetClientResponse>(res, {
+32 -87
View File
@@ -29,65 +29,9 @@ import {
} from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import NodeCache from "node-cache";
import semver from "semver";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
async function getLatestOlmVersion(): Promise<string | null> {
try {
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch(
"https://api.github.com/repos/fosrl/olm/tags",
{
signal: controller.signal
}
);
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
);
return null;
}
let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Olm repository");
return null;
}
tags = tags.filter((version) => !version.name.includes("rc"));
const latestVersion = tags[0].name;
olmVersionCache.set("latestOlmVersion", latestVersion, 3600);
return latestVersion;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.warn("Connection timeout while fetching latest Olm version");
} else {
logger.warn(
"Error fetching latest Olm version:",
error.message || error
);
}
return null;
}
}
const listClientsParamsSchema = z.strictObject({
orgId: z.string()
});
@@ -413,44 +357,45 @@ export async function listClients(
};
});
const latestOlVersionPromise = getLatestOlmVersion();
// REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW
// const latestOlmVersionPromise = getLatestOlmVersion();
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map(
(client) => {
const OlmWithUpdate: OlmWithUpdateAvailable = { ...client };
// Initially set to false, will be updated if version check succeeds
OlmWithUpdate.olmUpdateAvailable = false;
return OlmWithUpdate;
}
);
// const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map(
// (client) => {
// const OlmWithUpdate: OlmWithUpdateAvailable = { ...client };
// // Initially set to false, will be updated if version check succeeds
// OlmWithUpdate.olmUpdateAvailable = false;
// return OlmWithUpdate;
// }
// );
// Try to get the latest version, but don't block if it fails
try {
const latestOlVersion = await latestOlVersionPromise;
// try {
// const latestOlmVersion = await latestOlVersionPromise;
if (latestOlVersion) {
olmsWithUpdates.forEach((client) => {
try {
client.olmUpdateAvailable = semver.lt(
client.olmVersion ? client.olmVersion : "",
latestOlVersion
);
} catch (error) {
client.olmUpdateAvailable = false;
}
});
}
} catch (error) {
// Log the error but don't let it block the response
logger.warn(
"Failed to check for OLM updates, continuing without update info:",
error
);
}
// if (latestOlVersion) {
// olmsWithUpdates.forEach((client) => {
// try {
// client.olmUpdateAvailable = semver.lt(
// client.olmVersion ? client.olmVersion : "",
// latestOlVersion
// );
// } catch (error) {
// client.olmUpdateAvailable = false;
// }
// });
// }
// } catch (error) {
// // Log the error but don't let it block the response
// logger.warn(
// "Failed to check for OLM updates, continuing without update info:",
// error
// );
// }
return response<ListClientsResponse>(res, {
data: {
clients: olmsWithUpdates,
clients: clientsWithSites,
pagination: {
total: totalCount,
page,
+30 -77
View File
@@ -3,6 +3,8 @@ import {
clients,
currentFingerprint,
db,
idp,
idpOidcConfig,
olms,
orgs,
roleClients,
@@ -30,65 +32,10 @@ import {
} from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import NodeCache from "node-cache";
import semver from "semver";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
async function getLatestOlmVersion(): Promise<string | null> {
try {
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch(
"https://api.github.com/repos/fosrl/olm/tags",
{
signal: controller.signal
}
);
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
);
return null;
}
let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Olm repository");
return null;
}
tags = tags.filter((version) => !version.name.includes("rc"));
const latestVersion = tags[0].name;
olmVersionCache.set("latestOlmVersion", latestVersion, 3600);
return latestVersion;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.warn("Connection timeout while fetching latest Olm version");
} else {
logger.warn(
"Error fetching latest Olm version:",
error.message || error
);
}
return null;
}
}
const listUserDevicesParamsSchema = z.strictObject({
orgId: z.string()
});
@@ -220,6 +167,9 @@ function queryUserDevicesBase() {
userId: clients.userId,
username: users.username,
userEmail: users.email,
userType: users.type,
idpName: idp.name,
idpVariant: idpOidcConfig.variant,
niceId: clients.niceId,
agent: olms.agent,
approvalState: clients.approvalState,
@@ -239,6 +189,8 @@ function queryUserDevicesBase() {
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(users, eq(clients.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
}
@@ -453,29 +405,30 @@ export async function listUserDevices(
}
);
// Try to get the latest version, but don't block if it fails
try {
const latestOlmVersion = await getLatestOlmVersion();
// REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW
// // Try to get the latest version, but don't block if it fails
// try {
// const latestOlmVersion = await getLatestOlmVersion();
if (latestOlmVersion) {
olmsWithUpdates.forEach((client) => {
try {
client.olmUpdateAvailable = semver.lt(
client.olmVersion ? client.olmVersion : "",
latestOlmVersion
);
} catch (error) {
client.olmUpdateAvailable = false;
}
});
}
} catch (error) {
// Log the error but don't let it block the response
logger.warn(
"Failed to check for OLM updates, continuing without update info:",
error
);
}
// if (latestOlmVersion) {
// olmsWithUpdates.forEach((client) => {
// try {
// client.olmUpdateAvailable = semver.lt(
// client.olmVersion ? client.olmVersion : "",
// latestOlmVersion
// );
// } catch (error) {
// client.olmUpdateAvailable = false;
// }
// });
// }
// } catch (error) {
// // Log the error but don't let it block the response
// logger.warn(
// "Failed to check for OLM updates, continuing without update info:",
// error
// );
// }
return response<ListUserDevicesResponse>(res, {
data: {
+2 -1
View File
@@ -103,7 +103,8 @@ export async function listDomains(
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(domains);
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId));
return response<ListDomainsResponse>(res, {
data: {
+14
View File
@@ -285,6 +285,13 @@ authenticated.get(
site.listContainers
);
authenticated.get(
"/site/:siteId/status-history",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.getSite),
site.getSiteStatusHistory
);
// Site Resource endpoints
authenticated.put(
"/org/:orgId/site-resource",
@@ -420,6 +427,13 @@ authenticated.get(
resource.listResources
);
authenticated.get(
"/resource/:resourceId/status-history",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.getResource),
resource.getResourceStatusHistory
);
authenticated.get(
"/org/:orgId/resources",
verifyOrgAccess,
+9 -9
View File
@@ -88,11 +88,11 @@ async function dbQueryRows<T extends Record<string, unknown>>(
): Promise<T[]> {
const anyDb = db as any;
if (typeof anyDb.execute === "function") {
// PostgreSQL (node-postgres via Drizzle) returns { rows: [...] } or an array
// PostgreSQL (node-postgres via Drizzle) - returns { rows: [...] } or an array
const result = await anyDb.execute(query);
return (Array.isArray(result) ? result : (result.rows ?? [])) as T[];
}
// SQLite (better-sqlite3 via Drizzle) returns an array directly
// SQLite (better-sqlite3 via Drizzle) - returns an array directly
return (await anyDb.all(query)) as T[];
}
@@ -106,7 +106,7 @@ function isSQLite(): boolean {
* Swaps out the accumulator before writing so that any bandwidth messages
* received during the flush are captured in the new accumulator rather than
* being lost or causing contention. Sites are updated in chunks via a single
* batch UPDATE per chunk. Failed chunks are discarded exact per-flush
* batch UPDATE per chunk. Failed chunks are discarded - exact per-flush
* accuracy is not critical and re-queuing is not worth the added complexity.
*
* This function is exported so that the application's graceful-shutdown
@@ -125,7 +125,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
const currentTime = new Date().toISOString();
// Sort by publicKey for consistent lock ordering across concurrent
// writers deadlock-prevention strategy.
// writers - deadlock-prevention strategy.
const sortedEntries = [...snapshot.entries()].sort(([a], [b]) =>
a.localeCompare(b)
);
@@ -150,7 +150,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
try {
rows = await withDeadlockRetry(async () => {
if (isSQLite()) {
// SQLite: one UPDATE per row no need for batch efficiency here.
// SQLite: one UPDATE per row - no need for batch efficiency here.
const results: { orgId: string; pubKey: string }[] = [];
for (const [publicKey, { bytesIn, bytesOut }] of chunk) {
const result = await dbQueryRows<{
@@ -170,7 +170,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
return results;
}
// PostgreSQL: batch UPDATE … FROM (VALUES …) single round-trip per chunk.
// PostgreSQL: batch UPDATE … FROM (VALUES …) - single round-trip per chunk.
const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) =>
sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)`
);
@@ -191,7 +191,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
`Failed to flush bandwidth chunk [${i}${chunkEnd}], discarding ${chunk.length} site(s):`,
error
);
// Discard the chunk exact per-flush accuracy is not critical.
// Discard the chunk - exact per-flush accuracy is not critical.
continue;
}
@@ -232,7 +232,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
totalBandwidth
);
if (bandwidthUsage) {
// Fire-and-forget don't block the flush on limit checking.
// Fire-and-forget - don't block the flush on limit checking.
usageService
.checkLimitSet(
orgId,
@@ -298,7 +298,7 @@ export async function updateSiteBandwidth(
exitNodeId?: number
): Promise<void> {
for (const { publicKey, bytesIn, bytesOut } of bandwidthData) {
// Skip peers that haven't transferred any data writing zeros to the
// Skip peers that haven't transferred any data - writing zeros to the
// database would be a no-op anyway.
if (bytesIn <= 0 && bytesOut <= 0) {
continue;
+34
View File
@@ -0,0 +1,34 @@
export type ListHealthChecksResponse = {
healthChecks: {
targetHealthCheckId: number;
name: string;
siteId: number | null;
siteName: string | null;
siteNiceId: string | null;
hcEnabled: boolean;
hcHealth: "unknown" | "healthy" | "unhealthy";
hcMode: string | null;
hcHostname: string | null;
hcPort: number | null;
hcPath: string | null;
hcScheme: string | null;
hcMethod: string | null;
hcInterval: number | null;
hcUnhealthyInterval: number | null;
hcTimeout: number | null;
hcHeaders: string | null;
hcFollowRedirects: boolean | null;
hcStatus: number | null;
hcTlsServerName: string | null;
hcHealthyThreshold: number | null;
hcUnhealthyThreshold: number | null;
resourceId: number | null;
resourceName: string | null;
resourceNiceId: string | null;
}[];
pagination: {
total: number;
limit: number;
offset: number;
};
};
+14 -23
View File
@@ -336,31 +336,22 @@ export async function validateOidcCallback(
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
allOrgs = idpOrgs.map((o) => o.orgs);
// TODO: when there are multiple orgs we need to do this better!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!1
if (allOrgs.length > 1) {
// for some reason there is more than one org
logger.error(
"More than one organization linked to this IdP. This should not happen with auto-provisioning enabled."
for (const org of allOrgs) {
const subscribed = await isSubscribed(
org.orgId,
tierMatrix.autoProvisioning
);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Multiple organizations linked to this IdP. Please contact support."
)
);
}
if (!subscribed) {
// filter out the org
allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
const subscribed = await isSubscribed(
allOrgs[0].orgId,
tierMatrix.autoProvisioning
);
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
// return next(
// createHttpError(
// HttpCode.FORBIDDEN,
// "This organization's current plan does not support this feature."
// )
// );
}
}
} else {
allOrgs = await db.select().from(orgs);
+43 -30
View File
@@ -4,8 +4,10 @@ import {
clientSitesAssociationsCache,
db,
ExitNode,
networks,
resources,
Site,
siteNetworks,
siteResources,
targetHealthCheck,
targets
@@ -84,7 +86,8 @@ export async function buildClientConfigurationForNewtClient(
// )
// );
if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm
if (!client.clientSitesAssociationsCache.isJitMode) {
// if we are adding sites through jit then dont add the site to the olm
// update the peer info on the olm
// if the peer has not been added yet this will be a no-op
await updatePeer(client.clients.clientId, {
@@ -137,11 +140,14 @@ export async function buildClientConfigurationForNewtClient(
// Filter out any null values from peers that didn't have an olm
const validPeers = peers.filter((peer) => peer !== null);
// Get all enabled site resources for this site
// Get all enabled site resources for this site by joining through siteNetworks and networks
const allSiteResources = await db
.select()
.from(siteResources)
.where(eq(siteResources.siteId, siteId));
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where(eq(siteNetworks.siteId, siteId))
.then((rows) => rows.map((r) => r.siteResources));
const targetsToSend: SubnetProxyTargetV2[] = [];
@@ -168,13 +174,13 @@ export async function buildClientConfigurationForNewtClient(
)
);
const resourceTarget = generateSubnetProxyTargetV2(
const resourceTargets = await generateSubnetProxyTargetV2(
resource,
resourceClients
);
if (resourceTarget) {
targetsToSend.push(resourceTarget);
if (resourceTargets) {
targetsToSend.push(...resourceTargets);
}
}
@@ -184,7 +190,10 @@ export async function buildClientConfigurationForNewtClient(
};
}
export async function buildTargetConfigurationForNewtClient(siteId: number) {
export async function buildTargetConfigurationForNewtClient(
siteId: number,
version?: string | null
) {
// Get all enabled targets with their resource protocol information
const allTargets = await db
.select({
@@ -195,7 +204,15 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
port: targets.port,
internalPort: targets.internalPort,
enabled: targets.enabled,
protocol: resources.protocol,
protocol: resources.protocol
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
const allHealthChecks = await db
.select({
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
hcEnabled: targetHealthCheck.hcEnabled,
hcPath: targetHealthCheck.hcPath,
hcScheme: targetHealthCheck.hcScheme,
@@ -206,17 +223,15 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval,
hcTimeout: targetHealthCheck.hcTimeout,
hcHeaders: targetHealthCheck.hcHeaders,
hcFollowRedirects: targetHealthCheck.hcFollowRedirects,
hcMethod: targetHealthCheck.hcMethod,
hcTlsServerName: targetHealthCheck.hcTlsServerName,
hcStatus: targetHealthCheck.hcStatus
hcStatus: targetHealthCheck.hcStatus,
hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold,
hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.leftJoin(
targetHealthCheck,
eq(targets.targetId, targetHealthCheck.targetId)
)
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
.from(targetHealthCheck)
.where(eq(targetHealthCheck.siteId, siteId));
const { tcpTargets, udpTargets } = allTargets.reduce(
(acc, target) => {
@@ -240,19 +255,14 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
{ tcpTargets: [] as string[], udpTargets: [] as string[] }
);
const healthCheckTargets = allTargets.map((target) => {
const healthCheckTargets = allHealthChecks.map((target) => {
// make sure the stuff is defined
if (
!target.hcPath ||
!target.hcHostname ||
!target.hcPort ||
!target.hcInterval ||
!target.hcMethod
) {
// logger.debug(
// `Skipping adding target health check ${target.targetId} due to missing health check fields`
// );
return null; // Skip targets with missing health check fields
const isTCP = target.hcMode?.toLowerCase() === "tcp";
if (!target.hcHostname || !target.hcPort || !target.hcInterval) {
return null;
}
if (!isTCP && (!target.hcPath || !target.hcMethod)) {
return null;
}
// parse headers
@@ -269,7 +279,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
}
return {
id: target.targetId,
id: target.targetHealthCheckId,
hcEnabled: target.hcEnabled,
hcPath: target.hcPath,
hcScheme: target.hcScheme,
@@ -280,9 +290,12 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
hcUnhealthyInterval: target.hcUnhealthyInterval, // in seconds
hcTimeout: target.hcTimeout, // in seconds
hcHeaders: hcHeadersSend,
hcFollowRedirects: target.hcFollowRedirects,
hcMethod: target.hcMethod,
hcTlsServerName: target.hcTlsServerName,
hcStatus: target.hcStatus
hcStatus: target.hcStatus,
hcHealthyThreshold: target.hcHealthyThreshold,
hcUnhealthyThreshold: target.hcUnhealthyThreshold
};
});
@@ -2,6 +2,7 @@ import { MessageHandler } from "@server/routers/ws";
import { db, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOfflineAlert } from "@server/lib/alerts";
/**
* Handles disconnecting messages from sites to show disconnected in the ui
@@ -24,12 +25,23 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
try {
// Update the client's last ping timestamp
await db
.update(sites)
.set({
online: false
})
.where(eq(sites.siteId, newt.siteId));
await db.transaction(async (trx) => {
const [site] = await trx
.update(sites)
.set({
online: false
})
.where(eq(sites.siteId, newt.siteId!))
.returning();
await fireSiteOfflineAlert(
site.orgId,
site.siteId,
site.name,
undefined,
trx
);
});
} catch (error) {
logger.error("Error handling disconnecting message", { error });
}
@@ -10,7 +10,7 @@ import { convertTargetsIfNessicary } from "../client/targets";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
export const handleGetConfigMessage: MessageHandler = async (context) => {
export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
const newt = client as Newt;
@@ -56,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
logger.warn(
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
);
return;
}
@@ -113,7 +113,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
exitNode
);
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets);
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format
return {
message: {
+3 -171
View File
@@ -1,180 +1,12 @@
import { db, newts, sites, targetHealthCheck, targets } from "@server/db";
import {
hasActiveConnections,
getClientConfigVersion
} from "#dynamic/routers/ws";
import { db, sites } from "@server/db";
import { getClientConfigVersion } from "#dynamic/routers/ws";
import { MessageHandler } from "@server/routers/ws";
import { Newt } from "@server/db";
import { eq, lt, isNull, and, or, ne, not } from "drizzle-orm";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import { sendNewtSyncMessage } from "./sync";
import { recordPing } from "./pingAccumulator";
// Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null;
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
const OFFLINE_THRESHOLD_BANDWIDTH_MS = 8 * 60 * 1000; // 8 minutes
/**
* Starts the background interval that checks for newt sites that haven't
* pinged recently and marks them as offline. For backward compatibility,
* a site is only marked offline when there is no active WebSocket connection
* either — so older newt versions that don't send pings but remain connected
* continue to be treated as online.
*/
export const startNewtOfflineChecker = (): void => {
if (offlineCheckerInterval) {
return; // Already running
}
offlineCheckerInterval = setInterval(async () => {
try {
const twoMinutesAgo = Math.floor(
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
);
// Find all online newt-type sites that haven't pinged recently
// (or have never pinged at all). Join newts to obtain the newtId
// needed for the WebSocket connection check.
const staleSites = await db
.select({
siteId: sites.siteId,
newtId: newts.newtId,
lastPing: sites.lastPing
})
.from(sites)
.innerJoin(newts, eq(newts.siteId, sites.siteId))
.where(
and(
eq(sites.online, true),
eq(sites.type, "newt"),
or(
lt(sites.lastPing, twoMinutesAgo),
isNull(sites.lastPing)
)
)
);
for (const staleSite of staleSites) {
// Backward-compatibility check: if the newt still has an
// active WebSocket connection (older clients that don't send
// pings), keep the site online.
const isConnected = await hasActiveConnections(
staleSite.newtId
);
if (isConnected) {
logger.debug(
`Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online`
);
continue;
}
logger.info(
`Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection`
);
await db
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, staleSite.siteId));
const healthChecksOnSite = await db
.select()
.from(targetHealthCheck)
.innerJoin(
targets,
eq(targets.targetId, targetHealthCheck.targetId)
)
.innerJoin(sites, eq(sites.siteId, targets.siteId))
.where(eq(sites.siteId, staleSite.siteId));
for (const healthCheck of healthChecksOnSite) {
logger.info(
`Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline`
);
await db
.update(targetHealthCheck)
.set({ hcHealth: "unknown" })
.where(
eq(
targetHealthCheck.targetHealthCheckId,
healthCheck.targetHealthCheck
.targetHealthCheckId
)
);
}
}
// this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites
// select all of the wireguard sites to evaluate if they need to be offline due to the last bandwidth update
const allWireguardSites = await db
.select({
siteId: sites.siteId,
online: sites.online,
lastBandwidthUpdate: sites.lastBandwidthUpdate
})
.from(sites)
.where(
and(
eq(sites.type, "wireguard"),
not(isNull(sites.lastBandwidthUpdate))
)
);
const wireguardOfflineThreshold = Math.floor(
(Date.now() - OFFLINE_THRESHOLD_BANDWIDTH_MS) / 1000
);
// loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline
for (const site of allWireguardSites) {
const lastBandwidthUpdate =
new Date(site.lastBandwidthUpdate!).getTime() / 1000;
if (
lastBandwidthUpdate < wireguardOfflineThreshold &&
site.online
) {
logger.info(
`Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes`
);
await db
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, site.siteId));
} else if (
lastBandwidthUpdate >= wireguardOfflineThreshold &&
!site.online
) {
logger.info(
`Marking wireguard site ${site.siteId} online: recent bandwidth update`
);
await db
.update(sites)
.set({ online: true })
.where(eq(sites.siteId, site.siteId));
}
}
} catch (error) {
logger.error("Error in newt offline checker interval", { error });
}
}, OFFLINE_CHECK_INTERVAL);
logger.debug("Started newt offline checker interval");
};
/**
* Stops the background interval that checks for offline newt sites.
*/
export const stopNewtOfflineChecker = (): void => {
if (offlineCheckerInterval) {
clearInterval(offlineCheckerInterval);
offlineCheckerInterval = null;
logger.info("Stopped newt offline checker interval");
}
};
/**
* Handles ping messages from newt clients.
*
@@ -192,7 +192,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
}
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(siteId);
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
logger.debug(
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
@@ -88,7 +88,7 @@ export async function flushBandwidthToDb(): Promise<void> {
const currentTime = new Date().toISOString();
// Sort by publicKey for consistent lock ordering across concurrent
// writers this is the same deadlock-prevention strategy used in the
// writers - this is the same deadlock-prevention strategy used in the
// original per-message implementation.
const sortedEntries = [...snapshot.entries()].sort(([a], [b]) =>
a.localeCompare(b)
@@ -143,7 +143,7 @@ const flushTimer = setInterval(async () => {
}, FLUSH_INTERVAL_MS);
// Calling unref() means this timer will not keep the Node.js event loop alive
// on its own the process can still exit normally when there is no other work
// on its own - the process can still exit normally when there is no other work
// left. The graceful-shutdown path (see server/cleanup.ts) will call
// flushBandwidthToDb() explicitly before process.exit(), so no data is lost.
flushTimer.unref();
@@ -167,7 +167,7 @@ export const handleReceiveBandwidthMessage: MessageHandler = async (
// Accumulate the incoming data in memory; the periodic timer (and the
// shutdown hook) will take care of writing it to the database.
for (const { publicKey, bytesIn, bytesOut } of bandwidthData) {
// Skip peers that haven't transferred any data writing zeros to the
// Skip peers that haven't transferred any data - writing zeros to the
// database would be a no-op anyway.
if (bytesIn <= 0 && bytesOut <= 0) {
continue;
@@ -0,0 +1,9 @@
import { MessageHandler } from "@server/routers/ws";
export async function flushRequestLogToDb(): Promise<void> {
return;
}
export const handleRequestLogMessage: MessageHandler = async (context) => {
return;
};
+10 -10
View File
@@ -8,26 +8,26 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
const newt = client as Newt;
logger.info("Handling Docker socket check response");
logger.debug("Handling Docker socket check response");
if (!newt) {
logger.warn("Newt not found");
return;
}
logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`);
logger.debug(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`);
const { available, socketPath } = message.data;
logger.info(
logger.debug(
`Docker socket availability for Newt ${newt.newtId}: available=${available}, socketPath=${socketPath}`
);
if (available) {
logger.info(`Newt ${newt.newtId} has Docker socket access`);
logger.debug(`Newt ${newt.newtId} has Docker socket access`);
await cache.set(`${newt.newtId}:socketPath`, socketPath, 0);
await cache.set(`${newt.newtId}:isAvailable`, available, 0);
} else {
logger.warn(`Newt ${newt.newtId} does not have Docker socket access`);
logger.debug(`Newt ${newt.newtId} does not have Docker socket access`);
}
return;
@@ -39,28 +39,28 @@ export const handleDockerContainersMessage: MessageHandler = async (
const { message, client, sendToClient } = context;
const newt = client as Newt;
logger.info("Handling Docker containers response");
logger.debug("Handling Docker containers response");
if (!newt) {
logger.warn("Newt not found");
return;
}
logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`);
logger.debug(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`);
const { containers } = message.data;
logger.info(
logger.debug(
`Docker containers for Newt ${newt.newtId}: ${containers ? containers.length : 0}`
);
if (containers && containers.length > 0) {
await cache.set(`${newt.newtId}:dockerContainers`, containers, 0);
} else {
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
logger.debug(`Newt ${newt.newtId} does not have Docker containers`);
}
if (!newt.siteId) {
logger.warn("Newt has no site!");
logger.debug("Newt has no site!");
return;
}
+3 -1
View File
@@ -2,11 +2,13 @@ export * from "./createNewt";
export * from "./getNewtToken";
export * from "./handleNewtRegisterMessage";
export * from "./handleReceiveBandwidthMessage";
export * from "./handleGetConfigMessage";
export * from "./handleNewtGetConfigMessage";
export * from "./handleSocketMessages";
export * from "./handleNewtPingRequestMessage";
export * from "./handleApplyBlueprintMessage";
export * from "./handleNewtPingMessage";
export * from "./handleNewtDisconnectingMessage";
export * from "./handleConnectionLogMessage";
export * from "./handleRequestLogMessage";
export * from "./registerNewt";
export * from "./offlineChecker";
+178
View File
@@ -0,0 +1,178 @@
import { db, newts, sites } from "@server/db";
import { hasActiveConnections } from "#dynamic/routers/ws";
import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "@server/lib/alerts";
// Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null;
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
const OFFLINE_THRESHOLD_BANDWIDTH_MS = 8 * 60 * 1000; // 8 minutes
/**
* Starts the background interval that checks for newt sites that haven't
* pinged recently and marks them as offline. For backward compatibility,
* a site is only marked offline when there is no active WebSocket connection
* either - so older newt versions that don't send pings but remain connected
* continue to be treated as online.
*/
export const startNewtOfflineChecker = (): void => {
if (offlineCheckerInterval) {
return; // Already running
}
offlineCheckerInterval = setInterval(async () => {
try {
const twoMinutesAgo = Math.floor(
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
);
// Find all online newt-type sites that haven't pinged recently
// (or have never pinged at all). Join newts to obtain the newtId
// needed for the WebSocket connection check.
const staleSites = await db
.select({
siteId: sites.siteId,
orgId: sites.orgId,
name: sites.name,
newtId: newts.newtId,
lastPing: sites.lastPing
})
.from(sites)
.innerJoin(newts, eq(newts.siteId, sites.siteId))
.where(
and(
eq(sites.online, true),
eq(sites.type, "newt"),
or(
lt(sites.lastPing, twoMinutesAgo),
isNull(sites.lastPing)
)
)
);
for (const staleSite of staleSites) {
// Backward-compatibility check: if the newt still has an
// active WebSocket connection (older clients that don't send
// pings), keep the site online.
const isConnected = await hasActiveConnections(
staleSite.newtId
);
if (isConnected) {
logger.debug(
`Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket - keeping site ${staleSite.siteId} online`
);
continue;
}
logger.info(
`Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection`
);
await db.transaction(async (trx) => {
await trx
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, staleSite.siteId));
await fireSiteOfflineAlert(
staleSite.orgId,
staleSite.siteId,
staleSite.name,
undefined,
trx
);
});
}
// this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites
// select all of the wireguard sites to evaluate if they need to be offline due to the last bandwidth update
const allWireguardSites = await db
.select({
siteId: sites.siteId,
orgId: sites.orgId,
name: sites.name,
online: sites.online,
lastBandwidthUpdate: sites.lastBandwidthUpdate
})
.from(sites)
.where(
and(
eq(sites.type, "wireguard"),
not(isNull(sites.lastBandwidthUpdate))
)
);
const wireguardOfflineThreshold = Math.floor(
(Date.now() - OFFLINE_THRESHOLD_BANDWIDTH_MS) / 1000
);
// loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline
for (const site of allWireguardSites) {
const lastBandwidthUpdate =
new Date(site.lastBandwidthUpdate!).getTime() / 1000;
if (
lastBandwidthUpdate < wireguardOfflineThreshold &&
site.online
) {
logger.info(
`Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes`
);
await db.transaction(async (trx) => {
await trx
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, site.siteId));
await fireSiteOfflineAlert(
site.orgId,
site.siteId,
site.name,
undefined,
trx
);
});
} else if (
lastBandwidthUpdate >= wireguardOfflineThreshold &&
!site.online
) {
logger.info(
`Marking wireguard site ${site.siteId} online: recent bandwidth update`
);
await db.transaction(async (trx) => {
await trx
.update(sites)
.set({ online: true })
.where(eq(sites.siteId, site.siteId));
await fireSiteOnlineAlert(
site.orgId,
site.siteId,
site.name,
undefined,
trx
);
});
}
}
} catch (error) {
logger.error("Error in newt offline checker interval", { error });
}
}, OFFLINE_CHECK_INTERVAL);
logger.debug("Started newt offline checker interval");
};
/**
* Stops the background interval that checks for offline newt sites.
*/
export const stopNewtOfflineChecker = (): void => {
if (offlineCheckerInterval) {
clearInterval(offlineCheckerInterval);
offlineCheckerInterval = null;
logger.info("Stopped newt offline checker interval");
}
};
+49 -7
View File
@@ -1,7 +1,8 @@
import { db } from "@server/db";
import { sites, clients, olms } from "@server/db";
import { inArray } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOnlineAlert } from "@server/lib/alerts";
/**
* Ping Accumulator
@@ -110,15 +111,56 @@ async function flushSitePingsToDb(): Promise<void> {
const siteIds = batch.map(([id]) => id);
try {
await withRetry(async () => {
await db
const newlyOnlineSites = await withRetry(async () => {
// Only update sites that were offline - these are the
// offline→online transitions. .returning() gives us exactly
// the site IDs that changed state.
const transitioned = await db
.update(sites)
.set({
online: true,
lastPing: maxTimestamp
})
.where(inArray(sites.siteId, siteIds));
.where(
and(
inArray(sites.siteId, siteIds),
eq(sites.online, false)
)
)
.returning({
siteId: sites.siteId,
orgId: sites.orgId,
name: sites.name
});
// Update lastPing for sites that were already online.
// After the update above, the newly-online sites now have
// online = true, so this catches all remaining sites in the
// batch and keeps lastPing current for them too.
await db
.update(sites)
.set({ lastPing: maxTimestamp })
.where(
and(
inArray(sites.siteId, siteIds),
eq(sites.online, true)
)
);
return transitioned;
}, "flushSitePingsToDb");
for (const site of newlyOnlineSites) {
await db.transaction(async (trx) => {
await fireSiteOnlineAlert(
site.orgId,
site.siteId,
site.name,
undefined,
trx
);
});
}
} catch (error) {
logger.error(
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
@@ -219,7 +261,7 @@ async function flushClientPingsToDb(): Promise<void> {
}
/**
* Flush everything called by the interval timer and during shutdown.
* Flush everything - called by the interval timer and during shutdown.
*/
export async function flushPingsToDb(): Promise<void> {
await flushSitePingsToDb();
@@ -284,7 +326,7 @@ function isTransientError(error: any): boolean {
return true;
}
// PostgreSQL deadlock detected always safe to retry (one winner guaranteed)
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
@@ -344,7 +386,7 @@ export function startPingAccumulator(): void {
// Don't prevent the process from exiting
flushTimer.unref();
logger.info(
logger.debug(
`Ping accumulator started (flush interval: ${FLUSH_INTERVAL_MS}ms)`
);
}
+24 -8
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, logsDb, statusHistory } from "@server/db";
import {
siteProvisioningKeys,
siteProvisioningKeyOrg,
@@ -84,7 +84,7 @@ export async function registerNewt(
maxBatchSize: siteProvisioningKeys.maxBatchSize,
numUsed: siteProvisioningKeys.numUsed,
validUntil: siteProvisioningKeys.validUntil,
approveNewSites: siteProvisioningKeys.approveNewSites,
approveNewSites: siteProvisioningKeys.approveNewSites
})
.from(siteProvisioningKeys)
.innerJoin(
@@ -125,7 +125,10 @@ export async function registerNewt(
);
}
if (keyRecord.maxBatchSize && keyRecord.numUsed >= keyRecord.maxBatchSize) {
if (
keyRecord.maxBatchSize &&
keyRecord.numUsed >= keyRecord.maxBatchSize
) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
@@ -134,7 +137,10 @@ export async function registerNewt(
);
}
if (keyRecord.validUntil && new Date(keyRecord.validUntil) < new Date()) {
if (
keyRecord.validUntil &&
new Date(keyRecord.validUntil) < new Date()
) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
@@ -154,7 +160,10 @@ export async function registerNewt(
}
if (!org.subnet) {
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Organization subnet not found")
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Organization subnet not found"
)
);
}
@@ -195,7 +204,6 @@ export async function registerNewt(
let newSiteId: number | undefined;
await db.transaction(async (trx) => {
const newClientAddress = await getNextAvailableClientSubnet(orgId);
if (!newClientAddress) {
return next(
@@ -219,10 +227,18 @@ export async function registerNewt(
address: clientAddress,
type: "newt",
dockerSocketEnabled: true,
status: keyRecord.approveNewSites ? "approved" : "pending",
status: keyRecord.approveNewSites ? "approved" : "pending"
})
.returning();
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: newSite.siteId,
orgId: orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
newSiteId = newSite.siteId;
// Grant admin role access to the new site
@@ -249,7 +265,7 @@ export async function registerNewt(
dateCreated: moment().toISOString()
});
// Consume the provisioning key cascade removes siteProvisioningKeyOrg
// Consume the provisioning key - cascade removes siteProvisioningKeyOrg
await trx
.update(siteProvisioningKeys)
.set({
+155 -54
View File
@@ -1,7 +1,6 @@
import { Target, TargetHealthCheck, db, targetHealthCheck } from "@server/db";
import { Target, TargetHealthCheck } from "@server/db";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import { eq, inArray } from "drizzle-orm";
import { canCompress } from "@server/lib/clientVersionChecks";
export async function addTargets(
@@ -18,42 +17,36 @@ export async function addTargets(
}:${target.port}`;
});
await sendToClient(newtId, {
type: `newt/${protocol}/add`,
data: {
targets: payloadTargets
}
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
if (payloadTargets.length > 0) {
await sendToClient(
newtId,
{
type: `newt/${protocol}/add`,
data: {
targets: payloadTargets
}
},
{
incrementConfigVersion: true,
compress: canCompress(version, "newt")
}
);
}
// Create a map for quick lookup
const healthCheckMap = new Map<number, TargetHealthCheck>();
healthCheckData.forEach((hc) => {
healthCheckMap.set(hc.targetId, hc);
});
const healthCheckTargets = targets.map((target) => {
const hc = healthCheckMap.get(target.targetId);
// If no health check data found, skip this target
if (!hc) {
logger.warn(
`No health check configuration found for target ${target.targetId}`
const healthCheckTargets = healthCheckData.map((hc) => {
// Ensure all necessary fields are present
const isTCP = hc.hcMode?.toLowerCase() === "tcp";
if (!hc.hcHostname || !hc.hcPort || !hc.hcInterval) {
logger.debug(
`Skipping hc ${hc.targetHealthCheckId} due to missing health check fields`
);
return null;
}
// Ensure all necessary fields are present
if (
!hc.hcPath ||
!hc.hcHostname ||
!hc.hcPort ||
!hc.hcInterval ||
!hc.hcMethod
) {
if (!isTCP && (!hc.hcPath || !hc.hcMethod)) {
logger.debug(
`Skipping target ${target.targetId} due to missing health check fields`
`Skipping hc ${hc.targetHealthCheckId} due to missing HTTP health check fields`
);
return null; // Skip targets with missing health check fields
return null;
}
const hcHeadersParse = hc.hcHeaders ? JSON.parse(hc.hcHeaders) : null;
@@ -77,7 +70,7 @@ export async function addTargets(
}
return {
id: target.targetId,
id: hc.targetHealthCheckId,
hcEnabled: hc.hcEnabled,
hcPath: hc.hcPath,
hcScheme: hc.hcScheme,
@@ -88,28 +81,126 @@ export async function addTargets(
hcUnhealthyInterval: hc.hcUnhealthyInterval, // in seconds
hcTimeout: hc.hcTimeout, // in seconds
hcHeaders: hcHeadersSend,
hcFollowRedirects: hc.hcFollowRedirects,
hcMethod: hc.hcMethod,
hcStatus: hcStatus,
hcTlsServerName: hc.hcTlsServerName
hcTlsServerName: hc.hcTlsServerName,
hcHealthyThreshold: hc.hcHealthyThreshold,
hcUnhealthyThreshold: hc.hcUnhealthyThreshold
};
});
// Filter out any null values from health check targets
const validHealthCheckTargets = healthCheckTargets.filter(
(target) => target !== null
(hc) => hc !== null
);
await sendToClient(newtId, {
type: `newt/healthcheck/add`,
data: {
targets: validHealthCheckTargets
await sendToClient(
newtId,
{
type: `newt/healthcheck/add`,
data: {
targets: validHealthCheckTargets
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function addStandaloneHealthCheck(
newtId: string,
healthCheck: TargetHealthCheck,
version?: string | null
) {
const isTCP = healthCheck.hcMode?.toLowerCase() === "tcp";
if (
!healthCheck.hcHostname ||
!healthCheck.hcPort ||
!healthCheck.hcInterval
) {
logger.debug(
`Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing fields`
);
return;
}
if (!isTCP && (!healthCheck.hcPath || !healthCheck.hcMethod)) {
logger.debug(
`Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing HTTP health check fields`
);
return;
}
const hcHeadersParse = healthCheck.hcHeaders
? JSON.parse(healthCheck.hcHeaders)
: null;
const hcHeadersSend: { [key: string]: string } = {};
if (hcHeadersParse) {
hcHeadersParse.forEach((header: { name: string; value: string }) => {
hcHeadersSend[header.name] = header.value;
});
}
let hcStatus: number | undefined = undefined;
if (healthCheck.hcStatus) {
const parsedStatus = parseInt(healthCheck.hcStatus.toString());
if (!isNaN(parsedStatus)) {
hcStatus = parsedStatus;
}
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
}
await sendToClient(
newtId,
{
type: `newt/healthcheck/add`,
data: {
targets: [
{
id: healthCheck.targetHealthCheckId,
hcEnabled: healthCheck.hcEnabled,
hcPath: healthCheck.hcPath,
hcScheme: healthCheck.hcScheme,
hcMode: healthCheck.hcMode,
hcHostname: healthCheck.hcHostname,
hcPort: healthCheck.hcPort,
hcInterval: healthCheck.hcInterval,
hcUnhealthyInterval: healthCheck.hcUnhealthyInterval,
hcTimeout: healthCheck.hcTimeout,
hcHeaders: hcHeadersSend,
hcFollowRedirects: healthCheck.hcFollowRedirects,
hcMethod: healthCheck.hcMethod,
hcStatus: hcStatus,
hcTlsServerName: healthCheck.hcTlsServerName,
hcHealthyThreshold: healthCheck.hcHealthyThreshold,
hcUnhealthyThreshold: healthCheck.hcUnhealthyThreshold
}
]
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function removeStandaloneHealthCheck(
newtId: string,
healthCheckId: number,
version?: string | null
) {
await sendToClient(
newtId,
{
type: `newt/healthcheck/remove`,
data: {
ids: [healthCheckId]
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function removeTargets(
newtId: string,
targets: Target[],
healthCheckData: TargetHealthCheck[],
protocol: string,
version?: string | null
) {
@@ -120,21 +211,31 @@ export async function removeTargets(
}:${target.port}`;
});
await sendToClient(newtId, {
type: `newt/${protocol}/remove`,
data: {
targets: payloadTargets
}
}, { incrementConfigVersion: true });
if (payloadTargets.length > 0) {
await sendToClient(
newtId,
{
type: `newt/${protocol}/remove`,
data: {
targets: payloadTargets
}
},
{ incrementConfigVersion: true }
);
}
const healthCheckTargets = targets.map((target) => {
return target.targetId;
const healthCheckTargets = healthCheckData.map((hc) => {
return hc.targetHealthCheckId;
});
await sendToClient(newtId, {
type: `newt/healthcheck/remove`,
data: {
ids: healthCheckTargets
}
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
await sendToClient(
newtId,
{
type: `newt/healthcheck/remove`,
data: {
ids: healthCheckTargets
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
+12 -1
View File
@@ -4,6 +4,8 @@ import {
clientSitesAssociationsCache,
db,
exitNodes,
networks,
siteNetworks,
siteResources,
sites
} from "@server/db";
@@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient(
clientSiteResourcesAssociationsCache.siteResourceId
)
)
.innerJoin(
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin(
siteNetworks,
eq(networks.networkId, siteNetworks.networkId)
)
.where(
and(
eq(siteResources.siteId, site.siteId),
eq(siteNetworks.siteId, site.siteId),
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
@@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient(
)
);
if (jitMode) {
// Add site configuration to the array
siteConfigurations.push({
+3 -90
View File
@@ -1,104 +1,17 @@
import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws";
import { getClientConfigVersion } from "#dynamic/routers/ws";
import { db } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { clients, olms, Olm } from "@server/db";
import { eq, lt, isNull, and, or } from "drizzle-orm";
import { clients, Olm } from "@server/db";
import { eq } from "drizzle-orm";
import { recordClientPing } from "@server/routers/newt/pingAccumulator";
import logger from "@server/logger";
import { validateSessionToken } from "@server/auth/sessions/app";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { sendTerminateClient } from "../client/terminate";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { sendOlmSyncMessage } from "./sync";
import { OlmErrorCodes } from "./error";
import { handleFingerprintInsertion } from "./fingerprintingUtils";
// Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null;
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
/**
* Starts the background interval that checks for clients that haven't pinged recently
* and marks them as offline
*/
export const startOlmOfflineChecker = (): void => {
if (offlineCheckerInterval) {
return; // Already running
}
offlineCheckerInterval = setInterval(async () => {
try {
const twoMinutesAgo = Math.floor(
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
);
// TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING
// Find clients that haven't pinged in the last 2 minutes and mark them as offline
const offlineClients = await db
.update(clients)
.set({ online: false })
.where(
and(
eq(clients.online, true),
or(
lt(clients.lastPing, twoMinutesAgo),
isNull(clients.lastPing)
)
)
)
.returning();
for (const offlineClient of offlineClients) {
logger.info(
`Kicking offline olm client ${offlineClient.clientId} due to inactivity`
);
if (!offlineClient.olmId) {
logger.warn(
`Offline client ${offlineClient.clientId} has no olmId, cannot disconnect`
);
continue;
}
// Send a disconnect message to the client if connected
try {
await sendTerminateClient(
offlineClient.clientId,
OlmErrorCodes.TERMINATED_INACTIVITY,
offlineClient.olmId
); // terminate first
// wait a moment to ensure the message is sent
await new Promise((resolve) => setTimeout(resolve, 1000));
await disconnectClient(offlineClient.olmId);
} catch (error) {
logger.error(
`Error sending disconnect to offline olm ${offlineClient.clientId}`,
{ error }
);
}
}
} catch (error) {
logger.error("Error in offline checker interval", { error });
}
}, OFFLINE_CHECK_INTERVAL);
logger.debug("Started offline checker interval");
};
/**
* Stops the background interval that checks for offline clients
*/
export const stopOlmOfflineChecker = (): void => {
if (offlineCheckerInterval) {
clearInterval(offlineCheckerInterval);
offlineCheckerInterval = null;
logger.info("Stopped offline checker interval");
}
};
/**
* Handles ping messages from clients and responds with pong
*/
@@ -17,7 +17,6 @@ import { getUserDeviceName } from "@server/db/names";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { OlmErrorCodes, sendOlmError } from "./error";
import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { Alias } from "@server/lib/ip";
import { build } from "@server/build";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
@@ -4,10 +4,12 @@ import {
db,
exitNodes,
Site,
siteResources
siteNetworks,
siteResources,
sites
} from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { clients, Olm, sites } from "@server/db";
import { clients, Olm } from "@server/db";
import { and, eq, or } from "drizzle-orm";
import logger from "@server/logger";
import { initPeerAddHandshake } from "./peers";
@@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
const { siteId, resourceId, chainId } = message.data;
let site: Site | null = null;
const sendCancel = async () => {
await sendToClient(
olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: { chainId }
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
};
let sitesToProcess: Site[] = [];
if (siteId) {
// get the site
const [siteRes] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (siteRes) {
site = siteRes;
sitesToProcess = [siteRes];
}
}
if (resourceId && !site) {
} else if (resourceId) {
const resources = await db
.select()
.from(siteResources)
@@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
);
if (!resources || resources.length === 0) {
logger.error(`handleOlmServerPeerAddMessage: Resource not found`);
// cancel the request from the olm side to not keep doing this
await sendToClient(
olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
logger.error(
`handleOlmServerInitAddPeerHandshake: Resource not found`
);
await sendCancel();
return;
}
if (resources.length > 1) {
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
logger.error(
`handleOlmServerPeerAddMessage: Multiple resources found matching the criteria`
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
);
return;
}
@@ -117,125 +120,120 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
if (currentResourceAssociationCaches.length === 0) {
logger.error(
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
);
// cancel the request from the olm side to not keep doing this
await sendToClient(
olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
await sendCancel();
return;
}
const siteIdFromResource = resource.siteId;
// get the site
const [siteRes] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteIdFromResource));
if (!siteRes) {
if (!resource.networkId) {
logger.error(
`handleOlmServerPeerAddMessage: Site with ID ${site} not found`
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
);
await sendCancel();
return;
}
site = siteRes;
// Get all sites associated with this resource's network via siteNetworks
const siteRows = await db
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, resource.networkId));
if (!siteRows || siteRows.length === 0) {
logger.error(
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
);
await sendCancel();
return;
}
// Fetch full site objects for all network members
const foundSites = await Promise.all(
siteRows.map(async ({ siteId: sid }) => {
const [s] = await db
.select()
.from(sites)
.where(eq(sites.siteId, sid))
.limit(1);
return s ?? null;
})
);
sitesToProcess = foundSites.filter((s): s is Site => s !== null);
}
if (!site) {
logger.error(`handleOlmServerPeerAddMessage: Site not found`);
if (sitesToProcess.length === 0) {
logger.error(
`handleOlmServerInitAddPeerHandshake: No sites to process`
);
await sendCancel();
return;
}
// check if the client can access this site using the cache
const currentSiteAssociationCaches = await db
.select()
.from(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.clientId, client.clientId),
eq(clientSitesAssociationsCache.siteId, site.siteId)
)
);
let handshakeInitiated = false;
if (currentSiteAssociationCaches.length === 0) {
logger.error(
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}`
);
// cancel the request from the olm side to not keep doing this
await sendToClient(
olm.olmId,
for (const site of sitesToProcess) {
// Check if the client can access this site using the cache
const currentSiteAssociationCaches = await db
.select()
.from(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.clientId, client.clientId),
eq(clientSitesAssociationsCache.siteId, site.siteId)
)
);
if (currentSiteAssociationCaches.length === 0) {
logger.warn(
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping`
);
continue;
}
if (!site.exitNodeId) {
logger.error(
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
);
continue;
}
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
if (!exitNode) {
logger.error(
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
);
continue;
}
// Trigger the peer add handshake - if the peer was already added this will be a no-op
await initPeerAddHandshake(
client.clientId,
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
siteId: site.siteId,
exitNode: {
publicKey: exitNode.publicKey,
endpoint: exitNode.endpoint
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return;
}
if (!site.exitNodeId) {
logger.error(
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
);
// cancel the request from the olm side to not keep doing this
await sendToClient(
olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return;
}
// get the exit node from the side
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
if (!exitNode) {
logger.error(
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
chainId
);
return;
handshakeInitiated = true;
}
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
// if it has already been added this will be a no-op
await initPeerAddHandshake(
// this will kick off the add peer process for the client
client.clientId,
{
siteId: site.siteId,
exitNode: {
publicKey: exitNode.publicKey,
endpoint: exitNode.endpoint
}
},
olm.olmId,
chainId
);
if (!handshakeInitiated) {
logger.error(
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
);
await sendCancel();
}
return;
};
@@ -1,43 +1,25 @@
import {
Client,
clientSiteResourcesAssociationsCache,
db,
ExitNode,
Org,
orgs,
roleClients,
roles,
networks,
siteNetworks,
siteResources,
Transaction,
userClients,
userOrgs,
users
} from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import {
clients,
clientSitesAssociationsCache,
exitNodes,
Olm,
olms,
sites
} from "@server/db";
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import {
generateAliasConfig,
getNextAvailableClientSubnet
} from "@server/lib/ip";
import { generateRemoteSubnets } from "@server/lib/ip";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { validateSessionToken } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import {
addPeer as newtAddPeer,
deletePeer as newtDeletePeer
} from "@server/routers/newt/peers";
export const handleOlmServerPeerAddMessage: MessageHandler = async (
@@ -153,13 +135,21 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
clientSiteResourcesAssociationsCache.siteResourceId
)
)
.where(
.innerJoin(
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin(
siteNetworks,
and(
eq(siteResources.siteId, site.siteId),
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
eq(networks.networkId, siteNetworks.networkId),
eq(siteNetworks.siteId, site.siteId)
)
)
.where(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
);
+1
View File
@@ -12,3 +12,4 @@ export * from "./handleOlmUnRelayMessage";
export * from "./recoverOlmWithFingerprint";
export * from "./handleOlmDisconnectingMessage";
export * from "./handleOlmServerInitAddPeerHandshake";
export * from "./offlineChecker";
+92
View File
@@ -0,0 +1,92 @@
import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws";
import { db } from "@server/db";
import { clients } from "@server/db";
import { eq, lt, isNull, and, or } from "drizzle-orm";
import logger from "@server/logger";
import { sendTerminateClient } from "../client/terminate";
import { OlmErrorCodes } from "./error";
// Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null;
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
/**
* Starts the background interval that checks for clients that haven't pinged recently
* and marks them as offline
*/
export const startOlmOfflineChecker = (): void => {
if (offlineCheckerInterval) {
return; // Already running
}
offlineCheckerInterval = setInterval(async () => {
try {
const twoMinutesAgo = Math.floor(
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
);
// TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING
// Find clients that haven't pinged in the last 2 minutes and mark them as offline
const offlineClients = await db
.update(clients)
.set({ online: false })
.where(
and(
eq(clients.online, true),
or(
lt(clients.lastPing, twoMinutesAgo),
isNull(clients.lastPing)
)
)
)
.returning();
for (const offlineClient of offlineClients) {
logger.info(
`Kicking offline olm client ${offlineClient.clientId} due to inactivity`
);
if (!offlineClient.olmId) {
logger.warn(
`Offline client ${offlineClient.clientId} has no olmId, cannot disconnect`
);
continue;
}
// Send a disconnect message to the client if connected
try {
await sendTerminateClient(
offlineClient.clientId,
OlmErrorCodes.TERMINATED_INACTIVITY,
offlineClient.olmId
); // terminate first
// wait a moment to ensure the message is sent
await new Promise((resolve) => setTimeout(resolve, 1000));
await disconnectClient(offlineClient.olmId);
} catch (error) {
logger.error(
`Error sending disconnect to offline olm ${offlineClient.clientId}`,
{ error }
);
}
}
} catch (error) {
logger.error("Error in offline checker interval", { error });
}
}, OFFLINE_CHECK_INTERVAL);
logger.debug("Started offline checker interval");
};
/**
* Stops the background interval that checks for offline clients
*/
export const stopOlmOfflineChecker = (): void => {
if (offlineCheckerInterval) {
clearInterval(offlineCheckerInterval);
offlineCheckerInterval = null;
logger.info("Stopped offline checker interval");
}
};
+4 -1
View File
@@ -12,7 +12,9 @@ import {
userOrgRoles,
userOrgs,
users,
actions
actions,
customers,
subscriptions
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -31,6 +33,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
import { doCidrsOverlap } from "@server/lib/ip";
import { generateCA } from "@server/lib/sshCA";
import { encrypt } from "@server/lib/crypto";
import { generateId } from "@server/auth/sessions/app";
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;
+19
View File
@@ -25,3 +25,22 @@ export type ListOrgIdpsResponse = {
offset: number;
};
};
export type ListUserAdminOrgIdpsEntry = {
idpId: number;
orgId: string;
orgName: string;
name: string;
type: string;
variant: string;
tags: string | null;
};
export type ListUserAdminOrgIdpsResponse = {
idps: ListUserAdminOrgIdpsEntry[];
pagination: {
total: number;
limit: number;
offset: number;
};
};
+1
View File
@@ -21,6 +21,7 @@ export type ListRemoteExitNodesResponse = {
remoteExitNodeId: string;
dateCreated: string;
version: string | null;
updateAvailable?: boolean;
exitNodeId: number | null;
name: string;
address: string;
+35 -6
View File
@@ -17,14 +17,15 @@ import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { subdomainSchema } from "@server/lib/schemas";
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { getUniqueResourceName } from "@server/db/names";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const createResourceParamsSchema = z.strictObject({
@@ -44,7 +45,10 @@ const createHttpResourceSchema = z
.refine(
(data) => {
if (data.subdomain) {
return subdomainSchema.safeParse(data.subdomain).success;
return (
subdomainSchema.safeParse(data.subdomain).success ||
wildcardSubdomainSchema.safeParse(data.subdomain).success
);
}
return true;
},
@@ -198,9 +202,25 @@ async function createHttpResource(
const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession;
// Wildcard subdomains are a paid feature
if (subdomain && subdomain.includes("*")) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.wildcardSubdomain
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
)
);
}
}
if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) {
// grandfather in existing users
const lastAllowedDate = new Date("2026-04-12");
const lastAllowedDate = new Date("2026-04-13");
const userCreatedDate = new Date(req.user?.dateCreated || new Date());
if (userCreatedDate > lastAllowedDate) {
// check if this domain id is a namespace domain and if so, reject
@@ -232,7 +252,7 @@ async function createHttpResource(
return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error));
}
const { fullDomain, subdomain: finalSubdomain } = domainResult;
const { fullDomain, subdomain: finalSubdomain, wildcard } = domainResult;
logger.debug(`Full domain: ${fullDomain}`);
@@ -251,6 +271,13 @@ async function createHttpResource(
);
}
const wildcardConflict = await checkWildcardDomainConflict(fullDomain);
if (wildcardConflict.conflict) {
return next(
createHttpError(HttpCode.CONFLICT, wildcardConflict.message)
);
}
// Prevent creating resource with same domain as dashboard
const dashboardUrl = config.getRawConfig().app.dashboard_url;
if (dashboardUrl) {
@@ -299,7 +326,9 @@ async function createHttpResource(
protocol: "tcp",
ssl: true,
stickySession: stickySession,
postAuthPath: postAuthPath
postAuthPath: postAuthPath,
wildcard,
health: "unknown"
})
.returning();
+49 -40
View File
@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, targetHealthCheck } from "@server/db";
import { newts, resources, sites, targets } from "@server/db";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -52,6 +52,16 @@ export async function deleteResource(
.from(targets)
.where(eq(targets.resourceId, resourceId));
const healthChecksToBeRemoved = await db
.select()
.from(targetHealthCheck)
.where(
inArray(
targetHealthCheck.targetId,
targetsToBeRemoved.map((t) => t.targetId)
)
);
const [deletedResource] = await db
.delete(resources)
.where(eq(resources.resourceId, resourceId))
@@ -66,44 +76,43 @@ export async function deleteResource(
);
}
// const [site] = await db
// .select()
// .from(sites)
// .where(eq(sites.siteId, deletedResource.siteId!))
// .limit(1);
//
// if (!site) {
// return next(
// createHttpError(
// HttpCode.NOT_FOUND,
// `Site with ID ${deletedResource.siteId} not found`
// )
// );
// }
//
// if (site.pubKey) {
// if (site.type == "wireguard") {
// await addPeer(site.exitNodeId!, {
// publicKey: site.pubKey,
// allowedIps: await getAllowedIps(site.siteId)
// });
// } else if (site.type == "newt") {
// // get the newt on the site by querying the newt table for siteId
// const [newt] = await db
// .select()
// .from(newts)
// .where(eq(newts.siteId, site.siteId))
// .limit(1);
//
// removeTargets(
// newt.newtId,
// targetsToBeRemoved,
// deletedResource.protocol,
// deletedResource.proxyPort
// );
// }
// }
//
for (const target of targetsToBeRemoved) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, target.siteId))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${target.siteId} not found`
)
);
}
if (site.pubKey) {
if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
await removeTargets(
newt.newtId,
// [target],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
healthChecksToBeRemoved,
deletedResource.protocol,
newt.version
);
}
}
}
return response(res, {
data: null,
success: true,
@@ -32,6 +32,8 @@ export type GetResourceAuthInfoResponse = {
sso: boolean;
blockAccess: boolean;
url: string;
wildcard: boolean;
fullDomain: string | null;
whitelist: boolean;
skipToIdpId: number | null;
orgId: string;
@@ -130,7 +132,9 @@ export async function getResourceAuthInfo(
const headerAuthExtendedCompatibility =
result?.resourceHeaderAuthExtendedCompatibility;
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
const url = resource.fullDomain
? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
: null;
return response<GetResourceAuthInfoResponse>(res, {
data: {
@@ -145,7 +149,9 @@ export async function getResourceAuthInfo(
headerAuthExtendedCompatibility !== null,
sso: resource.sso,
blockAccess: resource.blockAccess,
url,
url: url ?? "",
wildcard: resource.wildcard ?? false,
fullDomain: resource.fullDomain,
whitelist: resource.emailWhitelistEnabled,
skipToIdpId: resource.skipToIdpId,
orgId: resource.orgId,
@@ -0,0 +1,62 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 {
getCachedStatusHistory,
statusHistoryQuerySchema,
StatusHistoryResponse
} from "@server/lib/statusHistory";
const resourceParamsSchema = z.object({
resourceId: z.string().transform((v) => parseInt(v, 10))
});
export async function getResourceStatusHistory(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = resourceParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedQuery = statusHistoryQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const entityType = "resource";
const entityId = parsedParams.data.resourceId;
const { days } = parsedQuery.data;
const data = await getCachedStatusHistory(entityType, entityId, days);
return response<StatusHistoryResponse>(res, {
data,
success: true,
error: false,
message: "Status history retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+33 -25
View File
@@ -86,7 +86,12 @@ export async function getUserResources(
.where(inArray(roleSiteResources.roleId, userRoleIds))
: Promise.resolve([]);
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([
const [
directResources,
roleResourceResults,
directSiteResourceResults,
roleSiteResourceResults
] = await Promise.all([
directResourcesQuery,
roleResourcesQuery,
directSiteResourcesQuery,
@@ -118,24 +123,24 @@ export async function getUserResources(
}> = [];
if (accessibleResourceIds.length > 0) {
resourcesData = await db
.select({
resourceId: resources.resourceId,
name: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
enabled: resources.enabled,
sso: resources.sso,
protocol: resources.protocol,
emailWhitelistEnabled: resources.emailWhitelistEnabled
})
.from(resources)
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true)
)
);
.select({
resourceId: resources.resourceId,
name: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
enabled: resources.enabled,
sso: resources.sso,
protocol: resources.protocol,
emailWhitelistEnabled: resources.emailWhitelistEnabled
})
.from(resources)
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true)
)
);
}
// Get site resource details for accessible site resources
@@ -145,7 +150,7 @@ export async function getUserResources(
niceId: string;
destination: string;
mode: string;
protocol: string | null;
scheme: string | null;
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
@@ -158,7 +163,7 @@ export async function getUserResources(
niceId: siteResources.niceId,
destination: siteResources.destination,
mode: siteResources.mode,
protocol: siteResources.protocol,
scheme: siteResources.scheme,
enabled: siteResources.enabled,
alias: siteResources.alias,
aliasAddress: siteResources.aliasAddress
@@ -166,7 +171,10 @@ export async function getUserResources(
.from(siteResources)
.where(
and(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds),
inArray(
siteResources.siteResourceId,
accessibleSiteResourceIds
),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true)
)
@@ -242,11 +250,11 @@ export async function getUserResources(
name: siteResource.name,
destination: siteResource.destination,
mode: siteResource.mode,
protocol: siteResource.protocol,
protocol: siteResource.scheme,
enabled: siteResource.enabled,
alias: siteResource.alias,
aliasAddress: siteResource.aliasAddress,
type: 'site' as const
type: "site" as const
};
});
@@ -291,7 +299,7 @@ export type GetUserResourcesResponse = {
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
type: 'site';
type: "site";
}>;
};
};
+1
View File
@@ -32,3 +32,4 @@ export * from "./addUserToResource";
export * from "./removeUserFromResource";
export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory";
+74 -65
View File
@@ -6,6 +6,7 @@ import {
resourcePincode,
resources,
roleResources,
sites,
targetHealthCheck,
targets,
userResources
@@ -104,15 +105,20 @@ const listResourcesSchema = z.object({
"Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)."
}),
healthStatus: z
.enum(["no_targets", "healthy", "degraded", "offline", "unknown"])
.enum(["healthy", "degraded", "unhealthy", "unknown"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["no_targets", "healthy", "degraded", "offline", "unknown"],
enum: ["healthy", "degraded", "offline", "unknown"],
description:
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets."
})
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status."
}),
siteId: z.coerce.number<string>().int().positive().optional().openapi({
type: "integer",
description:
"When set, only resources that have at least one target on this site are returned"
})
});
// grouped by resource with targets[])
@@ -132,36 +138,24 @@ export type ResourceWithTargets = {
domainId: string | null;
niceId: string;
headerAuthId: number | null;
wildcard: boolean;
health: string | null;
targets: Array<{
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
siteName: string | null;
}>;
sites: Array<{
siteId: number;
siteName: string;
siteNiceId: string;
online?: boolean; // undefined for local sites
}>;
};
// Aggregate filters
const total_targets = count(targets.targetId);
const healthy_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1
ELSE 0
END
) `;
const unknown_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1
ELSE 0
END
) `;
const unhealthy_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1
ELSE 0
END
) `;
function queryResourcesBase() {
return db
.select({
@@ -179,9 +173,11 @@ function queryResourcesBase() {
enabled: resources.enabled,
domainId: resources.domainId,
niceId: resources.niceId,
wildcard: resources.wildcard,
headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
health: resources.health
})
.from(resources)
.leftJoin(
@@ -258,7 +254,8 @@ export async function listResources(
query,
healthStatus,
sort_by,
order
order,
siteId
} = parsedQuery.data;
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
@@ -378,44 +375,19 @@ export async function listResources(
}
}
let aggregateFilters: SQL<any> | undefined = sql`1 = 1`;
if (typeof healthStatus !== "undefined") {
switch (healthStatus) {
case "healthy":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${healthy_targets} = ${total_targets}`
);
break;
case "degraded":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${unhealthy_targets} > 0`
);
break;
case "no_targets":
aggregateFilters = sql`${total_targets} = 0`;
break;
case "offline":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${healthy_targets} = 0`,
sql`${unhealthy_targets} = ${total_targets}`
);
break;
case "unknown":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${unknown_targets} = ${total_targets}`
);
break;
}
conditions.push(eq(resources.health, healthStatus));
}
if (siteId != null) {
const resourcesWithSite = db
.select({ resourceId: targets.resourceId })
.from(targets)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
conditions.push(inArray(resources.resourceId, resourcesWithSite));
}
const baseQuery = queryResourcesBase()
.where(and(...conditions))
.having(aggregateFilters);
const baseQuery = queryResourcesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(baseQuery.as("filtered_resources"));
@@ -442,18 +414,24 @@ export async function listResources(
.select({
targetId: targets.targetId,
resourceId: targets.resourceId,
siteId: targets.siteId,
ip: targets.ip,
port: targets.port,
enabled: targets.enabled,
healthStatus: targetHealthCheck.hcHealth,
hcEnabled: targetHealthCheck.hcEnabled
hcEnabled: targetHealthCheck.hcEnabled,
siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online,
siteType: sites.type
})
.from(targets)
.where(inArray(targets.resourceId, resourceIdList))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
);
)
.leftJoin(sites, eq(targets.siteId, sites.siteId));
// avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>();
@@ -474,10 +452,13 @@ export async function listResources(
http: row.http,
protocol: row.protocol,
proxyPort: row.proxyPort,
wildcard: row.wildcard,
enabled: row.enabled,
domainId: row.domainId,
headerAuthId: row.headerAuthId,
targets: []
health: row.health ?? null,
targets: [],
sites: []
};
map.set(row.resourceId, entry);
}
@@ -487,6 +468,34 @@ export async function listResources(
);
}
for (const entry of map.values()) {
const raw = allResourceTargets.filter(
(t) => t.resourceId === entry.resourceId
);
const siteById = new Map<
number,
{
siteId: number;
siteName: string;
siteNiceId: string;
online?: boolean;
}
>();
for (const t of raw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
const isLocal = t.siteType === "local";
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values());
}
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
return response<ListResourcesResponse>(res, {
+44 -8
View File
@@ -16,12 +16,15 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import config from "@server/lib/config";
import { tlsNameSchema } from "@server/lib/schemas";
import { subdomainSchema } from "@server/lib/schemas";
import {
tlsNameSchema,
subdomainSchema,
wildcardSubdomainSchema
} from "@server/lib/schemas";
import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -43,7 +46,7 @@ const updateHttpResourceBodySchema = z
"niceId can only contain letters, numbers, and dashes"
)
.optional(),
subdomain: subdomainSchema.nullable().optional(),
subdomain: z.string().nullable().optional(),
ssl: z.boolean().optional(),
sso: z.boolean().optional(),
blockAccess: z.boolean().optional(),
@@ -73,7 +76,10 @@ const updateHttpResourceBodySchema = z
.refine(
(data) => {
if (data.subdomain) {
return subdomainSchema.safeParse(data.subdomain).success;
return (
subdomainSchema.safeParse(data.subdomain).success ||
wildcardSubdomainSchema.safeParse(data.subdomain).success
);
}
return true;
},
@@ -318,6 +324,22 @@ async function updateHttpResource(
}
}
// Wildcard subdomains are a paid feature
if (updateData.subdomain && updateData.subdomain.includes("*")) {
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.wildcardSubdomain
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
)
);
}
}
if (updateData.domainId) {
const domainId = updateData.domainId;
@@ -326,7 +348,7 @@ async function updateHttpResource(
!isSubscribed(resource.orgId, tierMatrix.domainNamespaces)
) {
// grandfather in existing users
const lastAllowedDate = new Date("2026-04-12");
const lastAllowedDate = new Date("2026-04-13");
const userCreatedDate = new Date(
req.user?.dateCreated || new Date()
);
@@ -362,7 +384,11 @@ async function updateHttpResource(
);
}
const { fullDomain, subdomain: finalSubdomain } = domainResult;
const {
fullDomain,
subdomain: finalSubdomain,
wildcard
} = domainResult;
logger.debug(`Full domain: ${fullDomain}`);
@@ -384,6 +410,16 @@ async function updateHttpResource(
);
}
const wildcardConflict = await checkWildcardDomainConflict(
fullDomain,
resource.resourceId
);
if (wildcardConflict.conflict) {
return next(
createHttpError(HttpCode.CONFLICT, wildcardConflict.message)
);
}
// Prevent updating resource with same domain as dashboard
const dashboardUrl = config.getRawConfig().app.dashboard_url;
if (dashboardUrl) {
@@ -419,7 +455,7 @@ async function updateHttpResource(
if (fullDomain && fullDomain !== resource.fullDomain) {
await db
.update(resources)
.set({ fullDomain })
.set({ fullDomain, wildcard })
.where(eq(resources.resourceId, resource.resourceId));
}
+86 -35
View File
@@ -3,34 +3,68 @@ import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, like, sql } from "drizzle-orm";
import { ActionsEnum } from "@server/auth/actions";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { object, z } from "zod";
import { fromError } from "zod-validation-error";
import type { PaginatedResponse } from "@server/types/Pagination";
const listRolesParamsSchema = z.strictObject({
orgId: z.string()
});
const listRolesSchema = z.object({
limit: z
.string()
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.default("1000")
.transform(Number)
.pipe(z.int().nonnegative()),
offset: z
.string()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["name"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
})
});
async function queryRoles(orgId: string, limit: number, offset: number) {
return await db
function queryRolesBase() {
return db
.select({
roleId: roles.roleId,
orgId: roles.orgId,
@@ -45,20 +79,15 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
sshUnixGroups: roles.sshUnixGroups
})
.from(roles)
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
.where(eq(roles.orgId, orgId))
.limit(limit)
.offset(offset);
.leftJoin(orgs, eq(roles.orgId, orgs.orgId));
// .where(eq(roles.orgId, orgId))
// .limit(limit)
// .offset(offset);
}
export type ListRolesResponse = {
roles: NonNullable<Awaited<ReturnType<typeof queryRoles>>>;
pagination: {
total: number;
limit: number;
offset: number;
};
};
export type ListRolesResponse = PaginatedResponse<{
roles: NonNullable<Awaited<ReturnType<typeof queryRolesBase>>>;
}>;
registry.registerPath({
method: "get",
@@ -88,7 +117,7 @@ export async function listRoles(
);
}
const { limit, offset } = parsedQuery.data;
const { page, pageSize, query, sort_by, order } = parsedQuery.data;
const parsedParams = listRolesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -102,14 +131,36 @@ export async function listRoles(
const { orgId } = parsedParams.data;
const countQuery: any = db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(roles)
.where(eq(roles.orgId, orgId));
const conditions = [and(eq(roles.orgId, orgId))];
const rolesList = await queryRoles(orgId, limit, offset);
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
if (query) {
conditions.push(
like(sql`LOWER(${roles.name})`, "%" + query.toLowerCase() + "%")
);
}
const countQuery = db.$count(
queryRolesBase()
.where(and(...conditions))
.as("filtered_roles")
);
const rolesListQuery = queryRolesBase()
.where(and(...conditions))
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
? order === "asc"
? asc(roles[sort_by])
: desc(roles[sort_by])
: asc(roles.name)
);
const [totalCount, rolesList] = await Promise.all([
countQuery,
rolesListQuery
]);
let rolesWithAllowSsh = rolesList;
if (rolesList.length > 0) {
@@ -135,8 +186,8 @@ export async function listRoles(
roles: rolesWithAllowSsh,
pagination: {
total: totalCount,
limit,
offset
page,
pageSize
}
},
success: true,
+78 -16
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { clients, db, exitNodes } from "@server/db";
import { clients, db, exitNodes, logsDb, statusHistory } from "@server/db";
import { roles, userSites, sites, roleSites, Site, orgs } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -15,11 +15,12 @@ import moment from "moment";
import { OpenAPITags, registry } from "@server/openApi";
import { hashPassword } from "@server/auth/password";
import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip";
import { getNextAvailableClientSubnet, isIpInCidr } from "@server/lib/ip";
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { generateId } from "@server/auth/sessions/app";
const createSiteParamsSchema = z.strictObject({
orgId: z.string()
@@ -28,6 +29,7 @@ const createSiteParamsSchema = z.strictObject({
const createSiteSchema = z.strictObject({
name: z.string().min(1).max(255),
exitNodeId: z.int().positive().optional(),
niceId: z.string().min(1).max(255).optional(),
// subdomain: z
// .string()
// .min(1)
@@ -52,7 +54,10 @@ const createSiteSchema = z.strictObject({
export type CreateSiteBody = z.infer<typeof createSiteSchema>;
export type CreateSiteResponse = Site;
export type CreateSiteResponse = Site & {
newtId?: string;
secret?: string;
};
registry.registerPath({
method: "put",
@@ -64,7 +69,11 @@ registry.registerPath({
body: {
content: {
"application/json": {
schema: createSiteSchema
schema: createSiteSchema,
example: {
name: "My Site",
type: "newt"
}
}
}
}
@@ -96,9 +105,13 @@ export async function createSite(
subnet,
newtId,
secret,
address
address,
niceId
} = parsedBody.data;
const updatedNewtSecret = secret || generateId(48);
const updatedNewtId = newtId || generateId(15);
const parsedParams = createSiteParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
@@ -111,7 +124,10 @@ export async function createSite(
const { orgId } = parsedParams.data;
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
if (
req.user &&
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -227,6 +243,18 @@ export async function createSite(
)
);
}
} else {
const newClientAddress = await getNextAvailableClientSubnet(orgId);
if (!newClientAddress) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"No available address found"
)
);
}
updatedAddress = newClientAddress.split("/")[0];
}
if (subnet && exitNodeId) {
@@ -285,23 +313,51 @@ export async function createSite(
}
}
const niceId = await getUniqueSiteName(orgId);
let updatedNiceId = niceId;
if (!niceId) {
updatedNiceId = await getUniqueSiteName(orgId);
} else {
// make sure the niceId is unique
const existingSite = await db
.select()
.from(sites)
.where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId)))
.limit(1);
if (existingSite.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Nice ID ${niceId} already exists. Please choose a different one.`
)
);
}
}
let newSite: Site | undefined;
await db.transaction(async (trx) => {
if (type == "newt") {
[newSite] = await trx
.insert(sites)
.values({ // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
.values({
// NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
orgId,
name,
niceId,
niceId: updatedNiceId!,
address: updatedAddress || null,
type,
dockerSocketEnabled: true,
status: "approved"
})
.returning();
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: newSite.siteId,
orgId: orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
} else if (type == "wireguard") {
// we are creating a site with an exit node (tunneled)
if (!subnet) {
@@ -353,7 +409,7 @@ export async function createSite(
orgId,
exitNodeId,
name,
niceId,
niceId: updatedNiceId!,
subnet,
type,
pubKey: pubKey || null,
@@ -367,8 +423,7 @@ export async function createSite(
exitNodeId: exitNodeId || null,
orgId,
name,
niceId,
address: updatedAddress || null,
niceId: updatedNiceId!,
type,
dockerSocketEnabled: false,
online: true,
@@ -402,7 +457,10 @@ export async function createSite(
siteId: newSite.siteId
});
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
if (
req.user &&
!req.userOrgRoleIds?.includes(adminRole[0].roleId)
) {
// make sure the user can access the site
trx.insert(userSites).values({
userId: req.user?.userId!,
@@ -412,10 +470,10 @@ export async function createSite(
// add the peer to the exit node
if (type == "newt") {
const secretHash = await hashPassword(secret!);
const secretHash = await hashPassword(updatedNewtSecret);
await trx.insert(newts).values({
newtId: newtId!,
newtId: updatedNewtId,
secretHash,
siteId: newSite.siteId,
dateCreated: moment().toISOString()
@@ -458,7 +516,11 @@ export async function createSite(
}
return response<CreateSiteResponse>(res, {
data: newSite,
data: {
...newSite,
newtId: type == "newt" ? updatedNewtId : undefined,
secret: type == "newt" ? updatedNewtSecret : undefined
},
success: true,
error: false,
message: "Site created successfully",
+17 -9
View File
@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, Site, siteResources } from "@server/db";
import { db, Site, siteNetworks, siteResources } from "@server/db";
import { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -71,16 +71,24 @@ export async function deleteSite(
await deletePeer(site.exitNodeId!, site.pubKey);
}
} else if (site.type == "newt") {
// delete all of the site resources on this site
const siteResourcesOnSite = trx
.delete(siteResources)
.where(eq(siteResources.siteId, siteId))
.returning();
const networks = await trx
.select({ networkId: siteNetworks.networkId })
.from(siteNetworks)
.where(eq(siteNetworks.siteId, siteId));
// loop through them
for (const removedSiteResource of await siteResourcesOnSite) {
const updatedSiteResources = await trx
.select()
.from(siteResources)
.where(
inArray(
siteResources.networkId,
networks.map((n) => n.networkId)
)
);
for (const siteResource of updatedSiteResources) {
await rebuildClientAssociationsFromSiteResource(
removedSiteResource,
siteResource,
trx
);
}
+8 -4
View File
@@ -42,9 +42,12 @@ async function query(siteId?: number, niceId?: string, orgId?: string) {
}
}
export type GetSiteResponse = NonNullable<
Awaited<ReturnType<typeof query>>
>["sites"] & { newtId: string | null };
type SiteQueryRow = NonNullable<Awaited<ReturnType<typeof query>>>;
export type GetSiteResponse = SiteQueryRow["sites"] & {
newtId: string | null;
newtVersion: string | null;
};
registry.registerPath({
method: "get",
@@ -100,7 +103,8 @@ export async function getSite(
const data: GetSiteResponse = {
...site.sites,
newtId: site.newt ? site.newt.newtId : null
newtId: site.newt ? site.newt.newtId : null,
newtVersion: site.newt?.version ?? null
};
return response<GetSiteResponse>(res, {
+62
View File
@@ -0,0 +1,62 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 {
getCachedStatusHistory,
statusHistoryQuerySchema,
StatusHistoryResponse
} from "@server/lib/statusHistory";
const siteParamsSchema = z.object({
siteId: z.string().transform((v) => parseInt(v, 10))
});
export async function getSiteStatusHistory(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = siteParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedQuery = statusHistoryQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const entityType = "site";
const entityId = parsedParams.data.siteId;
const { days } = parsedQuery.data;
const data = await getCachedStatusHistory(entityType, entityId, days);
return response<StatusHistoryResponse>(res, {
data,
success: true,
error: false,
message: "Status history retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./getSite";
export * from "./getStatusHistory";
export * from "./createSite";
export * from "./deleteSite";
export * from "./updateSite";
+69 -11
View File
@@ -5,6 +5,9 @@ import {
orgs,
remoteExitNodes,
roleSites,
siteNetworks,
siteResources,
targets,
sites,
userSites
} from "@server/db";
@@ -21,15 +24,22 @@ import semver from "semver";
import { z } from "zod";
import { fromError } from "zod-validation-error";
// Stale-while-revalidate: keeps the last successfully fetched version so that
// a transient network failure / timeout does not flip every site back to
// newtUpdateAvailable: false.
let staleNewtVersion: string | null = null;
async function getLatestNewtVersion(): Promise<string | null> {
try {
const cachedVersion = await cache.get<string>("latestNewtVersion");
const cachedVersion = await cache.get<string>(
"cache:latestNewtVersion"
);
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500); // Reduced timeout to 1.5 seconds
const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch(
"https://api.github.com/repos/fosrl/newt/tags",
@@ -44,18 +54,46 @@ async function getLatestNewtVersion(): Promise<string | null> {
logger.warn(
`Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}`
);
return null;
return staleNewtVersion;
}
let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Newt repository");
return null;
return staleNewtVersion;
}
tags = tags.filter((version) => !version.name.includes("rc"));
// Remove release-candidates, then sort descending by semver so that
// duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks
// from the GitHub API do not cause an older tag to be selected.
tags = tags.filter((tag: any) => !tag.name.includes("rc"));
tags.sort((a: any, b: any) => {
const va = semver.coerce(a.name);
const vb = semver.coerce(b.name);
if (!va && !vb) return 0;
if (!va) return 1;
if (!vb) return -1;
return semver.rcompare(va, vb);
});
// Deduplicate: keep only the first (highest) entry per normalised version
const seen = new Set<string>();
tags = tags.filter((tag: any) => {
const normalised = semver.coerce(tag.name)?.version;
if (!normalised || seen.has(normalised)) return false;
seen.add(normalised);
return true;
});
if (tags.length === 0) {
logger.warn("No valid semver tags found for Newt repository");
return staleNewtVersion;
}
const latestVersion = tags[0].name;
await cache.set("latestNewtVersion", latestVersion, 3600);
staleNewtVersion = latestVersion;
await cache.set("cache:latestNewtVersion", latestVersion, 3600);
return latestVersion;
} catch (error: any) {
@@ -73,7 +111,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
error.message || error
);
}
return null;
return staleNewtVersion;
}
}
@@ -166,6 +204,18 @@ function querySitesBase() {
exitNodeName: exitNodes.name,
exitNodeEndpoint: exitNodes.endpoint,
remoteExitNodeId: remoteExitNodes.remoteExitNodeId,
resourceCount: sql<number>`(
SELECT COUNT(DISTINCT ${targets.resourceId})
FROM ${targets}
WHERE ${targets.siteId} = ${sites.siteId}
) + (
SELECT COUNT(DISTINCT ${siteResources.siteResourceId})
FROM ${siteResources}
INNER JOIN ${siteNetworks}
ON ${siteResources.networkId} = ${siteNetworks.networkId}
WHERE ${siteNetworks.siteId} = ${sites.siteId}
AND ${siteResources.orgId} = ${sites.orgId}
)`,
status: sites.status
})
.from(sites)
@@ -178,7 +228,10 @@ function querySitesBase() {
);
}
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
online?: SiteRowBase["online"]; // undefined for local sites
newtUpdateAvailable?: boolean;
};
@@ -286,12 +339,13 @@ export async function listSites(
if (typeof status !== "undefined") {
conditions.push(eq(sites.status, status));
}
const baseQuery = querySitesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
querySitesBase().where(and(...conditions)).as("filtered_sites")
querySitesBase()
.where(and(...conditions))
.as("filtered_sites")
);
const siteListQuery = baseQuery
@@ -350,9 +404,13 @@ export async function listSites(
);
}
const sitesPayload = sitesWithUpdates.map((site) =>
site.type === "local" ? { ...site, online: undefined } : site
);
return response<ListSitesResponse>(res, {
data: {
sites: sitesWithUpdates,
sites: sitesPayload,
pagination: {
total: totalCount,
pageSize,
+1 -1
View File
@@ -124,7 +124,7 @@ export async function pickSiteDefaults(
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"No available subnet found"
"No available address"
)
);
}
+216 -69
View File
@@ -5,6 +5,8 @@ import {
orgs,
roles,
roleSiteResources,
siteNetworks,
networks,
SiteResource,
siteResources,
sites,
@@ -17,17 +19,20 @@ import {
portRangeStringSchema
} from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } 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 { validateAndConstructDomain } from "@server/lib/domainUtils";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { build } from "@server/build";
const createSiteResourceParamsSchema = z.strictObject({
orgId: z.string()
@@ -36,11 +41,15 @@ const createSiteResourceParamsSchema = z.strictObject({
const createSiteResourceSchema = z
.strictObject({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "port"]),
siteId: z.int(),
niceId: z.string().optional(),
// protocol: z.enum(["tcp", "udp"]).optional(),
mode: z.enum(["host", "cidr", "http"]),
ssl: z.boolean().optional(), // only used for http mode
scheme: z.enum(["http", "https"]).optional(),
siteIds: z.array(z.int()).optional(),
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
// proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(),
destinationPort: z.int().positive().optional(),
destination: z.string().min(1),
enabled: z.boolean().default(true),
alias: z
@@ -57,20 +66,24 @@ const createSiteResourceSchema = z
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().optional(),
authDaemonMode: z.enum(["site", "remote"]).optional()
authDaemonMode: z.enum(["site", "remote"]).optional(),
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
})
.strict()
.refine(
(data) => {
if (data.mode === "host") {
// Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z
// .union([z.ipv4(), z.ipv6()])
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.safeParse(data.destination).success;
if (data.mode == "host") {
// Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z
// .union([z.ipv4(), z.ipv6()])
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.safeParse(data.destination).success;
if (isValidIP) {
return true;
if (isValidIP) {
return true;
}
}
// Check if it's a valid domain (hostname pattern, TLD not required)
@@ -105,6 +118,32 @@ const createSiteResourceSchema = z
{
message: "Destination must be a valid CIDR notation for cidr mode"
}
)
.refine(
(data) => {
if (data.mode !== "http") return true;
return (
data.scheme !== undefined &&
data.destinationPort !== undefined &&
data.destinationPort >= 1 &&
data.destinationPort <= 65535
);
},
{
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
)
.refine(
(data) => {
return (
(data.siteIds !== undefined && data.siteIds.length > 0) ||
data.siteId !== undefined
);
},
{
message: "At least one of siteIds or siteId must be provided"
}
);
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
@@ -159,13 +198,16 @@ export async function createSiteResource(
const { orgId } = parsedParams.data;
const {
name,
niceId,
siteIds: siteIdsInput = [],
siteId,
mode,
// protocol,
scheme,
// proxyPort,
// destinationPort,
destinationPort,
destination,
enabled,
ssl,
alias,
userIds,
roleIds,
@@ -174,18 +216,42 @@ export async function createSiteResource(
udpPortRangeString,
disableIcmp,
authDaemonPort,
authDaemonMode
authDaemonMode,
domainId,
subdomain
} = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
const siteIds = [...siteIdsInput];
if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId);
}
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.HTTPPrivateResources]
);
if (!hasHttpFeature) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"HTTP private resources are not included in your current plan. Please upgrade."
)
);
}
}
// Verify the site exists and belongs to the org
const [site] = await db
const sitesToAssign = await db
.select()
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
.where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId)));
if (!site) {
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
if (sitesToAssign.length !== siteIds.length) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
);
}
const [org] = await db
@@ -226,29 +292,50 @@ export async function createSiteResource(
);
}
// // check if resource with same protocol and proxy port already exists (only for port mode)
// if (mode === "port" && protocol && proxyPort) {
// const [existingResource] = await db
// .select()
// .from(siteResources)
// .where(
// and(
// eq(siteResources.siteId, siteId),
// eq(siteResources.orgId, orgId),
// eq(siteResources.protocol, protocol),
// eq(siteResources.proxyPort, proxyPort)
// )
// )
// .limit(1);
// if (existingResource && existingResource.siteResourceId) {
// return next(
// createHttpError(
// HttpCode.CONFLICT,
// "A resource with the same protocol and proxy port already exists"
// )
// );
// }
// }
if (domainId && alias) {
// throw an error because we can only have one or the other
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Alias and domain cannot both be set. Please choose one or the other."
)
);
}
let fullDomain: string | null = null;
let finalSubdomain: string | null = null;
if (domainId) {
// Validate domain and construct full domain
const domainResult = await validateAndConstructDomain(
domainId,
orgId,
subdomain
);
if (!domainResult.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, domainResult.error)
);
}
fullDomain = domainResult.fullDomain;
finalSubdomain = domainResult.subdomain;
// make sure the full domain is unique
const existingResource = await db
.select()
.from(siteResources)
.where(eq(siteResources.fullDomain, fullDomain));
if (existingResource.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
}
// make sure the alias is unique within the org if provided
if (alias) {
@@ -278,29 +365,56 @@ export async function createSiteResource(
tierMatrix.sshPam
);
const niceId = await getUniqueSiteResourceName(orgId);
let updatedNiceId = niceId;
if (!niceId) {
updatedNiceId = await getUniqueSiteResourceName(orgId);
}
let aliasAddress: string | null = null;
if (mode == "host") {
// we can only have an alias on a host
if (mode === "host" || mode === "http") {
aliasAddress = await getNextAvailableAliasAddress(orgId);
}
let newSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => {
const [network] = await trx
.insert(networks)
.values({
scope: "resource",
orgId: orgId
})
.returning();
if (!network) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Failed to create network`
)
);
}
// Create the site resource
const insertValues: typeof siteResources.$inferInsert = {
siteId,
niceId,
niceId: updatedNiceId!,
orgId,
name,
mode: mode as "host" | "cidr",
mode,
ssl,
networkId: network.networkId,
destination,
scheme,
destinationPort,
enabled,
alias,
alias: alias ? alias.trim() : null,
aliasAddress,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
tcpPortRangeString:
mode == "http" ? "443,80" : tcpPortRangeString,
udpPortRangeString: mode == "http" ? "" : udpPortRangeString,
disableIcmp: disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false
domainId,
subdomain: finalSubdomain,
fullDomain
};
if (isLicensedSshPam) {
if (authDaemonPort !== undefined)
@@ -317,6 +431,13 @@ export async function createSiteResource(
//////////////////// update the associations ////////////////////
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: network.networkId
});
}
const [adminRole] = await trx
.select()
.from(roles)
@@ -359,22 +480,22 @@ export async function createSiteResource(
);
}
const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
for (const siteToAssign of sitesToAssign) {
const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, siteToAssign.siteId))
.limit(1);
if (!newt) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
);
if (!newt) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Newt not found for site ${siteToAssign.siteId}`
)
);
}
}
await rebuildClientAssociationsFromSiteResource(
newSiteResource,
trx
); // we need to call this because we added to the admin role
});
if (!newSiteResource) {
@@ -387,9 +508,35 @@ export async function createSiteResource(
}
logger.info(
`Created site resource ${newSiteResource.siteResourceId} for site ${siteId}`
`Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
);
if (
ssl &&
mode === "http" &&
domainId &&
fullDomain &&
build != "oss"
) {
await createCertificate(domainId, fullDomain, db);
}
// Run in the background after the response is sent. Wrapped in its
// own transaction so it always executes on the primary — avoiding any
// replica-lag issues while still allowing the HTTP response to return
// early.
db.transaction(async (trx) => {
await rebuildClientAssociationsFromSiteResource(
newSiteResource!,
trx
);
}).catch((err) => {
logger.error(
`Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`,
err
);
});
return response(res, {
data: newSiteResource,
success: true,
@@ -63,29 +63,26 @@ export async function deleteSiteResource(
);
}
await db.transaction(async (trx) => {
// Delete the site resource
const [removedSiteResource] = await trx
.delete(siteResources)
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
.returning();
const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, removedSiteResource.siteId))
.limit(1);
if (!newt) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
);
}
// Delete the site resource
const [removedSiteResource] = await db
.delete(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.returning();
// Run in the background after the response is sent. Wrapped in its
// own transaction so it always executes on the primary — avoiding any
// replica-lag issues while still allowing the HTTP response to return
// early.
db.transaction(async (trx) => {
await rebuildClientAssociationsFromSiteResource(
removedSiteResource,
trx
);
}).catch((err) => {
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
err
);
});
logger.info(`Deleted site resource ${siteResourceId}`);
@@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({
.transform((val) => (val ? Number(val) : undefined))
.pipe(z.int().positive().optional())
.optional(),
siteId: z.string().transform(Number).pipe(z.int().positive()),
niceId: z.string().optional(),
orgId: z.string()
});
async function query(
siteResourceId?: number,
siteId?: number,
niceId?: string,
orgId?: string
) {
if (siteResourceId && siteId && orgId) {
if (siteResourceId && orgId) {
const [siteResource] = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.limit(1);
return siteResource;
} else if (niceId && siteId && orgId) {
} else if (niceId && orgId) {
const [siteResource] = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.niceId, niceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
@@ -84,7 +80,6 @@ registry.registerPath({
request: {
params: z.object({
niceId: z.string(),
siteId: z.number(),
orgId: z.string()
})
},
@@ -107,10 +102,10 @@ export async function getSiteResource(
);
}
const { siteResourceId, siteId, niceId, orgId } = parsedParams.data;
const { siteResourceId, niceId, orgId } = parsedParams.data;
// Get the site resource
const siteResource = await query(siteResourceId, siteId, niceId, orgId);
const siteResource = await query(siteResourceId, niceId, orgId);
if (!siteResource) {
return next(
@@ -1,10 +1,10 @@
import { db, SiteResource, siteResources, sites } from "@server/db";
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination";
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
}),
query: z.string().optional(),
mode: z
.enum(["host", "cidr"])
.enum(["host", "cidr", "http"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["host", "cidr"],
enum: ["host", "cidr", "http"],
description: "Filter site resources by mode"
}),
sort_by: z
@@ -68,27 +68,95 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
siteId: z.coerce
.number<string>()
.int()
.positive()
.optional()
.openapi({
type: "integer",
description:
"When set, only site resources associated with this site (via network) are returned"
})
});
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteResources: (SiteResource & {
siteName: string;
siteNiceId: string;
siteAddress: string | null;
siteOnlines: boolean[];
siteIds: number[];
siteNames: string[];
siteNiceIds: string[];
siteAddresses: (string | null)[];
})[];
}>;
/**
* Returns an aggregation expression compatible with both SQLite and PostgreSQL.
* - SQLite: json_group_array(col) returns a JSON array string, parsed after fetch
* - PostgreSQL: array_agg(col) returns a native array
*/
function aggCol<T>(column: any) {
if (DB_TYPE === "sqlite") {
// json_group_array will include NULLs for left-joined missing rows;
// we filter them out in transformSiteResourceRow keeping arrays aligned.
return sql<T>`json_group_array(${column})`;
}
return sql<T>`COALESCE(array_agg(${column}) FILTER (WHERE ${sites.siteId} IS NOT NULL), '{}')`;
}
/**
* For SQLite the aggregated columns come back as JSON strings; parse them into
* proper arrays. For PostgreSQL the driver already returns native arrays, so
* the row is returned unchanged.
*/
function transformSiteResourceRow(row: any) {
if (DB_TYPE !== "sqlite") {
return row;
}
const siteIdsRaw = JSON.parse(row.siteIds) as (number | null)[];
const siteNamesRaw = JSON.parse(row.siteNames) as (string | null)[];
const siteNiceIdsRaw = JSON.parse(row.siteNiceIds) as (string | null)[];
const siteAddressesRaw = JSON.parse(row.siteAddresses) as (string | null)[];
const siteOnlinesRaw = JSON.parse(row.siteOnlines) as (0 | 1 | null)[];
// When a site resource has no associated sites (left join produced no
// matches), the aggregated arrays will contain a single NULL entry. Strip
// those out, keeping the parallel arrays aligned by siteId presence.
const siteIds: number[] = [];
const siteNames: string[] = [];
const siteNiceIds: string[] = [];
const siteAddresses: (string | null)[] = [];
const siteOnlines: boolean[] = [];
for (let i = 0; i < siteIdsRaw.length; i++) {
if (siteIdsRaw[i] == null) continue;
siteIds.push(siteIdsRaw[i] as number);
siteNames.push((siteNamesRaw[i] ?? "") as string);
siteNiceIds.push((siteNiceIdsRaw[i] ?? "") as string);
siteAddresses.push(siteAddressesRaw[i] ?? null);
siteOnlines.push(siteOnlinesRaw[i] === 1);
}
return {
...row,
siteNames,
siteNiceIds,
siteIds,
siteAddresses,
siteOnlines
};
}
function querySiteResourcesBase() {
return db
.select({
siteResourceId: siteResources.siteResourceId,
siteId: siteResources.siteId,
orgId: siteResources.orgId,
niceId: siteResources.niceId,
name: siteResources.name,
mode: siteResources.mode,
protocol: siteResources.protocol,
ssl: siteResources.ssl,
scheme: siteResources.scheme,
proxyPort: siteResources.proxyPort,
destinationPort: siteResources.destinationPort,
destination: siteResources.destination,
@@ -100,12 +168,24 @@ function querySiteResourcesBase() {
disableIcmp: siteResources.disableIcmp,
authDaemonMode: siteResources.authDaemonMode,
authDaemonPort: siteResources.authDaemonPort,
siteName: sites.name,
siteNiceId: sites.niceId,
siteAddress: sites.address
subdomain: siteResources.subdomain,
domainId: siteResources.domainId,
fullDomain: siteResources.fullDomain,
networkId: siteResources.networkId,
defaultNetworkId: siteResources.defaultNetworkId,
siteNames: aggCol<string[]>(sites.name),
siteNiceIds: aggCol<string[]>(sites.niceId),
siteIds: aggCol<number[]>(sites.siteId),
siteAddresses: aggCol<(string | null)[]>(sites.address),
siteOnlines: aggCol<boolean[]>(sites.online)
})
.from(siteResources)
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.groupBy(siteResources.siteResourceId);
}
registry.registerPath({
@@ -151,10 +231,33 @@ export async function listAllSiteResourcesByOrg(
}
const { orgId } = parsedParams.data;
const { page, pageSize, query, mode, sort_by, order } =
const { page, pageSize, query, mode, sort_by, order, siteId } =
parsedQuery.data;
const conditions = [and(eq(siteResources.orgId, orgId))];
if (siteId != null) {
// Keep inner joins here: filtering by a specific site implies the
// resource must have at least one matching site.
const resourcesForSite = db
.select({ id: siteResources.siteResourceId })
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.orgId, orgId),
eq(sites.orgId, orgId),
eq(sites.siteId, siteId)
)
);
conditions.push(
inArray(siteResources.siteResourceId, resourcesForSite)
);
}
if (query) {
conditions.push(
or(
@@ -193,10 +296,12 @@ export async function listAllSiteResourcesByOrg(
const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count(
querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources")
querySiteResourcesBase()
.where(and(...conditions))
.as("filtered_site_resources")
);
const [siteResourcesList, totalCount] = await Promise.all([
const [siteResourcesRaw, totalCount] = await Promise.all([
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
@@ -210,6 +315,8 @@ export async function listAllSiteResourcesByOrg(
countQuery
]);
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
return response<ListAllSiteResourcesByOrgResponse>(res, {
data: {
siteResources: siteResourcesList,
@@ -233,4 +340,4 @@ export async function listAllSiteResourcesByOrg(
)
);
}
}
}
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, networks, siteNetworks } from "@server/db";
import { siteResources, sites, SiteResource } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -108,13 +108,18 @@ export async function listSiteResources(
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
}
// Get site resources
// Get site resources by joining networks to siteResources via siteNetworks
const siteResourcesList = await db
.select()
.from(siteResources)
.from(siteNetworks)
.innerJoin(networks, eq(siteNetworks.networkId, networks.networkId))
.innerJoin(
siteResources,
eq(siteResources.networkId, networks.networkId)
)
.where(
and(
eq(siteResources.siteId, siteId),
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
+353 -152
View File
@@ -1,4 +1,3 @@
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import {
clientSiteResources,
clientSiteResourcesAssociationsCache,
@@ -7,13 +6,21 @@ import {
orgs,
roles,
roleSiteResources,
siteNetworks,
SiteResource,
siteResources,
sites,
networks,
Transaction,
userSiteResources
} from "@server/db";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import response from "@server/lib/response";
import { eq, and, ne, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import {
generateAliasConfig,
generateRemoteSubnets,
@@ -22,12 +29,8 @@ import {
portRangeStringSchema
} from "@server/lib/ip";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import HttpCode from "@server/types/HttpCode";
import { and, eq, ne } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -40,7 +43,9 @@ const updateSiteResourceParamsSchema = z.strictObject({
const updateSiteResourceSchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
siteId: z.int(),
siteIds: z.array(z.int()).optional(),
siteId: z.int().positive().optional(),
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
niceId: z
.string()
.min(1)
@@ -51,10 +56,11 @@ const updateSiteResourceSchema = z
)
.optional(),
// mode: z.enum(["host", "cidr", "port"]).optional(),
mode: z.enum(["host", "cidr"]).optional(),
// protocol: z.enum(["tcp", "udp"]).nullish(),
mode: z.enum(["host", "cidr", "http"]).optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).nullish(),
// proxyPort: z.int().positive().nullish(),
// destinationPort: z.int().positive().nullish(),
destinationPort: z.int().positive().nullish(),
destination: z.string().min(1).optional(),
enabled: z.boolean().optional(),
alias: z
@@ -71,7 +77,9 @@ const updateSiteResourceSchema = z
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().nullish(),
authDaemonMode: z.enum(["site", "remote"]).optional()
authDaemonMode: z.enum(["site", "remote"]).optional(),
domainId: z.string().optional(),
subdomain: z.string().optional()
})
.strict()
.refine(
@@ -118,6 +126,34 @@ const updateSiteResourceSchema = z
{
message: "Destination must be a valid CIDR notation for cidr mode"
}
)
.refine(
(data) => {
if (data.mode !== "http") return true;
return (
data.scheme !== undefined &&
data.scheme !== null &&
data.destinationPort !== undefined &&
data.destinationPort !== null &&
data.destinationPort >= 1 &&
data.destinationPort <= 65535
);
},
{
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
)
.refine(
(data) => {
return (
(data.siteIds !== undefined && data.siteIds.length > 0) ||
data.siteId !== undefined
);
},
{
message: "At least one of siteIds or siteId must be provided"
}
);
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
@@ -172,11 +208,15 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data;
const {
name,
siteId, // because it can change
siteIds: siteIdsInput = [], // because it can change
siteId,
niceId,
mode,
scheme,
destination,
destinationPort,
alias,
ssl,
enabled,
userIds,
roleIds,
@@ -185,17 +225,15 @@ export async function updateSiteResource(
udpPortRangeString,
disableIcmp,
authDaemonPort,
authDaemonMode
authDaemonMode,
domainId,
subdomain
} = parsedBody.data;
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) {
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
// Backward compatibility: merge deprecated siteId into siteIds array
const siteIds = [...siteIdsInput];
if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId);
}
// Check if site resource exists
@@ -211,6 +249,21 @@ export async function updateSiteResource(
);
}
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
existingSiteResource.orgId,
tierMatrix[TierFeature.HTTPPrivateResources]
);
if (!hasHttpFeature) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"HTTP private resources are not included in your current plan. Please upgrade."
)
);
}
}
const isLicensedSshPam = await isLicensedOrSubscribed(
existingSiteResource.orgId,
tierMatrix.sshPam
@@ -237,6 +290,23 @@ export async function updateSiteResource(
);
}
// Verify the site exists and belongs to the org
const sitesToAssign = await db
.select()
.from(sites)
.where(
and(
inArray(sites.siteId, siteIds),
eq(sites.orgId, existingSiteResource.orgId)
)
);
if (sitesToAssign.length !== siteIds.length) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
);
}
// Only check if destination is an IP address
const isIp = z
.union([z.ipv4(), z.ipv6()])
@@ -254,22 +324,60 @@ export async function updateSiteResource(
);
}
let existingSite = site;
let siteChanged = false;
if (existingSiteResource.siteId !== siteId) {
siteChanged = true;
// get the existing site
[existingSite] = await db
.select()
.from(sites)
.where(eq(sites.siteId, existingSiteResource.siteId))
.limit(1);
let sitesChanged = false;
const existingSiteIds = existingSiteResource.networkId
? await db
.select()
.from(siteNetworks)
.where(
eq(siteNetworks.networkId, existingSiteResource.networkId)
)
: [];
if (!existingSite) {
const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId));
const newSiteIdSet = new Set(siteIds);
if (
existingSiteIdSet.size !== newSiteIdSet.size ||
![...existingSiteIdSet].every((id) => newSiteIdSet.has(id))
) {
sitesChanged = true;
}
let fullDomain: string | null = null;
let finalSubdomain: string | null = null;
if (domainId) {
// Validate domain and construct full domain
const domainResult = await validateAndConstructDomain(
domainId,
org.orgId,
subdomain
);
if (!domainResult.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, domainResult.error)
);
}
fullDomain = domainResult.fullDomain;
finalSubdomain = domainResult.subdomain;
// make sure the full domain is unique
const [existingDomain] = await db
.select()
.from(siteResources)
.where(eq(siteResources.fullDomain, fullDomain));
if (
existingDomain &&
existingDomain.siteResourceId !==
existingSiteResource.siteResourceId
) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Existing site not found"
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
@@ -302,7 +410,7 @@ export async function updateSiteResource(
let updatedSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => {
// if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
if (siteChanged) {
if (sitesChanged) {
// delete the existing site resource
await trx
.delete(siteResources)
@@ -323,9 +431,6 @@ export async function updateSiteResource(
})
.returning();
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
const sshPamSet =
isLicensedSshPam &&
(authDaemonPort !== undefined ||
@@ -343,15 +448,23 @@ export async function updateSiteResource(
.update(siteResources)
.set({
name,
siteId,
niceId,
mode,
scheme,
ssl,
destination,
destinationPort,
enabled,
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString,
udpPortRangeString,
disableIcmp,
alias: alias ? alias.trim() : null,
tcpPortRangeString:
mode == "http" ? "443,80" : tcpPortRangeString,
udpPortRangeString:
mode == "http" ? "" : udpPortRangeString,
disableIcmp:
disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false
domainId,
subdomain: finalSubdomain,
fullDomain,
...sshPamSet
})
.where(
@@ -372,6 +485,23 @@ export async function updateSiteResource(
//////////////////// update the associations ////////////////////
// delete the site - site resources associations
await trx
.delete(siteNetworks)
.where(
eq(
siteNetworks.networkId,
updatedSiteResource.networkId!
)
);
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: updatedSiteResource.networkId!
});
}
const [adminRole] = await trx
.select()
.from(roles)
@@ -423,11 +553,6 @@ export async function updateSiteResource(
}))
);
}
await rebuildClientAssociationsFromSiteResource(
updatedSiteResource,
trx
);
} else {
// Update the site resource
const sshPamSet =
@@ -447,14 +572,20 @@ export async function updateSiteResource(
.update(siteResources)
.set({
name: name,
siteId: siteId,
niceId: niceId,
mode: mode,
scheme,
ssl,
destination: destination,
destinationPort: destinationPort,
enabled: enabled,
alias: alias && alias.trim() ? alias : null,
alias: alias ? alias.trim() : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp,
domainId,
subdomain: finalSubdomain,
fullDomain,
...sshPamSet
})
.where(
@@ -464,6 +595,23 @@ export async function updateSiteResource(
//////////////////// update the associations ////////////////////
// delete the site - site resources associations
await trx
.delete(siteNetworks)
.where(
eq(
siteNetworks.networkId,
updatedSiteResource.networkId!
)
);
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: updatedSiteResource.networkId!
});
}
await trx
.delete(clientSiteResources)
.where(
@@ -533,17 +681,40 @@ export async function updateSiteResource(
);
}
logger.info(
`Updated site resource ${siteResourceId} for site ${siteId}`
);
logger.info(`Updated site resource ${siteResourceId}`);
}
});
// Background: wait for removal messages to propagate, then rebuild
// associations for the re-created resource. Own transaction ensures
// execution on the primary against fully committed state.
(async () => {
await db.transaction(async (trx) => {
if (!updatedSiteResource) {
throw new Error("No updated resource found after update");
}
if (sitesChanged) {
await new Promise((resolve) => setTimeout(resolve, 750));
await rebuildClientAssociationsFromSiteResource(
updatedSiteResource,
trx
);
}
await handleMessagingForUpdatedSiteResource(
existingSiteResource,
updatedSiteResource,
{ siteId: site.siteId, orgId: site.orgId },
siteIds.map((siteId) => ({
siteId,
orgId: existingSiteResource.orgId
})),
trx
);
}
});
})().catch((err) => {
logger.error(
`Error rebuilding client associations for site resource ${updatedSiteResource?.siteResourceId}:`,
err
);
});
return response(res, {
@@ -567,7 +738,7 @@ export async function updateSiteResource(
export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource | undefined,
updatedSiteResource: SiteResource,
site: { siteId: number; orgId: string },
sites: { siteId: number; orgId: string }[],
trx: Transaction
) {
logger.debug(
@@ -589,9 +760,19 @@ export async function handleMessagingForUpdatedSiteResource(
const destinationChanged =
existingSiteResource &&
existingSiteResource.destination !== updatedSiteResource.destination;
const destinationPortChanged =
existingSiteResource &&
existingSiteResource.destinationPort !==
updatedSiteResource.destinationPort;
const aliasChanged =
existingSiteResource &&
existingSiteResource.alias !== updatedSiteResource.alias;
const fullDomainChanged =
existingSiteResource &&
existingSiteResource.fullDomain !== updatedSiteResource.fullDomain;
const sslChanged =
existingSiteResource &&
existingSiteResource.ssl !== updatedSiteResource.ssl;
const portRangesChanged =
existingSiteResource &&
(existingSiteResource.tcpPortRangeString !==
@@ -603,106 +784,126 @@ export async function handleMessagingForUpdatedSiteResource(
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (destinationChanged || aliasChanged || portRangesChanged) {
const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (!newt) {
throw new Error(
"Newt not found for site during site resource update"
);
}
// Only update targets on newt if destination changed
if (destinationChanged || portRangesChanged) {
const oldTarget = generateSubnetProxyTargetV2(
existingSiteResource,
mergedAllClients
);
const newTarget = generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
);
await updateTargets(
newt.newtId,
{
oldTargets: oldTarget ? [oldTarget] : [],
newTargets: newTarget ? [newTarget] : []
},
newt.version
);
}
const olmJobs: Promise<void>[] = [];
for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
// todo: optimize this query if needed
const oldDestinationStillInUseSites = await trx
if (
destinationChanged ||
aliasChanged ||
fullDomainChanged ||
sslChanged ||
portRangesChanged ||
destinationPortChanged
) {
for (const site of sites) {
const [newt] = await trx
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteResources.siteId, site.siteId),
eq(
siteResources.destination,
existingSiteResource.destination
),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (!newt) {
throw new Error(
"Newt not found for site during site resource update"
);
}
// Only update targets on newt if these items change
if (
destinationChanged ||
sslChanged || // we need to push a new cert if the ssl changed
portRangesChanged ||
fullDomainChanged || // if the domain changes we need to update the certs and stuff
destinationPortChanged
) {
const oldTargets = await generateSubnetProxyTargetV2(
existingSiteResource,
mergedAllClients
);
const newTargets = await generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
);
const oldDestinationStillInUseByASite =
oldDestinationStillInUseSites.length > 0;
await updateTargets(
newt.newtId,
{
oldTargets: oldTargets ? oldTargets : [],
newTargets: newTargets ? newTargets : []
},
newt.version
);
}
// we also need to update the remote subnets on the olms for each client that has access to this site
olmJobs.push(
updatePeerData(
client.clientId,
updatedSiteResource.siteId,
destinationChanged
? {
oldRemoteSubnets: !oldDestinationStillInUseByASite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliasChanged
? {
oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
)
);
const olmJobs: Promise<void>[] = [];
for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
// todo: optimize this query if needed
const oldDestinationStillInUseSites = await trx
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteNetworks.siteId, site.siteId),
eq(
siteResources.destination,
existingSiteResource.destination
),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
);
const oldDestinationStillInUseByASite =
oldDestinationStillInUseSites.length > 0;
// we also need to update the remote subnets on the olms for each client that has access to this site
olmJobs.push(
updatePeerData(
client.clientId,
site.siteId,
destinationChanged
? {
oldRemoteSubnets:
!oldDestinationStillInUseByASite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliasChanged || fullDomainChanged // the full domain is sent down as an alias
? {
oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
)
);
}
await Promise.all(olmJobs);
}
await Promise.all(olmJobs);
}
}
+154 -103
View File
@@ -1,6 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, TargetHealthCheck, targetHealthCheck } from "@server/db";
import {
db,
statusHistory,
TargetHealthCheck,
targetHealthCheck
} from "@server/db";
import { newts, resources, sites, Target, targets } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -14,6 +19,11 @@ import { eq } from "drizzle-orm";
import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert,
fireHealthCheckUnknownAlert
} from "@server/lib/alerts";
const createTargetParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -31,8 +41,8 @@ const createTargetSchema = z.strictObject({
hcMode: z.string().optional().nullable(),
hcHostname: z.string().optional().nullable(),
hcPort: z.int().positive().optional().nullable(),
hcInterval: z.int().positive().min(5).optional().nullable(),
hcUnhealthyInterval: z.int().positive().min(5).optional().nullable(),
hcInterval: z.int().positive().min(1).optional().nullable(),
hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(),
hcTimeout: z.int().positive().min(1).optional().nullable(),
hcHeaders: z
.array(z.strictObject({ name: z.string(), value: z.string() }))
@@ -42,6 +52,8 @@ const createTargetSchema = z.strictObject({
hcMethod: z.string().min(1).optional().nullable(),
hcStatus: z.int().optional().nullable(),
hcTlsServerName: z.string().optional().nullable(),
hcHealthyThreshold: z.int().positive().min(1).optional().nullable(),
hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
rewritePath: z.string().optional().nullable(),
@@ -134,116 +146,155 @@ export async function createTarget(
);
}
const existingTargets = await db
.select()
.from(targets)
.where(eq(targets.resourceId, resourceId));
const existingTarget = existingTargets.find(
(target) =>
target.ip === targetData.ip &&
target.port === targetData.port &&
target.method === targetData.method &&
target.siteId === targetData.siteId
);
if (existingTarget) {
// log a warning
logger.warn(
`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}`
);
}
let newTarget: Target[] = [];
let healthCheck: TargetHealthCheck[] = [];
let targetIps: string[] = [];
if (site.type == "local") {
newTarget = await db
.insert(targets)
.values({
resourceId,
...targetData,
priority: targetData.priority || 100
})
.returning();
} else {
// make sure the target is within the site subnet
if (
site.type == "wireguard" &&
!isIpInCidr(targetData.ip, site.subnet!)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Target IP is not within the site subnet`
)
);
}
let healthCheck: TargetHealthCheck[] = [];
await db.transaction(async (trx) => {
const existingTargets = await trx
.select()
.from(targets)
.where(eq(targets.resourceId, resourceId));
const { internalPort, targetIps: newTargetIps } = await pickPort(
site.siteId!,
db
const existingTarget = existingTargets.find(
(target) =>
target.ip === targetData.ip &&
target.port === targetData.port &&
target.method === targetData.method &&
target.siteId === targetData.siteId
);
if (!internalPort) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`No available internal port`
)
if (existingTarget) {
// log a warning
logger.warn(
`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}`
);
}
newTarget = await db
.insert(targets)
if (site.type == "local") {
newTarget = await trx
.insert(targets)
.values({
resourceId,
...targetData,
priority: targetData.priority || 100
})
.returning();
} else {
// make sure the target is within the site subnet
if (
site.type == "wireguard" &&
!isIpInCidr(targetData.ip, site.subnet!)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Target IP is not within the site subnet`
)
);
}
const { internalPort, targetIps: newTargetIps } =
await pickPort(site.siteId!, trx);
if (!internalPort) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`No available internal port`
)
);
}
newTarget = await trx
.insert(targets)
.values({
resourceId,
siteId: site.siteId,
ip: targetData.ip,
method: targetData.method,
port: targetData.port,
internalPort,
enabled: targetData.enabled,
path: targetData.path,
pathMatchType: targetData.pathMatchType,
rewritePath: targetData.rewritePath,
rewritePathType: targetData.rewritePathType,
priority: targetData.priority || 100
})
.returning();
// add the new target to the targetIps array
newTargetIps.push(`${targetData.ip}/32`);
targetIps = newTargetIps;
}
let hcHeaders = null;
if (targetData.hcHeaders) {
hcHeaders = JSON.stringify(targetData.hcHeaders);
}
healthCheck = await trx
.insert(targetHealthCheck)
.values({
resourceId,
siteId: site.siteId,
ip: targetData.ip,
method: targetData.method,
port: targetData.port,
internalPort,
enabled: targetData.enabled,
path: targetData.path,
pathMatchType: targetData.pathMatchType,
rewritePath: targetData.rewritePath,
rewritePathType: targetData.rewritePathType,
priority: targetData.priority || 100
orgId: resource.orgId,
targetId: newTarget[0].targetId,
siteId: targetData.siteId,
name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`,
hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null,
hcMode: targetData.hcMode ?? null,
hcHostname: targetData.hcHostname ?? null,
hcPort: targetData.hcPort ?? null,
hcInterval: targetData.hcInterval ?? null,
hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null,
hcTimeout: targetData.hcTimeout ?? null,
hcHeaders: hcHeaders,
hcFollowRedirects: targetData.hcFollowRedirects ?? null,
hcMethod: targetData.hcMethod ?? null,
hcStatus: targetData.hcStatus ?? null,
hcHealth: targetData.hcEnabled ? "unhealthy" : "unknown",
hcTlsServerName: targetData.hcTlsServerName ?? null,
hcHealthyThreshold: targetData.hcHealthyThreshold ?? null,
hcUnhealthyThreshold:
targetData.hcUnhealthyThreshold ?? null
})
.returning();
// add the new target to the targetIps array
newTargetIps.push(`${targetData.ip}/32`);
targetIps = newTargetIps;
}
let hcHeaders = null;
if (targetData.hcHeaders) {
hcHeaders = JSON.stringify(targetData.hcHeaders);
}
healthCheck = await db
.insert(targetHealthCheck)
.values({
targetId: newTarget[0].targetId,
hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null,
hcMode: targetData.hcMode ?? null,
hcHostname: targetData.hcHostname ?? null,
hcPort: targetData.hcPort ?? null,
hcInterval: targetData.hcInterval ?? null,
hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null,
hcTimeout: targetData.hcTimeout ?? null,
hcHeaders: hcHeaders,
hcFollowRedirects: targetData.hcFollowRedirects ?? null,
hcMethod: targetData.hcMethod ?? null,
hcStatus: targetData.hcStatus ?? null,
hcHealth: "unknown",
hcTlsServerName: targetData.hcTlsServerName ?? null
})
.returning();
if (healthCheck[0].hcHealth === "unhealthy") {
await fireHealthCheckUnhealthyAlert(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name || "",
healthCheck[0].targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
} else if (healthCheck[0].hcHealth === "unknown") {
// if the health is unknown, we want to fire an alert to notify users to enable health checks
await fireHealthCheckUnknownAlert(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name,
healthCheck[0].targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
} else if (healthCheck[0].hcHealth === "healthy") {
await fireHealthCheckHealthyAlert(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name || "",
healthCheck[0].targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
}
});
if (site.pubKey) {
if (site.type == "wireguard") {
@@ -271,8 +322,8 @@ export async function createTarget(
return response<CreateTargetResponse>(res, {
data: {
...newTarget[0],
...healthCheck[0]
...healthCheck[0],
...newTarget[0]
},
success: true,
error: false,
+60 -35
View File
@@ -2,16 +2,15 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { newts, resources, sites, targets } from "@server/db";
import { eq } from "drizzle-orm";
import { eq, ne, and } 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 { addPeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets";
import { getAllowedIps } from "./helpers";
import { OpenAPITags, registry } from "@server/openApi";
import { targetHealthCheck } from "@server/db";
const deleteTargetSchema = z.strictObject({
targetId: z.string().transform(Number).pipe(z.int().positive())
@@ -46,6 +45,11 @@ export async function deleteTarget(
const { targetId } = parsedParams.data;
const [deletedHealthCheck] = await db
.delete(targetHealthCheck)
.where(eq(targetHealthCheck.targetId, targetId))
.returning();
const [deletedTarget] = await db
.delete(targets)
.where(eq(targets.targetId, targetId))
@@ -74,38 +78,59 @@ export async function deleteTarget(
);
}
// const [site] = await db
// .select()
// .from(sites)
// .where(eq(sites.siteId, resource.siteId!))
// .limit(1);
//
// if (!site) {
// return next(
// createHttpError(
// HttpCode.NOT_FOUND,
// `Site with ID ${resource.siteId} not found`
// )
// );
// }
//
// if (site.pubKey) {
// if (site.type == "wireguard") {
// await addPeer(site.exitNodeId!, {
// publicKey: site.pubKey,
// allowedIps: await getAllowedIps(site.siteId)
// });
// } else if (site.type == "newt") {
// // get the newt on the site by querying the newt table for siteId
// const [newt] = await db
// .select()
// .from(newts)
// .where(eq(newts.siteId, site.siteId))
// .limit(1);
//
// removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort);
// }
// }
// check if there are other targets on the resource
const otherTargets = await db
.select()
.from(targets)
.where(
and(
eq(targets.resourceId, resource.resourceId),
ne(targets.targetId, targetId)
)
);
if (otherTargets.length == 0) {
// set the resource status
await db
.update(resources)
.set({ health: "unknown" })
.where(eq(resources.resourceId, resource.resourceId));
}
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, deletedTarget.siteId))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${targets.siteId} not found`
)
);
}
if (site.pubKey) {
if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
await removeTargets(
newt.newtId,
// [deletedTarget],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
[deletedHealthCheck],
resource.protocol,
newt.version
);
}
}
return response(res, {
data: null,
+5 -6
View File
@@ -15,8 +15,8 @@ const getTargetSchema = z.strictObject({
});
type GetTargetResponse = Target &
Omit<TargetHealthCheck, "hcHeaders"> & {
hcHeaders: { name: string; value: string }[] | null;
Partial<Omit<TargetHealthCheck, "hcHeaders" | "targetId">> & {
hcHeaders: { name: string; value: string }[] | null | undefined;
};
registry.registerPath({
@@ -70,20 +70,19 @@ export async function getTarget(
.limit(1);
// Parse hcHeaders from JSON string back to array
let parsedHcHeaders = null;
let parsedHcHeaders: { name: string; value: string }[] | null = null;
if (targetHc?.hcHeaders) {
try {
parsedHcHeaders = JSON.parse(targetHc.hcHeaders);
} catch (error) {
// If parsing fails, keep as string for backward compatibility
parsedHcHeaders = targetHc.hcHeaders;
// If parsing fails, keep as null for safety
}
}
return response<GetTargetResponse>(res, {
data: {
...target[0],
...targetHc,
...target[0],
hcHeaders: parsedHcHeaders
},
success: true,
@@ -1,9 +1,12 @@
import { db, targets, resources, sites, targetHealthCheck } from "@server/db";
import { db, primaryDb, targetHealthCheck } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { Newt } from "@server/db";
import { eq, and } from "drizzle-orm";
import { eq, and, ne } from "drizzle-orm";
import logger from "@server/logger";
import { unknown } from "zod";
import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert
} from "@server/lib/alerts";
interface TargetHealthStatus {
status: string;
@@ -11,7 +14,7 @@ interface TargetHealthStatus {
checkCount: number;
lastError?: string;
config: {
id: string;
id: string; // this could be the hc id or the target id, depending on the version of newt
hcEnabled: boolean;
hcPath?: string;
hcScheme?: string;
@@ -22,7 +25,11 @@ interface TargetHealthStatus {
hcUnhealthyInterval?: number;
hcTimeout?: number;
hcHeaders?: any;
hcFollowRedirects?: boolean;
hcMethod?: string;
hcTlsServerName?: string;
hcHealthyThreshold?: number;
hcUnhealthyThreshold?: number;
};
}
@@ -74,54 +81,87 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
continue;
}
const [targetCheck] = await db
const [targetCheck] = await primaryDb // using the primary db here in case it has just been updated and we are getting the immediate status back and it has not made it out to the repliacs yet
.select({
targetId: targets.targetId,
siteId: targets.siteId,
hcStatus: targetHealthCheck.hcHealth
targetId: targetHealthCheck.targetId,
orgId: targetHealthCheck.orgId,
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
name: targetHealthCheck.name,
hcHealth: targetHealthCheck.hcHealth,
hcEnabled: targetHealthCheck.hcEnabled
})
.from(targets)
.innerJoin(
resources,
eq(targets.resourceId, resources.resourceId)
)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.innerJoin(targetHealthCheck, eq(targets.targetId, targetHealthCheck.targetId))
.from(targetHealthCheck)
.where(
and(
eq(targets.targetId, targetIdNum),
eq(sites.siteId, newt.siteId)
eq(targetHealthCheck.targetHealthCheckId, targetIdNum),
eq(targetHealthCheck.siteId, newt.siteId)
)
)
.limit(1);
if (!targetCheck) {
logger.warn(
logger.debug(
`Target ${targetId} not found or does not belong to site ${newt.siteId}`
);
errorCount++;
continue;
}
if (!targetCheck.hcEnabled) {
logger.debug(
`Health check for target ${targetId} is not enabled, skipping update`
);
continue;
}
// check if the status has changed
if (targetCheck.hcStatus === healthStatus.status) {
if (targetCheck.hcHealth === healthStatus.status) {
logger.debug(
`Health status for target ${targetId} is already ${healthStatus.status}, skipping update`
);
continue;
}
// Update the target's health status in the database
await db
.update(targetHealthCheck)
.set({
hcHealth: healthStatus.status as
| "unknown"
| "healthy"
| "unhealthy"
})
.where(eq(targetHealthCheck.targetId, targetIdNum))
.execute();
// Update the target's health status in the database and fire alert in a transaction
await db.transaction(async (trx) => {
await trx
.update(targetHealthCheck)
.set({
hcHealth: healthStatus.status as
| "unknown"
| "healthy"
| "unhealthy"
})
.where(
eq(
targetHealthCheck.targetHealthCheckId,
targetCheck.targetHealthCheckId
)
);
// because we are checking above if there was a change we can fire the alert here because it changed
if (healthStatus.status === "unhealthy") {
await fireHealthCheckUnhealthyAlert(
targetCheck.orgId,
targetCheck.targetHealthCheckId,
targetCheck.name ?? undefined,
targetCheck.targetId,
undefined,
true,
trx
);
} else if (healthStatus.status === "healthy") {
await fireHealthCheckHealthyAlert(
targetCheck.orgId,
targetCheck.targetHealthCheckId,
targetCheck.name ?? undefined,
targetCheck.targetId,
undefined,
true,
trx
);
}
});
logger.debug(
`Updated health status for target ${targetId} to ${healthStatus.status}`
+150 -80
View File
@@ -10,10 +10,14 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets";
import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnknownAlert,
fireHealthCheckUnhealthyAlert
} from "@server/lib/alerts";
import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
import { vs } from "@react-email/components";
const updateTargetParamsSchema = z.strictObject({
targetId: z.string().transform(Number).pipe(z.int().positive())
@@ -32,8 +36,8 @@ const updateTargetBodySchema = z
hcMode: z.string().optional().nullable(),
hcHostname: z.string().optional().nullable(),
hcPort: z.int().positive().optional().nullable(),
hcInterval: z.int().positive().min(5).optional().nullable(),
hcUnhealthyInterval: z.int().positive().min(5).optional().nullable(),
hcInterval: z.int().positive().min(1).optional().nullable(),
hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(),
hcTimeout: z.int().positive().min(1).optional().nullable(),
hcHeaders: z
.array(z.strictObject({ name: z.string(), value: z.string() }))
@@ -43,6 +47,8 @@ const updateTargetBodySchema = z
hcMethod: z.string().min(1).optional().nullable(),
hcStatus: z.int().optional().nullable(),
hcTlsServerName: z.string().optional().nullable(),
hcHealthyThreshold: z.int().positive().min(1).optional().nullable(),
hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(),
path: z.string().optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
@@ -151,32 +157,6 @@ export async function updateTarget(
);
}
const targetData = {
...target,
...parsedBody.data
};
const existingTargets = await db
.select()
.from(targets)
.where(eq(targets.resourceId, target.resourceId));
const foundTarget = existingTargets.find(
(target) =>
target.targetId !== targetId && // Exclude the current target being updated
target.ip === targetData.ip &&
target.port === targetData.port &&
target.method === targetData.method &&
target.siteId === targetData.siteId
);
if (foundTarget) {
// log a warning
logger.warn(
`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${target.resourceId}`
);
}
const { internalPort, targetIps } = await pickPort(site.siteId!, db);
if (!internalPort) {
@@ -190,60 +170,149 @@ export async function updateTarget(
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
const [updatedTarget] = await db
.update(targets)
.set({
siteId: parsedBody.data.siteId,
ip: parsedBody.data.ip,
method: parsedBody.data.method,
port: parsedBody.data.port,
internalPort,
enabled: parsedBody.data.enabled,
path: parsedBody.data.path,
pathMatchType: parsedBody.data.pathMatchType,
priority: parsedBody.data.priority,
rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath,
rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType
})
.where(eq(targets.targetId, targetId))
.returning();
let updatedTarget: any;
let updatedHc: any;
await db.transaction(async (trx) => {
[updatedTarget] = await trx
.update(targets)
.set({
siteId: parsedBody.data.siteId,
ip: parsedBody.data.ip,
method: parsedBody.data.method,
port: parsedBody.data.port,
internalPort,
enabled: parsedBody.data.enabled,
path: parsedBody.data.path,
pathMatchType: parsedBody.data.pathMatchType,
priority: parsedBody.data.priority,
rewritePath: pathMatchTypeRemoved
? null
: parsedBody.data.rewritePath,
rewritePathType: pathMatchTypeRemoved
? null
: parsedBody.data.rewritePathType
})
.where(eq(targets.targetId, targetId))
.returning();
let hcHeaders = null;
if (parsedBody.data.hcHeaders) {
hcHeaders = JSON.stringify(parsedBody.data.hcHeaders);
}
const [existingHc] = await trx
.select()
.from(targetHealthCheck)
.where(eq(targetHealthCheck.targetId, targetId))
.limit(1);
// When health check is disabled, reset hcHealth to "unknown"
// to prevent previously unhealthy targets from being excluded
// Also when the site is not a newt, set hcHealth to "unknown"
const hcHealthValue =
parsedBody.data.hcEnabled === false ||
parsedBody.data.hcEnabled === null ||
site.type !== "newt"
? "unknown"
: undefined;
if (!existingHc) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Health check for target with ID ${targetId} not found`
)
);
}
const [updatedHc] = await db
.update(targetHealthCheck)
.set({
hcEnabled: parsedBody.data.hcEnabled || false,
hcPath: parsedBody.data.hcPath,
hcScheme: parsedBody.data.hcScheme,
hcMode: parsedBody.data.hcMode,
hcHostname: parsedBody.data.hcHostname,
hcPort: parsedBody.data.hcPort,
hcInterval: parsedBody.data.hcInterval,
hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval,
hcTimeout: parsedBody.data.hcTimeout,
hcHeaders: hcHeaders,
hcFollowRedirects: parsedBody.data.hcFollowRedirects,
hcMethod: parsedBody.data.hcMethod,
hcStatus: parsedBody.data.hcStatus,
hcTlsServerName: parsedBody.data.hcTlsServerName,
...(hcHealthValue !== undefined && { hcHealth: hcHealthValue })
})
.where(eq(targetHealthCheck.targetId, targetId))
.returning();
let hcHeaders = null;
if (parsedBody.data.hcHeaders) {
hcHeaders = JSON.stringify(parsedBody.data.hcHeaders);
}
// When health check is disabled, reset hcHealth to "unknown"
// to prevent previously unhealthy targets from being excluded.
// Also when the site is not a newt, set hcHealth to "unknown".
// If hcEnabled is being turned on (was false, now true), set to "unhealthy"
// so the target must pass a health check before being considered healthy.
const hcEnabledTurnedOn =
parsedBody.data.hcEnabled === true &&
existingHc.hcEnabled === false;
let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined;
if (
parsedBody.data.hcEnabled === false ||
parsedBody.data.hcEnabled === null ||
site.type !== "newt"
) {
hcHealthValue = "unknown";
} else if (hcEnabledTurnedOn) {
hcHealthValue = "unhealthy";
} else {
hcHealthValue = undefined;
}
[updatedHc] = await trx
.update(targetHealthCheck)
.set({
siteId: parsedBody.data.siteId,
hcEnabled: parsedBody.data.hcEnabled || false,
hcPath: parsedBody.data.hcPath,
hcScheme: parsedBody.data.hcScheme,
hcMode: parsedBody.data.hcMode,
hcHostname: parsedBody.data.hcHostname,
hcPort: parsedBody.data.hcPort,
hcInterval: parsedBody.data.hcInterval,
hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval,
hcTimeout: parsedBody.data.hcTimeout,
hcHeaders: hcHeaders,
hcFollowRedirects: parsedBody.data.hcFollowRedirects,
hcMethod: parsedBody.data.hcMethod,
hcStatus: parsedBody.data.hcStatus,
hcTlsServerName: parsedBody.data.hcTlsServerName,
hcHealthyThreshold: parsedBody.data.hcHealthyThreshold,
hcUnhealthyThreshold: parsedBody.data.hcUnhealthyThreshold,
hcHealth: hcHealthValue
})
.where(eq(targetHealthCheck.targetId, targetId))
.returning();
if (
updatedHc.hcHealth === "unhealthy" &&
existingHc.hcHealth !== "unhealthy"
) {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unhealthy, firing alert`
);
await fireHealthCheckUnhealthyAlert(
updatedHc.orgId,
updatedHc.targetHealthCheckId,
updatedHc.name || "",
updatedHc.targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
} else if (
updatedHc.hcHealth === "unknown" &&
existingHc.hcHealth !== "unknown"
) {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unknown, firing alert`
);
// if the health is unknown, we want to fire an alert to notify users to enable health checks
await fireHealthCheckUnknownAlert(
updatedHc.orgId,
updatedHc.targetHealthCheckId,
updatedHc.name,
updatedHc.targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
} else if (
updatedHc.hcHealth === "healthy" &&
existingHc.hcHealth !== "healthy"
) {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert`
);
await fireHealthCheckHealthyAlert(
updatedHc.orgId,
updatedHc.targetHealthCheckId,
updatedHc.name,
updatedHc.targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
}
});
if (site.pubKey) {
if (site.type == "wireguard") {
@@ -268,6 +337,7 @@ export async function updateTarget(
);
}
}
return response(res, {
data: {
...updatedTarget,
+195 -31
View File
@@ -1,31 +1,98 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, idp, users } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, eq } from "drizzle-orm";
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
import logger from "@server/logger";
import { idp, users } from "@server/db";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import type { PaginatedResponse } from "@server/types/Pagination";
import { UserType } from "@server/types/UserTypes";
const listUsersSchema = z.strictObject({
limit: z
.string()
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.default("1000")
.transform(Number)
.pipe(z.int().nonnegative()),
offset: z
.string()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.min(0)
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["username", "email", "name"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["username", "email", "name"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
idp_id: z
.preprocess(
(val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (val === "internal") {
return "internal";
}
if (typeof val === "string" && /^\d+$/.test(val)) {
return parseInt(val, 10);
}
return undefined;
},
z
.union([z.literal("internal"), z.number().int().positive()])
.optional()
)
.openapi({
description:
'Filter by identity provider id, or "internal" for internal users'
}),
two_factor: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(undefined)
.openapi({
type: "boolean",
description:
"Filter by 2FA state matching: enabled if twoFactorEnabled or twoFactorSetupRequested"
})
});
async function queryUsers(limit: number, offset: number) {
return await db
function queryUsersBase() {
return db
.select({
id: users.userId,
email: users.email,
@@ -40,17 +107,39 @@ async function queryUsers(limit: number, offset: number) {
twoFactorSetupRequested: users.twoFactorSetupRequested
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(users.serverAdmin, false))
.limit(limit)
.offset(offset);
.leftJoin(idp, eq(users.idpId, idp.idpId));
}
export type AdminListUsersResponse = {
users: NonNullable<Awaited<ReturnType<typeof queryUsers>>>;
pagination: { total: number; limit: number; offset: number };
/** Row shape returned by `queryUsersBase()` (matches selected columns + join). */
export type AdminListUserRow = {
id: string;
email: string | null;
username: string;
name: string | null;
dateCreated: string;
serverAdmin: boolean;
type: string;
idpName: string | null;
idpId: number | null;
twoFactorEnabled: boolean;
twoFactorSetupRequested: boolean | null;
};
export type AdminListUsersResponse = PaginatedResponse<{
users: AdminListUserRow[];
}>;
registry.registerPath({
method: "get",
path: "/users",
description: "List nonserver-admin users (server admin).",
tags: [OpenAPITags.User],
request: {
query: listUsersSchema
},
responses: {}
});
export async function adminListUsers(
req: Request,
res: Response,
@@ -66,21 +155,96 @@ export async function adminListUsers(
)
);
}
const { limit, offset } = parsedQuery.data;
const {
page,
pageSize,
query,
sort_by,
order,
idp_id,
two_factor: twoFactorFilter
} = parsedQuery.data;
const allUsers = await queryUsers(limit, offset);
if (typeof idp_id === "number") {
const idpOk = await db
.select({ one: sql`1` })
.from(idp)
.where(eq(idp.idpId, idp_id))
.limit(1);
if (idpOk.length === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"idp_id does not exist"
)
);
}
}
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(users);
const conditions = [eq(users.serverAdmin, false)];
if (query) {
const q = "%" + query.toLowerCase() + "%";
conditions.push(
or(
like(sql`LOWER(${users.username})`, q),
like(sql`LOWER(${users.email})`, q),
like(sql`LOWER(${users.name})`, q)
)!
);
}
if (idp_id === "internal") {
conditions.push(eq(users.type, UserType.Internal));
} else if (typeof idp_id === "number") {
conditions.push(eq(users.idpId, idp_id));
}
if (typeof twoFactorFilter === "boolean") {
if (twoFactorFilter) {
conditions.push(
or(
eq(users.twoFactorEnabled, true),
eq(users.twoFactorSetupRequested, true)
)!
);
} else {
conditions.push(
and(
eq(users.twoFactorEnabled, false),
eq(users.twoFactorSetupRequested, false)
)!
);
}
}
const whereClause = and(...conditions);
const countQuery = db.$count(
queryUsersBase().where(whereClause).as("filtered_admin_users")
);
const userListQuery = queryUsersBase()
.where(whereClause)
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
? order === "asc"
? asc(users[sort_by])
: desc(users[sort_by])
: asc(users.username)
);
const [total, rows] = await Promise.all([countQuery, userListQuery]);
return response<AdminListUsersResponse>(res, {
data: {
users: allUsers,
users: rows,
pagination: {
total: count,
limit,
offset
total,
page,
pageSize
}
},
success: true,
+2 -1
View File
@@ -21,7 +21,8 @@ async function queryUser(userId: string) {
serverAdmin: users.serverAdmin,
idpName: idp.name,
idpId: users.idpId,
locale: users.locale
locale: users.locale,
dateCreated: users.dateCreated
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
+246 -71
View File
@@ -1,36 +1,113 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idpOidcConfig } from "@server/db";
import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db";
import {
idp,
idpOrg,
roles,
userOrgRoles,
userOrgs,
users
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, asc, desc, eq, exists, inArray, like, or, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import type { PaginatedResponse } from "@server/types/Pagination";
import { UserType } from "@server/types/UserTypes";
const listUsersParamsSchema = z.strictObject({
orgId: z.string()
});
const listUsersSchema = z.strictObject({
limit: z
.string()
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.default("1000")
.transform(Number)
.pipe(z.int().nonnegative()),
offset: z
.string()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["username"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["username"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
idp_id: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (val === "internal") {
return "internal";
}
if (typeof val === "string" && /^\d+$/.test(val)) {
return parseInt(val, 10);
}
return undefined;
}, z.union([z.literal("internal"), z.number().int().positive()]).optional())
.openapi({
description:
'Filter by identity provider id, or "internal" for internal users'
}),
role_id: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
const raw = Array.isArray(val) ? val : [val];
const nums = raw
.map((v) =>
typeof v === "string" ? parseInt(v, 10) : Number(v)
)
.filter((n) => Number.isInteger(n) && n > 0);
const unique = [...new Set(nums)];
return unique.length ? unique : undefined;
}, z.array(z.number().int().positive()).max(50).optional())
.openapi({
description:
"Filter users who have any of these role ids in the organization (repeat query param)"
})
});
async function queryUsers(orgId: string, limit: number, offset: number) {
const rows = await db
function queryUsersBase() {
return db
.select({
id: users.userId,
email: users.email,
@@ -50,53 +127,19 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
.from(users)
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(eq(userOrgs.orgId, orgId))
.limit(limit)
.offset(offset);
const userIds = rows.map((r) => r.id);
const roleRows =
userIds.length === 0
? []
: await db
.select({
userId: userOrgRoles.userId,
roleId: userOrgRoles.roleId,
roleName: roles.name
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.orgId, orgId),
inArray(userOrgRoles.userId, userIds)
)
);
const rolesByUser = new Map<
string,
{ roleId: number; roleName: string }[]
>();
for (const r of roleRows) {
const list = rolesByUser.get(r.userId) ?? [];
list.push({ roleId: r.roleId, roleName: r.roleName ?? "" });
rolesByUser.set(r.userId, list);
}
return rows.map((row) => {
const userRoles = rolesByUser.get(row.id) ?? [];
return {
...row,
roles: userRoles
};
});
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId));
}
export type ListUsersResponse = {
users: NonNullable<Awaited<ReturnType<typeof queryUsers>>>;
pagination: { total: number; limit: number; offset: number };
};
export type ListUsersResponse = PaginatedResponse<{
users: Array<
NonNullable<Awaited<ReturnType<typeof queryUsersBase>>>[number] & {
roles: Array<{
roleId: number;
roleName: string;
}>;
}
>;
}>;
registry.registerPath({
method: "get",
@@ -125,7 +168,9 @@ export async function listUsers(
)
);
}
const { limit, offset } = parsedQuery.data;
const { page, pageSize, sort_by, order, query, idp_id, role_id } =
parsedQuery.data;
const roleIds = role_id ?? [];
const parsedParams = listUsersParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -139,24 +184,154 @@ export async function listUsers(
const { orgId } = parsedParams.data;
const usersWithRoles = await queryUsers(
orgId.toString(),
limit,
offset
if (typeof idp_id === "number") {
const idpOk = await db
.select({ one: sql`1` })
.from(idpOrg)
.where(
and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idp_id))
)
.limit(1);
if (idpOk.length === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"idp_id is not linked to this organization"
)
);
}
}
if (roleIds.length > 0) {
const validRoles = await db
.select({ roleId: roles.roleId })
.from(roles)
.where(
and(eq(roles.orgId, orgId), inArray(roles.roleId, roleIds))
);
if (validRoles.length !== roleIds.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"One or more role_id values are not valid for this organization"
)
);
}
}
const conditions = [and(eq(userOrgs.orgId, orgId))];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${users.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${users.username})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${users.email})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (idp_id === "internal") {
conditions.push(eq(users.type, UserType.Internal));
} else if (typeof idp_id === "number") {
conditions.push(eq(users.idpId, idp_id));
}
if (roleIds.length > 0) {
conditions.push(
exists(
db
.select()
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, users.userId),
eq(userOrgRoles.orgId, orgId),
inArray(userOrgRoles.roleId, roleIds)
)
)
)
);
}
const countQuery = db.$count(
queryUsersBase()
.where(and(...conditions))
.as("filtered_users")
);
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
const userListQuery = queryUsersBase()
.where(and(...conditions))
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
? order === "asc"
? asc(users[sort_by])
: desc(users[sort_by])
: asc(users.name)
);
const [total, usersWithoutRoles] = await Promise.all([
countQuery,
userListQuery
]);
const userIds = usersWithoutRoles.map((r) => r.id);
const roleRows =
userIds.length === 0
? []
: await db
.select({
userId: userOrgRoles.userId,
roleId: userOrgRoles.roleId,
roleName: roles.name
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.orgId, orgId),
inArray(userOrgRoles.userId, userIds)
)
);
const rolesByUser = new Map<
string,
{ roleId: number; roleName: string }[]
>();
for (const r of roleRows) {
const list = rolesByUser.get(r.userId) ?? [];
list.push({ roleId: r.roleId, roleName: r.roleName ?? "" });
rolesByUser.set(r.userId, list);
}
const usersWithRoles: ListUsersResponse["users"] = [];
for (const user of usersWithoutRoles) {
const userRoles = rolesByUser.get(user.id) ?? [];
usersWithRoles.push({
...user,
roles: userRoles
});
}
return response<ListUsersResponse>(res, {
data: {
users: usersWithRoles,
pagination: {
total: count,
limit,
offset
total,
page,
pageSize
}
},
success: true,
+2 -1
View File
@@ -64,7 +64,8 @@ export async function myDevice(
serverAdmin: users.serverAdmin,
idpName: idp.name,
idpId: users.idpId,
locale: users.locale
locale: users.locale,
dateCreated: users.dateCreated
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
+3 -3
View File
@@ -2,7 +2,7 @@ import { build } from "@server/build";
import {
handleNewtRegisterMessage,
handleReceiveBandwidthMessage,
handleGetConfigMessage,
handleNewtGetConfigMessage,
handleDockerStatusMessage,
handleDockerContainersMessage,
handleNewtPingRequestMessage,
@@ -37,7 +37,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
"newt/disconnecting": handleNewtDisconnectingMessage,
"newt/ping": handleNewtPingMessage,
"newt/wg/register": handleNewtRegisterMessage,
"newt/wg/get-config": handleGetConfigMessage,
"newt/wg/get-config": handleNewtGetConfigMessage,
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
"newt/socket/status": handleDockerStatusMessage,
"newt/socket/containers": handleDockerContainersMessage,
@@ -47,7 +47,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
"ws/round-trip/complete": handleRoundTripMessage
};
// Start the ping accumulator for all builds it batches per-site online/lastPing
// Start the ping accumulator for all builds - it batches per-site online/lastPing
// updates into periodic bulk writes, preventing connection pool exhaustion.
startPingAccumulator();