Change features, remove site uptime

This commit is contained in:
Owen
2026-02-11 10:05:53 -08:00
parent b4c01349d1
commit 7ffb260d7c
9 changed files with 161 additions and 109 deletions

View File

@@ -1404,7 +1404,7 @@
"billingUsageLimitsOverview": "Usage Limits Overview", "billingUsageLimitsOverview": "Usage Limits Overview",
"billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.", "billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.",
"billingDataUsage": "Data Usage", "billingDataUsage": "Data Usage",
"billingOnlineTime": "Site Online Time", "billingSites": "Sites",
"billingUsers": "Active Users", "billingUsers": "Active Users",
"billingDomains": "Active Domains", "billingDomains": "Active Domains",
"billingRemoteExitNodes": "Active Self-hosted Nodes", "billingRemoteExitNodes": "Active Self-hosted Nodes",
@@ -1432,10 +1432,10 @@
"billingFailedToGetPortalUrl": "Failed to get portal URL", "billingFailedToGetPortalUrl": "Failed to get portal URL",
"billingPortalError": "Portal Error", "billingPortalError": "Portal Error",
"billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.", "billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.",
"billingOnlineTimeInfo": "You're charged based on how long your sites stay connected to the cloud. For example, 44,640 minutes equals one site running 24/7 for a full month. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Time is not charged when using nodes.", "billingSInfo": "How many sites you can use",
"billingUsersInfo": "You're charged for each user in the organization. Billing is calculated daily based on the number of active user accounts in your org.", "billingUsersInfo": "How many users you can use",
"billingDomainInfo": "You're charged for each domain in the organization. Billing is calculated daily based on the number of active domain accounts in your org.", "billingDomainInfo": "How many domains you can use",
"billingRemoteExitNodesInfo": "You're charged for each managed Node in the organization. Billing is calculated daily based on the number of active managed Nodes in your org.", "billingRemoteExitNodesInfo": "How many remote nodes you can use",
"billingLicenseKeys": "License Keys", "billingLicenseKeys": "License Keys",
"billingLicenseKeysDescription": "Manage your license key subscriptions", "billingLicenseKeysDescription": "Manage your license key subscriptions",
"billingLicenseSubscription": "License Subscription", "billingLicenseSubscription": "License Subscription",
@@ -1444,7 +1444,6 @@
"billingQuantity": "Quantity", "billingQuantity": "Quantity",
"billingTotal": "total", "billingTotal": "total",
"billingModifyLicenses": "Modify License Subscription", "billingModifyLicenses": "Modify License Subscription",
"billingPricingCalculatorLink": "View Pricing Calculator",
"domainNotFound": "Domain Not Found", "domainNotFound": "Domain Not Found",
"domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.",
"failed": "Failed", "failed": "Failed",

View File

@@ -1,30 +1,22 @@
import Stripe from "stripe"; import Stripe from "stripe";
export enum FeatureId { export enum FeatureId {
SITE_UPTIME = "siteUptime",
USERS = "users", USERS = "users",
SITES = "sites",
EGRESS_DATA_MB = "egressDataMb", EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains", DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes" REMOTE_EXIT_NODES = "remoteExitNodes"
} }
export const FeatureMeterIds: Record<FeatureId, string> = { export const FeatureMeterIds: Partial<Record<FeatureId, string>> = {
[FeatureId.SITE_UPTIME]: "mtr_61Srrej5wUJuiTWgo41D3Ee2Ir7WmDLU", [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
[FeatureId.USERS]: "mtr_61SrreISyIWpwUNGR41D3Ee2Ir7WmQro",
[FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW",
[FeatureId.DOMAINS]: "mtr_61Ss9nIKDNMw0LDRU41D3Ee2Ir7WmRPU",
[FeatureId.REMOTE_EXIT_NODES]: "mtr_61T86UXnfxTVXy9sD41D3Ee2Ir7WmFTE"
}; };
export const FeatureMeterIdsSandbox: Record<FeatureId, string> = { export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
[FeatureId.SITE_UPTIME]: "mtr_test_61Snh3cees4w60gv841DCpkOb237BDEu", [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
[FeatureId.USERS]: "mtr_test_61Sn5fLtq1gSfRkyA41DCpkOb237B6au",
[FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ",
[FeatureId.DOMAINS]: "mtr_test_61SsA8qrdAlgPpFRQ41DCpkOb237BGts",
[FeatureId.REMOTE_EXIT_NODES]: "mtr_test_61T86Vqmwa3D9ra3341DCpkOb237B94K"
}; };
export function getFeatureMeterId(featureId: FeatureId): string { export function getFeatureMeterId(featureId: FeatureId): string | undefined {
if ( if (
process.env.ENVIRONMENT == "prod" && process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true" process.env.SANDBOX_MODE !== "true"
@@ -43,38 +35,43 @@ export function getFeatureIdByMetricId(
)?.[0]; )?.[0];
} }
export type FeaturePriceSet = { export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
[key in Exclude<FeatureId, FeatureId.DOMAINS>]: string;
} & { export const starterFeaturePriceSet: FeaturePriceSet = {
[FeatureId.DOMAINS]?: string; // Optional since domains are not billed [FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea"
}; };
export const standardFeaturePriceSet: FeaturePriceSet = { export const starterFeaturePriceSetSandbox: FeaturePriceSet = {
// Free tier matches the freeLimitSet [FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF"
[FeatureId.SITE_UPTIME]: "price_1RrQc4D3Ee2Ir7WmaJGZ3MtF",
[FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea",
[FeatureId.EGRESS_DATA_MB]: "price_1RrQXFD3Ee2Ir7WmvGDlgxQk",
// [FeatureId.DOMAINS]: "price_1Rz3tMD3Ee2Ir7Wm5qLeASzC",
[FeatureId.REMOTE_EXIT_NODES]: "price_1S46weD3Ee2Ir7Wm94KEHI4h"
}; };
export const standardFeaturePriceSetSandbox: FeaturePriceSet = { export function getStarterFeaturePriceSet(): FeaturePriceSet {
// Free tier matches the freeLimitSet
[FeatureId.SITE_UPTIME]: "price_1RefFBDCpkOb237BPrKZ8IEU",
[FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF",
[FeatureId.EGRESS_DATA_MB]: "price_1Rfp9LDCpkOb237BwuN5Oiu0",
// [FeatureId.DOMAINS]: "price_1Ryi88DCpkOb237B2D6DM80b",
[FeatureId.REMOTE_EXIT_NODES]: "price_1RyiZvDCpkOb237BXpmoIYJL"
};
export function getStandardFeaturePriceSet(): FeaturePriceSet {
if ( if (
process.env.ENVIRONMENT == "prod" && process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true" process.env.SANDBOX_MODE !== "true"
) { ) {
return standardFeaturePriceSet; return starterFeaturePriceSet;
} else { } else {
return standardFeaturePriceSetSandbox; return starterFeaturePriceSetSandbox;
}
}
export const scaleFeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea"
};
export const scaleFeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF"
};
export function getScaleFeaturePriceSet(): FeaturePriceSet {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
) {
return scaleFeaturePriceSet;
} else {
return scaleFeaturePriceSetSandbox;
} }
} }

View File

@@ -8,7 +8,7 @@ export type LimitSet = {
}; };
export const sandboxLimitSet: LimitSet = { export const sandboxLimitSet: LimitSet = {
[FeatureId.SITE_UPTIME]: { value: 2880, description: "Sandbox limit" }, // 1 site up for 2 days [FeatureId.SITES]: { value: 1, description: "Sandbox limit" }, // 1 site up for 2 days
[FeatureId.USERS]: { value: 1, description: "Sandbox limit" }, [FeatureId.USERS]: { value: 1, description: "Sandbox limit" },
[FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB [FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" }, [FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
@@ -16,7 +16,7 @@ export const sandboxLimitSet: LimitSet = {
}; };
export const freeLimitSet: LimitSet = { export const freeLimitSet: LimitSet = {
[FeatureId.SITE_UPTIME]: { value: 46080, description: "Free tier limit" }, // 1 site up for 32 days [FeatureId.SITES]: { value: 3, description: "Free tier limit" }, // 1 site up for 32 days
[FeatureId.USERS]: { value: 3, description: "Free tier limit" }, [FeatureId.USERS]: { value: 3, description: "Free tier limit" },
[FeatureId.EGRESS_DATA_MB]: { [FeatureId.EGRESS_DATA_MB]: {
value: 25000, value: 25000,
@@ -26,9 +26,32 @@ export const freeLimitSet: LimitSet = {
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" } [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" }
}; };
export const subscribedLimitSet: LimitSet = { export const starterLimitSet: LimitSet = {
[FeatureId.SITE_UPTIME]: { [FeatureId.SITES]: {
value: 2232000, value: 10,
description: "Contact us to increase soft limit."
}, // 50 sites up for 31 days
[FeatureId.USERS]: {
value: 150,
description: "Contact us to increase soft limit."
},
[FeatureId.EGRESS_DATA_MB]: {
value: 12000000,
description: "Contact us to increase soft limit."
}, // 12000 GB
[FeatureId.DOMAINS]: {
value: 250,
description: "Contact us to increase soft limit."
},
[FeatureId.REMOTE_EXIT_NODES]: {
value: 5,
description: "Contact us to increase soft limit."
}
};
export const scaleLimitSet: LimitSet = {
[FeatureId.SITES]: {
value: 10,
description: "Contact us to increase soft limit." description: "Contact us to increase soft limit."
}, // 50 sites up for 31 days }, // 50 sites up for 31 days
[FeatureId.USERS]: { [FeatureId.USERS]: {

View File

@@ -78,9 +78,9 @@ export async function getOrgUsage(
// Get usage for org // Get usage for org
const usageData = []; const usageData = [];
const siteUptime = await usageService.getUsage( const sites = await usageService.getUsage(
orgId, orgId,
FeatureId.SITE_UPTIME FeatureId.SITES
); );
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS); const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
const domains = await usageService.getUsageDaily( const domains = await usageService.getUsageDaily(
@@ -96,8 +96,8 @@ export async function getOrgUsage(
FeatureId.EGRESS_DATA_MB FeatureId.EGRESS_DATA_MB
); );
if (siteUptime) { if (sites) {
usageData.push(siteUptime); usageData.push(sites);
} }
if (users) { if (users) {
usageData.push(users); usageData.push(users);

View File

@@ -114,7 +114,6 @@ export async function updateSiteBandwidth(
// Aggregate usage data by organization (collected outside transaction) // Aggregate usage data by organization (collected outside transaction)
const orgUsageMap = new Map<string, number>(); const orgUsageMap = new Map<string, number>();
const orgUptimeMap = new Map<string, number>();
if (activePeers.length > 0) { if (activePeers.length > 0) {
// Remove any active peers from offline tracking since they're sending data // Remove any active peers from offline tracking since they're sending data
@@ -166,14 +165,6 @@ export async function updateSiteBandwidth(
updatedSite.orgId, updatedSite.orgId,
currentOrgUsage + totalBandwidth currentOrgUsage + totalBandwidth
); );
// Add 10 seconds of uptime for each active site
const currentOrgUptime =
orgUptimeMap.get(updatedSite.orgId) || 0;
orgUptimeMap.set(
updatedSite.orgId,
currentOrgUptime + 10 / 60
);
} }
} catch (error) { } catch (error) {
logger.error( logger.error(
@@ -187,10 +178,10 @@ export async function updateSiteBandwidth(
// Process usage updates outside of site update transactions // Process usage updates outside of site update transactions
// This separates the concerns and reduces lock contention // This separates the concerns and reduces lock contention
if (calcUsageAndLimits && (orgUsageMap.size > 0 || orgUptimeMap.size > 0)) { if (calcUsageAndLimits && (orgUsageMap.size > 0)) {
// Sort org IDs to ensure consistent lock ordering // Sort org IDs to ensure consistent lock ordering
const allOrgIds = [ const allOrgIds = [
...new Set([...orgUsageMap.keys(), ...orgUptimeMap.keys()]) ...new Set([...orgUsageMap.keys()])
].sort(); ].sort();
for (const orgId of allOrgIds) { for (const orgId of allOrgIds) {
@@ -220,32 +211,6 @@ export async function updateSiteBandwidth(
}); });
} }
} }
// Process uptime usage for this org
const totalUptime = orgUptimeMap.get(orgId);
if (totalUptime) {
const uptimeUsage = await usageService.add(
orgId,
FeatureId.SITE_UPTIME,
totalUptime
);
if (uptimeUsage) {
// Fire and forget - don't block on limit checking
usageService
.checkLimitSet(
orgId,
true,
FeatureId.SITE_UPTIME,
uptimeUsage
)
.catch((error: any) => {
logger.error(
`Error checking uptime limits for org ${orgId}:`,
error
);
});
}
}
} catch (error) { } catch (error) {
logger.error(`Error processing usage for org ${orgId}:`, error); logger.error(`Error processing usage for org ${orgId}:`, error);
// Continue with other orgs // Continue with other orgs

View File

@@ -96,10 +96,10 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
fetchContainers(newt.newtId); fetchContainers(newt.newtId);
} }
const rejectSiteUptime = await usageService.checkLimitSet( const rejectSites = await usageService.checkLimitSet(
oldSite.orgId, oldSite.orgId,
false, false,
FeatureId.SITE_UPTIME FeatureId.SITES
); );
const rejectEgressDataMb = await usageService.checkLimitSet( const rejectEgressDataMb = await usageService.checkLimitSet(
oldSite.orgId, oldSite.orgId,
@@ -111,8 +111,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
// const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS); // const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS);
// const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS); // const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS);
// if (rejectEgressDataMb || rejectSiteUptime || rejectUsers || rejectDomains) { // if (rejectEgressDataMb || rejectSites || rejectUsers || rejectDomains) {
if (rejectEgressDataMb || rejectSiteUptime) { if (rejectEgressDataMb || rejectSites) {
logger.info( logger.info(
`Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.` `Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.`
); );

View File

@@ -17,6 +17,9 @@ import { hashPassword } from "@server/auth/password";
import { isValidIP } from "@server/lib/validators"; import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip"; import { isIpInCidr } from "@server/lib/ip";
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
const createSiteParamsSchema = z.strictObject({ const createSiteParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -125,6 +128,35 @@ export async function createSite(
); );
} }
if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.SITES);
if (!usage) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No usage data found for this organization"
)
);
}
const rejectSites = await usageService.checkLimitSet(
orgId,
false,
FeatureId.SITES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectSites) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Sites limit exceeded. Please upgrade your plan."
)
);
}
}
let updatedAddress = null; let updatedAddress = null;
if (address) { if (address) {
if (!org.subnet) { if (!org.subnet) {
@@ -255,8 +287,8 @@ export async function createSite(
const niceId = await getUniqueSiteName(orgId); const niceId = await getUniqueSiteName(orgId);
let newSite: Site; let newSite: Site | undefined;
let numSites: Site[] | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
if (type == "newt") { if (type == "newt") {
[newSite] = await trx [newSite] = await trx
@@ -411,13 +443,35 @@ export async function createSite(
}); });
} }
return response<CreateSiteResponse>(res, { numSites = await trx
data: newSite, .select()
success: true, .from(sites)
error: false, .where(eq(sites.orgId, orgId));
message: "Site created successfully", });
status: HttpCode.CREATED
}); if (numSites) {
await usageService.updateDaily(
orgId,
FeatureId.SITES,
numSites.length
);
}
if (!newSite) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create site"
)
);
}
return response<CreateSiteResponse>(res, {
data: newSite,
success: true,
error: false,
message: "Site created successfully",
status: HttpCode.CREATED
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, siteResources } from "@server/db"; import { db, Site, siteResources } from "@server/db";
import { newts, newtSessions, sites } from "@server/db"; import { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -12,6 +12,8 @@ import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
const deleteSiteSchema = z.strictObject({ const deleteSiteSchema = z.strictObject({
siteId: z.string().transform(Number).pipe(z.int().positive()) siteId: z.string().transform(Number).pipe(z.int().positive())
@@ -62,6 +64,7 @@ export async function deleteSite(
} }
let deletedNewtId: string | null = null; let deletedNewtId: string | null = null;
let numSites: Site[] | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
if (site.type == "wireguard") { if (site.type == "wireguard") {
@@ -99,8 +102,20 @@ export async function deleteSite(
} }
await trx.delete(sites).where(eq(sites.siteId, siteId)); await trx.delete(sites).where(eq(sites.siteId, siteId));
numSites = await trx
.select()
.from(sites)
.where(eq(sites.orgId, site.orgId));
}); });
if (numSites) {
await usageService.updateDaily(
site.orgId,
FeatureId.SITES,
numSites.length
);
}
// Send termination message outside of transaction to prevent blocking // Send termination message outside of transaction to prevent blocking
if (deletedNewtId) { if (deletedNewtId) {
const payload = { const payload = {

View File

@@ -207,7 +207,7 @@ export default function GeneralPage() {
}; };
// Usage IDs // Usage IDs
const SITE_UPTIME = "siteUptime"; const SITES = "sites";
const USERS = "users"; const USERS = "users";
const EGRESS_DATA_MB = "egressDataMb"; const EGRESS_DATA_MB = "egressDataMb";
const DOMAINS = "domains"; const DOMAINS = "domains";
@@ -362,12 +362,11 @@ export default function GeneralPage() {
getLimitUsage: (v: any) => v.latestValue getLimitUsage: (v: any) => v.latestValue
}, },
{ {
id: SITE_UPTIME, id: SITES,
label: t("billingOnlineTime"), label: t("billingSites"),
icon: <Clock className="h-4 w-4 text-green-500" />, icon: <Clock className="h-4 w-4 text-green-500" />,
unit: "min", unit: "",
info: t("billingOnlineTimeInfo"), info: t("billingSitesInfo"),
note: "Not counted on self-hosted nodes",
getDisplay: (v: any) => v.latestValue, getDisplay: (v: any) => v.latestValue,
getLimitDisplay: (v: any) => v.value, getLimitDisplay: (v: any) => v.value,
getUsage: (v: any) => v.latestValue, getUsage: (v: any) => v.latestValue,