Merge remote-tracking branch 'upstream/dev' into feature-response-headers

# Conflicts:
#	server/db/pg/schema/schema.ts
#	server/lib/blueprints/proxyResources.ts
#	server/routers/resource/getResource.ts
#	src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx
#	src/components/HealthCheckCredenza.tsx
This commit is contained in:
Julian van der Horst
2026-07-07 11:13:15 +02:00
695 changed files with 67748 additions and 22904 deletions
@@ -22,7 +22,22 @@ registry.registerPath({
request: {
params: deleteAccessTokenParamsSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function deleteAccessToken(
@@ -27,11 +27,12 @@ import { OpenAPITags, registry } from "@server/openApi";
export const generateAccessTokenBodySchema = z.strictObject({
validForSeconds: z.int().positive().optional(), // seconds
title: z.string().optional(),
path: z.string().optional(),
description: z.string().optional()
});
export const generateAccssTokenParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive())
resourceId: z.coerce.number().int().positive()
});
export type GenerateAccessTokenResponse = Omit<
@@ -54,7 +55,22 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function generateAccessToken(
@@ -85,7 +101,7 @@ export async function generateAccessToken(
}
const { resourceId } = parsedParams.data;
const { validForSeconds, title, description } = parsedBody.data;
const { validForSeconds, title, path, description } = parsedBody.data;
const [resource] = await db
.select()
@@ -121,6 +137,7 @@ export async function generateAccessToken(
expiresAt: expiresAt || null,
sessionLength: sessionLength,
title: title || null,
path: path || null,
description: description || null,
createdAt: new Date().getTime()
})
@@ -131,6 +148,7 @@ export async function generateAccessToken(
expiresAt: resourceAccessToken.expiresAt,
sessionLength: resourceAccessToken.sessionLength,
title: resourceAccessToken.title,
path: resourceAccessToken.path,
description: resourceAccessToken.description,
createdAt: resourceAccessToken.createdAt
})
+33 -3
View File
@@ -30,7 +30,7 @@ const listAccessTokensParamsSchema = z
error: "Either resourceId or orgId must be provided, but not both"
});
const listAccessTokensSchema = z.object({
const listAccessTokensSchema = z.strictObject({
limit: z
.string()
.optional()
@@ -129,7 +129,22 @@ registry.registerPath({
}),
query: listAccessTokensSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
@@ -143,7 +158,22 @@ registry.registerPath({
}),
query: listAccessTokensSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function listAccessTokens(
+19 -1
View File
@@ -2,6 +2,7 @@ import { NextFunction, Request, Response } from "express";
import { db } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import { z } from "zod";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { apiKeyOrg, apiKeys } from "@server/db";
import { fromError } from "zod-validation-error";
import createHttpError from "http-errors";
@@ -32,6 +33,14 @@ export type CreateOrgApiKeyResponse = {
lastChars: string;
createdAt: string;
};
const CreateOrgApiKeyResponseDataSchema = z.object({
apiKeyId: z.string(),
name: z.string(),
apiKey: z.string(),
lastChars: z.string(),
createdAt: z.string()
});
registry.registerPath({
method: "put",
@@ -48,7 +57,16 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(CreateOrgApiKeyResponseDataSchema)
}
}
}
}
});
export async function createOrgApiKey(
+16 -1
View File
@@ -22,7 +22,22 @@ registry.registerPath({
request: {
params: paramsSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function deleteApiKey(
+27 -2
View File
@@ -9,12 +9,13 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
const paramsSchema = z.object({
apiKeyId: z.string().nonempty()
});
const querySchema = z.object({
const querySchema = z.strictObject({
limit: z
.string()
.optional()
@@ -44,6 +45,19 @@ export type ListApiKeyActionsResponse = {
pagination: { total: number; limit: number; offset: number };
};
const ListApiKeyActionsResponseDataSchema = z.object({
actions: z.array(
z.object({
actionId: z.string()
})
),
pagination: z.object({
total: z.number(),
limit: z.number(),
offset: z.number()
})
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/api-key/{apiKeyId}/actions",
@@ -53,7 +67,18 @@ registry.registerPath({
params: paramsSchema,
query: querySchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
ListApiKeyActionsResponseDataSchema
)
}
}
}
}
});
export async function listApiKeyActions(
+31 -2
View File
@@ -9,8 +9,9 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
const querySchema = z.object({
const querySchema = z.strictObject({
limit: z
.string()
.optional()
@@ -48,6 +49,23 @@ export type ListOrgApiKeysResponse = {
pagination: { total: number; limit: number; offset: number };
};
const ListOrgApiKeysResponseDataSchema = z.object({
apiKeys: z.array(
z.object({
apiKeyId: z.string(),
orgId: z.string(),
lastChars: z.string(),
createdAt: z.string(),
name: z.string()
})
),
pagination: z.object({
total: z.number(),
limit: z.number(),
offset: z.number()
})
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/api-keys",
@@ -57,7 +75,18 @@ registry.registerPath({
params: paramsSchema,
query: querySchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
ListOrgApiKeysResponseDataSchema
)
}
}
}
}
});
export async function listOrgApiKeys(
+1 -1
View File
@@ -9,7 +9,7 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm";
const querySchema = z.object({
const querySchema = z.strictObject({
limit: z
.string()
.optional()
+16 -1
View File
@@ -36,7 +36,22 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function setApiKeyActions(
@@ -5,6 +5,7 @@ import { OpenAPITags } from "@server/openApi";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import { z } from "zod";
import logger from "@server/logger";
import {
queryAccessAuditLogsQuery,
@@ -28,7 +29,22 @@ registry.registerPath({
}),
params: queryRequestAuditLogsParams
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function exportRequestAuditLogs(
@@ -1,4 +1,4 @@
import { logsDb, requestAuditLog, driver, primaryLogsDb } from "@server/db";
import { logsDb, requestAuditLog, driver } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
@@ -74,12 +74,12 @@ async function query(query: Q) {
);
}
const [all] = await primaryLogsDb
const [all] = await logsDb
.select({ total: count() })
.from(requestAuditLog)
.where(baseConditions);
const [blocked] = await primaryLogsDb
const [blocked] = await logsDb
.select({ total: count() })
.from(requestAuditLog)
.where(and(baseConditions, eq(requestAuditLog.action, false)));
@@ -90,7 +90,7 @@ async function query(query: Q) {
const DISTINCT_LIMIT = 500;
const requestsPerCountry = await primaryLogsDb
const requestsPerCountry = await logsDb
.selectDistinct({
code: requestAuditLog.location,
count: totalQ
@@ -118,7 +118,7 @@ async function query(query: Q) {
const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
const requestsPerDay = await primaryLogsDb
const requestsPerDay = await logsDb
.select({
day: groupByDayFunction.as("day"),
allowedCount:
@@ -156,7 +156,22 @@ registry.registerPath({
query: queryAccessAuditLogsQuery,
params: queryRequestAuditLogsParams
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export type QueryRequestAnalyticsResponse = Awaited<ReturnType<typeof query>>;
@@ -1,4 +1,11 @@
import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
import {
logsDb,
requestAuditLog,
resources,
siteResources,
db,
primaryDb
} from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
@@ -13,7 +20,7 @@ import response from "@server/lib/response";
import logger from "@server/logger";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
export const queryAccessAuditLogsQuery = z.object({
export const queryAccessAuditLogsQuery = z.strictObject({
// iso string just validate its a parseable date
timeStart: z
.string()
@@ -86,6 +93,20 @@ export const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge(
);
type Q = z.infer<typeof queryRequestAuditLogsCombined>;
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
items: T[]
): T[] {
return [...items].sort((a, b) => {
const nameA = a.name ?? "";
const nameB = b.name ?? "";
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return a.id - b.id;
});
}
function getWhere(data: Q) {
return and(
gt(requestAuditLog.timestamp, data.timeStart),
@@ -110,19 +131,19 @@ function getWhere(data: Q) {
}
export function queryRequest(data: Q) {
return primaryLogsDb
return logsDb
.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,
siteResourceId: requestAuditLog.siteResourceId,
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,
@@ -140,21 +161,30 @@ export function queryRequest(data: Q) {
.orderBy(desc(requestAuditLog.timestamp));
}
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
async function enrichWithResourceDetails(
logs: Awaited<ReturnType<typeof queryRequest>>
) {
const resourceIds = logs
.map(log => log.resourceId)
.map((log) => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined);
const siteResourceIds = logs
.filter(log => log.resourceId == null && log.siteResourceId != null)
.map(log => log.siteResourceId)
.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 }));
return logs.map((log) => ({
...log,
resourceName: null,
resourceNiceId: null
}));
}
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>();
const resourceMap = new Map<
number,
{ name: string | null; niceId: string | null }
>();
if (resourceIds.length > 0) {
const resourceDetails = await primaryDb
@@ -171,7 +201,10 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
}
}
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>();
const siteResourceMap = new Map<
number,
{ name: string | null; niceId: string | null }
>();
if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb
@@ -184,12 +217,15 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
.where(inArray(siteResources.siteResourceId, siteResourceIds));
for (const r of siteResourceDetails) {
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId });
siteResourceMap.set(r.siteResourceId, {
name: r.name,
niceId: r.niceId
});
}
}
// Enrich logs with resource details
return logs.map(log => {
return logs.map((log) => {
if (log.resourceId != null) {
const details = resourceMap.get(log.resourceId);
return {
@@ -211,7 +247,7 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
}
export function countRequestQuery(data: Q) {
const countQuery = primaryLogsDb
const countQuery = logsDb
.select({ count: count() })
.from(requestAuditLog)
.where(getWhere(data));
@@ -227,7 +263,22 @@ registry.registerPath({
query: queryAccessAuditLogsQuery,
params: queryRequestAuditLogsParams
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
async function queryUniqueFilterAttributes(
@@ -254,34 +305,34 @@ async function queryUniqueFilterAttributes(
uniqueResources,
uniqueSiteResources
] = await Promise.all([
primaryLogsDb
logsDb
.selectDistinct({ actor: requestAuditLog.actor })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
logsDb
.selectDistinct({ locations: requestAuditLog.location })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
logsDb
.selectDistinct({ hosts: requestAuditLog.host })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
logsDb
.selectDistinct({ paths: requestAuditLog.path })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
logsDb
.selectDistinct({
id: requestAuditLog.resourceId
})
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
logsDb
.selectDistinct({
id: requestAuditLog.siteResourceId
})
@@ -304,11 +355,11 @@ async function queryUniqueFilterAttributes(
// Fetch resource names from main database for the unique resource IDs
const resourceIds = uniqueResources
.map(row => row.id)
.map((row) => row.id)
.filter((id): id is number => id !== null);
const siteResourceIds = uniqueSiteResources
.map(row => row.id)
.map((row) => row.id)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
@@ -324,7 +375,7 @@ async function queryUniqueFilterAttributes(
resourcesWithNames = [
...resourcesWithNames,
...resourceDetails.map(r => ({
...resourceDetails.map((r) => ({
id: r.resourceId,
name: r.name
}))
@@ -342,7 +393,7 @@ async function queryUniqueFilterAttributes(
resourcesWithNames = [
...resourcesWithNames,
...siteResourceDetails.map(r => ({
...siteResourceDetails.map((r) => ({
id: r.siteResourceId,
name: r.name
}))
@@ -353,7 +404,7 @@ async function queryUniqueFilterAttributes(
actors: uniqueActors
.map((row) => row.actor)
.filter((actor): actor is string => actor !== null),
resources: resourcesWithNames,
resources: sortNamedFilterOptions(resourcesWithNames),
locations: uniqueLocations
.map((row) => row.locations)
.filter((location): location is string => location !== null),
+1
View File
@@ -68,6 +68,7 @@ export type QueryAccessAuditLogResponse = {
actorType: string | null;
actorId: string | null;
resourceId: number | null;
siteResourceId: number | null;
resourceName: string | null;
resourceNiceId: string | null;
ip: string | null;
+2 -45
View File
@@ -10,9 +10,8 @@ import { hashPassword, verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp";
import logger from "@server/logger";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { invalidateAllSessions } from "@server/auth/sessions/app";
import { sessions, resourceSessions } from "@server/db";
import { and, eq, ne, inArray } from "drizzle-orm";
import { invalidateAllSessionsExceptCurrent } from "@server/auth/sessions/app";
import { eq } from "drizzle-orm";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
import { sendEmail } from "@server/emails";
@@ -31,48 +30,6 @@ export type ChangePasswordResponse = {
codeRequested?: boolean;
};
async function invalidateAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
// Get all user sessions except the current one
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
// Delete resource sessions for the sessions we're invalidating
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
// Delete the user sessions (except current)
await trx
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export async function changePassword(
req: Request,
res: Response,
+1 -1
View File
@@ -9,7 +9,7 @@ import logger from "@server/logger";
export const params = z.strictObject({
token: z.string(),
resourceId: z.string().transform(Number).pipe(z.int().positive())
resourceId: z.coerce.number().int().positive()
});
export type CheckResourceSessionParams = z.infer<typeof params>;
+3 -3
View File
@@ -20,7 +20,7 @@ import { getOrgTierData } from "#dynamic/lib/billing";
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";
import { LimitId } from "@server/lib/billing";
const deleteMyAccountBody = z.strictObject({
password: z.string().optional(),
@@ -220,11 +220,11 @@ export async function deleteMyAccount(
await trx.delete(users).where(eq(users.userId, userId));
// loop through the other orgs and decrement the count
for (const userOrg of otherOrgsTheUserWasIn) {
await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx);
await usageService.add(userOrg.orgId, LimitId.USERS, -1, trx);
}
});
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
calculateUserClientsForOrgs(userId).catch((e) => {
logger.error(
`Failed to calculate user clients after deleting account for user ${userId}: ${e}`
);
+56 -40
View File
@@ -1,14 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import {
users,
userOrgs,
orgs,
idpOrg,
idp,
idpOidcConfig
} from "@server/db";
import { users, userOrgs, orgs, idpOrg, idp, idpOidcConfig } from "@server/db";
import { eq, or, sql, and, isNotNull, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -51,7 +44,22 @@ export type LookupUserResponse = {
// request: {
// body: lookupBodySchema
// },
// responses: {}
// responses: {
// 200: {
// description: "Successful response",
// content: {
// "application/json": {
// schema: z.object({
// data: z.record(z.string(), z.any()).nullable(),
// success: z.boolean(),
// error: z.boolean(),
// message: z.string(),
// status: z.number()
// })
// }
// }
// }
// }
// });
export async function lookupUser(
@@ -154,46 +162,54 @@ export async function lookupUser(
);
// Deduplicate orgs (user might have multiple memberships in same org)
const uniqueOrgs = new Map<string, typeof userOrgMemberships[0]>();
const uniqueOrgs = new Map<
string,
(typeof userOrgMemberships)[0]
>();
for (const membership of userOrgMemberships) {
if (!uniqueOrgs.has(membership.orgId)) {
uniqueOrgs.set(membership.orgId, membership);
}
}
const orgsData = Array.from(uniqueOrgs.values()).map((membership) => {
// Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP
// Only show IdPs where the user's idpId matches
// Internal users don't have an idpId, so they won't see any IdPs
const orgIdpsList = orgIdps
.filter((idp) => {
if (idp.orgId !== membership.orgId) {
const orgsData = Array.from(uniqueOrgs.values()).map(
(membership) => {
// Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP
// Only show IdPs where the user's idpId matches
// Internal users don't have an idpId, so they won't see any IdPs
const orgIdpsList = orgIdps
.filter((idp) => {
if (idp.orgId !== membership.orgId) {
return false;
}
// Only show IdPs where the user (with exact identifier) is authenticated via that IdP
// This means user.idpId must match idp.idpId
if (
user.idpId !== null &&
user.idpId === idp.idpId
) {
return true;
}
return false;
}
// Only show IdPs where the user (with exact identifier) is authenticated via that IdP
// This means user.idpId must match idp.idpId
if (user.idpId !== null && user.idpId === idp.idpId) {
return true;
}
return false;
})
.map((idp) => ({
idpId: idp.idpId,
name: idp.idpName,
variant: idp.variant
}));
})
.map((idp) => ({
idpId: idp.idpId,
name: idp.idpName,
variant: idp.variant
}));
// Check if user has internal auth for this org
// User has internal auth if they have an internal account type
const orgHasInternalAuth = hasInternalAuth;
// Check if user has internal auth for this org
// User has internal auth if they have an internal account type
const orgHasInternalAuth = hasInternalAuth;
return {
orgId: membership.orgId,
orgName: membership.orgName,
idps: orgIdpsList,
hasInternalAuth: orgHasInternalAuth
};
});
return {
orgId: membership.orgId,
orgName: membership.orgName,
idps: orgIdpsList,
hasInternalAuth: orgHasInternalAuth
};
}
);
accounts.push({
userId: user.userId,
+7 -1
View File
@@ -25,6 +25,7 @@ import { UserType } from "@server/types/UserTypes";
import { verifyPassword } from "@server/auth/password";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { verifyTotpCode } from "@server/auth/totp";
import { getFirstString } from "@server/lib/requestParams";
// The RP ID is the domain name of your application
const rpID = (() => {
@@ -406,7 +407,12 @@ export async function deleteSecurityKey(
res: Response,
next: NextFunction
): Promise<any> {
const { credentialId: encodedCredentialId } = req.params;
const encodedCredentialId = getFirstString(req.params.credentialId);
if (!encodedCredentialId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid credential ID")
);
}
const credentialId = decodeURIComponent(encodedCredentialId);
const user = req.user as User;
+13
View File
@@ -15,6 +15,10 @@ import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNot
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
import { generateBackupCodes } from "@server/lib/totp";
import {
invalidateAllSessions,
invalidateAllSessionsExceptCurrent
} from "@server/auth/sessions/app";
import { verifySession } from "@server/auth/sessions/verifySession";
import { unauthorized } from "@server/auth/unauthorizedResponse";
@@ -168,6 +172,15 @@ export async function verifyTotp(
);
}
if (existingSession) {
await invalidateAllSessionsExceptCurrent(
user.userId,
existingSession.sessionId
);
} else {
await invalidateAllSessions(user.userId);
}
sendEmail(
TwoFactorAuthNotification({
email: user.email!,
+3
View File
@@ -188,6 +188,9 @@ export async function exchangeSession(
userSessionId: requestSession.userSessionId,
whitelistId: requestSession.whitelistId,
accessTokenId: requestSession.accessTokenId,
policyPasswordId: requestSession.policyPasswordId,
policyPincodeId: requestSession.policyPincodeId,
policyWhitelistId: requestSession.policyWhitelistId,
doNotExtend: false,
expiresAt: expires,
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
+117 -70
View File
@@ -1,5 +1,6 @@
import { assertEquals } from "@test/assert";
import { REGIONS } from "@server/db/regions";
import { isPathAllowed } from "@server/lib/pathMatch";
function isIpInRegion(
ipCountryCode: string | undefined,
@@ -33,76 +34,6 @@ function isIpInRegion(
return false;
}
function isPathAllowed(pattern: string, path: string): boolean {
// Normalize and split paths into segments
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
// Recursive function to try different wildcard matches
function matchSegments(patternIndex: number, pathIndex: number): boolean {
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
const currentPatternPart = patternParts[patternIndex];
const currentPathPart = pathParts[pathIndex];
// If we've consumed all pattern parts, we should have consumed all path parts
if (patternIndex >= patternParts.length) {
const result = pathIndex >= pathParts.length;
return result;
}
// If we've consumed all path parts but still have pattern parts
if (pathIndex >= pathParts.length) {
// The only way this can match is if all remaining pattern parts are wildcards
const remainingPattern = patternParts.slice(patternIndex);
const result = remainingPattern.every((p) => p === "*");
return result;
}
// For full segment wildcards, try consuming different numbers of path segments
if (currentPatternPart === "*") {
// Try consuming 0 segments (skip the wildcard)
if (matchSegments(patternIndex + 1, pathIndex)) {
return true;
}
// Try consuming current segment and recursively try rest
if (matchSegments(patternIndex, pathIndex + 1)) {
return true;
}
return false;
}
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
if (currentPatternPart.includes("*")) {
// Convert the pattern segment to a regex pattern
const regexPattern = currentPatternPart
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(currentPathPart)) {
return matchSegments(patternIndex + 1, pathIndex + 1);
}
return false;
}
// For regular segments, they must match exactly
if (currentPatternPart !== currentPathPart) {
return false;
}
// Move to next segments in both pattern and path
return matchSegments(patternIndex + 1, pathIndex + 1);
}
const result = matchSegments(0, 0);
return result;
}
function runTests() {
console.log("Running path matching tests...");
@@ -308,6 +239,121 @@ function runTests() {
console.log("All path matching tests passed!");
}
function runSpecialCharacterTests() {
console.log("\nRunning special character tests...");
let threw = false;
try {
isPathAllowed("(api*", "anything");
isPathAllowed("a(b*", "a(bc");
isPathAllowed("c[d*", "c[de");
isPathAllowed("x{2}*", "x{2}y");
isPathAllowed("a|b*", "a|bc");
isPathAllowed("back\\slash*", "back\\slashed");
} catch (e) {
threw = true;
console.error(
"Patterns accepted by isValidUrlGlobPattern crashed the matcher:",
e instanceof Error ? e.message : e
);
}
assertEquals(
threw,
false,
"Patterns with regex metacharacters must not throw"
);
assertEquals(
isPathAllowed("(api*", "(api-v1"),
true,
"Parenthesis should be treated as a literal character"
);
assertEquals(
isPathAllowed("(api*", "xapi-v1"),
false,
"Parenthesis should not match other characters"
);
assertEquals(
isPathAllowed("a(b)*", "a(b)c"),
true,
"Parentheses pair should be treated as literal characters"
);
assertEquals(
isPathAllowed("*.png", "image.png"),
true,
"Dot should match a literal dot"
);
assertEquals(
isPathAllowed("*.png", "imageXpng"),
false,
"Dot should not act as a regex wildcard"
);
assertEquals(
isPathAllowed("v1.0*", "v1.0.1"),
true,
"Version-like literal should match itself"
);
assertEquals(
isPathAllowed("v1.0*", "v1x0-beta"),
false,
"Version-like literal should not match arbitrary characters"
);
assertEquals(
isPathAllowed("a+b*", "a+bc"),
true,
"Plus should be treated as a literal character"
);
assertEquals(
isPathAllowed("a+b*", "aaabc"),
false,
"Plus should not act as a regex quantifier"
);
assertEquals(
isPathAllowed("$ref*", "$refs"),
true,
"Dollar sign should be treated as a literal character"
);
assertEquals(
isPathAllowed("price$*", "price$100"),
true,
"Dollar sign mid-pattern should be treated as a literal character"
);
assertEquals(
isPathAllowed("^start*", "^started"),
true,
"Caret should be treated as a literal character"
);
assertEquals(
isPathAllowed("a|b*", "a|bc"),
true,
"Pipe should be treated as a literal character"
);
assertEquals(
isPathAllowed("a|b*", "a"),
false,
"Pipe should not act as regex alternation"
);
assertEquals(
isPathAllowed("file?*", "fileX"),
true,
"Question mark should still act as a single-character wildcard"
);
assertEquals(
isPathAllowed("api/*", "api/" + "x/".repeat(50)),
true,
"Deeply nested paths should still match"
);
console.log("All special character tests passed!");
}
function runRegionTests() {
console.log("\nRunning isIpInRegion tests...");
@@ -367,6 +413,7 @@ function runRegionTests() {
// Run all tests
try {
runTests();
runSpecialCharacterTests();
runRegionTests();
console.log("\n✅ All tests passed!");
} catch (error) {
+94 -189
View File
@@ -17,10 +17,15 @@ import {
ResourceHeaderAuthExtendedCompatibility,
ResourcePassword,
ResourcePincode,
ResourceRule
ResourcePolicyPincode,
ResourcePolicyPassword,
ResourcePolicyHeaderAuth,
ResourceRule,
ResourceSession
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
import { isPathAllowed } from "@server/lib/pathMatch";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -61,6 +66,8 @@ export type VerifyResourceSessionSchema = z.infer<
>;
type BasicUserData = {
dontStripSession?: boolean;
userId: string;
username: string;
email: string | null;
name: string | null;
@@ -73,6 +80,7 @@ export type VerifyUserResponse = {
redirectUrl?: string;
userData?: BasicUserData;
pangolinVersion?: string;
dontStripSession?: boolean;
};
export async function verifyResourceSession(
@@ -131,10 +139,16 @@ export async function verifyResourceSession(
let resourceData:
| {
resource: Resource | null;
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
pincode: ResourcePincode | ResourcePolicyPincode | null;
password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth:
| ResourceHeaderAuth
| ResourcePolicyHeaderAuth
| null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean | null;
sso: boolean | null;
emailWhitelistEnabled: boolean | null;
org: Org;
}
| undefined = localCache.get(resourceCacheKey);
@@ -166,9 +180,12 @@ export async function verifyResourceSession(
const {
resource,
applyRules,
sso,
pincode,
password,
headerAuth,
emailWhitelistEnabled,
headerAuthExtendedCompatibility
} = resourceData;
@@ -190,7 +207,8 @@ export async function verifyResourceSession(
return notAllowed(res);
}
const { sso, blockAccess } = resource;
const { blockAccess, mode } = resource;
const dontStripSession = ["ssh", "rdp", "vnc"].includes(mode);
if (blockAccess) {
logger.debug("Resource blocked", host);
@@ -210,7 +228,7 @@ export async function verifyResourceSession(
}
// check the rules
if (resource.applyRules) {
if (applyRules) {
const action = await checkRules(
resource.resourceId,
clientIp,
@@ -233,7 +251,7 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res);
return allowed(res, undefined, dontStripSession);
} else if (action == "DROP") {
logger.debug("Resource denied by rule");
@@ -265,7 +283,7 @@ export async function verifyResourceSession(
!sso &&
!pincode &&
!password &&
!resource.emailWhitelistEnabled &&
!emailWhitelistEnabled &&
!headerAuth
) {
logger.debug("Resource allowed because no auth");
@@ -281,7 +299,7 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res);
return allowed(res, undefined, dontStripSession);
}
const redirectPath = `/auth/resource/${encodeURIComponent(
@@ -347,7 +365,7 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res);
return allowed(res, undefined, dontStripSession);
}
}
@@ -398,7 +416,7 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res);
return allowed(res, undefined, dontStripSession);
}
}
@@ -421,7 +439,7 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res);
return allowed(res, undefined, dontStripSession);
} else if (
await verifyPassword(
clientHeaderAuth,
@@ -442,7 +460,7 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res);
return allowed(res, undefined, dontStripSession);
}
if (
@@ -450,7 +468,7 @@ export async function verifyResourceSession(
!sso &&
!pincode &&
!password &&
!resource.emailWhitelistEnabled &&
!emailWhitelistEnabled &&
!headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated
) {
logRequestAudit(
@@ -472,7 +490,7 @@ export async function verifyResourceSession(
!sso &&
!pincode &&
!password &&
!resource.emailWhitelistEnabled &&
!emailWhitelistEnabled &&
!headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated
) {
logRequestAudit(
@@ -520,7 +538,8 @@ export async function verifyResourceSession(
if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: any = localCache.get(sessionCacheKey);
let resourceSession: ResourceSession | null | undefined =
localCache.get(sessionCacheKey);
if (!resourceSession) {
const result = await validateResourceSessionToken(
@@ -573,7 +592,11 @@ export async function verifyResourceSession(
return notAllowed(res, redirectPath, resource.orgId);
}
if (pincode && resourceSession.pincodeId) {
if (
pincode &&
(resourceSession.pincodeId ||
resourceSession.policyPincodeId)
) {
logger.debug(
"Resource allowed because pincode session is valid"
);
@@ -589,10 +612,14 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res);
return allowed(res, undefined, dontStripSession);
}
if (password && resourceSession.passwordId) {
if (
password &&
(resourceSession.passwordId ||
resourceSession.policyPasswordId)
) {
logger.debug(
"Resource allowed because password session is valid"
);
@@ -608,12 +635,13 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res);
return allowed(res, undefined, dontStripSession);
}
if (
resource.emailWhitelistEnabled &&
resourceSession.whitelistId
emailWhitelistEnabled &&
(resourceSession.whitelistId ||
resourceSession.policyWhitelistId)
) {
logger.debug(
"Resource allowed because whitelist session is valid"
@@ -630,7 +658,7 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res);
return allowed(res, undefined, dontStripSession);
}
if (resourceSession.accessTokenId) {
@@ -646,14 +674,14 @@ export async function verifyResourceSession(
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: resourceSession.accessTokenTitle,
name: null,
apiKeyId: resourceSession.accessTokenId
}
},
parsedBody.data
);
return allowed(res);
return allowed(res, undefined, dontStripSession);
}
if (resourceSession.userSessionId && sso) {
@@ -671,7 +699,8 @@ export async function verifyResourceSession(
resourceData.org
);
localCache.set(userAccessCacheKey, allowedUserData, 5);
// this is query intensive so let it cache a little longer
localCache.set(userAccessCacheKey, allowedUserData, 12);
}
if (
@@ -691,13 +720,17 @@ export async function verifyResourceSession(
location: ipCC,
user: {
username: allowedUserData.username,
userId: resourceSession.userId
userId: allowedUserData.userId
}
},
parsedBody.data
);
return allowed(res, allowedUserData);
return allowed(
res,
{ ...allowedUserData, dontStripSession },
dontStripSession
);
}
}
}
@@ -830,21 +863,29 @@ async function notAllowed(
message: "Access denied",
status: HttpCode.OK
};
logger.debug(JSON.stringify(data));
// logger.debug(JSON.stringify(data));
return response<VerifyUserResponse>(res, data);
}
function allowed(res: Response, userData?: BasicUserData) {
function allowed(
res: Response,
userData?: BasicUserData,
dontStripSession?: boolean
) {
const baseData =
userData !== undefined && userData !== null
? { valid: true, ...userData, pangolinVersion: APP_VERSION }
: { valid: true, pangolinVersion: APP_VERSION };
const data = {
data:
userData !== undefined && userData !== null
? { valid: true, ...userData, pangolinVersion: APP_VERSION }
: { valid: true, pangolinVersion: APP_VERSION },
data: dontStripSession
? { ...baseData, dontStripSession: true }
: baseData,
success: true,
error: false,
message: "Access allowed",
status: HttpCode.OK
};
logger.debug("Access allowed, response data:", data);
return response<VerifyUserResponse>(res, data);
}
@@ -892,7 +933,7 @@ async function headerAuthChallenged(
message: "Access denied",
status: HttpCode.OK
};
logger.debug(JSON.stringify(data));
// logger.debug(JSON.stringify(data));
return response<VerifyUserResponse>(res, data);
}
@@ -944,6 +985,7 @@ async function isUserAllowedToAccessResource(
);
if (roleResourceAccess && roleResourceAccess.length > 0) {
return {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
@@ -958,6 +1000,7 @@ async function isUserAllowedToAccessResource(
if (userResourceAccess) {
return {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
@@ -1003,11 +1046,7 @@ async function checkRules(
isIpInCidr(clientIp, rule.value)
) {
return rule.action as any;
} else if (
clientIp &&
rule.match == "IP" &&
clientIp == rule.value
) {
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
return rule.action as any;
} else if (
path &&
@@ -1015,10 +1054,7 @@ async function checkRules(
isPathAllowed(rule.value, path)
) {
return rule.action as any;
} else if (
clientIp &&
rule.match == "COUNTRY"
) {
} else if (clientIp && rule.match == "COUNTRY") {
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
if (
rule.value.toUpperCase() === "ALL" &&
@@ -1030,10 +1066,7 @@ async function checkRules(
if (await isIpInGeoIP(ipCC, rule.value)) {
return rule.action as any;
}
} else if (
clientIp &&
rule.match == "ASN"
) {
} else if (clientIp && rule.match == "ASN") {
// ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
if (
(rule.value.toUpperCase() === "ALL" ||
@@ -1058,143 +1091,7 @@ async function checkRules(
return;
}
export function isPathAllowed(pattern: string, path: string): boolean {
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
// Normalize and split paths into segments
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
// Maximum recursion depth to prevent stack overflow and memory issues
const MAX_RECURSION_DEPTH = 100;
// Recursive function to try different wildcard matches
function matchSegments(
patternIndex: number,
pathIndex: number,
depth: number = 0
): boolean {
// Check recursion depth limit
if (depth > MAX_RECURSION_DEPTH) {
logger.warn(
`Path matching exceeded maximum recursion depth (${MAX_RECURSION_DEPTH}) for pattern "${pattern}" and path "${path}"`
);
return false;
}
const indent = " ".repeat(depth); // Indent based on recursion depth
const currentPatternPart = patternParts[patternIndex];
const currentPathPart = pathParts[pathIndex];
logger.debug(
`${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"}) [depth=${depth}]`
);
// If we've consumed all pattern parts, we should have consumed all path parts
if (patternIndex >= patternParts.length) {
const result = pathIndex >= pathParts.length;
logger.debug(
`${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}`
);
return result;
}
// If we've consumed all path parts but still have pattern parts
if (pathIndex >= pathParts.length) {
// The only way this can match is if all remaining pattern parts are wildcards
const remainingPattern = patternParts.slice(patternIndex);
const result = remainingPattern.every((p) => p === "*");
logger.debug(
`${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}`
);
return result;
}
// For full segment wildcards, try consuming different numbers of path segments
if (currentPatternPart === "*") {
logger.debug(
`${indent}Found wildcard at pattern index ${patternIndex}`
);
// Try consuming 0 segments (skip the wildcard)
logger.debug(
`${indent}Trying to skip wildcard (consume 0 segments)`
);
if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) {
logger.debug(
`${indent}Successfully matched by skipping wildcard`
);
return true;
}
// Try consuming current segment and recursively try rest
logger.debug(
`${indent}Trying to consume segment "${currentPathPart}" for wildcard`
);
if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) {
logger.debug(
`${indent}Successfully matched by consuming segment for wildcard`
);
return true;
}
logger.debug(`${indent}Failed to match wildcard`);
return false;
}
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
if (currentPatternPart.includes("*")) {
logger.debug(
`${indent}Found in-segment wildcard in "${currentPatternPart}"`
);
// Convert the pattern segment to a regex pattern
const regexPattern = currentPatternPart
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(currentPathPart)) {
logger.debug(
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
);
return matchSegments(
patternIndex + 1,
pathIndex + 1,
depth + 1
);
}
logger.debug(
`${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"`
);
return false;
}
// For regular segments, they must match exactly
if (currentPatternPart !== currentPathPart) {
logger.debug(
`${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
);
return false;
}
logger.debug(
`${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"`
);
// Move to next segments in both pattern and path
return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
}
const result = matchSegments(0, 0, 0);
logger.debug(`Final result: ${result}`);
return result;
}
export { isPathAllowed };
async function isIpInGeoIP(
ipCountryCode: string | undefined,
@@ -1272,11 +1169,15 @@ export async function isIpInRegion(
if (region.id === checkRegionCode) {
for (const subregion of region.includes) {
if (subregion.countries.includes(upperCode)) {
logger.debug(`Country ${upperCode} is in region ${region.id} (${region.name})`);
logger.debug(
`Country ${upperCode} is in region ${region.id} (${region.name})`
);
return true;
}
}
logger.debug(`Country ${upperCode} is not in region ${region.id} (${region.name})`);
logger.debug(
`Country ${upperCode} is not in region ${region.id} (${region.name})`
);
return false;
}
@@ -1284,10 +1185,14 @@ export async function isIpInRegion(
for (const subregion of region.includes) {
if (subregion.id === checkRegionCode) {
if (subregion.countries.includes(upperCode)) {
logger.debug(`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`);
logger.debug(
`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`
);
return true;
}
logger.debug(`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`);
logger.debug(
`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`
);
return false;
}
}
@@ -31,7 +31,22 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function applyJSONBlueprint(
@@ -84,7 +99,7 @@ export async function applyJSONBlueprint(
source: "API"
});
} catch (error) {
logger.error(`Failed to update database from config: ${error}`);
logger.debug(`Failed to update database from config: ${error}`);
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -54,7 +54,22 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function applyYAMLBlueprint(
+17 -3
View File
@@ -7,13 +7,12 @@ import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { BlueprintData } from "./types";
const getBlueprintSchema = z.strictObject({
blueprintId: z.string().transform(stoi).pipe(z.int().positive()),
blueprintId: z.coerce.number().int().positive(),
orgId: z.string()
});
@@ -57,7 +56,22 @@ registry.registerPath({
request: {
params: getBlueprintSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function getBlueprint(
+16 -1
View File
@@ -74,7 +74,22 @@ registry.registerPath({
}),
query: listBluePrintsSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function listBlueprints(
@@ -0,0 +1 @@
export * from "./types";
@@ -0,0 +1,11 @@
export type GetBrowserTargetResponse = {
ip: string;
port: number;
authToken: string;
orgId: string;
resourceId: number;
niceId: string;
name: string;
pamMode: "passthrough" | "push" | null;
authDaemonMode: "site" | "remote" | "native" | null;
};
+17 -2
View File
@@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const archiveClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
clientId: z.coerce.number().int().positive()
});
registry.registerPath({
@@ -22,7 +22,22 @@ registry.registerPath({
request: {
params: archiveClientSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function archiveClient(
+22 -3
View File
@@ -13,7 +13,7 @@ import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
const blockClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
clientId: z.coerce.number().int().positive()
});
registry.registerPath({
@@ -24,7 +24,22 @@ registry.registerPath({
request: {
params: blockClientSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function blockClient(
@@ -79,7 +94,11 @@ export async function blockClient(
// Send terminate signal if there's an associated OLM and it's connected
if (client.olmId && client.online) {
await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, client.olmId);
await sendTerminateClient(
client.clientId,
OlmErrorCodes.TERMINATED_BLOCKED,
client.olmId
);
}
});
+71 -10
View File
@@ -24,9 +24,14 @@ import { isIpInCidr } from "@server/lib/ip";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { generateId } from "@server/auth/sessions/app";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import { getUniqueClientName } from "@server/db/names";
import { build } from "@server/build";
import { LimitId } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
const createClientParamsSchema = z.strictObject({
orgId: z.string()
@@ -59,7 +64,22 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function createClient(
@@ -110,6 +130,38 @@ export async function createClient(
);
}
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.MACHINE_CLIENTS
);
if (!usage) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No usage data found for this organization"
)
);
}
const rejectClient = await usageService.checkLimitSet(
orgId,
LimitId.MACHINE_CLIENTS,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectClient) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Machine client limit exceeded. Please upgrade your plan."
)
);
}
}
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
if (!org) {
@@ -139,6 +191,15 @@ export async function createClient(
);
}
if (await isOrgRebuildRateLimited(orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
// make sure the subnet is unique
@@ -262,23 +323,23 @@ export async function createClient(
clientId: newClient.clientId,
dateCreated: moment().toISOString()
});
await usageService.add(orgId, LimitId.MACHINE_CLIENTS, 1, trx);
});
if (newClient) {
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client associations after creating client: ${e}`
);
}
);
rebuildClientAssociationsFromClient(newClient).catch((e) => {
logger.error(
`Failed to rebuild client associations after creating client: ${e}`
);
});
}
return response<CreateClientResponse>(res, {
data: newClient,
success: true,
error: false,
message: "Site created successfully",
message: "Client created successfully",
status: HttpCode.CREATED
});
} catch (error) {
+34 -9
View File
@@ -21,7 +21,10 @@ import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import { getUniqueClientName } from "@server/db/names";
const paramsSchema = z
@@ -60,7 +63,22 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function createUserClient(
@@ -131,6 +149,15 @@ export async function createUserClient(
);
}
if (await isOrgRebuildRateLimited(orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
// make sure the subnet is unique
@@ -240,13 +267,11 @@ export async function createUserClient(
});
if (newClient) {
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client associations after creating user client: ${e}`
);
}
);
rebuildClientAssociationsFromClient(newClient).catch((e) => {
logger.error(
`Failed to rebuild client associations after creating user client: ${e}`
);
});
}
return response<CreateClientAndOlmResponse>(res, {
+44 -10
View File
@@ -9,12 +9,17 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
import { LimitId } from "@server/lib/billing/features";
import { usageService } from "@server/lib/billing/usageService";
const deleteClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
clientId: z.coerce.number().int().positive()
});
registry.registerPath({
@@ -25,7 +30,22 @@ registry.registerPath({
request: {
params: deleteClientSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function deleteClient(
@@ -61,6 +81,15 @@ export async function deleteClient(
);
}
if (await isOrgRebuildRateLimited(client.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Only allow deletion of machine clients (clients without userId)
if (client.userId) {
return next(
@@ -91,16 +120,21 @@ export async function deleteClient(
if (!client.userId && client.olmId) {
await trx.delete(olms).where(eq(olms.olmId, client.olmId));
}
await usageService.add(
deletedClient.orgId,
LimitId.MACHINE_CLIENTS,
-1,
trx
);
});
if (deletedClient) {
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client associations after deleting client ${clientId}: ${e}`
);
}
);
rebuildClientAssociationsFromClient(deletedClient).catch((e) => {
logger.error(
`Failed to rebuild client associations after deleting client ${clientId}: ${e}`
);
});
if (olm) {
sendTerminateClient(
deletedClient.clientId,
+44 -14
View File
@@ -253,7 +253,22 @@ registry.registerPath({
niceId: z.string()
})
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
@@ -266,7 +281,22 @@ registry.registerPath({
clientId: z.number()
})
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function getClient(
@@ -310,18 +340,18 @@ export async function getClient(
// Build fingerprint data if available
const fingerprintData = client.currentFingerprint
? {
username: client.currentFingerprint.username || null,
hostname: client.currentFingerprint.hostname || null,
platform: client.currentFingerprint.platform || null,
osVersion: client.currentFingerprint.osVersion || null,
kernelVersion:
client.currentFingerprint.kernelVersion || null,
arch: client.currentFingerprint.arch || null,
deviceModel: client.currentFingerprint.deviceModel || null,
serialNumber: client.currentFingerprint.serialNumber || null,
firstSeen: client.currentFingerprint.firstSeen || null,
lastSeen: client.currentFingerprint.lastSeen || null
}
username: client.currentFingerprint.username || null,
hostname: client.currentFingerprint.hostname || null,
platform: client.currentFingerprint.platform || null,
osVersion: client.currentFingerprint.osVersion || null,
kernelVersion:
client.currentFingerprint.kernelVersion || null,
arch: client.currentFingerprint.arch || null,
deviceModel: client.currentFingerprint.deviceModel || null,
serialNumber: client.currentFingerprint.serialNumber || null,
firstSeen: client.currentFingerprint.firstSeen || null,
lastSeen: client.currentFingerprint.lastSeen || null
}
: null;
// Build posture data if available (platform-specific)
+17 -2
View File
@@ -41,7 +41,7 @@ const listClientsParamsSchema = z.strictObject({
orgId: z.string()
});
const listClientsSchema = z.object({
const listClientsSchema = z.strictObject({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
@@ -212,7 +212,22 @@ registry.registerPath({
query: listClientsSchema,
params: listClientsParamsSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function listClients(
+17 -27
View File
@@ -40,7 +40,7 @@ const listUserDevicesParamsSchema = z.strictObject({
orgId: z.string()
});
const listUserDevicesSchema = z.object({
const listUserDevicesSchema = z.strictObject({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
@@ -213,7 +213,22 @@ registry.registerPath({
query: listUserDevicesSchema,
params: listUserDevicesParamsSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function listUserDevices(
@@ -405,31 +420,6 @@ export async function listUserDevices(
}
);
// 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
// );
// }
return response<ListUserDevicesResponse>(res, {
data: {
devices: olmsWithUpdates,
+20 -2
View File
@@ -6,6 +6,7 @@ import logger from "@server/logger";
import { generateId } from "@server/auth/sessions/app";
import { getNextAvailableClientSubnet } from "@server/lib/ip";
import { z } from "zod";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
@@ -14,6 +15,12 @@ export type PickClientDefaultsResponse = {
olmSecret: string;
subnet: string;
};
const PickClientDefaultsResponseDataSchema = z.object({
olmId: z.string(),
olmSecret: z.string(),
subnet: z.string()
});
const pickClientDefaultsSchema = z.strictObject({
orgId: z.string()
@@ -27,7 +34,16 @@ registry.registerPath({
request: {
params: pickClientDefaultsSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(PickClientDefaultsResponseDataSchema)
}
}
}
}
});
export async function pickClientDefaults(
@@ -51,7 +67,9 @@ export async function pickClientDefaults(
const olmId = generateId(15);
const secret = generateId(48);
const newSubnet = await getNextAvailableClientSubnet(orgId);
const { value: newSubnet, release } =
await getNextAvailableClientSubnet(orgId);
await release(); // release immediately — this endpoint only previews the next available value
if (!newSubnet) {
return next(
createHttpError(
@@ -9,7 +9,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { rebuildClientAssociationsFromClient, isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -60,13 +60,26 @@ export async function rebuildClientAssociationsCacheRoute(
);
}
await rebuildClientAssociationsFromClient(client);
if (await isOrgRebuildRateLimited(client.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
rebuildClientAssociationsFromClient(client).catch((e) => {
logger.error(
`Failed to rebuild client associations for client ${clientId}: ${e}`
);
});
return response(res, {
data: null,
success: true,
error: false,
message: "Client association cache rebuilt successfully",
message: "Client association cache queued successfully",
status: HttpCode.OK
});
} catch (error) {
+306 -5
View File
@@ -1,4 +1,4 @@
import { sendToClient } from "#dynamic/routers/ws";
import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
import { db, newts, olms } from "@server/db";
import {
Alias,
@@ -8,12 +8,12 @@ import {
} from "@server/lib/ip";
import { canCompress } from "@server/lib/clientVersionChecks";
import logger from "@server/logger";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import semver from "semver";
const NEWT_V2_TARGETS_VERSION = ">=1.10.3";
export async function convertTargetsIfNessicary(
export async function convertTargetsIfNecessary(
newtId: string,
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]
) {
@@ -47,7 +47,7 @@ export async function addTargets(
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
version?: string | null
) {
targets = await convertTargetsIfNessicary(newtId, targets);
targets = await convertTargetsIfNecessary(newtId, targets);
await sendToClient(
newtId,
@@ -59,12 +59,48 @@ export async function addTargets(
);
}
export async function addTargetsBatch(
entries: {
newtId: string;
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolved = await Promise.all(
entries.map(async (entry) => ({
...entry,
targets: await convertTargetsIfNecessary(
entry.newtId,
entry.targets
)
}))
);
await sendToClientsBatch(
resolved.map((entry) => ({
clientId: entry.newtId,
message: {
type: `newt/wg/targets/add`,
data: entry.targets
},
options: {
incrementConfigVersion: true,
compress: canCompress(entry.version, "newt")
}
}))
);
}
export async function removeTargets(
newtId: string,
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
version?: string | null
) {
targets = await convertTargetsIfNessicary(newtId, targets);
targets = await convertTargetsIfNecessary(newtId, targets);
await sendToClient(
newtId,
@@ -76,6 +112,42 @@ export async function removeTargets(
);
}
export async function removeTargetsBatch(
entries: {
newtId: string;
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolved = await Promise.all(
entries.map(async (entry) => ({
...entry,
targets: await convertTargetsIfNecessary(
entry.newtId,
entry.targets
)
}))
);
await sendToClientsBatch(
resolved.map((entry) => ({
clientId: entry.newtId,
message: {
type: `newt/wg/targets/remove`,
data: entry.targets
},
options: {
incrementConfigVersion: true,
compress: canCompress(entry.version, "newt")
}
}))
);
}
export async function updateTargets(
newtId: string,
targets: {
@@ -201,6 +273,235 @@ export async function removePeerData(
});
}
const resolveOlmTargets = async (
entries: {
clientId: number;
olmId?: string;
version?: string | null;
}[]
) => {
const unresolvedClientIds = entries
.filter((entry) => !entry.olmId)
.map((entry) => entry.clientId);
const olmMap = new Map<number, { olmId: string; version: string | null }>();
if (unresolvedClientIds.length > 0) {
const olmRows = await db
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, unresolvedClientIds));
for (const row of olmRows) {
if (row.clientId !== null) {
olmMap.set(row.clientId, {
olmId: row.olmId,
version: row.version
});
}
}
}
return entries
.map((entry) => {
if (entry.olmId) {
return {
clientId: entry.clientId,
olmId: entry.olmId,
version: entry.version
};
}
const resolved = olmMap.get(entry.clientId);
if (!resolved) {
return null;
}
return {
clientId: entry.clientId,
olmId: resolved.olmId,
version: entry.version ?? resolved.version
};
})
.filter((entry) => entry !== null);
};
export async function addPeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: Alias[];
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/add`,
data: {
siteId: entry.siteId,
remoteSubnets: entry.remoteSubnets,
aliases: entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function removePeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: Alias[];
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/remove`,
data: {
siteId: entry.siteId,
remoteSubnets: entry.remoteSubnets,
aliases: entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function updatePeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets:
| {
oldRemoteSubnets: string[];
newRemoteSubnets: string[];
}
| undefined;
aliases:
| {
oldAliases: Alias[];
newAliases: Alias[];
}
| undefined;
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/update`,
data: {
siteId: entry.siteId,
...entry.remoteSubnets,
...entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function updatePeerData(
clientId: number,
siteId: number,
+17 -2
View File
@@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const unarchiveClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
clientId: z.coerce.number().int().positive()
});
registry.registerPath({
@@ -22,7 +22,22 @@ registry.registerPath({
request: {
params: unarchiveClientSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function unarchiveClient(
+17 -2
View File
@@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const unblockClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
clientId: z.coerce.number().int().positive()
});
registry.registerPath({
@@ -22,7 +22,22 @@ registry.registerPath({
request: {
params: unblockClientSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function unblockClient(
+17 -2
View File
@@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const updateClientParamsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
clientId: z.coerce.number().int().positive()
});
const updateClientSchema = z.strictObject({
@@ -36,7 +36,22 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function updateClient(
+4 -4
View File
@@ -17,7 +17,7 @@ import { subdomainSchema } from "@server/lib/schemas";
import { generateId } from "@server/auth/sessions/app";
import { eq, and } from "drizzle-orm";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators";
import { build } from "@server/build";
import config from "@server/lib/config";
@@ -120,7 +120,7 @@ export async function createOrgDomain(
}
if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.DOMAINS);
const usage = await usageService.getUsage(orgId, LimitId.DOMAINS);
if (!usage) {
return next(
createHttpError(
@@ -132,7 +132,7 @@ export async function createOrgDomain(
const rejectDomains = await usageService.checkLimitSet(
orgId,
FeatureId.DOMAINS,
LimitId.DOMAINS,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -346,7 +346,7 @@ export async function createOrgDomain(
await trx.insert(dnsRecords).values(recordsToInsert);
}
await usageService.add(orgId, FeatureId.DOMAINS, 1, trx);
await usageService.add(orgId, LimitId.DOMAINS, 1, trx);
});
if (!returned) {
+2 -2
View File
@@ -8,7 +8,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { and, eq } from "drizzle-orm";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
const paramsSchema = z.strictObject({
domainId: z.string(),
@@ -77,7 +77,7 @@ export async function deleteAccountDomain(
await trx.delete(domains).where(eq(domains.domainId, domainId));
await usageService.add(orgId, FeatureId.DOMAINS, -1, trx);
await usageService.add(orgId, LimitId.DOMAINS, -1, trx);
});
return response<DeleteAccountDomainResponse>(res, {
+16 -1
View File
@@ -37,7 +37,22 @@ registry.registerPath({
orgId: z.string()
})
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function getDNSRecords(
+16 -2
View File
@@ -8,7 +8,6 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { domain } from "zod/v4/core/regexes";
const getDomainSchema = z.strictObject({
domainId: z.string().optional(),
@@ -39,7 +38,22 @@ registry.registerPath({
orgId: z.string()
})
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function getDomain(
+33 -1
View File
@@ -9,6 +9,7 @@ import { eq, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
const listDomainsParamsSchema = z.strictObject({
orgId: z.string()
@@ -56,6 +57,28 @@ export type ListDomainsResponse = {
pagination: { total: number; limit: number; offset: number };
};
const ListDomainsResponseDataSchema = z.object({
domains: z.array(
z.object({
domainId: z.string(),
baseDomain: z.string(),
verified: z.boolean(),
type: z.string().nullable(),
failed: z.boolean(),
tries: z.number(),
configManaged: z.boolean(),
certResolver: z.string().nullable(),
preferWildcardCert: z.boolean().nullable(),
errorMessage: z.string().nullable()
})
),
pagination: z.object({
total: z.number(),
limit: z.number(),
offset: z.number()
})
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/domains",
@@ -67,7 +90,16 @@ registry.registerPath({
}),
query: listDomainsSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(ListDomainsResponseDataSchema)
}
}
}
}
});
export async function listDomains(
+17 -1
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { db, domains, orgDomains } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -24,6 +25,12 @@ export type UpdateDomainResponse = {
certResolver: string | null;
preferWildcardCert: boolean | null;
};
const UpdateDomainResponseDataSchema = z.object({
domainId: z.string(),
certResolver: z.string().nullable(),
preferWildcardCert: z.boolean().nullable()
});
registry.registerPath({
method: "patch",
@@ -36,7 +43,16 @@ registry.registerPath({
orgId: z.string()
})
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(UpdateDomainResponseDataSchema)
}
}
}
}
});
export async function updateOrgDomain(
+294 -93
View File
@@ -3,6 +3,7 @@ import config from "@server/lib/config";
import * as site from "./site";
import * as org from "./org";
import * as resource from "./resource";
import * as policy from "./policy";
import * as domain from "./domain";
import * as target from "./target";
import * as user from "./user";
@@ -16,6 +17,7 @@ import * as idp from "./idp";
import * as blueprints from "./blueprints";
import * as apiKeys from "./apiKeys";
import * as logs from "./auditLogs";
import * as launcher from "./launcher";
import * as newt from "./newt";
import * as olm from "./olm";
import * as serverInfo from "./serverInfo";
@@ -42,7 +44,8 @@ import {
verifyUserIsOrgOwner,
verifySiteResourceAccess,
verifyOlmAccess,
verifyLimits
verifyLimits,
verifyResourcePolicyAccess
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
@@ -103,7 +106,6 @@ authenticated.put(
site.createSite
);
authenticated.get(
"/org/:orgId/sites",
verifyOrgAccess,
@@ -253,6 +255,14 @@ authenticated.delete(
site.deleteSite
);
authenticated.post(
"/site/:siteId/restart",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.restartSite),
logActionAudit(ActionsEnum.restartSite),
site.restartSite
);
// TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite"
authenticated.get(
"/site/:siteId/docker/status",
@@ -317,6 +327,14 @@ authenticated.get(
siteResource.listAllSiteResourcesByOrg
);
authenticated.get(
"/org/:orgId/site-resource/:siteResourceId",
verifyOrgAccess,
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.getSiteResource),
siteResource.getSiteResource
);
authenticated.get(
"/site-resource/:siteResourceId",
verifySiteResourceAccess,
@@ -454,6 +472,78 @@ authenticated.get(
resource.getUserResources
);
authenticated.get(
"/org/:orgId/launcher/groups",
verifyOrgAccess,
launcher.listLauncherGroups
);
authenticated.get(
"/org/:orgId/launcher/scale",
verifyOrgAccess,
launcher.listLauncherScale
);
authenticated.get(
"/org/:orgId/launcher/resources",
verifyOrgAccess,
launcher.listLauncherResources
);
authenticated.get(
"/org/:orgId/launcher/sites",
verifyOrgAccess,
launcher.listLauncherSites
);
authenticated.get(
"/org/:orgId/launcher/labels",
verifyOrgAccess,
launcher.listLauncherLabels
);
authenticated.get(
"/org/:orgId/launcher/views",
verifyOrgAccess,
launcher.listLauncherViews
);
authenticated.post(
"/org/:orgId/launcher/invalidate-cache",
verifyOrgAccess,
launcher.invalidateLauncherCache
);
authenticated.post(
"/org/:orgId/launcher/views",
verifyOrgAccess,
launcher.createLauncherView
);
authenticated.put(
"/org/:orgId/launcher/views/:viewId",
verifyOrgAccess,
launcher.updateLauncherView
);
authenticated.put(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.upsertLauncherDefaultView
);
authenticated.delete(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.deleteLauncherDefaultView
);
authenticated.delete(
"/org/:orgId/launcher/views/:viewId",
verifyOrgAccess,
launcher.deleteLauncherView
);
authenticated.get(
"/org/:orgId/user-resource-aliases",
verifyOrgAccess,
@@ -540,6 +630,7 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getResource),
resource.getResource
);
authenticated.post(
"/resource/:resourceId",
verifyResourceAccess,
@@ -559,6 +650,7 @@ authenticated.delete(
authenticated.put(
"/resource/:resourceId/target",
verifyResourceAccess,
verifySiteAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createTarget),
logActionAudit(ActionsEnum.createTarget),
@@ -610,6 +702,7 @@ authenticated.get(
authenticated.post(
"/target/:targetId",
verifyTargetAccess,
verifySiteAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateTarget),
logActionAudit(ActionsEnum.updateTarget),
@@ -646,6 +739,36 @@ authenticated.post(
logActionAudit(ActionsEnum.updateRole),
role.updateRole
);
authenticated.get(
"/org/:orgId/resource-policy/:niceId",
verifyOrgAccess,
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
authenticated.get(
"/resource/:resourceId/policies",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.getResourcePolicy),
resource.getResourcePolicies
);
authenticated.get(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
authenticated.put(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.updateResourcePolicy),
policy.updateResourcePolicy
);
// authenticated.get(
// "/role/:roleId",
// verifyRoleAccess,
@@ -697,6 +820,59 @@ authenticated.post(
resource.setResourceUsers
);
authenticated.put(
"/resource-policy/:resourcePolicyId/access-control",
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
logActionAudit(ActionsEnum.setResourcePolicyUsers),
policy.setResourcePolicyAccessControl
);
authenticated.put(
"/resource-policy/:resourcePolicyId/password",
verifyResourcePolicyAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyPassword),
logActionAudit(ActionsEnum.setResourcePolicyPassword),
policy.setResourcePolicyPassword
);
authenticated.put(
"/resource-policy/:resourcePolicyId/pincode",
verifyResourcePolicyAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyPincode),
logActionAudit(ActionsEnum.setResourcePolicyPincode),
policy.setResourcePolicyPincode
);
authenticated.put(
"/resource-policy/:resourcePolicyId/header-auth",
verifyResourcePolicyAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
policy.setResourcePolicyHeaderAuth
);
authenticated.put(
"/resource-policy/:resourcePolicyId/whitelist",
verifyResourcePolicyAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyWhitelist),
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
policy.setResourcePolicyWhitelist
);
authenticated.put(
"/resource-policy/:resourcePolicyId/rules",
verifyResourcePolicyAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyRules),
logActionAudit(ActionsEnum.setResourcePolicyRules),
policy.setResourcePolicyRules
);
authenticated.post(
`/resource/:resourceId/password`,
verifyResourceAccess,
@@ -823,19 +999,6 @@ unauthenticated.post(
);
unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice);
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser);
authenticated.post(
"/user/:userId/generate-password-reset-code",
verifyUserIsServerAdmin,
user.adminGeneratePasswordResetCode
);
authenticated.delete(
"/user/:userId",
verifyUserIsServerAdmin,
user.adminRemoveUser
);
authenticated.put(
"/org/:orgId/user",
verifyOrgAccess,
@@ -858,12 +1021,6 @@ authenticated.post(
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess);
authenticated.post(
"/user/:userId/2fa",
verifyUserIsServerAdmin,
user.updateUser2FA
);
authenticated.get(
"/org/:orgId/users",
verifyOrgAccess,
@@ -946,85 +1103,112 @@ authenticated.post(
olm.recoverOlmWithFingerprint
);
authenticated.put(
"/idp/oidc",
verifyUserIsServerAdmin,
// verifyUserHasAction(ActionsEnum.createIdp),
idp.createOidcIdp
);
if (build !== "saas") {
authenticated.put(
"/idp/oidc",
verifyUserIsServerAdmin,
// verifyUserHasAction(ActionsEnum.createIdp),
idp.createOidcIdp
);
authenticated.post(
"/idp/:idpId/oidc",
verifyUserIsServerAdmin,
idp.updateOidcIdp
);
authenticated.post(
"/idp/:idpId/oidc",
verifyUserIsServerAdmin,
idp.updateOidcIdp
);
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
authenticated.put(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.createIdpOrgPolicy
);
authenticated.put(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.createIdpOrgPolicy
);
authenticated.post(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.updateIdpOrgPolicy
);
authenticated.post(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.updateIdpOrgPolicy
);
authenticated.delete(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.deleteIdpOrgPolicy
);
authenticated.delete(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.deleteIdpOrgPolicy
);
authenticated.get(
"/idp/:idpId/org",
verifyUserIsServerAdmin,
idp.listIdpOrgPolicies
);
authenticated.get(
"/idp/:idpId/org",
verifyUserIsServerAdmin,
idp.listIdpOrgPolicies
);
authenticated.get(
`/api-key/:apiKeyId`,
verifyUserIsServerAdmin,
apiKeys.getApiKey
);
authenticated.put(
`/api-key`,
verifyUserIsServerAdmin,
apiKeys.createRootApiKey
);
authenticated.delete(
`/api-key/:apiKeyId`,
verifyUserIsServerAdmin,
apiKeys.deleteApiKey
);
authenticated.get(
`/api-keys`,
verifyUserIsServerAdmin,
apiKeys.listRootApiKeys
);
authenticated.get(
`/api-key/:apiKeyId/actions`,
verifyUserIsServerAdmin,
apiKeys.listApiKeyActions
);
authenticated.post(
`/api-key/:apiKeyId/actions`,
verifyUserIsServerAdmin,
apiKeys.setApiKeyActions
);
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
authenticated.get(
"/user/:userId",
verifyUserIsServerAdmin,
user.adminGetUser
);
authenticated.post(
"/user/:userId/generate-password-reset-code",
verifyUserIsServerAdmin,
user.adminGeneratePasswordResetCode
);
authenticated.delete(
"/user/:userId",
verifyUserIsServerAdmin,
user.adminRemoveUser
);
authenticated.post(
"/user/:userId/2fa",
verifyUserIsServerAdmin,
user.updateUser2FA
);
}
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
authenticated.get(
`/api-key/:apiKeyId`,
verifyUserIsServerAdmin,
apiKeys.getApiKey
);
authenticated.put(
`/api-key`,
verifyUserIsServerAdmin,
apiKeys.createRootApiKey
);
authenticated.delete(
`/api-key/:apiKeyId`,
verifyUserIsServerAdmin,
apiKeys.deleteApiKey
);
authenticated.get(
`/api-keys`,
verifyUserIsServerAdmin,
apiKeys.listRootApiKeys
);
authenticated.get(
`/api-key/:apiKeyId/actions`,
verifyUserIsServerAdmin,
apiKeys.listApiKeyActions
);
authenticated.post(
`/api-key/:apiKeyId/actions`,
verifyUserIsServerAdmin,
apiKeys.setApiKeyActions
);
authenticated.get(
`/org/:orgId/api-keys`,
@@ -1156,7 +1340,8 @@ export const authRouter = Router();
unauthenticated.use("/auth", authRouter);
authRouter.use(
rateLimit({
windowMs: config.getRawConfig().rate_limits.auth.window_minutes,
windowMs:
config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000,
max: config.getRawConfig().rate_limits.auth.max_requests,
keyGenerator: (req) =>
`authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
@@ -1231,6 +1416,22 @@ authRouter.post(
newt.getNewtToken
);
authRouter.post(
"/newt/version",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
keyGenerator: (req) =>
`newtVersion:${req.body.newtId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only check the Newt version ${60} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
},
store: createStore()
}),
newt.getNewtVersion
);
authRouter.post(
"/newt/register",
rateLimit({
@@ -1252,7 +1453,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 900,
keyGenerator: (req) =>
`olmGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`,
`olmGetToken:${req.body.olmId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
+34 -30
View File
@@ -13,37 +13,41 @@ export async function createExitNode(
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
let exitNode: ExitNode;
if (!exitNodeQuery) {
const address = await getNextAvailableSubnet();
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
const { value: address, release } = await getNextAvailableSubnet();
try {
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
online: true,
listenPort,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} finally {
await release();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
online: true,
listenPort,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} else {
// update the existing exit node
[exitNode] = await db
+7 -9
View File
@@ -6,7 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing/features";
import { LimitId } from "@server/lib/billing/features";
import { checkExitNodeOrg } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
@@ -171,8 +171,9 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
}
// PostgreSQL: batch UPDATE … FROM (VALUES …) - single round-trip per chunk.
const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) =>
sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)`
const valuesList = chunk.map(
([publicKey, { bytesIn, bytesOut }]) =>
sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)`
);
const valuesClause = sql.join(valuesList, sql`, `);
return dbQueryRows<{ orgId: string; pubKey: string }>(sql`
@@ -228,7 +229,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
const totalBandwidth = orgUsageMap.get(orgId)!;
const bandwidthUsage = await usageService.add(
orgId,
FeatureId.EGRESS_DATA_MB,
LimitId.EGRESS_DATA_MB,
totalBandwidth
);
if (bandwidthUsage) {
@@ -236,7 +237,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
usageService
.checkLimitSet(
orgId,
FeatureId.EGRESS_DATA_MB,
LimitId.EGRESS_DATA_MB,
bandwidthUsage
)
.catch((error: any) => {
@@ -247,10 +248,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
});
}
} catch (error) {
logger.error(
`Error processing usage for org ${orgId}:`,
error
);
logger.error(`Error processing usage for org ${orgId}:`, error);
// Continue with other orgs.
}
}
+13 -1
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -22,6 +23,8 @@ const bodySchema = z.strictObject({
});
export type CreateIdpOrgPolicyResponse = {};
const CreateIdpOrgPolicyResponseDataSchema = z.object({});
registry.registerPath({
method: "put",
@@ -38,7 +41,16 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(CreateIdpOrgPolicyResponseDataSchema)
}
}
}
}
});
export async function createIdpOrgPolicy(
+16 -1
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -33,6 +34,11 @@ export type CreateIdpResponse = {
idpId: number;
redirectUrl: string;
};
const CreateIdpResponseDataSchema = z.object({
idpId: z.number(),
redirectUrl: z.string()
});
registry.registerPath({
method: "put",
@@ -48,7 +54,16 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(CreateIdpResponseDataSchema)
}
}
}
}
});
export async function createOidcIdp(
+16 -1
View File
@@ -25,7 +25,22 @@ registry.registerPath({
request: {
params: paramsSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function deleteIdp(
+16 -1
View File
@@ -23,7 +23,22 @@ registry.registerPath({
request: {
params: paramsSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function deleteIdpOrgPolicy(
+16 -1
View File
@@ -38,7 +38,22 @@ registry.registerPath({
request: {
params: paramsSchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function getIdp(
+28 -1
View File
@@ -9,6 +9,7 @@ import { eq, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
const paramsSchema = z.object({
idpId: z.coerce.number<number>()
@@ -44,6 +45,21 @@ export type ListIdpOrgPoliciesResponse = {
pagination: { total: number; limit: number; offset: number };
};
const ListIdpOrgPoliciesResponseDataSchema = z.object({
policies: z.array(
z.object({
idpId: z.number(),
orgId: z.string(),
assignDefaultOrgRoleId: z.number().nullable()
})
),
pagination: z.object({
total: z.number(),
limit: z.number(),
offset: z.number()
})
});
registry.registerPath({
method: "get",
path: "/idp/{idpId}/org",
@@ -53,7 +69,18 @@ registry.registerPath({
params: paramsSchema,
query: querySchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
ListIdpOrgPoliciesResponseDataSchema
)
}
}
}
}
});
export async function listIdpOrgPolicies(
+30 -1
View File
@@ -9,6 +9,7 @@ import { eq, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
const querySchema = z.strictObject({
limit: z
@@ -54,6 +55,25 @@ export type ListIdpsResponse = {
};
};
const ListIdpsResponseDataSchema = z.object({
idps: z.array(
z.object({
idpId: z.number(),
name: z.string(),
type: z.string(),
variant: z.string().nullable(),
orgCount: z.number(),
autoProvision: z.boolean().nullable(),
tags: z.string().nullable()
})
),
pagination: z.object({
total: z.number(),
limit: z.number(),
offset: z.number()
})
});
registry.registerPath({
method: "get",
path: "/idp",
@@ -62,7 +82,16 @@ registry.registerPath({
request: {
query: querySchema
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(ListIdpsResponseDataSchema)
}
}
}
}
});
export async function listIdps(
+13 -1
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -21,6 +22,8 @@ const bodySchema = z.strictObject({
});
export type UpdateIdpOrgPolicyResponse = {};
const UpdateIdpOrgPolicyResponseDataSchema = z.object({});
registry.registerPath({
method: "post",
@@ -37,7 +40,16 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(UpdateIdpOrgPolicyResponseDataSchema)
}
}
}
}
});
export async function updateIdpOrgPolicy(
+15 -1
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -38,6 +39,10 @@ const bodySchema = z.strictObject({
export type UpdateIdpResponse = {
idpId: number;
};
const UpdateIdpResponseDataSchema = z.object({
idpId: z.number()
});
registry.registerPath({
method: "post",
@@ -54,7 +59,16 @@ registry.registerPath({
}
}
},
responses: {}
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(UpdateIdpResponseDataSchema)
}
}
}
}
});
export async function updateOidcIdp(
+4 -17
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, Org } from "@server/db";
import { db, Org, primaryDb } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -31,7 +31,7 @@ import {
} from "@server/auth/sessions/app";
import { decrypt } from "@server/lib/crypto";
import { UserType } from "@server/types/UserTypes";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
@@ -332,17 +332,6 @@ export async function validateOidcCallback(
.where(eq(idpOrg.idpId, existingIdp.idp.idpId))
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
allOrgs = idpOrgs.map((o) => o.orgs);
for (const org of allOrgs) {
const subscribed = await isSubscribed(
org.orgId,
tierMatrix.autoProvisioning
);
if (!subscribed) {
// filter out the org
allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
}
}
} else {
allOrgs = await db.select().from(orgs);
}
@@ -646,9 +635,7 @@ export async function validateOidcCallback(
}
});
db.transaction(async (trx) => {
await calculateUserClientsForOrgs(userId!, trx);
}).catch((err) => {
calculateUserClientsForOrgs(userId!).catch((err) => {
logger.error(
"Error calculating user clients after syncing orgs and roles for OIDC user",
{ error: err }
@@ -658,7 +645,7 @@ export async function validateOidcCallback(
for (const orgCount of orgUserCounts) {
await usageService.updateCount(
orgCount.orgId,
FeatureId.USERS,
LimitId.USERS,
orgCount.userCount
);
}
+90 -2
View File
@@ -2,6 +2,7 @@ import * as site from "./site";
import * as org from "./org";
import * as blueprints from "./blueprints";
import * as resource from "./resource";
import * as policy from "./policy";
import * as domain from "./domain";
import * as target from "./target";
import * as user from "./user";
@@ -16,7 +17,6 @@ import {
verifyApiKey,
verifyApiKeyOrgAccess,
verifyApiKeyHasAction,
verifyApiKeyCanSetUserOrgRoles,
verifyApiKeySiteAccess,
verifyApiKeyResourceAccess,
verifyApiKeyTargetAccess,
@@ -29,7 +29,9 @@ import {
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients,
verifyLimits,
verifyApiKeyDomainAccess
verifyApiKeyDomainAccess,
verifyApiKeyResourcePolicyAccess,
verifyUserHasAction
} from "@server/middlewares";
import HttpCode from "@server/types/HttpCode";
import { Router } from "express";
@@ -157,6 +159,7 @@ authenticated.get(
verifyApiKeyOrgAccess,
resource.getUserResources
);
// Site Resource endpoints
authenticated.put(
"/org/:orgId/site-resource",
@@ -459,6 +462,20 @@ authenticated.get(
resource.getResource
);
authenticated.get(
"/resource-policy/:resourcePolicyId",
verifyApiKeyResourcePolicyAccess,
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
authenticated.get(
"/resource/:resourceId/policies",
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
resource.getResourcePolicies
);
authenticated.post(
"/resource/:resourceId",
verifyApiKeyResourceAccess,
@@ -468,6 +485,13 @@ authenticated.post(
resource.updateResource
);
authenticated.put(
"/resource-policy/:resourcePolicyId",
verifyApiKeyResourcePolicyAccess,
verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy),
policy.updateResourcePolicy
);
authenticated.delete(
"/resource/:resourceId",
verifyApiKeyResourceAccess,
@@ -619,6 +643,63 @@ authenticated.post(
resource.setResourceUsers
);
authenticated.put(
"/resource-policy/:resourcePolicyId/access-control",
verifyApiKeyResourcePolicyAccess,
verifyApiKeyRoleAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
verifyUserHasAction(ActionsEnum.setResourcePolicyRoles),
logActionAudit(ActionsEnum.setResourcePolicyUsers),
logActionAudit(ActionsEnum.setResourcePolicyRoles),
policy.setResourcePolicyAccessControl
);
authenticated.put(
"/resource-policy/:resourcePolicyId/password",
verifyApiKeyResourcePolicyAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPassword),
logActionAudit(ActionsEnum.setResourcePolicyPassword),
policy.setResourcePolicyPassword
);
authenticated.put(
"/resource-policy/:resourcePolicyId/pincode",
verifyApiKeyResourcePolicyAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPincode),
logActionAudit(ActionsEnum.setResourcePolicyPincode),
policy.setResourcePolicyPincode
);
authenticated.put(
"/resource-policy/:resourcePolicyId/header-auth",
verifyApiKeyResourcePolicyAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
policy.setResourcePolicyHeaderAuth
);
authenticated.put(
"/resource-policy/:resourcePolicyId/whitelist",
verifyApiKeyResourcePolicyAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist),
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
policy.setResourcePolicyWhitelist
);
authenticated.put(
"/resource-policy/:resourcePolicyId/rules",
verifyApiKeyResourcePolicyAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules),
logActionAudit(ActionsEnum.setResourcePolicyRules),
policy.setResourcePolicyRules
);
authenticated.post(
"/resource/:resourceId/roles/add",
verifyApiKeyResourceAccess,
@@ -893,6 +974,13 @@ authenticated.get(
idp.getIdp
);
authenticated.delete(
"/idp/:idpId",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.deleteIdp),
idp.deleteIdp
);
authenticated.put(
"/idp/:idpId/org/:orgId",
verifyApiKeyIsRoot,
@@ -0,0 +1,102 @@
import { db, launcherViews } from "@server/db";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import moment from "moment";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { launcherViewConfigSchema } from "./types";
const createLauncherViewBodySchema = z.strictObject({
name: z.string().min(1).max(128),
config: launcherViewConfigSchema,
orgWide: z.boolean().optional().default(false)
});
export async function createLauncherView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = createLauncherViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.orgWide) {
const canCreateOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canCreateOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
}
const now = moment().toISOString();
const [created] = await db
.insert(launcherViews)
.values({
orgId,
userId: parsed.data.orgWide ? null : userId,
name: parsed.data.name,
config: JSON.stringify(parsed.data.config),
createdAt: now,
updatedAt: now
})
.returning();
return response(res, {
data: {
viewId: created.viewId,
orgId: created.orgId,
userId: created.userId,
name: created.name,
config: launcherViewConfigSchema.parse(
JSON.parse(created.config)
),
createdAt: created.createdAt,
updatedAt: created.updatedAt,
isOrgWide: created.userId == null,
isDefault: created.isDefault
},
success: true,
error: false,
message: "Launcher view created successfully",
status: HttpCode.CREATED
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error creating launcher view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,104 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import {
deleteAllDefaultViewOverrides,
deleteDefaultViewOverride
} from "./launcherDefaultView";
const deleteLauncherDefaultViewBodySchema = z.strictObject({
orgWide: z.boolean().optional().default(false),
all: z.boolean().optional().default(false)
});
export async function deleteLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.all) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteAllDefaultViewOverrides(orgId, userId);
} else if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: true
});
} else {
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: false
});
}
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher default view reset successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error resetting launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,95 @@
import { db, launcherViews } from "@server/db";
import { response } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
export async function deleteLauncherView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
const viewId = Number.parseInt(
getFirstString(req.params.viewId) ?? "",
10
);
if (!orgId || !Number.isFinite(viewId)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid request parameters"
)
);
}
const [existing] = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.viewId, viewId),
eq(launcherViews.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Launcher view not found")
);
}
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The default view cannot be deleted from here"
)
);
}
const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You do not have permission to delete this view"
)
);
}
await db.delete(launcherViews).where(eq(launcherViews.viewId, viewId));
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher view deleted successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error deleting launcher view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,172 @@
import { formatEndpoint, parseEndpoint } from "@server/lib/ip";
export type SiteResourceDestinationInput = {
mode: "host" | "cidr" | "http" | "ssh";
destination: string | null;
destinationPort: number | null;
scheme: "http" | "https" | null;
};
export function resolveHttpHttpsDisplayPort(
mode: "http",
destinationPort: number | null
): number {
if (destinationPort != null) {
return destinationPort;
}
return 80;
}
export function formatSiteResourceDestinationDisplay(
row: SiteResourceDestinationInput
): string {
if (!row.destination) {
return "";
}
const { mode, destination, destinationPort, scheme } = row;
if (mode !== "http") {
return destination;
}
const port = resolveHttpHttpsDisplayPort(mode, destinationPort);
const downstreamScheme = scheme ?? "http";
const hostPart =
destination.includes(":") && !destination.startsWith("[")
? `[${destination}]`
: destination;
return `${downstreamScheme}://${hostPart}:${port}`;
}
export type PublicResourceAccessInput = {
mode: string;
fullDomain: string | null;
ssl: boolean;
proxyPort: number | null;
wildcard: boolean;
exitNodeEndpoint?: string | null;
};
export type SiteResourceAccessInput = {
mode: string;
destination: string | null;
destinationPort: number | null;
scheme: "http" | "https" | null;
ssl: boolean;
fullDomain: string | null;
alias: string | null;
aliasAddress: string | null;
};
export type LauncherAccessFields = {
accessDisplay: string;
accessCopyValue: string;
accessUrl: string | null;
};
function formatTcpUdpResourceAccess(
exitNodeEndpoint: string | null | undefined,
proxyPort: number | null
): LauncherAccessFields {
if (proxyPort == null) {
return {
accessDisplay: "",
accessCopyValue: "",
accessUrl: null
};
}
if (!exitNodeEndpoint?.trim()) {
const port = proxyPort.toString();
return {
accessDisplay: port,
accessCopyValue: port,
accessUrl: null
};
}
const parsed = parseEndpoint(exitNodeEndpoint);
const host = parsed?.ip ?? exitNodeEndpoint.trim();
const access = formatEndpoint(host, proxyPort);
return {
accessDisplay: access,
accessCopyValue: access,
accessUrl: null
};
}
export function formatPublicResourceAccess(
resource: PublicResourceAccessInput
): LauncherAccessFields {
const browserModes = ["http", "ssh", "rdp", "vnc"];
if (!browserModes.includes(resource.mode)) {
return formatTcpUdpResourceAccess(
resource.exitNodeEndpoint,
resource.proxyPort
);
}
if (!resource.fullDomain) {
return {
accessDisplay: "",
accessCopyValue: "",
accessUrl: null
};
}
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
return {
accessDisplay: url,
accessCopyValue: url,
accessUrl: resource.wildcard ? null : url
};
}
export function formatSiteResourceAccess(
resource: SiteResourceAccessInput
): LauncherAccessFields {
if (resource.alias) {
return {
accessDisplay: resource.alias,
accessCopyValue: resource.alias,
accessUrl: null
};
}
if (resource.mode === "http" && resource.fullDomain) {
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
return {
accessDisplay: url,
accessCopyValue: url,
accessUrl: url
};
}
const destination = formatSiteResourceDestinationDisplay({
mode: resource.mode as SiteResourceDestinationInput["mode"],
destination: resource.destination,
destinationPort: resource.destinationPort,
scheme: resource.scheme
});
if (destination) {
return {
accessDisplay: destination,
accessCopyValue: destination,
accessUrl: resource.mode === "http" ? destination : null
};
}
if (resource.aliasAddress) {
return {
accessDisplay: resource.aliasAddress,
accessCopyValue: resource.aliasAddress,
accessUrl: null
};
}
return {
accessDisplay: "",
accessCopyValue: "",
accessUrl: null
};
}
+13
View File
@@ -0,0 +1,13 @@
export * from "./types";
export { listLauncherGroups } from "./listLauncherGroups";
export { listLauncherScale } from "./listLauncherScale";
export { listLauncherResources } from "./listLauncherResources";
export { listLauncherSites } from "./listLauncherSites";
export { listLauncherLabels } from "./listLauncherLabels";
export { listLauncherViews } from "./listLauncherViews";
export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView";
export { deleteLauncherView } from "./deleteLauncherView";
export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
export { invalidateLauncherCache } from "./invalidateLauncherCache";
@@ -0,0 +1,65 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
async function invalidateLauncherCacheForUser(
orgId: string,
userId: string
): Promise<void> {
const prefixes = [
`launcherAccessibleIds:${orgId}:${userId}:`,
`launcher:groups:${orgId}:${userId}:`,
`launcher:results:${orgId}:${userId}:`,
`launcher:scale:counts:${orgId}:${userId}:`
];
const keys = (
await Promise.all(
prefixes.map((prefix) => cache.keysWithPrefix(prefix))
)
).flat();
if (keys.length > 0) {
await cache.del(keys);
}
}
export async function invalidateLauncherCache(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
await invalidateLauncherCacheForUser(orgId, userId);
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher cache invalidated successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error invalidating launcher cache:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,137 @@
import { db, launcherViews } from "@server/db";
import { and, eq, isNull } from "drizzle-orm";
import moment from "moment";
import {
launcherViewConfigSchema,
type LauncherDefaultViewOverrides,
type LauncherViewConfig,
type LauncherViewRecord
} from "./types";
export function mapViewRow(
row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null,
isDefault: row.isDefault
};
}
export function extractDefaultViewOverrides(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherDefaultViewOverrides {
const overrideRows = rows.filter((row) => row.isDefault);
const personalRow = overrideRows.find((row) => row.userId !== null);
const orgWideRow = overrideRows.find((row) => row.userId === null);
return {
personal: personalRow ? mapViewRow(personalRow) : null,
orgWide: orgWideRow ? mapViewRow(orgWideRow) : null
};
}
export function listVisibleLauncherViews(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherViewRecord[] {
return rows.filter((row) => !row.isDefault).map(mapViewRow);
}
export async function findDefaultViewOverride(
orgId: string,
orgWide: boolean,
userId: string
) {
const [existing] = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.orgId, orgId),
eq(launcherViews.isDefault, true),
orgWide
? isNull(launcherViews.userId)
: eq(launcherViews.userId, userId)
)
)
.limit(1);
return existing ?? null;
}
export async function upsertDefaultViewOverride({
orgId,
userId,
orgWide,
config
}: {
orgId: string;
userId: string;
orgWide: boolean;
config: LauncherViewConfig;
}) {
const now = moment().toISOString();
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (existing) {
const [updated] = await db
.update(launcherViews)
.set({
config: JSON.stringify(config),
updatedAt: now
})
.where(eq(launcherViews.viewId, existing.viewId))
.returning();
return mapViewRow(updated);
}
const [created] = await db
.insert(launcherViews)
.values({
orgId,
userId: orgWide ? null : userId,
name: "",
isDefault: true,
config: JSON.stringify(config),
createdAt: now,
updatedAt: now
})
.returning();
return mapViewRow(created);
}
export async function deleteDefaultViewOverride({
orgId,
userId,
orgWide
}: {
orgId: string;
userId: string;
orgWide: boolean;
}) {
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (!existing) {
return;
}
await db
.delete(launcherViews)
.where(eq(launcherViews.viewId, existing.viewId));
}
export async function deleteAllDefaultViewOverrides(
orgId: string,
userId: string
) {
await deleteDefaultViewOverride({ orgId, userId, orgWide: false });
await deleteDefaultViewOverride({ orgId, userId, orgWide: true });
}
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import {
listLauncherGroupsForUser,
resolveAccessibleIds
} from "./launcherResourceAccess";
import {
parseIdListParam,
type LauncherScaleInfo,
type LauncherScaleQuery
} from "./types";
export const LAUNCHER_FULL_MAX_RESOURCES = 2000;
export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200;
export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100;
export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20;
const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60;
type LauncherScaleCountsCacheEntry = {
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
mode: LauncherScaleInfo["mode"];
};
function launcherScaleCountsCacheKey(
orgId: string,
userId: string,
roleIds: number[]
) {
const rolesKey = [...roleIds].sort((a, b) => a - b).join(",");
return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`;
}
function buildScaleCapabilities(
counts: LauncherScaleCountsCacheEntry,
query: LauncherScaleQuery
): LauncherScaleInfo["capabilities"] {
const siteFilterIds = parseIdListParam(query.siteIds);
const labelFilterIds = parseIdListParam(query.labelIds);
return {
allowSiteGrouping:
counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
(siteFilterIds.length > 0 &&
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
allowLabelGrouping:
counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS ||
(labelFilterIds.length > 0 &&
labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
requireSearchOrFilter:
counts.mode === "compact" &&
counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES
};
}
async function getLauncherScaleCountsForUser(
orgId: string,
userId: string,
userRoleIds: number[]
): Promise<LauncherScaleCountsCacheEntry> {
const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
const cached = await cache.get<LauncherScaleCountsCacheEntry>(cacheKey);
if (cached) {
return cached;
}
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const resourceCount =
accessible.resourceIds.length + accessible.siteResourceIds.length;
const baselineQuery = {
query: "",
groupBy: "site" as const,
siteIds: undefined,
labelIds: undefined,
sort_by: "name" as const,
order: "asc" as const,
page: 1,
pageSize: 1
};
const [{ total: siteGroupCount }, { total: labelGroupCount }] =
await Promise.all([
listLauncherGroupsForUser(
orgId,
userId,
userRoleIds,
baselineQuery
),
listLauncherGroupsForUser(orgId, userId, userRoleIds, {
...baselineQuery,
groupBy: "label"
})
]);
const mode =
resourceCount <= LAUNCHER_FULL_MAX_RESOURCES &&
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS
? "full"
: "compact";
const result: LauncherScaleCountsCacheEntry = {
resourceCount,
siteGroupCount,
labelGroupCount,
mode
};
await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC);
return result;
}
export async function getLauncherScaleForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherScaleQuery
): Promise<LauncherScaleInfo> {
const counts = await getLauncherScaleCountsForUser(
orgId,
userId,
userRoleIds
);
return {
mode: counts.mode,
resourceCount: counts.resourceCount,
siteGroupCount: counts.siteGroupCount,
labelGroupCount: counts.labelGroupCount,
capabilities: buildScaleCapabilities(counts, query)
};
}
@@ -0,0 +1,67 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { listLauncherGroupsForUser } from "./launcherResourceAccess";
import { launcherListQuerySchema } from "./types";
export async function listLauncherGroups(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherListQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const { groups, total } = await listLauncherGroupsForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: {
groups,
pagination: {
total,
page: parsed.data.page,
pageSize: parsed.data.pageSize
}
},
success: true,
error: false,
message: "Launcher groups retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher groups:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,67 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { listAccessibleLauncherLabelsForUser } from "./launcherResourceAccess";
import { launcherFilterListQuerySchema } from "./types";
export async function listLauncherLabels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherFilterListQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const { labels, total } = await listAccessibleLauncherLabelsForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: {
labels,
pagination: {
total,
page: parsed.data.page,
pageSize: parsed.data.pageSize
}
},
success: true,
error: false,
message: "Launcher labels retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher labels:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,72 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { listLauncherResourcesForUser } from "./launcherResourceAccess";
import { launcherListQuerySchema } from "./types";
const listLauncherResourcesQuerySchema = launcherListQuerySchema.extend({
groupKey: z.string().min(1)
});
export async function listLauncherResources(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = listLauncherResourcesQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const { resources, total } = await listLauncherResourcesForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: {
resources,
pagination: {
total,
page: parsed.data.page,
pageSize: parsed.data.pageSize
}
},
success: true,
error: false,
message: "Launcher resources retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher resources:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,60 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { getLauncherScaleForUser } from "./launcherScale";
import { launcherScaleQuerySchema } from "./types";
export async function listLauncherScale(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherScaleQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const scale = await getLauncherScaleForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: { scale },
success: true,
error: false,
message: "Launcher scale retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher scale:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,67 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { listAccessibleLauncherSitesForUser } from "./launcherResourceAccess";
import { launcherFilterListQuerySchema } from "./types";
export async function listLauncherSites(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherFilterListQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const { sites, total } = await listAccessibleLauncherSitesForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: {
sites,
pagination: {
total,
page: parsed.data.page,
pageSize: parsed.data.pageSize
}
},
success: true,
error: false,
message: "Launcher sites retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher sites:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,62 @@
import { db, launcherViews } from "@server/db";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull, or } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import {
extractDefaultViewOverrides,
listVisibleLauncherViews
} from "./launcherDefaultView";
export async function listLauncherViews(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const rows = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.orgId, orgId),
or(
eq(launcherViews.userId, userId),
isNull(launcherViews.userId)
)
)
);
return response(res, {
data: {
views: listVisibleLauncherViews(rows),
defaultViewOverrides: extractDefaultViewOverrides(rows)
},
success: true,
error: false,
message: "Launcher views retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher views:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
+216
View File
@@ -0,0 +1,216 @@
import { z } from "zod";
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
export const launcherViewConfigSchema = z.object({
groupBy: z.enum(["site", "label", "none"]).default("site"),
layout: z.enum(["grid", "list"]).default("grid"),
sortBy: z.literal("name").default("name"),
order: z.enum(["asc", "desc"]).default("asc"),
showLabels: z.boolean().default(true),
showSiteTags: z.boolean().default(true),
showRecents: z.boolean().default(false).optional(),
siteIds: z.array(z.number()).default([]),
labelIds: z.array(z.number()).default([]),
query: z.string().default("")
});
export type LauncherViewConfig = z.infer<typeof launcherViewConfigSchema>;
export const defaultLauncherViewConfig: LauncherViewConfig =
launcherViewConfigSchema.parse({});
export type LauncherLabel = {
labelId: number;
name: string;
color: string;
};
export type LauncherSiteInfo = {
siteId: number;
name: string;
type: string;
online?: boolean;
};
export type LauncherResource = {
launcherResourceKey: string;
resourceType: "public" | "site";
resourceId: number;
siteResourceId?: number;
niceId: string;
name: string;
accessDisplay: string;
accessCopyValue: string;
accessUrl: string | null;
iconUrl: string | null;
enabled: boolean;
mode: string;
labels: LauncherLabel[];
site?: LauncherSiteInfo;
};
export type LauncherGroup = {
groupKey: string;
name: string;
groupType: "site" | "label";
itemCount: number;
siteType?: string;
siteOnline?: boolean;
labelColor?: string;
};
export type ListLauncherGroupsResponse = {
groups: LauncherGroup[];
pagination: {
total: number;
page: number;
pageSize: number;
};
};
export type ListLauncherResourcesResponse = {
resources: LauncherResource[];
pagination: {
total: number;
page: number;
pageSize: number;
};
};
export type LauncherViewRecord = {
viewId: number;
orgId: string;
userId: string | null;
name: string;
config: LauncherViewConfig;
createdAt: string;
updatedAt: string;
isOrgWide: boolean;
isDefault: boolean;
};
export type LauncherDefaultViewOverrides = {
personal: LauncherViewRecord | null;
orgWide: LauncherViewRecord | null;
};
export function getEffectiveDefaultLauncherConfig(
overrides: LauncherDefaultViewOverrides
): LauncherViewConfig {
if (overrides.personal) {
return overrides.personal.config;
}
if (overrides.orgWide) {
return overrides.orgWide.config;
}
return defaultLauncherViewConfig;
}
export type ListLauncherViewsResponse = {
views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
};
export const launcherFilterListQuerySchema = z.strictObject({
pageSize: z.coerce
.number()
.int()
.positive()
.optional()
.catch(20)
.default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default("")
});
export type LauncherFilterListQuery = z.infer<
typeof launcherFilterListQuerySchema
>;
export type ListLauncherSitesResponse = {
sites: LauncherSiteInfo[];
pagination: {
total: number;
page: number;
pageSize: number;
};
};
export type ListLauncherLabelsResponse = {
labels: LauncherLabel[];
pagination: {
total: number;
page: number;
pageSize: number;
};
};
export const launcherListQuerySchema = z.strictObject({
pageSize: z.coerce
.number()
.int()
.positive()
.optional()
.catch(20)
.default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default(""),
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
groupKey: z.string().optional(),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
sort_by: z.literal("name").optional().default("name"),
order: z.enum(["asc", "desc"]).optional().default("asc")
});
export type LauncherListQuery = z.infer<typeof launcherListQuerySchema>;
export function parseIdListParam(value: string | undefined): number[] {
if (!value?.trim()) {
return [];
}
return value
.split(",")
.map((part) => Number.parseInt(part.trim(), 10))
.filter((id) => Number.isFinite(id));
}
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
export type LauncherViewSelection =
| { type: "default" }
| { type: "saved"; viewId: number };
export type LauncherScaleCapabilities = {
allowSiteGrouping: boolean;
allowLabelGrouping: boolean;
requireSearchOrFilter: boolean;
};
export type LauncherScaleInfo = {
mode: "full" | "compact";
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
capabilities: LauncherScaleCapabilities;
};
export type ListLauncherScaleResponse = {
scale: LauncherScaleInfo;
};
export const launcherScaleQuerySchema = z.strictObject({
query: z.string().optional().default(""),
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
sort_by: z.literal("name").optional().default("name"),
order: z.enum(["asc", "desc"]).optional().default("asc")
});
export type LauncherScaleQuery = z.infer<typeof launcherScaleQuerySchema>;
@@ -0,0 +1,167 @@
import { db, launcherViews } from "@server/db";
import { response } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import moment from "moment";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { launcherViewConfigSchema } from "./types";
const updateLauncherViewBodySchema = z.strictObject({
name: z.string().min(1).max(128).optional(),
config: launcherViewConfigSchema.optional(),
orgWide: z.boolean().optional()
});
export async function updateLauncherView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
const viewId = Number.parseInt(
getFirstString(req.params.viewId) ?? "",
10
);
if (!orgId || !Number.isFinite(viewId)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid request parameters"
)
);
}
const parsed = updateLauncherViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const [existing] = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.viewId, viewId),
eq(launcherViews.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Launcher view not found")
);
}
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Use the default view save endpoint for this view"
)
);
}
const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You do not have permission to update this view"
)
);
}
if (parsed.data.orgWide === true && !canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
if (
parsed.data.orgWide === false &&
isOrgWideView &&
!canManageOrgWide
) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
const nextUserId =
parsed.data.orgWide === true
? null
: parsed.data.orgWide === false
? userId
: existing.userId;
const [updated] = await db
.update(launcherViews)
.set({
name: parsed.data.name ?? existing.name,
config: parsed.data.config
? JSON.stringify(parsed.data.config)
: existing.config,
userId: nextUserId,
updatedAt: moment().toISOString()
})
.where(eq(launcherViews.viewId, viewId))
.returning();
return response(res, {
data: {
viewId: updated.viewId,
orgId: updated.orgId,
userId: updated.userId,
name: updated.name,
config: launcherViewConfigSchema.parse(
JSON.parse(updated.config)
),
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
isOrgWide: updated.userId == null,
isDefault: updated.isDefault
},
success: true,
error: false,
message: "Launcher view updated successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error updating launcher view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,82 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { upsertDefaultViewOverride } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types";
const upsertLauncherDefaultViewBodySchema = z.strictObject({
config: launcherViewConfigSchema,
orgWide: z.boolean().optional().default(false)
});
export async function upsertLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
}
const view = await upsertDefaultViewOverride({
orgId,
userId,
orgWide: parsed.data.orgWide,
config: parsed.data.config
});
return response(res, {
data: view,
success: true,
error: false,
message: "Launcher default view saved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error saving launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
+128 -36
View File
@@ -5,6 +5,7 @@ import {
db,
ExitNode,
networks,
remoteExitNodeResources,
resources,
Site,
siteNetworks,
@@ -14,8 +15,9 @@ import {
} from "@server/db";
import logger from "@server/logger";
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
import { eq, and } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import {
formatEndpoint,
generateSubnetProxyTargetV2,
@@ -50,13 +52,13 @@ export async function buildClientConfigurationForNewtClient(
clientsRes
.filter((client) => {
if (!client.clients.pubKey) {
logger.warn(
logger.debug(
`Client ${client.clients.clientId} has no public key, skipping`
);
return false;
}
if (!client.clients.subnet) {
logger.warn(
logger.debug(
`Client ${client.clients.clientId} has no subnet, skipping`
);
return false;
@@ -151,34 +153,64 @@ export async function buildClientConfigurationForNewtClient(
const targetsToSend: SubnetProxyTargetV2[] = [];
for (const resource of allSiteResources) {
// Get clients associated with this specific resource
const resourceClients = await db
.select({
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clients.clientId,
clientSiteResourcesAssociationsCache.clientId
)
)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
resource.siteResourceId
)
);
if (allSiteResources.length === 0) {
return {
peers: validPeers,
targets: targetsToSend
};
}
const resourceTargets = await generateSubnetProxyTargetV2(
resource,
resourceClients
// Batch fetch all client associations for every site resource in one query
// to avoid an N+1 lookup that would issue thousands of queries when a site
// has many resources.
const siteResourceIds = allSiteResources.map((r) => r.siteResourceId);
const resourceClientRows = await db
.select({
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId,
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(clients.clientId, clientSiteResourcesAssociationsCache.clientId)
)
.where(
inArray(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResourceIds
)
);
const clientsByResourceId = new Map<
number,
{ clientId: number; pubKey: string | null; subnet: string | null }[]
>();
for (const row of resourceClientRows) {
let list = clientsByResourceId.get(row.siteResourceId);
if (!list) {
list = [];
clientsByResourceId.set(row.siteResourceId, list);
}
list.push({
clientId: row.clientId,
pubKey: row.pubKey,
subnet: row.subnet
});
}
const resourceTargetsArr = await Promise.all(
allSiteResources.map((resource) =>
generateSubnetProxyTargetV2(
resource,
clientsByResourceId.get(resource.siteResourceId) ?? []
)
)
);
for (const resourceTargets of resourceTargetsArr) {
if (resourceTargets) {
targetsToSend.push(...resourceTargets);
}
@@ -192,9 +224,10 @@ export async function buildClientConfigurationForNewtClient(
export async function buildTargetConfigurationForNewtClient(
siteId: number,
version?: string | null
version?: string | null,
remoteExitNodeId?: string
) {
// Get all enabled targets with their resource protocol information
// Get all enabled targets with their resource mode information
const allTargets = await db
.select({
resourceId: targets.resourceId,
@@ -204,11 +237,17 @@ export async function buildTargetConfigurationForNewtClient(
port: targets.port,
internalPort: targets.internalPort,
enabled: targets.enabled,
protocol: resources.protocol
mode: resources.mode
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
.where(
and(
eq(targets.siteId, siteId),
eq(targets.enabled, true),
inArray(targets.mode, ["http", "udp", "tcp"])
)
);
const allHealthChecks = await db
.select({
@@ -233,6 +272,28 @@ export async function buildTargetConfigurationForNewtClient(
.from(targetHealthCheck)
.where(eq(targetHealthCheck.siteId, siteId));
// Get all enabled targets with their resource mode information
const allBrowserGatewayTargets = await db
.select({
resourceId: targets.resourceId,
targetId: targets.targetId,
ip: targets.ip,
method: targets.method,
port: targets.port,
enabled: targets.enabled,
mode: resources.mode,
authToken: targets.authToken
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.where(
and(
eq(targets.siteId, siteId),
eq(targets.enabled, true),
inArray(targets.mode, ["ssh", "rdp", "vnc"])
)
);
const { tcpTargets, udpTargets } = allTargets.reduce(
(acc, target) => {
// Filter out invalid targets
@@ -244,10 +305,11 @@ export async function buildTargetConfigurationForNewtClient(
const formattedTarget = `${target.internalPort}:${formatEndpoint(target.ip, target.port)}`;
// Add to the appropriate protocol array
if (target.protocol === "tcp") {
acc.tcpTargets.push(formattedTarget);
} else {
if (target.mode === "udp") {
acc.udpTargets.push(formattedTarget);
} else {
// all other modes are tcp
acc.tcpTargets.push(formattedTarget);
}
return acc;
@@ -304,9 +366,39 @@ export async function buildTargetConfigurationForNewtClient(
(target) => target !== null
);
const serverSecret = config.getRawConfig().server.secret!;
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
if (!t.ip || !t.port || !t.authToken) {
return null;
}
const decryptAuthToken = decrypt(t.authToken, serverSecret);
return {
id: t.targetId,
type: t.mode,
destination: t.ip,
destinationPort: t.port,
authToken: decryptAuthToken
};
});
let remoteExitNodeSubnets: string[] = [];
if (remoteExitNodeId) {
const remoteNodeResources = await db
.select()
.from(remoteExitNodeResources)
.where(
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
);
// filter through these and provide the subnets
remoteExitNodeSubnets = remoteNodeResources.map((r) => r.destination);
}
return {
validHealthCheckTargets,
tcpTargets,
udpTargets
udpTargets,
browserGatewayTargets,
remoteExitNodeSubnets
};
}
+322
View File
@@ -0,0 +1,322 @@
import { db, orgs, sites } from "@server/db";
import { newts } from "@server/db";
import { eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import semver from "semver";
import { verifyPassword } from "@server/auth/password";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
import { regionalCache as cache } from "#dynamic/lib/cache";
import config from "@server/lib/config";
// Stale-while-revalidate in-memory fallback for the releases API.
type ReleaseInfo = {
version: string;
// binary filename -> sha256 hex (sourced from asset `digest` field in GitHub API)
assetDigests: Record<string, string>;
};
let staleReleaseInfo: ReleaseInfo | null = null;
/**
* Fetches the latest stable newt release from GitHub and returns the version
* tag together with a map of asset-name → sha256 hex digest.
* Results are cached for one hour; stale data is returned on failure.
*/
async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
try {
const cached = await cache.get<ReleaseInfo>("cache:newtReleaseInfo");
if (cached) {
return cached;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const fetchResponse = await fetch(
"https://api.github.com/repos/fosrl/newt/releases",
{ signal: controller.signal }
);
clearTimeout(timeoutId);
if (!fetchResponse.ok) {
logger.warn(
`Failed to fetch Newt releases from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}`
);
return staleReleaseInfo;
}
let releases: any[] = await fetchResponse.json();
if (!Array.isArray(releases) || releases.length === 0) {
logger.warn("No releases found for Newt repository");
return staleReleaseInfo;
}
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
// Drop drafts, pre-releases, anything with "rc" in the tag name,
// and releases published less than 1 day ago.
releases = releases.filter(
(r: any) =>
!r.draft &&
!r.prerelease &&
!r.tag_name.includes("rc") &&
!r.tag_name.includes("v") &&
r.published_at &&
new Date(r.published_at) <= oneDayAgo
);
// Sort descending by semver to find the true latest stable release.
releases.sort((a: any, b: any) => {
const va = semver.coerce(a.tag_name);
const vb = semver.coerce(b.tag_name);
if (!va && !vb) return 0;
if (!va) return 1;
if (!vb) return -1;
return semver.rcompare(va, vb);
});
if (releases.length === 0) {
logger.warn("No stable releases found for Newt repository");
return staleReleaseInfo;
}
const latest = releases[0];
const version: string = latest.tag_name;
// Build a map of binary filename → sha256 hex from the asset `digest`
// field returned by the GitHub API (format: "sha256:<hex>").
const assetDigests: Record<string, string> = {};
if (Array.isArray(latest.assets)) {
for (const asset of latest.assets) {
if (
typeof asset.name === "string" &&
typeof asset.digest === "string" &&
asset.digest.startsWith("sha256:")
) {
assetDigests[asset.name] = asset.digest.slice(
"sha256:".length
);
}
}
}
const info: ReleaseInfo = { version, assetDigests };
staleReleaseInfo = info;
await cache.set("cache:newtReleaseInfo", info, 3600);
return info;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn("Request to fetch Newt releases timed out (5s)");
} else {
logger.warn(
"Error fetching Newt releases:",
error.message || error
);
}
return staleReleaseInfo;
}
}
const bodySchema = z.object({
newtId: z.string(),
secret: z.string(),
platform: z.string() // e.g. "linux_amd64", "darwin_arm64"
});
export type GetNewtVersionBody = z.infer<typeof bodySchema>;
export type GetNewtVersionResponse = {
latestVersion: string;
currentIsLatest: boolean;
downloadUrl: string;
sha256: string;
};
export async function getNewtVersion(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { newtId, secret, platform } = parsedBody.data;
try {
// Verify newt credentials
const [existingNewt] = await db
.select()
.from(newts)
.where(eq(newts.newtId, newtId))
.limit(1);
if (!existingNewt) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Newt version check: no newt found with ID ${newtId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid credentials")
);
}
if (!existingNewt.siteId) {
logger.warn(`Newt ${newtId} has no associated site`);
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Not associated with a site"
)
);
}
const validSecret = await verifyPassword(
secret,
existingNewt.secretHash
);
if (!validSecret) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Newt version check: invalid secret for newt ID ${newtId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid credentials")
);
}
// check if udpates are enabled for the org or the site
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, existingNewt.siteId))
.limit(1);
if (!site) {
logger.warn(`Site with ID ${existingNewt.siteId} not found`);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Associated site not found"
)
);
}
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, site.orgId))
.limit(1);
if (!org) {
logger.warn(`Org with ID ${site.orgId} not found`);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Associated organization not found"
)
);
}
let doUpdate = false;
if (site.autoUpdateOverrideOrg) {
doUpdate = site.autoUpdateEnabled;
} else {
doUpdate = org.settingsEnableGlobalNewtAutoUpdate;
}
if (!doUpdate) {
// return no content http code
return response(res, {
data: {
latestVersion: existingNewt.version ?? "",
currentIsLatest: true,
downloadUrl: "",
sha256: ""
},
success: true,
error: false,
message:
"Auto-updates are disabled for this site and organization",
status: HttpCode.NO_CONTENT
});
}
// Fetch latest release info (version + asset digests) in one API call.
const releaseInfo = await getLatestReleaseInfo();
if (!releaseInfo) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Unable to determine latest Newt version"
)
);
}
const latestVersion = releaseInfo.version;
// Binary name follows the get-newt.sh convention: newt_<platform>[.exe]
const binaryName = platform.includes("windows")
? `newt_${platform}.exe`
: `newt_${platform}`;
const downloadUrl = `https://github.com/fosrl/newt/releases/download/${latestVersion}/${binaryName}`;
// Look up the SHA256 digest for this specific binary from the GitHub
// release asset metadata (the `digest` field, format "sha256:<hex>").
const sha256 = releaseInfo.assetDigests[binaryName] ?? "";
// Determine whether the newt that's asking is already up to date.
// We store the current version on the newt row when it registers.
const currentVersion = existingNewt.version ?? null;
let currentIsLatest = false;
if (currentVersion) {
try {
const latest = semver.coerce(latestVersion);
const current = semver.coerce(currentVersion);
if (latest && current) {
currentIsLatest = !semver.lt(current, latest);
}
} catch {
// If we can't compare, assume not latest
}
}
return response<GetNewtVersionResponse>(res, {
data: {
latestVersion,
currentIsLatest,
downloadUrl,
sha256
},
success: true,
error: false,
message: "Version info retrieved successfully",
status: HttpCode.OK
});
} catch (e) {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to retrieve version info"
)
);
}
}
@@ -50,7 +50,7 @@ export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
source: "NEWT"
});
} catch (error) {
logger.error(`Failed to update database from config: ${error}`);
logger.debug(`Failed to update database from config: ${error}`);
return {
message: {
type: "newt/blueprint/results",
@@ -19,7 +19,7 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
}
if (!newt.siteId) {
logger.warn("Newt has no client ID!");
logger.warn("Newt has no site ID!");
return;
}
@@ -34,6 +34,12 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
.where(eq(sites.siteId, newt.siteId!))
.returning();
if (!site) {
throw new Error(
`Could not find site ${newt.siteId} to update disconnection from disconnect message`
);
}
await fireSiteOfflineAlert(
site.orgId,
site.siteId,
@@ -43,6 +49,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
);
});
} catch (error) {
logger.error("Error handling disconnecting message", { error });
logger.error("Error handling site disconnecting message", error);
}
};
@@ -6,9 +6,10 @@ import { db, ExitNode, exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
import { convertTargetsIfNessicary } from "../client/targets";
import { convertTargetsIfNecessary } from "../client/targets";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
import { waitForSiteRebuildIdle } from "@server/lib/rebuildClientAssociations";
export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
@@ -54,13 +55,15 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
// TODO: somehow we should make sure a recent hole punch has happened if this occurs (hole punch could be from the last restart if done quickly)
}
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 12) {
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 site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
);
return;
}
await waitForSiteRebuildIdle(siteId);
// update the endpoint and the public key
const [site] = await db
.update(sites)
@@ -113,7 +116,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
exitNode
);
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format
const targetsToSend = await convertTargetsIfNecessary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format
return {
message: {
+24 -12
View File
@@ -5,7 +5,20 @@ import { Newt } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import { sendNewtSyncMessage } from "./sync";
import { recordPing } from "./pingAccumulator";
import semver from "semver";
import { recordSitePing } from "./pingAccumulator";
const NEWT_SUPPORTS_SYNC_VERSION = ">=1.14.0";
const PONG = {
message: {
type: "pong",
data: {
timestamp: new Date().toISOString()
}
},
broadcast: false,
excludeSender: false
};
/**
* Handles ping messages from newt clients.
@@ -35,7 +48,15 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
// batched UPDATE instead of one query per ping. This prevents
// connection pool exhaustion under load, especially with
// cross-region latency to the database.
recordPing(newt.siteId);
recordSitePing(newt.siteId);
if (
newt.version &&
!semver.satisfies(newt.version, NEWT_SUPPORTS_SYNC_VERSION)
) {
// Newt does not support the sync message so not checking - stop here -
return PONG;
}
// Check config version and sync if stale.
const configVersion = await getClientConfigVersion(newt.newtId);
@@ -65,14 +86,5 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
await sendNewtSyncMessage(newt, site);
}
return {
message: {
type: "pong",
data: {
timestamp: new Date().toISOString()
}
},
broadcast: false,
excludeSender: false
};
return PONG;
};
@@ -38,7 +38,8 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
const exitNodesList = await listExitNodes(
site.orgId,
true,
noCloud || false
noCloud || false,
newt.siteId
); // filter for only the online ones
let lastExitNodeId = null;
@@ -1,4 +1,4 @@
import { db, ExitNode, newts, Transaction } from "@server/db";
import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm";
@@ -43,8 +43,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const siteId = newt.siteId;
const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } =
message.data;
const {
publicKey,
pingResults,
newtVersion,
backwardsCompatible,
chainId
} = message.data;
if (!publicKey) {
logger.warn("Public key not provided");
return;
@@ -191,8 +196,29 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.where(eq(newts.newtId, newt.newtId));
}
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
let remoteExitNodeId: string | undefined;
if (exitNode.type == "remoteExitNode") {
// get the remote exit node ID associated with this exit node
const [remoteExitNode] = await db
.select()
.from(remoteExitNodes)
.where(eq(remoteExitNodes.exitNodeId, exitNode.exitNodeId))
.limit(1);
remoteExitNodeId = remoteExitNode?.remoteExitNodeId;
}
const {
tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets,
remoteExitNodeSubnets
} = await buildTargetConfigurationForNewtClient(
siteId,
newtVersion,
remoteExitNodeId // this is for the remote node resources
);
logger.debug(
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
@@ -212,6 +238,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
tcp: tcpTargets
},
healthCheckTargets: validHealthCheckTargets,
browserGatewayTargets: browserGatewayTargets,
remoteExitNodeSubnets: remoteExitNodeSubnets,
chainId: chainId
}
},
+1 -1
View File
@@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws";
import logger from "@server/logger";
import { Newt } from "@server/db";
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
import cache from "#dynamic/lib/cache";
import cache from "#dynamic/lib/cache"; // not using regional here because we dont know where the site is
export const handleDockerStatusMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
+1
View File
@@ -1,5 +1,6 @@
export * from "./createNewt";
export * from "./getNewtToken";
export * from "./getNewtVersion";
export * from "./handleNewtRegisterMessage";
export * from "./handleReceiveBandwidthMessage";
export * from "./handleNewtGetConfigMessage";
+59 -18
View File
@@ -1,7 +1,7 @@
import { db, Site } from "@server/db";
import { newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import { sendToClient } from "#dynamic/routers/ws";
import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
import logger from "@server/logger";
export async function addPeer(
@@ -36,10 +36,14 @@ export async function addPeer(
newtId = newt.newtId;
}
await sendToClient(newtId, {
type: "newt/wg/peer/add",
data: peer
}, { incrementConfigVersion: true }).catch((error) => {
await sendToClient(
newtId,
{
type: "newt/wg/peer/add",
data: peer
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
@@ -76,12 +80,16 @@ export async function deletePeer(
newtId = newt.newtId;
}
await sendToClient(newtId, {
type: "newt/wg/peer/remove",
data: {
publicKey
}
}, { incrementConfigVersion: true }).catch((error) => {
await sendToClient(
newtId,
{
type: "newt/wg/peer/remove",
data: {
publicKey
}
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
@@ -90,6 +98,35 @@ export async function deletePeer(
return site;
}
export async function deletePeersBatch(
peers: {
siteId: number;
publicKey: string;
newtId: string;
}[]
) {
if (peers.length === 0) {
return;
}
await sendToClientsBatch(
peers.map((peer) => ({
clientId: peer.newtId,
message: {
type: "newt/wg/peer/remove",
data: {
publicKey: peer.publicKey
}
},
options: { incrementConfigVersion: true }
}))
).catch((error) => {
logger.warn(`Error sending batched newt peer removals:`, error);
});
logger.info(`Deleted ${peers.length} peer(s) from newts (batch)`);
}
export async function updatePeer(
siteId: number,
publicKey: string,
@@ -122,13 +159,17 @@ export async function updatePeer(
newtId = newt.newtId;
}
await sendToClient(newtId, {
type: "newt/wg/peer/update",
data: {
publicKey,
...peer
}
}, { incrementConfigVersion: true }).catch((error) => {
await sendToClient(
newtId,
{
type: "newt/wg/peer/update",
data: {
publicKey,
...peer
}
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
+2 -84
View File
@@ -3,6 +3,7 @@ import { sites, clients, olms } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOnlineAlert } from "@server/lib/alerts";
import { withRetry } from "@server/lib/dbRetry";
/**
* Ping Accumulator
@@ -22,8 +23,6 @@ import { fireSiteOnlineAlert } from "@server/lib/alerts";
*/
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50;
// ── Site (newt) pings ──────────────────────────────────────────────────
// Map of siteId -> latest ping timestamp (unix seconds)
@@ -57,9 +56,6 @@ export function recordSitePing(siteId: number): void {
pendingSitePings.set(siteId, now);
}
/** @deprecated Use `recordSitePing` instead. Alias kept for existing call-sites. */
export const recordPing = recordSitePing;
/**
* Record a ping for an OLM client. Batches the `clients` table update
* (`online`, `lastPing`, `archived`) and, when `olmArchived` is true,
@@ -269,85 +265,7 @@ export async function flushPingsToDb(): Promise<void> {
}
// ── Retry / Error Helpers ──────────────────────────────────────────────
/**
* Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects).
*
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
* guarantees exactly one winner per deadlock pair, so the loser just needs
* to try again. MAX_RETRIES is intentionally higher than typical connection
* retry budgets to give deadlock victims enough chances to succeed.
*/
async function withRetry<T>(
operation: () => Promise<T>,
context: string
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error: any) {
if (isTransientError(error) && attempt < MAX_RETRIES) {
attempt++;
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
/**
* Detect transient errors that are safe to retry.
*/
function isTransientError(error: any): boolean {
if (!error) return false;
const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || "";
// Connection timeout / terminated
if (
message.includes("connection timeout") ||
message.includes("connection terminated") ||
message.includes("timeout exceeded when trying to connect") ||
causeMessage.includes("connection terminated unexpectedly") ||
causeMessage.includes("connection timeout")
) {
return true;
}
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
// PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
return false;
}
// See @server/lib/dbRetry for the shared withRetry/isTransientError helpers.
// ── Lifecycle ──────────────────────────────────────────────────────────
+75 -77
View File
@@ -25,7 +25,7 @@ import { getUniqueSiteName } from "@server/db/names";
import moment from "moment";
import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
import { INSPECT_MAX_BYTES } from "buffer";
import { getNextAvailableClientSubnet } from "@server/lib/ip";
@@ -169,7 +169,7 @@ export async function registerNewt(
// SaaS billing check
if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.SITES);
const usage = await usageService.getUsage(orgId, LimitId.SITES);
if (!usage) {
return next(
createHttpError(
@@ -180,7 +180,7 @@ export async function registerNewt(
}
const rejectSites = await usageService.checkLimitSet(
orgId,
FeatureId.SITES,
LimitId.SITES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -203,84 +203,82 @@ export async function registerNewt(
let newSiteId: number | undefined;
await db.transaction(async (trx) => {
const newClientAddress = await getNextAvailableClientSubnet(orgId);
if (!newClientAddress) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"No available subnet found"
)
);
}
const { value: newClientAddress, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId);
try {
await db.transaction(async (trx) => {
let clientAddress = newClientAddress.split("/")[0];
clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org
let clientAddress = newClientAddress.split("/")[0];
clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org
// Create the site (type "newt", name = niceId)
const [newSite] = await trx
.insert(sites)
.values({
orgId,
name: name || niceId,
niceId,
address: clientAddress,
type: "newt",
dockerSocketEnabled: true,
status: keyRecord.approveNewSites
? "approved"
: "pending"
})
.returning();
// Create the site (type "newt", name = niceId)
const [newSite] = await trx
.insert(sites)
.values({
orgId,
name: name || niceId,
niceId,
address: clientAddress,
type: "newt",
dockerSocketEnabled: true,
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)
});
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
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
throw new Error(`Admin role not found for org ${orgId}`);
}
await trx.insert(roleSites).values({
roleId: adminRole.roleId,
siteId: newSite.siteId
});
// Create the newt for this site
await trx.insert(newts).values({
newtId,
secretHash,
siteId: newSite.siteId,
dateCreated: moment().toISOString()
});
// Consume the provisioning key - cascade removes siteProvisioningKeyOrg
await trx
.update(siteProvisioningKeys)
.set({
lastUsed: moment().toISOString(),
numUsed: sql`${siteProvisioningKeys.numUsed} + 1`
})
.where(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
provisioningKeyId
)
);
await usageService.add(orgId, LimitId.SITES, 1, trx);
});
newSiteId = newSite.siteId;
// Grant admin role access to the new site
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
throw new Error(`Admin role not found for org ${orgId}`);
}
await trx.insert(roleSites).values({
roleId: adminRole.roleId,
siteId: newSite.siteId
});
// Create the newt for this site
await trx.insert(newts).values({
newtId,
secretHash,
siteId: newSite.siteId,
dateCreated: moment().toISOString()
});
// Consume the provisioning key - cascade removes siteProvisioningKeyOrg
await trx
.update(siteProvisioningKeys)
.set({
lastUsed: moment().toISOString(),
numUsed: sql`${siteProvisioningKeys.numUsed} + 1`
})
.where(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
provisioningKeyId
)
);
await usageService.add(orgId, FeatureId.SITES, 1, trx);
});
} finally {
await releaseSubnetLock();
}
logger.info(
`Provisioned new site (ID: ${newSiteId}) and newt (ID: ${newtId}) for org ${orgId} via provisioning key ${provisioningKeyId}`
+10 -5
View File
@@ -9,9 +9,13 @@ import {
import { canCompress } from "@server/lib/clientVersionChecks";
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(site.siteId);
const {
tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets,
remoteExitNodeSubnets
} = await buildTargetConfigurationForNewtClient(site.siteId);
let exitNode: ExitNode | undefined;
if (site.exitNodeId) {
[exitNode] = await db
@@ -24,7 +28,6 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
site,
exitNode
);
await sendToClient(
newt.newtId,
{
@@ -36,7 +39,9 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
},
healthCheckTargets: validHealthCheckTargets,
peers: peers,
clientTargets: targets
clientTargets: targets,
browserGatewayTargets: browserGatewayTargets,
remoteExitNodeSubnets: remoteExitNodeSubnets
}
},
{
+58
View File
@@ -2,6 +2,8 @@ import { Target, TargetHealthCheck } from "@server/db";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import { canCompress } from "@server/lib/clientVersionChecks";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
export async function addTargets(
newtId: string,
@@ -239,3 +241,59 @@ export async function removeTargets(
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function sendBrowserGatewayTargets(
newtId: string,
targets: Target[],
version?: string | null
) {
if (targets.length === 0) return;
// filter out the ones without auth tokens
const filteredTargets = targets.filter((t) => t.authToken);
if (filteredTargets.length === 0) return;
const payload = filteredTargets.map((t) => {
const decryptAuthToken = decrypt(
t.authToken!,
config.getRawConfig().server.secret!
);
return {
id: t.targetId,
resourceId: t.resourceId,
siteId: t.siteId,
type: t.mode,
destination: t.ip,
destinationPort: t.port,
authToken: decryptAuthToken
};
});
await sendToClient(
newtId,
{
type: "newt/browsergateway/add",
data: {
targets: payload
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function removeBrowserGatewayTarget(
newtId: string,
browserGatewayTargetId: number,
version?: string | null
) {
await sendToClient(
newtId,
{
type: "newt/browsergateway/remove",
data: {
ids: [browserGatewayTargetId]
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
+1 -1
View File
@@ -161,7 +161,7 @@ export async function buildSiteConfigurationForOlmClient(
}
if (!site.subnet) {
logger.warn(`Site ${site.siteId} has no subnet, skipping`);
logger.debug(`Site ${site.siteId} has no subnet, skipping`);
continue;
}
+17 -2
View File
@@ -43,7 +43,22 @@ export type CreateOlmResponse = {
// },
// params: paramsSchema
// },
// responses: {}
// responses: {
// 200: {
// description: "Successful response",
// content: {
// "application/json": {
// schema: z.object({
// data: z.record(z.string(), z.any()).nullable(),
// success: z.boolean(),
// error: z.boolean(),
// message: z.string(),
// status: z.number()
// })
// }
// }
// }
// }
// });
export async function createUserOlm(
@@ -89,7 +104,7 @@ export async function createUserOlm(
dateCreated: moment().toISOString()
});
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
calculateUserClientsForOrgs(userId).catch((e) => {
console.error(
"Error calculating user clients after creating olm:",
e
+49 -9
View File
@@ -9,7 +9,10 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "../client/terminate";
import { OlmErrorCodes } from "./error";
@@ -28,7 +31,22 @@ const paramsSchema = z
// request: {
// params: paramsSchema
// },
// responses: {}
// responses: {
// 200: {
// description: "Successful response",
// content: {
// "application/json": {
// schema: z.object({
// data: z.record(z.string(), z.any()).nullable(),
// success: z.boolean(),
// error: z.boolean(),
// message: z.string(),
// status: z.number()
// })
// }
// }
// }
// }
// });
export async function deleteUserOlm(
@@ -49,6 +67,30 @@ export async function deleteUserOlm(
const { olmId } = parsedParams.data;
// get the client first
const [client] = await db
.select()
.from(clients)
.where(eq(clients.olmId, olmId));
if (!client) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`No client found for olmId ${olmId}`
)
);
}
if (await isOrgRebuildRateLimited(client.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
let deletedClient: Client | undefined;
// Delete associated clients and the OLM in a transaction
await db.transaction(async (trx) => {
@@ -71,13 +113,11 @@ export async function deleteUserOlm(
});
if (deletedClient) {
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}`
);
}
);
rebuildClientAssociationsFromClient(deletedClient).catch((e) => {
logger.error(
`Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}`
);
});
sendTerminateClient(
deletedClient.clientId,
OlmErrorCodes.TERMINATED_DELETED,
+8
View File
@@ -1,4 +1,8 @@
import { sendToClient } from "#dynamic/routers/ws";
import config from "@server/lib/config";
const udpPort = config.getRawConfig().gerbil.clients_start_port;
// Error codes for registration failures
export const OlmErrorCodes = {
OLM_NOT_FOUND: {
@@ -86,6 +90,10 @@ export const OlmErrorCodes = {
TERMINATED_BLOCKED: {
code: "TERMINATED_BLOCKED",
message: "This session was terminated because access was blocked."
},
HOLEPUNCH_MISSING: {
code: "HOLEPUNCH_MISSING",
message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.`
}
} as const;
+73 -2
View File
@@ -13,7 +13,7 @@ import {
import { olms } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { and, eq, inArray } from "drizzle-orm";
import { and, count, eq, inArray } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -24,6 +24,7 @@ import {
EXPIRES
} from "@server/auth/sessions/olm";
import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { verifyPassword } from "@server/auth/password";
import logger from "@server/logger";
import config from "@server/lib/config";
@@ -150,6 +151,7 @@ export async function getOlmToken(
);
let clientIdToUse;
let orgIdToUse: string;
if (orgId) {
// we did provide the org
const [client] = await db
@@ -183,6 +185,7 @@ export async function getOlmToken(
}
clientIdToUse = client.clientId;
orgIdToUse = orgId;
} else {
if (!existingOlm.clientId) {
return next(
@@ -209,6 +212,7 @@ export async function getOlmToken(
}
clientIdToUse = client.clientId;
orgIdToUse = client.orgId;
}
// Get all exit nodes from sites where the client has peers
@@ -265,7 +269,7 @@ export async function getOlmToken(
}
}
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
let exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
return {
publicKey: exitNode.publicKey,
relayPort: config.getRawConfig().gerbil.clients_start_port,
@@ -274,6 +278,73 @@ export async function getOlmToken(
};
});
// If no exit nodes were found for the client's sites, fall back to
// finding an available node in the same region (as newt does on ping).
if (exitNodesHpData.length === 0) {
logger.debug(
`No exit nodes found for olm ${olmId} client sites; falling back to region node selection`
);
const fallbackNodes = await listExitNodes(orgIdToUse!, true);
const weightedNodes = await Promise.all(
fallbackNodes.map(async (node) => {
let weight = 1;
const maxConnections = node.maxConnections;
if (
maxConnections !== null &&
maxConnections !== undefined
) {
const [currentConnections] = await db
.select({ count: count() })
.from(sites)
.where(
and(
eq(sites.exitNodeId, node.exitNodeId),
eq(sites.online, true)
)
);
if (currentConnections.count >= maxConnections) {
return null;
}
weight =
(maxConnections - currentConnections.count) /
maxConnections;
}
return { node, weight };
})
);
const availableNodes = weightedNodes
.filter(
(
n
): n is {
node: (typeof fallbackNodes)[0];
weight: number;
} => n !== null
)
.sort((a, b) => b.weight - a.weight);
if (availableNodes.length > 0) {
const best = availableNodes[0].node;
exitNodesHpData = [
{
publicKey: best.publicKey,
relayPort:
config.getRawConfig().gerbil.clients_start_port,
endpoint: best.endpoint,
siteIds: []
// it should still HP without the site ids but it will get stuck in the client
// if a site is removed or something because its not tied to a site which is okay for the session
}
];
} else {
logger.warn(
`No available fallback exit nodes found for olm ${olmId}`
);
}
}
logger.debug("Token created successfully");
return response<{

Some files were not shown because too many files have changed in this diff Show More