Compare commits

...

1 Commits

Author SHA1 Message Date
Owen
f899326189 Change features, remove site uptime 2026-02-05 14:56:07 -08:00
9 changed files with 160 additions and 109 deletions

View File

@@ -1404,7 +1404,7 @@
"billingUsageLimitsOverview": "Usage Limits Overview",
"billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.",
"billingDataUsage": "Data Usage",
"billingOnlineTime": "Site Online Time",
"billingSites": "Sites",
"billingUsers": "Active Users",
"billingDomains": "Active Domains",
"billingRemoteExitNodes": "Active Self-hosted Nodes",
@@ -1432,10 +1432,10 @@
"billingFailedToGetPortalUrl": "Failed to get portal URL",
"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.",
"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.",
"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.",
"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.",
"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.",
"billingSInfo": "How many sites you can use",
"billingUsersInfo": "How many users you can use",
"billingDomainInfo": "How many domains you can use",
"billingRemoteExitNodesInfo": "How many remote nodes you can use",
"billingLicenseKeys": "License Keys",
"billingLicenseKeysDescription": "Manage your license key subscriptions",
"billingLicenseSubscription": "License Subscription",
@@ -1444,7 +1444,6 @@
"billingQuantity": "Quantity",
"billingTotal": "total",
"billingModifyLicenses": "Modify License Subscription",
"billingPricingCalculatorLink": "View Pricing Calculator",
"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.",
"failed": "Failed",

View File

@@ -1,30 +1,22 @@
import Stripe from "stripe";
export enum FeatureId {
SITE_UPTIME = "siteUptime",
USERS = "users",
SITES = "sites",
EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes"
}
export const FeatureMeterIds: Record<FeatureId, string> = {
[FeatureId.SITE_UPTIME]: "mtr_61Srrej5wUJuiTWgo41D3Ee2Ir7WmDLU",
[FeatureId.USERS]: "mtr_61SrreISyIWpwUNGR41D3Ee2Ir7WmQro",
[FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW",
[FeatureId.DOMAINS]: "mtr_61Ss9nIKDNMw0LDRU41D3Ee2Ir7WmRPU",
[FeatureId.REMOTE_EXIT_NODES]: "mtr_61T86UXnfxTVXy9sD41D3Ee2Ir7WmFTE"
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = {
[FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
};
export const FeatureMeterIdsSandbox: Record<FeatureId, string> = {
[FeatureId.SITE_UPTIME]: "mtr_test_61Snh3cees4w60gv841DCpkOb237BDEu",
[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 const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
[FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
};
export function getFeatureMeterId(featureId: FeatureId): string {
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
@@ -43,38 +35,43 @@ export function getFeatureIdByMetricId(
)?.[0];
}
export type FeaturePriceSet = {
[key in Exclude<FeatureId, FeatureId.DOMAINS>]: string;
} & {
[FeatureId.DOMAINS]?: string; // Optional since domains are not billed
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
export const starterFeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea"
};
export const standardFeaturePriceSet: FeaturePriceSet = {
// Free tier matches the freeLimitSet
[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 starterFeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF"
};
export const standardFeaturePriceSetSandbox: 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 {
export function getStarterFeaturePriceSet(): FeaturePriceSet {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
) {
return standardFeaturePriceSet;
return starterFeaturePriceSet;
} 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 = {
[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.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
@@ -16,7 +16,7 @@ export const sandboxLimitSet: 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.EGRESS_DATA_MB]: {
value: 25000,
@@ -26,9 +26,32 @@ export const freeLimitSet: LimitSet = {
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" }
};
export const subscribedLimitSet: LimitSet = {
[FeatureId.SITE_UPTIME]: {
value: 2232000,
export const starterLimitSet: LimitSet = {
[FeatureId.SITES]: {
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."
}, // 50 sites up for 31 days
[FeatureId.USERS]: {

View File

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

View File

@@ -114,7 +114,6 @@ export async function updateSiteBandwidth(
// Aggregate usage data by organization (collected outside transaction)
const orgUsageMap = new Map<string, number>();
const orgUptimeMap = new Map<string, number>();
if (activePeers.length > 0) {
// Remove any active peers from offline tracking since they're sending data
@@ -166,14 +165,6 @@ export async function updateSiteBandwidth(
updatedSite.orgId,
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) {
logger.error(
@@ -187,10 +178,10 @@ export async function updateSiteBandwidth(
// Process usage updates outside of site update transactions
// 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
const allOrgIds = [
...new Set([...orgUsageMap.keys(), ...orgUptimeMap.keys()])
...new Set([...orgUsageMap.keys()])
].sort();
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) {
logger.error(`Error processing usage for org ${orgId}:`, error);
// Continue with other orgs

View File

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

View File

@@ -18,6 +18,8 @@ import { isValidIP } from "@server/lib/validators";
import { 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";
const createSiteParamsSchema = z.strictObject({
orgId: z.string()
@@ -126,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;
if (address) {
if (!org.subnet) {
@@ -256,8 +287,8 @@ export async function createSite(
const niceId = await getUniqueSiteName(orgId);
let newSite: Site;
let newSite: Site | undefined;
let numSites: Site[] | undefined;
await db.transaction(async (trx) => {
if (type == "wireguard" || type == "newt") {
// we are creating a site with an exit node (tunneled)
@@ -402,13 +433,35 @@ export async function createSite(
});
}
return response<CreateSiteResponse>(res, {
data: newSite,
success: true,
error: false,
message: "Site created successfully",
status: HttpCode.CREATED
});
numSites = await trx
.select()
.from(sites)
.where(eq(sites.orgId, orgId));
});
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) {
logger.error(error);

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
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 { eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -12,6 +12,8 @@ import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
const deleteSiteSchema = z.strictObject({
siteId: z.string().transform(Number).pipe(z.int().positive())
@@ -62,6 +64,7 @@ export async function deleteSite(
}
let deletedNewtId: string | null = null;
let numSites: Site[] | undefined;
await db.transaction(async (trx) => {
if (site.type == "wireguard") {
@@ -99,8 +102,20 @@ export async function deleteSite(
}
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
if (deletedNewtId) {
const payload = {

View File

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