Compare commits

...

19 Commits

Author SHA1 Message Date
Owen
e5652cdb8a Dont enable admin routes 2026-06-29 20:45:38 -04:00
Owen
7c2ea153c5 Use regional cache for rate limiting 2026-06-29 18:33:03 -04:00
Owen
ccabddc225 Add logging for access for new public resources 2026-06-29 18:05:29 -04:00
Owen
42d98fa83b Comment back in the sync command 2026-06-29 16:34:10 -04:00
Owen
2f2b7f43c1 Add usage tracking to blueprints 2026-06-29 16:13:12 -04:00
Owen
528bbeca26 Implement usage tracking on resources, clients 2026-06-29 15:39:30 -04:00
Owen
d60c15b0ae Fix typo 2026-06-29 15:24:16 -04:00
Owen
ff89a64453 Rename to limit id 2026-06-29 15:22:35 -04:00
Owen
4718c489d3 Add concurrency guard calculateUserClientsForOrgs 2026-06-29 15:02:41 -04:00
Owen
d5d99a4804 Add org rebuild rate limit 2026-06-29 14:59:05 -04:00
Owen
faee9e6330 Merge branch 'main' into dev 2026-06-29 11:29:12 -04:00
miloschwartz
31725eb3cc display resource type in info card 2026-06-29 10:45:38 -04:00
miloschwartz
04d4e298e8 fix spacing on endpoint field on site 2026-06-29 10:42:25 -04:00
miloschwartz
7506c0420d properly pass org policy error message in olm register 2026-06-26 17:11:32 -04:00
Owen Schwartz
5572822c4a Merge pull request #3351 from fosrl/copilot/fix-rest-ruleid-changing
fix: preserve rule IDs when saving policy rules via the GUI
2026-06-26 15:05:31 -04:00
Owen
ea3f1c341b Move hashing outside of transaction 2026-06-26 14:40:39 -04:00
Owen
35dffe71cb Make error statement debug 2026-06-26 14:40:31 -04:00
copilot-swe-agent[bot]
5428bf4ed0 fix: preserve rule IDs when saving policy rules through the GUI
The `setResourcePolicyRules` endpoint was deleting all existing rules and
re-inserting them on every save, causing all ruleIDs to change.

Backend: Accept an optional `ruleId` per rule in the request body and
perform an upsert — update existing rules (matched by ruleId), insert
new ones (no ruleId), and delete only rules absent from the incoming list.

Frontend: Include `ruleId` in the rules payload for existing (non-new)
rules so the backend can match and preserve them.
2026-06-26 14:37:34 +00:00
copilot-swe-agent[bot]
9a89579e08 Initial plan 2026-06-26 14:33:35 +00:00
76 changed files with 1329 additions and 382 deletions

View File

@@ -1,28 +1,33 @@
export enum FeatureId {
export enum LimitId {
USERS = "users",
SITES = "sites",
EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes",
ORGINIZATIONS = "organizations",
ORGANIZATIONS = "organizations",
PUBLIC_RESOURCES = "publicResources",
PRIVATE_RESOURCES = "privateResources",
MACHINE_CLIENTS = "machineClients",
TIER1 = "tier1"
}
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
export async function getFeatureDisplayName(
featureId: LimitId
): Promise<string> {
switch (featureId) {
case FeatureId.USERS:
case LimitId.USERS:
return "Users";
case FeatureId.SITES:
case LimitId.SITES:
return "Sites";
case FeatureId.EGRESS_DATA_MB:
case LimitId.EGRESS_DATA_MB:
return "Egress Data (MB)";
case FeatureId.DOMAINS:
case LimitId.DOMAINS:
return "Domains";
case FeatureId.REMOTE_EXIT_NODES:
case LimitId.REMOTE_EXIT_NODES:
return "Remote Exit Nodes";
case FeatureId.ORGINIZATIONS:
case LimitId.ORGANIZATIONS:
return "Organizations";
case FeatureId.TIER1:
case LimitId.TIER1:
return "Home Lab";
default:
return featureId;
@@ -30,15 +35,16 @@ export async function getFeatureDisplayName(featureId: FeatureId): Promise<strin
}
// this is from the old system
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data
export const FeatureMeterIds: Partial<Record<LimitId, string>> = {
// right now we are not charging for any data
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
};
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
export const FeatureMeterIdsSandbox: Partial<Record<LimitId, string>> = {
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
};
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
export function getFeatureMeterId(featureId: LimitId): string | undefined {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
@@ -49,22 +55,20 @@ export function getFeatureMeterId(featureId: FeatureId): string | undefined {
}
}
export function getFeatureIdByMetricId(
metricId: string
): FeatureId | undefined {
return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find(
export function getFeatureIdByMetricId(metricId: string): LimitId | undefined {
return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find(
([_, v]) => v === metricId
)?.[0];
}
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
export type FeaturePriceSet = Partial<Record<LimitId, string>>;
export const tier1FeaturePriceSet: FeaturePriceSet = {
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
[LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
};
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
[LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
};
export function getTier1FeaturePriceSet(): FeaturePriceSet {
@@ -79,11 +83,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet {
}
export const tier2FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
[LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
};
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
[LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
};
export function getTier2FeaturePriceSet(): FeaturePriceSet {
@@ -98,11 +102,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet {
}
export const tier3FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
[LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
};
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
[LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
};
export function getTier3FeaturePriceSet(): FeaturePriceSet {
@@ -116,7 +120,7 @@ export function getTier3FeaturePriceSet(): FeaturePriceSet {
}
}
export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
// Check all feature price sets
const allPriceSets = [
getTier1FeaturePriceSet(),
@@ -125,7 +129,7 @@ export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
];
for (const priceSet of allPriceSets) {
const entry = (Object.entries(priceSet) as [FeatureId, string][]).find(
const entry = (Object.entries(priceSet) as [LimitId, string][]).find(
([_, price]) => price === priceId
);
if (entry) {

View File

@@ -1,19 +1,19 @@
import Stripe from "stripe";
import { FeatureId, FeaturePriceSet } from "./features";
import { LimitId, FeaturePriceSet } from "./features";
import { usageService } from "./usageService";
export async function getLineItems(
featurePriceSet: FeaturePriceSet,
orgId: string,
orgId: string
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const users = await usageService.getUsage(orgId, FeatureId.USERS);
const users = await usageService.getUsage(orgId, LimitId.USERS);
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
let quantity: number | undefined;
if (featureId === FeatureId.USERS) {
if (featureId === LimitId.USERS) {
quantity = users?.instantaneousValue || 1;
} else if (featureId === FeatureId.TIER1) {
} else if (featureId === LimitId.TIER1) {
quantity = 1;
}

View File

@@ -1,70 +1,82 @@
import { FeatureId } from "./features";
import { LimitId } from "./features";
export type LimitSet = Partial<{
[key in FeatureId]: {
[key in LimitId]: {
value: number | null; // null indicates no limit
description?: string;
};
}>;
export const freeLimitSet: LimitSet = {
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" },
[LimitId.SITES]: { value: 5, description: "Basic limit" },
[LimitId.USERS]: { value: 5, description: "Basic limit" },
[LimitId.DOMAINS]: { value: 5, description: "Basic limit" },
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
[LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" },
[LimitId.PUBLIC_RESOURCES]: { value: 15, description: "Basic limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 15, description: "Basic limit" },
[LimitId.MACHINE_CLIENTS]: { value: 5, description: "Basic limit" }
};
export const tier1LimitSet: LimitSet = {
[FeatureId.USERS]: { value: 7, description: "Home limit" },
[FeatureId.SITES]: { value: 10, description: "Home limit" },
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" },
[LimitId.USERS]: { value: 7, description: "Home limit" },
[LimitId.SITES]: { value: 10, description: "Home limit" },
[LimitId.DOMAINS]: { value: 10, description: "Home limit" },
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
[LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" },
[LimitId.PUBLIC_RESOURCES]: { value: 30, description: "Home limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 30, description: "Home limit" },
[LimitId.MACHINE_CLIENTS]: { value: 10, description: "Home limit" }
};
export const tier2LimitSet: LimitSet = {
[FeatureId.USERS]: {
[LimitId.USERS]: {
value: 50,
description: "Team limit"
},
[FeatureId.SITES]: {
[LimitId.SITES]: {
value: 50,
description: "Team limit"
},
[FeatureId.DOMAINS]: {
[LimitId.DOMAINS]: {
value: 50,
description: "Team limit"
},
[FeatureId.REMOTE_EXIT_NODES]: {
[LimitId.REMOTE_EXIT_NODES]: {
value: 3,
description: "Team limit"
},
[FeatureId.ORGINIZATIONS]: {
[LimitId.ORGANIZATIONS]: {
value: 1,
description: "Team limit"
}
},
[LimitId.PUBLIC_RESOURCES]: { value: 150, description: "Team limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 150, description: "Team limit" },
[LimitId.MACHINE_CLIENTS]: { value: 25, description: "Team limit" }
};
export const tier3LimitSet: LimitSet = {
[FeatureId.USERS]: {
[LimitId.USERS]: {
value: 250,
description: "Business limit"
},
[FeatureId.SITES]: {
[LimitId.SITES]: {
value: 250,
description: "Business limit"
},
[FeatureId.DOMAINS]: {
[LimitId.DOMAINS]: {
value: 100,
description: "Business limit"
},
[FeatureId.REMOTE_EXIT_NODES]: {
[LimitId.REMOTE_EXIT_NODES]: {
value: 20,
description: "Business limit"
},
[FeatureId.ORGINIZATIONS]: {
[LimitId.ORGANIZATIONS]: {
value: 5,
description: "Business limit"
},
[LimitId.PUBLIC_RESOURCES]: { value: 750, description: "Business limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 750, description: "Business limit" },
[LimitId.MACHINE_CLIENTS]: { value: 100, description: "Business limit" }
};

View File

@@ -1,7 +1,7 @@
import { db, limits } from "@server/db";
import { and, eq } from "drizzle-orm";
import { LimitSet } from "./limitSet";
import { FeatureId } from "./features";
import { LimitId } from "./features";
import logger from "@server/logger";
class LimitService {
@@ -38,7 +38,7 @@ class LimitService {
async getOrgLimit(
orgId: string,
featureId: FeatureId
featureId: LimitId
): Promise<number | null> {
const limitId = `${orgId}-${featureId}`;
const [limit] = await db

View File

@@ -9,7 +9,7 @@ import {
Transaction,
orgs
} from "@server/db";
import { FeatureId, getFeatureMeterId } from "./features";
import { LimitId, getFeatureMeterId } from "./features";
import logger from "@server/logger";
import { build } from "@server/build";
import { regionalCache as cache } from "#dynamic/lib/cache";
@@ -37,7 +37,7 @@ export class UsageService {
public async add(
orgId: string,
featureId: FeatureId,
featureId: LimitId,
value: number,
transaction: any = null
): Promise<Usage | null> {
@@ -114,7 +114,7 @@ export class UsageService {
private async internalAddUsage(
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
featureId: FeatureId,
featureId: LimitId,
value: number,
trx: Transaction
): Promise<Usage> {
@@ -163,7 +163,7 @@ export class UsageService {
async updateCount(
orgId: string,
featureId: FeatureId,
featureId: LimitId,
value?: number,
customerId?: string
): Promise<void> {
@@ -227,7 +227,7 @@ export class UsageService {
private async getCustomerId(
orgId: string,
featureId: FeatureId
featureId: LimitId
): Promise<string | null> {
const orgIdToUse = await this.getBillingOrg(orgId);
@@ -269,7 +269,7 @@ export class UsageService {
public async getUsage(
orgId: string,
featureId: FeatureId,
featureId: LimitId,
trx: Transaction | typeof db = db
): Promise<Usage | null> {
if (noop()) {
@@ -376,7 +376,7 @@ export class UsageService {
public async checkLimitSet(
orgId: string,
featureId?: FeatureId,
featureId?: LimitId,
usage?: Usage,
trx: Transaction | typeof db = db
): Promise<boolean> {
@@ -424,7 +424,7 @@ export class UsageService {
} else {
currentUsage = await this.getUsage(
orgIdToUse,
limit.featureId as FeatureId,
limit.featureId as LimitId,
trx
);
}

View File

@@ -8,7 +8,7 @@ import {
userSiteResources,
clientSiteResources
} from "@server/db";
import { Config, ConfigSchema } from "./types";
import { Config, ConfigSchema, isTargetsOnlyResource } from "./types";
import {
PublicResourcesResults,
updatePublicResources
@@ -34,6 +34,12 @@ import {
rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle
} from "../rebuildClientAssociations";
import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
type ApplyBlueprintArgs = {
orgId: string;
@@ -64,6 +70,7 @@ export async function applyBlueprint({
let publicResourcesResults: PublicResourcesResults = [];
let privateResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => {
await updateResourcePolicies(orgId, config, trx);
@@ -172,7 +179,9 @@ export async function applyBlueprint({
} catch (err) {
blueprintSucceeded = false;
blueprintMessage = `Blueprint applied with errors: ${err}`;
logger.error(blueprintMessage);
logger.debug(
`Org ${orgId} blueprint apply issues: ${blueprintMessage}`
);
error = err;
}

View File

@@ -25,6 +25,12 @@ import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
async function getDomainForSiteResource(
siteResourceId: number | undefined,
@@ -413,6 +419,34 @@ export async function updatePrivateResources(
oldSites: existingSiteIds
});
} else {
// create a brand new resource
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PRIVATE_RESOURCES
);
if (!usage) {
throw new Error(
`Usage data not found for org ${orgId} and limit ${LimitId.PRIVATE_RESOURCES}`
);
}
const rejectResource = await usageService.checkLimitSet(
orgId,
LimitId.PRIVATE_RESOURCES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectResource) {
throw new Error(
"Private resource limit exceeded. Please upgrade your plan."
);
}
}
let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null;
if (
@@ -609,6 +643,8 @@ export async function updatePrivateResources(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
);
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
results.push({
newSiteResource: newResource,
newSites: allSites,

View File

@@ -51,6 +51,11 @@ import { build } from "@server/build";
import { encrypt } from "@server/lib/crypto";
import { generateId } from "@server/auth/sessions/app";
import serverConfig from "@server/lib/config";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
export type PublicResourcesResults = {
proxyResource: Resource;
@@ -1005,6 +1010,33 @@ export async function updatePublicResources(
logger.debug(`Updated resource ${existingResource.resourceId}`);
} else {
// create a brand new resource
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PUBLIC_RESOURCES
);
if (!usage) {
throw new Error(
`Usage data not found for org ${orgId} and limit ${LimitId.PUBLIC_RESOURCES}`
);
}
const rejectResource = await usageService.checkLimitSet(
orgId,
LimitId.PUBLIC_RESOURCES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectResource) {
throw new Error(
"Public resource limit exceeded. Please upgrade your plan."
);
}
}
let domain;
if (
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
@@ -1294,6 +1326,8 @@ export async function updatePublicResources(
await createTarget(newResource.resourceId, targetData);
}
await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx);
logger.debug(`Created resource ${newResource.resourceId}`);
}

View File

@@ -24,7 +24,7 @@ import { deletePeer } from "@server/routers/gerbil/peers";
import { OlmErrorCodes } from "@server/routers/olm/error";
import { sendTerminateClient } from "@server/routers/client/terminate";
import { usageService } from "./billing/usageService";
import { FeatureId } from "./billing";
import { LimitId } from "./billing";
export type DeleteOrgByIdResult = {
deletedNewtIds: string[];
@@ -140,7 +140,9 @@ export async function deleteOrgById(
.select({ count: count() })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`);
logger.info(
`Found ${orgCount.count} orgs using domain ${domainId}`
);
if (orgCount.count === 1) {
domainIdsToDelete.push(domainId);
}
@@ -152,7 +154,7 @@ export async function deleteOrgById(
.where(inArray(domains.domainId, domainIdsToDelete));
}
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
await usageService.add(orgId, LimitId.ORGANIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
@@ -199,22 +201,22 @@ export async function deleteOrgById(
if (org.billingOrgId) {
usageService.updateCount(
org.billingOrgId,
FeatureId.DOMAINS,
LimitId.DOMAINS,
domainCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.SITES,
LimitId.SITES,
siteCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.USERS,
LimitId.USERS,
userCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.REMOTE_EXIT_NODES,
LimitId.REMOTE_EXIT_NODES,
remoteExitNodeCount ?? 0
);
}

View File

@@ -0,0 +1,24 @@
export const ORG_REBUILD_CONCURRENCY_LIMIT = 10;
const orgActiveRebuilds = new Map<string, number>();
export async function incrementOrgRebuildCount(orgId: string): Promise<void> {
orgActiveRebuilds.set(orgId, (orgActiveRebuilds.get(orgId) ?? 0) + 1);
}
export async function decrementOrgRebuildCount(orgId: string): Promise<void> {
const current = orgActiveRebuilds.get(orgId) ?? 0;
if (current <= 1) {
orgActiveRebuilds.delete(orgId);
} else {
orgActiveRebuilds.set(orgId, current - 1);
}
}
export async function getOrgActiveRebuildCount(orgId: string): Promise<number> {
return orgActiveRebuilds.get(orgId) ?? 0;
}
export async function checkOrgRebuildRateLimit(orgId: string): Promise<boolean> {
return (orgActiveRebuilds.get(orgId) ?? 0) >= ORG_REBUILD_CONCURRENCY_LIMIT;
}

View File

@@ -45,11 +45,23 @@ import {
} from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock";
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
import {
checkOrgRebuildRateLimit,
decrementOrgRebuildCount,
incrementOrgRebuildCount,
ORG_REBUILD_CONCURRENCY_LIMIT
} from "#dynamic/lib/orgRebuildCounter";
export { ORG_REBUILD_CONCURRENCY_LIMIT };
// TTL for rebuild-association locks. These functions can fan out into many
// peer/proxy updates, so give them a generous window.
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
export async function isOrgRebuildRateLimited(orgId: string): Promise<boolean> {
return checkOrgRebuildRateLimit(orgId);
}
const REBUILD_IDLE_POLL_INTERVAL_MS = 300;
const REBUILD_IDLE_DEFAULT_TIMEOUT_MS = 130_000; // slightly longer than lock TTL
const REBUILD_IDLE_HANDLER_TIMEOUT_MS = 5_000;
@@ -271,6 +283,7 @@ export async function getClientSiteResourceAccess(
export async function rebuildClientAssociationsFromSiteResource(
siteResource: SiteResource
) {
await incrementOrgRebuildCount(siteResource.orgId);
try {
return await lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
@@ -292,6 +305,8 @@ export async function rebuildClientAssociationsFromSiteResource(
return { mergedAllClients: [] };
}
throw err;
} finally {
await decrementOrgRebuildCount(siteResource.orgId);
}
}
@@ -1638,8 +1653,9 @@ export async function handleMessagingForUpdatedSiteResource(
export async function rebuildClientAssociationsFromClient(
client: Client
): Promise<void> {
const trx = primaryDb;
await incrementOrgRebuildCount(client.orgId);
try {
const trx = primaryDb;
return await lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx),
@@ -1660,6 +1676,8 @@ export async function rebuildClientAssociationsFromClient(
return;
}
throw err;
} finally {
await decrementOrgRebuildCount(client.orgId);
}
}

View File

@@ -14,7 +14,7 @@ import {
} from "@server/db";
import { eq, and, inArray, ne, exists } from "drizzle-orm";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
export async function assignUserToOrg(
org: Org,
@@ -61,7 +61,7 @@ export async function assignUserToOrg(
);
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
await usageService.add(org.orgId, FeatureId.USERS, 1, trx);
await usageService.add(org.orgId, LimitId.USERS, 1, trx);
}
}
}
@@ -157,7 +157,7 @@ export async function removeUserFromOrg(
);
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
await usageService.add(org.orgId, FeatureId.USERS, -1, trx);
await usageService.add(org.orgId, LimitId.USERS, -1, trx);
}
}
}

View File

@@ -0,0 +1,105 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { redis } from "#private/lib/redis";
import logger from "@server/logger";
export const ORG_REBUILD_CONCURRENCY_LIMIT = 5;
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
// server process dies while holding a rebuild, this ensures the counter key
// eventually expires rather than staying inflated forever.
const ORG_REBUILD_COUNT_TTL_MS = 180000;
const KEY_PREFIX = "rebuild-org-count:";
// In-memory fallback used when Redis is unavailable.
const localFallback = new Map<string, number>();
function isRedisReady(): boolean {
return !!(redis && redis.status === "ready");
}
export async function incrementOrgRebuildCount(orgId: string): Promise<void> {
if (!isRedisReady()) {
localFallback.set(orgId, (localFallback.get(orgId) ?? 0) + 1);
return;
}
try {
const key = `${KEY_PREFIX}${orgId}`;
await redis!.incr(key);
// Always refresh the TTL so the key doesn't expire while rebuilds are
// still in progress. The TTL is purely a crash-recovery safety net.
await redis!.pexpire(key, ORG_REBUILD_COUNT_TTL_MS);
} catch (err) {
logger.warn(
`orgRebuildCounter: Redis increment failed for org ${orgId}, falling back to local:`,
err
);
localFallback.set(orgId, (localFallback.get(orgId) ?? 0) + 1);
}
}
export async function decrementOrgRebuildCount(orgId: string): Promise<void> {
if (!isRedisReady()) {
const current = localFallback.get(orgId) ?? 0;
if (current <= 1) {
localFallback.delete(orgId);
} else {
localFallback.set(orgId, current - 1);
}
return;
}
try {
const key = `${KEY_PREFIX}${orgId}`;
const count = await redis!.decr(key);
if (count <= 0) {
await redis!.del(key);
}
} catch (err) {
logger.warn(
`orgRebuildCounter: Redis decrement failed for org ${orgId}, falling back to local:`,
err
);
const current = localFallback.get(orgId) ?? 0;
if (current <= 1) {
localFallback.delete(orgId);
} else {
localFallback.set(orgId, current - 1);
}
}
}
export async function getOrgActiveRebuildCount(orgId: string): Promise<number> {
if (!isRedisReady()) {
return localFallback.get(orgId) ?? 0;
}
try {
const key = `${KEY_PREFIX}${orgId}`;
const val = await redis!.get(key);
return val ? parseInt(val, 10) : 0;
} catch (err) {
logger.warn(
`orgRebuildCounter: Redis get failed for org ${orgId}, falling back to local:`,
err
);
return localFallback.get(orgId) ?? 0;
}
}
export async function checkOrgRebuildRateLimit(
orgId: string
): Promise<boolean> {
return (
(await getOrgActiveRebuildCount(orgId)) >= ORG_REBUILD_CONCURRENCY_LIMIT
);
}

View File

@@ -12,7 +12,7 @@
*/
import logger from "@server/logger";
import redisManager from "#private/lib/redis";
import { regionalRedisManager as redisManager } from "#private/lib/redis";
import { build } from "@server/build";
// Rate limiting configuration
@@ -152,10 +152,9 @@ export class RateLimitService {
);
// Set TTL using the client directly - this prevents the key from persisting forever
if (redisManager.getClient()) {
await redisManager
.getClient()
.expire(globalKey, RATE_LIMIT_WINDOW + 10);
const writeClient = redisManager.getClient();
if (writeClient) {
await writeClient.expire(globalKey, RATE_LIMIT_WINDOW + 10);
}
// Update tracking
@@ -204,10 +203,12 @@ export class RateLimitService {
);
// Set TTL using the client directly - this prevents the key from persisting forever
if (redisManager.getClient()) {
await redisManager
.getClient()
.expire(messageTypeKey, RATE_LIMIT_WINDOW + 10);
const writeClient = redisManager.getClient();
if (writeClient) {
await writeClient.expire(
messageTypeKey,
RATE_LIMIT_WINDOW + 10
);
}
// Update tracking
@@ -487,16 +488,13 @@ export class RateLimitService {
await redisManager.del(globalKey);
// Get all message type keys for this client and delete them
const client = redisManager.getClient();
if (client) {
const messageTypeKeys = await client.keys(
`ratelimit:${clientId}:*`
const messageTypeKeys = await redisManager.keys(
`ratelimit:${clientId}:*`
);
if (messageTypeKeys.length > 0) {
await Promise.all(
messageTypeKeys.map((key) => redisManager.del(key))
);
if (messageTypeKeys.length > 0) {
await Promise.all(
messageTypeKeys.map((key) => redisManager.del(key))
);
}
}
}
}

View File

@@ -1000,6 +1000,45 @@ class RegionalRedisManager {
}
}
public getClient(): Redis | null {
return this.writeClient;
}
public async hget(key: string, field: string): Promise<string | null> {
if (!this.isRedisEnabled() || !this.readClient) return null;
try {
return await this.readClient.hget(key, field);
} catch (error) {
logger.error("Regional Redis HGET error:", error);
return null;
}
}
public async hset(
key: string,
field: string,
value: string
): Promise<boolean> {
if (!this.isRedisEnabled() || !this.writeClient) return false;
try {
await this.writeClient.hset(key, field, value);
return true;
} catch (error) {
logger.error("Regional Redis HSET error:", error);
return false;
}
}
public async hgetall(key: string): Promise<Record<string, string>> {
if (!this.isRedisEnabled() || !this.readClient) return {};
try {
return await this.readClient.hgetall(key);
} catch (error) {
logger.error("Regional Redis HGETALL error:", error);
return {};
}
}
public async disconnect(): Promise<void> {
try {
if (this.writeClient) {

View File

@@ -17,3 +17,4 @@ export * from "./queryAccessAuditLog";
export * from "./exportAccessAuditLog";
export * from "./queryConnectionAuditLog";
export * from "./exportConnectionAuditLog";
export * from "./logAccessAuditAttempt";

View File

@@ -0,0 +1,95 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { NextFunction } from "express";
import { Request, Response } from "express";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import response from "@server/lib/response";
import logger from "@server/logger";
import { logAccessAudit } from "#private/lib/logAccessAudit";
export const logAccessAuditAttemptSchema = z.object({
resourceId: z.number().int().positive(),
action: z.boolean(),
type: z.enum(["login", "ssh", "vnc", "rdp"])
});
export const logAccessAuditAttemptParams = z.object({
orgId: z.string()
});
export async function logAccessAuditAttempt(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = logAccessAuditAttemptSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error)
)
);
}
const parsedParams = logAccessAuditAttemptParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
const { resourceId, action, type } = parsedBody.data;
const username = req.user?.username;
const userId = req.user?.userId;
await logAccessAudit({
orgId: orgId,
resourceId: resourceId,
action: action,
...(username && userId
? {
user: {
username,
userId
}
}
: {}),
type: type,
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Access audit attempt logged successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -22,7 +22,7 @@ import {
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray, isNull } from "drizzle-orm";
import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
@@ -120,7 +120,10 @@ function getWhere(data: Q) {
lt(accessAuditLog.timestamp, data.timeEnd),
eq(accessAuditLog.orgId, data.orgId),
data.resourceId
? eq(accessAuditLog.resourceId, data.resourceId)
? or(
eq(accessAuditLog.resourceId, data.resourceId),
eq(accessAuditLog.siteResourceId, data.resourceId)
)
: undefined,
data.actor ? eq(accessAuditLog.actor, data.actor) : undefined,
data.actorType
@@ -233,7 +236,6 @@ async function enrichWithResourceDetails(
const details = siteResourceMap.get(log.siteResourceId);
return {
...log,
resourceId: log.siteResourceId,
resourceName: details?.name ?? null,
resourceNiceId: details?.niceId ?? null
};

View File

@@ -25,7 +25,7 @@ import {
getTier1FeaturePriceSet,
getTier3FeaturePriceSet,
getTier2FeaturePriceSet,
FeatureId,
LimitId,
type FeaturePriceSet
} from "@server/lib/billing";
import { getLineItems } from "@server/lib/billing/getLineItems";
@@ -214,7 +214,7 @@ export async function changeTier(
}
// Map to the corresponding feature in the new tier
const newPriceId = targetPriceSet[FeatureId.USERS];
const newPriceId = targetPriceSet[LimitId.USERS];
if (newPriceId) {
return {

View File

@@ -24,7 +24,7 @@ import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { Limit, limits, Usage, usage } from "@server/db";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
import { GetOrgUsageResponse } from "@server/routers/billing/types";
const getOrgSchema = z.strictObject({
@@ -93,16 +93,16 @@ export async function getOrgUsage(
// Get usage for org
const usageData = [];
const sites = await usageService.getUsage(orgId, FeatureId.SITES);
const users = await usageService.getUsage(orgId, FeatureId.USERS);
const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS);
const sites = await usageService.getUsage(orgId, LimitId.SITES);
const users = await usageService.getUsage(orgId, LimitId.USERS);
const domains = await usageService.getUsage(orgId, LimitId.DOMAINS);
const remoteExitNodes = await usageService.getUsage(
orgId,
FeatureId.REMOTE_EXIT_NODES
LimitId.REMOTE_EXIT_NODES
);
const organizations = await usageService.getUsage(
orgId,
FeatureId.ORGINIZATIONS
LimitId.ORGANIZATIONS
);
// const egressData = await usageService.getUsage(
// orgId,

View File

@@ -495,29 +495,31 @@ authRouter.post(
auth.transferSession
);
authenticated.post(
"/license/activate",
verifyUserIsServerAdmin,
license.activateLicense
);
if (build !== "saas") {
authenticated.post(
"/license/activate",
verifyUserIsServerAdmin,
license.activateLicense
);
authenticated.get(
"/license/keys",
verifyUserIsServerAdmin,
license.listLicenseKeys
);
authenticated.get(
"/license/keys",
verifyUserIsServerAdmin,
license.listLicenseKeys
);
authenticated.delete(
"/license/:licenseKey",
verifyUserIsServerAdmin,
license.deleteLicenseKey
);
authenticated.delete(
"/license/:licenseKey",
verifyUserIsServerAdmin,
license.deleteLicenseKey
);
authenticated.post(
"/license/recheck",
verifyUserIsServerAdmin,
license.recheckStatus
);
authenticated.post(
"/license/recheck",
verifyUserIsServerAdmin,
license.recheckStatus
);
}
authenticated.get(
"/org/:orgId/logs/action",
@@ -878,3 +880,9 @@ authenticated.post(
verifyClientAccess,
client.rebuildClientAssociationsCacheRoute
);
authenticated.post(
"/org/:orgId/logs/access/attempt",
verifyOrgAccess,
logs.logAccessAuditAttempt
);

View File

@@ -23,6 +23,7 @@ import { and, eq, sql } from "drizzle-orm";
import { removeUserFromOrg } from "@server/lib/userOrg";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { OpenAPITags, registry } from "@server/openApi";
import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z
.object({
@@ -90,6 +91,15 @@ export async function unassociateOrgIdp(
);
}
if (await isOrgRebuildRateLimited(org.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
const orgUsersFromIdp = await db
.select({
userId: userOrgs.userId,

View File

@@ -35,7 +35,7 @@ import logger from "@server/logger";
import { and, eq, inArray, ne } from "drizzle-orm";
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
import { CreateRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types";
export const paramsSchema = z.object({
@@ -79,7 +79,10 @@ export async function createRemoteExitNode(
const { remoteExitNodeId, secret } = parsedBody.data;
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
if (
req.user &&
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -87,13 +90,13 @@ export async function createRemoteExitNode(
const usage = await usageService.getUsage(
orgId,
FeatureId.REMOTE_EXIT_NODES
LimitId.REMOTE_EXIT_NODES
);
if (usage) {
const rejectRemoteExitNodes = await usageService.checkLimitSet(
orgId,
FeatureId.REMOTE_EXIT_NODES,
LimitId.REMOTE_EXIT_NODES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -264,7 +267,7 @@ export async function createRemoteExitNode(
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
await usageService.add(
orgId,
FeatureId.REMOTE_EXIT_NODES,
LimitId.REMOTE_EXIT_NODES,
1,
trx
);

View File

@@ -22,7 +22,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
const paramsSchema = z.strictObject({
orgId: z.string().min(1),
@@ -117,7 +117,7 @@ export async function deleteRemoteExitNode(
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
await usageService.add(
orgId,
FeatureId.REMOTE_EXIT_NODES,
LimitId.REMOTE_EXIT_NODES,
-1,
trx
);

View File

@@ -23,7 +23,10 @@ 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 addUserRoleParamsSchema = z.strictObject({
userId: z.string(),
@@ -128,6 +131,15 @@ export async function addUserRole(
);
}
if (await isOrgRebuildRateLimited(role.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
let newUserRole: {
userId: string;
orgId: string;

View File

@@ -21,7 +21,10 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const setUserOrgRolesParamsSchema = z.strictObject({
orgId: z.string(),
@@ -87,6 +90,15 @@ export async function setUserOrgRoles(
);
}
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 orgRoles = await db
.select({ roleId: roles.roleId, isAdmin: roles.isAdmin })
.from(roles)

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;

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,7 +220,7 @@ 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);
}
});

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()
@@ -125,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) {
@@ -154,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
@@ -277,6 +323,8 @@ export async function createClient(
clientId: newClient.clientId,
dateCreated: moment().toISOString()
});
await usageService.add(orgId, LimitId.MACHINE_CLIENTS, 1, trx);
});
if (newClient) {
@@ -291,7 +339,7 @@ export async function createClient(
data: newClient,
success: true,
error: false,
message: "Site created successfully",
message: "Client created successfully",
status: HttpCode.CREATED
});
} catch (error) {

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

View File

@@ -9,9 +9,14 @@ 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.coerce.number().int().positive()
@@ -76,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(
@@ -106,6 +120,13 @@ 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) {

View File

@@ -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,6 +60,15 @@ export async function rebuildClientAssociationsCacheRoute(
);
}
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}`

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) {

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, {

View File

@@ -910,19 +910,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,
@@ -945,12 +932,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,
@@ -1033,85 +1014,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`,

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.
}
}

View File

@@ -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";
@@ -645,7 +645,7 @@ export async function validateOidcCallback(
for (const orgCount of orgUserCounts) {
await usageService.updateCount(
orgCount.orgId,
FeatureId.USERS,
LimitId.USERS,
orgCount.userCount
);
}

View File

@@ -49,22 +49,20 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`
);
// TODO: IMPLEMENT THE SYNC ON THE NEWT SIDE AND COMMENT THIS BACK IN
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, newt.siteId))
.limit(1);
// const [site] = await db
// .select()
// .from(sites)
// .where(eq(sites.siteId, newt.siteId))
// .limit(1);
if (!site) {
logger.warn(
`Newt ping message: site with ID ${newt.siteId} not found`
);
return;
}
// if (!site) {
// logger.warn(
// `Newt ping message: site with ID ${newt.siteId} not found`
// );
// return;
// }
// await sendNewtSyncMessage(newt, site);
await sendNewtSyncMessage(newt, site);
}
return {

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
@@ -274,7 +274,7 @@ export async function registerNewt(
)
);
await usageService.add(orgId, FeatureId.SITES, 1, trx);
await usageService.add(orgId, LimitId.SITES, 1, trx);
});
} finally {
await releaseSubnetLock();

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";
@@ -64,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) => {

View File

@@ -197,15 +197,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
policyCheck
});
if (policyCheck?.error) {
logger.error(
`[handleOlmRegisterMessage] Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`,
{ orgId: client.orgId, clientId: client.clientId }
);
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
return;
}
if (policyCheck.policies?.passwordAge?.compliant === false) {
logger.warn(
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`,
@@ -238,7 +229,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
olm.olmId
);
return;
} else if (!policyCheck.allowed) {
} else if (!policyCheck.allowed || policyCheck.error) {
logger.warn(
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`,
{ orgId: client.orgId, clientId: client.clientId }

View File

@@ -27,7 +27,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { isValidCIDR } from "@server/lib/validators";
import { createCustomer } from "#dynamic/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId, limitsService, freeLimitSet } from "@server/lib/billing";
import { LimitId, limitsService, freeLimitSet } from "@server/lib/billing";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { doCidrsOverlap } from "@server/lib/ip";
@@ -202,7 +202,7 @@ export async function createOrg(
if (build == "saas" && billingOrgIdForNewOrg) {
const usage = await usageService.getUsage(
billingOrgIdForNewOrg,
FeatureId.ORGINIZATIONS
LimitId.ORGANIZATIONS
);
if (!usage) {
return next(
@@ -214,7 +214,7 @@ export async function createOrg(
}
const rejectOrgs = await usageService.checkLimitSet(
billingOrgIdForNewOrg,
FeatureId.ORGINIZATIONS,
LimitId.ORGANIZATIONS,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -421,7 +421,7 @@ export async function createOrg(
if (customerId) {
await usageService.updateCount(
orgId,
FeatureId.USERS,
LimitId.USERS,
1,
customerId
); // Only 1 because we are creating the org
@@ -431,7 +431,7 @@ export async function createOrg(
if (numOrgs) {
usageService.updateCount(
billingOrgIdForNewOrg || orgId,
FeatureId.ORGINIZATIONS,
LimitId.ORGANIZATIONS,
numOrgs
);
}

View File

@@ -76,6 +76,15 @@ export async function setResourcePolicyHeaderAuth(
const { resourcePolicyId } = parsedParams.data;
const { headerAuth } = parsedBody.data;
const headerAuthHash =
headerAuth !== null
? await hashPassword(
Buffer.from(
`${headerAuth.user}:${headerAuth.password}`
).toString("base64")
)
: null;
await db.transaction(async (trx) => {
await trx
.delete(resourcePolicyHeaderAuth)
@@ -86,13 +95,7 @@ export async function setResourcePolicyHeaderAuth(
)
);
if (headerAuth !== null) {
const headerAuthHash = await hashPassword(
Buffer.from(
`${headerAuth.user}:${headerAuth.password}`
).toString("base64")
);
if (headerAuth !== null && headerAuthHash !== null) {
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId,
headerAuthHash,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resourcePolicyRules, resourcePolicies } from "@server/db";
import { eq } from "drizzle-orm";
import { and, eq, notInArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -14,6 +14,7 @@ import {
import { OpenAPITags, registry } from "@server/openApi";
const ruleSchema = z.strictObject({
ruleId: z.int().positive().optional(),
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
type: "string",
enum: ["ACCEPT", "DROP", "PASS"],
@@ -121,17 +122,74 @@ export async function setResourcePolicyRules(
.set({ applyRules })
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
await trx
.delete(resourcePolicyRules)
.where(
eq(resourcePolicyRules.resourcePolicyId, resourcePolicyId)
);
const incomingRuleIds = rules
.map((r) => r.ruleId)
.filter((id): id is number => id !== undefined);
if (rules.length > 0) {
// Delete rules that are no longer in the incoming list
if (incomingRuleIds.length > 0) {
await trx
.delete(resourcePolicyRules)
.where(
and(
eq(
resourcePolicyRules.resourcePolicyId,
resourcePolicyId
),
notInArray(
resourcePolicyRules.ruleId,
incomingRuleIds
)
)
);
} else {
await trx
.delete(resourcePolicyRules)
.where(
eq(
resourcePolicyRules.resourcePolicyId,
resourcePolicyId
)
);
}
// Update existing rules (those with a ruleId)
const existingRules = rules.filter(
(r): r is typeof r & { ruleId: number } =>
r.ruleId !== undefined
);
for (const rule of existingRules) {
await trx
.update(resourcePolicyRules)
.set({
action: rule.action,
match: rule.match,
value: rule.value,
priority: rule.priority,
enabled: rule.enabled
})
.where(
and(
eq(resourcePolicyRules.ruleId, rule.ruleId),
eq(
resourcePolicyRules.resourcePolicyId,
resourcePolicyId
)
)
);
}
// Insert new rules (those without a ruleId)
const newRules = rules.filter((r) => r.ruleId === undefined);
if (newRules.length > 0) {
await trx.insert(resourcePolicyRules).values(
rules.map((rule) => ({
newRules.map((rule) => ({
resourcePolicyId,
...rule
action: rule.action,
match: rule.match,
value: rule.value,
priority: rule.priority,
enabled: rule.enabled
}))
);
}

View File

@@ -36,6 +36,8 @@ import {
getUniqueResourceName,
getUniqueResourcePolicyName
} from "@server/db/names";
import { usageService } from "@server/lib/billing/usageService";
import { LimitId } from "@server/lib/billing";
const createResourceParamsSchema = z.strictObject({
orgId: z.string()
@@ -235,6 +237,38 @@ export async function createResource(
req.body.mode = resolvedMode.mode;
}
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PUBLIC_RESOURCES
);
if (!usage) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No usage data found for this organization"
)
);
}
const rejectResource = await usageService.checkLimitSet(
orgId,
LimitId.PUBLIC_RESOURCES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectResource) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Public resource limit exceeded. Please upgrade your plan."
)
);
}
}
if (typeof req.body.proxyPort === "number") {
if (
!config.getRawConfig().flags?.allow_raw_resources &&
@@ -503,6 +537,8 @@ async function createHttpResource(
}
resource = newResource[0];
await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx);
});
if (!resource) {
@@ -631,6 +667,8 @@ async function createRawResource(
}
resource = newResource[0];
await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx);
});
if (!resource) {

View File

@@ -11,6 +11,8 @@ import {
performDeleteResource,
runResourceDeleteSideEffects
} from "@server/lib/deleteResource";
import { LimitId } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
const deleteResourceSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
@@ -64,6 +66,14 @@ export async function deleteResource(
await db.transaction(async (trx) => {
deleteResult = await performDeleteResource(resourceId, trx);
if (deleteResult?.deletedResource?.orgId) {
await usageService.add(
deleteResult?.deletedResource?.orgId,
LimitId.PUBLIC_RESOURCES,
-1,
trx
);
}
});
if (!deleteResult) {

View File

@@ -107,6 +107,13 @@ export async function setResourceHeaderAuth(
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
const headerAuthHash =
user && password && extendedCompatibility !== null
? await hashPassword(
Buffer.from(`${user}:${password}`).toString("base64")
)
: null;
await db.transaction(async (trx) => {
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
@@ -116,11 +123,7 @@ export async function setResourceHeaderAuth(
eq(resourcePolicyHeaderAuth.resourcePolicyId, policyId)
);
if (user && password && extendedCompatibility !== null) {
const headerAuthHash = await hashPassword(
Buffer.from(`${user}:${password}`).toString("base64")
);
if (headerAuthHash !== null && extendedCompatibility !== null) {
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId: policyId,
headerAuthHash,
@@ -140,11 +143,7 @@ export async function setResourceHeaderAuth(
)
);
if (user && password && extendedCompatibility !== null) {
const headerAuthHash = await hashPassword(
Buffer.from(`${user}:${password}`).toString("base64")
);
if (headerAuthHash !== null && extendedCompatibility !== null) {
await Promise.all([
trx
.insert(resourceHeaderAuth)

View File

@@ -19,7 +19,7 @@ import { getNextAvailableClientSubnet, isIpInCidr } from "@server/lib/ip";
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
import { generateId } from "@server/auth/sessions/app";
const createSiteParamsSchema = z.strictObject({
@@ -160,7 +160,7 @@ export async function createSite(
}
if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.SITES);
const usage = await usageService.getUsage(orgId, LimitId.SITES);
if (!usage) {
return next(
createHttpError(
@@ -172,7 +172,7 @@ export async function createSite(
const rejectSites = await usageService.checkLimitSet(
orgId,
FeatureId.SITES,
LimitId.SITES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -519,7 +519,7 @@ export async function createSite(
});
}
await usageService.add(orgId, FeatureId.SITES, 1, trx);
await usageService.add(orgId, LimitId.SITES, 1, trx);
});
} finally {
await releaseSubnetLock?.();

View File

@@ -13,7 +13,7 @@ import { sendToClient } from "#dynamic/routers/ws";
import { OpenAPITags, registry } from "@server/openApi";
import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import {
deleteAssociatedResourcesForSite,
@@ -177,7 +177,7 @@ export async function deleteSite(
}
await trx.delete(sites).where(eq(sites.siteId, siteId));
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
await usageService.add(site.orgId, LimitId.SITES, -1, trx);
});
if (deleteResources) {

View File

@@ -8,7 +8,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const addClientToSiteResourceBodySchema = z
.object({
@@ -128,6 +131,15 @@ export async function addClientToSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Check if client already exists in site resource
const existingEntry = await db
.select()

View File

@@ -9,7 +9,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const addRoleToSiteResourceBodySchema = z
.object({
@@ -104,6 +107,15 @@ export async function addRoleToSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// verify the role exists and belongs to the same org
const [role] = await db
.select()

View File

@@ -9,7 +9,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const addUserToSiteResourceBodySchema = z
.object({
@@ -104,6 +107,15 @@ export async function addUserToSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Check if user already exists in site resource
const existingEntry = await db
.select()

View File

@@ -15,7 +15,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const batchAddClientToSiteResourcesParamsSchema = z
.object({
@@ -186,6 +189,15 @@ export async function batchAddClientToSiteResources(
);
}
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."
)
);
}
if (client.userId !== null) {
return next(
createHttpError(

View File

@@ -21,7 +21,10 @@ import {
} from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -34,6 +37,8 @@ import { fromError } from "zod-validation-error";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService";
import { LimitId } from "@server/lib/billing";
const createSiteResourceParamsSchema = z.strictObject({
orgId: z.string()
@@ -291,6 +296,38 @@ export async function createSiteResource(
siteIds.push(siteId);
}
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PRIVATE_RESOURCES
);
if (!usage) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No usage data found for this organization"
)
);
}
const rejectResource = await usageService.checkLimitSet(
orgId,
LimitId.PRIVATE_RESOURCES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectResource) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Private resource limit exceeded. Please upgrade your plan."
)
);
}
}
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
@@ -339,6 +376,15 @@ export async function createSiteResource(
);
}
if (await isOrgRebuildRateLimited(org.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Only check if destination is an IP address
const isIp = z
.union([z.ipv4(), z.ipv6()])
@@ -593,6 +639,13 @@ export async function createSiteResource(
);
}
}
await usageService.add(
orgId,
LimitId.PRIVATE_RESOURCES,
1,
trx
);
});
} finally {
await releaseAliasLock?.();

View File

@@ -12,6 +12,8 @@ import {
performDeleteSiteResource,
runSiteResourceDeleteSideEffects
} from "@server/lib/deleteSiteResource";
import { LimitId } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
const deleteSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
@@ -86,6 +88,14 @@ export async function deleteSiteResource(
siteResourceId,
trx
);
if (removedSiteResource?.orgId) {
await usageService.add(
removedSiteResource?.orgId,
LimitId.PRIVATE_RESOURCES,
-1,
trx
);
}
});
if (!removedSiteResource) {

View File

@@ -8,7 +8,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const removeClientFromSiteResourceBodySchema = z
.object({
@@ -106,6 +109,14 @@ export async function removeClientFromSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Check if client exists and has a userId
const [client] = await db
.select()

View File

@@ -9,7 +9,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const removeRoleFromSiteResourceBodySchema = z
.object({
@@ -106,6 +109,15 @@ export async function removeRoleFromSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Check if the role is an admin role
const [roleToCheck] = await db
.select()

View File

@@ -9,7 +9,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const removeUserFromSiteResourceBodySchema = z
.object({
@@ -106,6 +109,15 @@ export async function removeUserFromSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Check if user exists in site resource
const existingEntry = await db
.select()

View File

@@ -8,7 +8,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const setSiteResourceClientsBodySchema = z
.object({
@@ -107,6 +110,15 @@ export async function setSiteResourceClients(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Check if any clients have a userId (associated with a user)
if (clientIds.length > 0) {
const clientsWithUsers = await db

View File

@@ -9,7 +9,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and, ne, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import { rebuildClientAssociationsFromSiteResource, isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
const setSiteResourceRolesBodySchema = z
.object({
@@ -167,6 +167,15 @@ export async function setSiteResourceRoles(
}
});
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => {
logger.error(
`Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}`

View File

@@ -9,7 +9,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import { error } from "node:console";
const setSiteResourceUsersBodySchema = z
@@ -109,6 +112,15 @@ export async function setSiteResourceUsers(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
await db.transaction(async (trx) => {
await trx
.delete(userSiteResources)

View File

@@ -19,6 +19,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip";
import {
handleMessagingForUpdatedSiteResource,
isOrgRebuildRateLimited,
rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle
} from "@server/lib/rebuildClientAssociations";
@@ -345,6 +346,15 @@ export async function updateSiteResource(
);
}
if (await isOrgRebuildRateLimited(org.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Verify the site exists and belongs to the org
const sitesToAssign = await db
.select()

View File

@@ -17,10 +17,11 @@ import { fromError } from "zod-validation-error";
import { checkValidInvite } from "@server/auth/checkValidInvite";
import { verifySession } from "@server/auth/sessions/verifySession";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { build } from "@server/build";
import { assignUserToOrg } from "@server/lib/userOrg";
import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
const acceptInviteBodySchema = z.strictObject({
token: z.string(),
@@ -103,7 +104,7 @@ export async function acceptInvite(
if (build == "saas") {
const usage = await usageService.getUsage(
existingInvite.orgId,
FeatureId.USERS
LimitId.USERS
);
if (!usage) {
return next(
@@ -116,7 +117,7 @@ export async function acceptInvite(
const rejectUsers = await usageService.checkLimitSet(
existingInvite.orgId,
FeatureId.USERS,
LimitId.USERS,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -147,6 +148,15 @@ export async function acceptInvite(
);
}
if (await isOrgRebuildRateLimited(org.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
const inviteRoleRows = await db
.select({ roleId: userInviteRoles.roleId })
.from(userInviteRoles)

View File

@@ -10,7 +10,10 @@ 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";
/** Legacy path param order: /role/:roleId/add/:userId */
const addUserRoleLegacyParamsSchema = z.strictObject({
@@ -127,6 +130,15 @@ export async function addUserRoleLegacy(
);
}
if (await isOrgRebuildRateLimited(role.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
let orgClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => {

View File

@@ -12,13 +12,14 @@ import { and, eq, inArray } from "drizzle-orm";
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
import { generateId } from "@server/auth/sessions/app";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { assignUserToOrg } from "@server/lib/userOrg";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
@@ -122,7 +123,7 @@ export async function createOrgUser(
} = parsedBody.data;
if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.USERS);
const usage = await usageService.getUsage(orgId, LimitId.USERS);
if (!usage) {
return next(
createHttpError(
@@ -134,7 +135,7 @@ export async function createOrgUser(
const rejectUsers = await usageService.checkLimitSet(
orgId,
FeatureId.USERS,
LimitId.USERS,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -229,6 +230,15 @@ export async function createOrgUser(
);
}
if (await isOrgRebuildRateLimited(org.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
const [idpRes] = await db
.select()
.from(idp)

View File

@@ -25,7 +25,7 @@ import SendInviteLink from "@server/emails/templates/SendInviteLink";
import { OpenAPITags, registry } from "@server/openApi";
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";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
import cache from "#dynamic/lib/cache";
@@ -73,7 +73,6 @@ const InviteUserResponseDataSchema = z.object({
expiresAt: z.number()
});
registry.registerPath({
method: "post",
path: "/org/{orgId}/create-invite",
@@ -94,7 +93,9 @@ registry.registerPath({
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(InviteUserResponseDataSchema)
schema: createApiResponseSchema(
InviteUserResponseDataSchema
)
}
}
}
@@ -181,7 +182,7 @@ export async function inviteUser(
}
if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.USERS);
const usage = await usageService.getUsage(orgId, LimitId.USERS);
if (!usage) {
return next(
createHttpError(
@@ -192,7 +193,7 @@ export async function inviteUser(
}
const rejectUsers = await usageService.checkLimitSet(
orgId,
FeatureId.USERS,
LimitId.USERS,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1

View File

@@ -1,29 +1,17 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
orgs,
resources,
siteResources,
sites,
UserOrg,
userSiteResources,
primaryDb
} from "@server/db";
import { userOrgs, userResources, users, userSites } from "@server/db";
import { and, count, eq, exists, inArray } from "drizzle-orm";
import { db, orgs } from "@server/db";
import { userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { build } from "@server/build";
import { UserType } from "@server/types/UserTypes";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { removeUserFromOrg } from "@server/lib/userOrg";
import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
const removeUserSchema = z.strictObject({
userId: z.string(),
@@ -93,6 +81,15 @@ export async function removeUserOrg(
);
}
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 [org] = await db
.select()
.from(orgs)

View File

@@ -58,7 +58,7 @@ import {
tier2LimitSet,
tier3LimitSet
} from "@server/lib/billing/limitSet";
import { FeatureId } from "@server/lib/billing/features";
import { LimitId } from "@server/lib/billing/features";
import TrialBillingBanner from "@app/components/TrialBillingBanner";
// Plan tier definitions matching the mockup
@@ -161,32 +161,32 @@ const tierLimits: Record<
}
> = {
basic: {
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: freeLimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
users: freeLimitSet[LimitId.USERS]?.value ?? 0,
sites: freeLimitSet[LimitId.SITES]?.value ?? 0,
domains: freeLimitSet[LimitId.DOMAINS]?.value ?? 0,
remoteNodes: freeLimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: freeLimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
},
tier1: {
users: tier1LimitSet[FeatureId.USERS]?.value ?? 0,
sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0,
domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0,
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: tier1LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
users: tier1LimitSet[LimitId.USERS]?.value ?? 0,
sites: tier1LimitSet[LimitId.SITES]?.value ?? 0,
domains: tier1LimitSet[LimitId.DOMAINS]?.value ?? 0,
remoteNodes: tier1LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: tier1LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
},
tier2: {
users: tier2LimitSet[FeatureId.USERS]?.value ?? 0,
sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0,
domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0,
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: tier2LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
users: tier2LimitSet[LimitId.USERS]?.value ?? 0,
sites: tier2LimitSet[LimitId.SITES]?.value ?? 0,
domains: tier2LimitSet[LimitId.DOMAINS]?.value ?? 0,
remoteNodes: tier2LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: tier2LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
},
tier3: {
users: tier3LimitSet[FeatureId.USERS]?.value ?? 0,
sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0,
domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0,
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: tier3LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
users: tier3LimitSet[LimitId.USERS]?.value ?? 0,
sites: tier3LimitSet[LimitId.SITES]?.value ?? 0,
domains: tier3LimitSet[LimitId.DOMAINS]?.value ?? 0,
remoteNodes: tier3LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: tier3LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
},
enterprise: {
users: 0, // Custom for enterprise

View File

@@ -342,7 +342,7 @@ export default function GeneralPage() {
return (
<Link
href={
row.original.type === "ssh"
row.original.siteResourceId != null
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
}
@@ -369,7 +369,9 @@ export default function GeneralPage() {
value: "whitelistedEmail",
label: "Whitelisted Email"
},
{ value: "ssh", label: "SSH" }
{ value: "ssh", label: "SSH" },
{ value: "rdp", label: "RDP" },
{ value: "vnc", label: "VNC" }
]}
label={t("type")}
selectedValue={filters.type}
@@ -384,8 +386,10 @@ export default function GeneralPage() {
},
cell: ({ row }) => {
const typeLabel =
row.original.type === "ssh"
? "SSH"
row.original.type === "ssh" ||
row.original.type === "rdp" ||
row.original.type === "vnc"
? row.original.type.toUpperCase()
: row.original.type.charAt(0).toUpperCase() +
row.original.type.slice(1);
return <span>{typeLabel || "-"}</span>;
@@ -513,7 +517,15 @@ export default function GeneralPage() {
function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"];
const types = [
"password",
"pincode",
"login",
"whitelistedEmail",
"ssh",
"rdp",
"vnc"
];
const actors = [
"alice@example.com",
"bob@example.com",
@@ -538,6 +550,7 @@ function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
actor,
actorId: actor ? `user-${i}` : null,
resourceId: Math.floor(Math.random() * 5) + 1,
siteResourceId: null,
resourceNiceId: `resource-${(i % 3) + 1}`,
resourceName: `Resource ${(i % 3) + 1}`,
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,

View File

@@ -13,6 +13,7 @@ import { Layout } from "@app/components/Layout";
import { adminNavSections } from "../navigation";
import { pullEnv } from "@app/lib/pullEnv";
import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider";
import { build } from "@server/build";
export const dynamic = "force-dynamic";
@@ -29,6 +30,11 @@ export default async function AdminLayout(props: LayoutProps) {
const getUser = cache(verifySession);
const user = await getUser();
// Disable the admin page on saas
if (build == "saas") {
redirect(`/`);
}
const env = pullEnv();
if (!user || !user.serverAdmin) {

View File

@@ -42,6 +42,8 @@ import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
declare module "react" {
namespace JSX {
@@ -96,6 +98,7 @@ export default function RdpClient({
primaryColor?: string | null;
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const STORAGE_KEY = "pangolin_rdp_credentials";
const resourceName = target?.name?.trim() || null;
@@ -311,6 +314,11 @@ export default function RdpClient({
values,
target.authToken
);
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
resourceId: target.resourceId,
action: true,
type: "rdp"
});
setConnecting(false);
setShowLogin(false);
userInteraction.setVisibility(true);
@@ -320,6 +328,11 @@ export default function RdpClient({
fileTransferRef.current = null;
setShowLogin(true);
} catch (err) {
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
resourceId: target.resourceId,
action: false,
type: "rdp"
});
setConnecting(false);
setShowLogin(true);
if (isIronError(err)) {

View File

@@ -36,6 +36,8 @@ import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
type AuthTab = "password" | "privateKey";
@@ -73,6 +75,7 @@ export default function SshClient({
}) {
const STORAGE_KEY = "pangolin_ssh_credentials";
const t = useTranslations();
const api = createApiClient(useEnvContext());
const resourceName = target?.name?.trim() || null;
const passwordTabSchema = z.object({
@@ -263,6 +266,17 @@ export default function SshClient({
let authConfirmed = false;
let authErrorShown = false;
let socketOpened = false;
let auditLogged = false;
const logAudit = (action: boolean) => {
if (auditLogged || !target) return;
auditLogged = true;
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
resourceId: target.resourceId,
action,
type: "ssh"
});
};
ws.onopen = () => {
socketOpened = true;
@@ -294,6 +308,7 @@ export default function SshClient({
if (msg.type === "data" && msg.data) {
if (!authConfirmed) {
authConfirmed = true;
logAudit(true);
setConnecting(false);
setConnected(true);
}
@@ -301,6 +316,7 @@ export default function SshClient({
} else if (msg.type === "error") {
if (!authConfirmed) {
authErrorShown = true;
logAudit(false);
setConnecting(false);
setConnectError(
msg.error ?? t("sshErrorAuthFailed")
@@ -323,6 +339,7 @@ export default function SshClient({
evt.data.text().then((text) => {
if (!authConfirmed) {
authConfirmed = true;
logAudit(true);
setConnecting(false);
setConnected(true);
}
@@ -332,6 +349,7 @@ export default function SshClient({
};
ws.onerror = () => {
logAudit(false);
setConnecting(false);
setConnected(false);
setConnectError(t("sshErrorWebSocket"));
@@ -355,6 +373,7 @@ export default function SshClient({
);
}
if (!authConfirmed && !authErrorShown) {
logAudit(false);
setConnectError(t("sshErrorConnectionClosed"));
}
};

View File

@@ -33,6 +33,8 @@ import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
type VncCredentialsForm = {
password: string;
@@ -52,6 +54,7 @@ export default function VncClient({
primaryColor?: string | null;
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const STORAGE_KEY = "pangolin_vnc_credentials";
const resourceName = target?.name?.trim() || null;
@@ -179,6 +182,7 @@ export default function VncClient({
}
let authConfirmed = false;
let auditLogged = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
@@ -190,6 +194,12 @@ export default function VncClient({
target.authToken
);
authConfirmed = true;
auditLogged = true;
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
resourceId: target.resourceId,
action: true,
type: "vnc"
});
setConnecting(false);
setConnected(true);
});
@@ -201,6 +211,17 @@ export default function VncClient({
setConnecting(false);
setConnected(false);
if (!authConfirmed && !e.detail.clean) {
if (!auditLogged) {
auditLogged = true;
void api.post(
`/org/${target.orgId}/logs/access/attempt`,
{
resourceId: target.resourceId,
action: false,
type: "vnc"
}
);
}
setConnectError(t("sshErrorConnectionClosed"));
}
}
@@ -209,6 +230,12 @@ export default function VncClient({
rfb.addEventListener(
"securityfailure",
(e: { detail: { status: number; reason?: string } }) => {
auditLogged = true;
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
resourceId: target.resourceId,
action: false,
type: "vnc"
});
disconnect();
setConnectError(
e.detail.reason ??

View File

@@ -90,7 +90,11 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionTitle>
<InfoSectionContent>
<span className="inline-flex items-center">
{resource.ssl ? "HTTPS" : "HTTP"}
{resource.mode == "http"
? resource.ssl
? "HTTPS"
: "HTTP"
: resource.mode?.toUpperCase()}
</span>
</InfoSectionContent>
</InfoSection>

View File

@@ -54,7 +54,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle>
<InfoSectionContent>
{formatPublicEndpoint(site.endpoint)}&nbsp;
<span className="text-lg">
<span>
{site.countryCode &&
countryCodeToFlagEmoji(site.countryCode)}
</span>

View File

@@ -340,7 +340,8 @@ function PolicyAccessRulesSectionEdit({
? rules.filter((rule) => !rule.fromPolicy)
: rules;
const rulesPayload = rulesToValidate.map(
({ action, match, value, priority, enabled }) => ({
({ ruleId, action, match, value, priority, enabled, new: isNew }) => ({
...(isNew ? {} : { ruleId }),
action,
match,
value,