Add license tiers

This commit is contained in:
Owen
2026-09-15 16:25:43 -04:00
parent 64bb6d9f9c
commit 324f3e50ff
14 changed files with 116 additions and 81 deletions
+4 -2
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "No sites found.",
"pangolinServerAdmin": "Server Admin - Pangolin",
"licenseTierProfessional": "Professional License",
"licenseTierEnterprise": "Enterprise License",
"licenseTierPersonal": "Personal License",
"licenseTierEnterprise": "Enterprise",
"licenseTierPersonal": "Personal",
"licenseTierTier1": "Starter",
"licenseTierTier2": "Scale",
"licensed": "Licensed",
"yes": "Yes",
"no": "No",
+6 -6
View File
@@ -1,6 +1,6 @@
export enum LicenseId {
SMALL_LICENSE = "small_license",
BIG_LICENSE = "big_license"
TIER1 = "tier1",
TIER2 = "tier2"
}
export type LicensePriceSet = {
@@ -9,15 +9,15 @@ export type LicensePriceSet = {
export const licensePriceSet: LicensePriceSet = {
// Free license matches the freeLimitSet
[LicenseId.SMALL_LICENSE]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
[LicenseId.BIG_LICENSE]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
[LicenseId.TIER1]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
[LicenseId.TIER2]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
};
export const licensePriceSetSandbox: LicensePriceSet = {
// Free license matches the freeLimitSet
// when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value
[LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN",
[LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl"
[LicenseId.TIER1]: "price_1SxDwuDCpkOb237Bz0yTiOgN",
[LicenseId.TIER2]: "price_1SxDy0DCpkOb237BWJxrxYkl"
};
export function getLicensePriceSet(
+6 -1
View File
@@ -14,6 +14,7 @@ import { getTraefikConfig } from "#dynamic/lib/traefik";
import { getValidCertificatesForDomains } from "@server/lib/certificates";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
import license from "#dynamic/license/license";
export class TraefikConfigManager {
private intervalId: NodeJS.Timeout | null = null;
@@ -357,7 +358,11 @@ export class TraefikConfigManager {
this.lastActiveDomains = new Set(domains);
}
if (process.env.CERT_MODE === "pangolin" && build != "oss") {
if (
process.env.CERT_MODE === "pangolin" &&
build != "oss" &&
(await license.hasTier(["personal", "tier2", "enterprise"]))
) {
// Scan current local certificate state
this.lastLocalCertificateState =
await this.scanLocalCertificateState();
+6 -2
View File
@@ -4,7 +4,7 @@ import { setHostMeta } from "@server/lib/hostMeta";
const keyTypes = ["host"] as const;
export type LicenseKeyType = (typeof keyTypes)[number];
const keyTiers = ["personal", "enterprise"] as const;
const keyTiers = ["personal", "enterprise", "tier1", "tier2"] as const;
export type LicenseKeyTier = (typeof keyTiers)[number];
export type LicenseStatus = {
@@ -33,7 +33,7 @@ export type LicenseKeyCache = {
export class License {
private serverSecret!: string;
constructor(private hostMeta: HostMeta) { }
constructor(private hostMeta: HostMeta) {}
public async check(): Promise<LicenseStatus> {
return {
@@ -50,6 +50,10 @@ export class License {
public async isUnlocked() {
return false;
}
public async hasTier(tier: LicenseKeyTier[]) {
return false;
}
}
await setHostMeta();
+2 -2
View File
@@ -45,9 +45,9 @@ export class JobScheduler {
label: string
): () => Promise<void> {
return async () => {
if (!(await license.isUnlocked())) {
if (!(await license.hasTier(["personal", "tier2", "enterprise"]))) {
logger.debug(
`Skipping ${label} tick - license is not subscribed`
`Skipping ${label} tick - requires a tier2 license`
);
return;
}
+11 -6
View File
@@ -63,10 +63,11 @@ export class AuthoritativeDNSServer {
private allDomains: Set<string> = new Set();
private domainRefreshInterval: NodeJS.Timeout | null = null;
// Cached license/subscription status. license.isUnlocked() does a DB
// round-trip on every call, so it can't be checked per-query on a UDP
// server that may see very high query volume - instead it's polled on
// the same cadence as the domain set refresh and read from memory here.
// Cached license/plan status - only a tier2 license unlocks the DNS
// server. license.hasPlan() does a DB round-trip on every call, so it
// can't be checked per-query on a UDP server that may see very high
// query volume - instead it's polled on the same cadence as the domain
// set refresh and read from memory here.
private isLicensed: boolean = false;
private licenseRefreshInterval: NodeJS.Timeout | null = null;
@@ -128,7 +129,7 @@ export class AuthoritativeDNSServer {
}
if (!this.isLicensed) {
logger.debug("Refusing DNS query - license is not subscribed");
logger.debug("Refusing DNS query - requires a tier2 license");
// REFUSED (rcode=5) indicates a policy refusal by this nameserver.
this.sendResponse(packet, [], rinfo, false, 5, []);
return;
@@ -1042,7 +1043,11 @@ export class AuthoritativeDNSServer {
private async refreshLicenseStatus(): Promise<void> {
try {
this.isLicensed = await license.isUnlocked();
this.isLicensed = await license.hasTier([
"personal",
"tier2",
"enterprise"
]);
} catch (error) {
logger.error("Failed to refresh license status:", error);
this.isLicensed = false;
+16 -26
View File
@@ -54,6 +54,7 @@ import {
getValidCertificatesForDomains
} from "@server/lib/certificates";
import { build } from "@server/build";
import license from "#private/license/license";
import regionalCache from "#private/lib/cache";
import { TargetWithSite } from "@server/lib/traefik/types";
import { buildWildcardTls } from "@server/lib/traefik/certResolver";
@@ -395,8 +396,15 @@ export async function getTraefikConfig(
)
);
// Pangolin-managed DNS-01/ACME cert mode requires either a tier1
// license (self-hosted) or a saas build - otherwise fall back to
// Traefik's own cert resolvers (buildWildcardTls) throughout.
const pangolinCertModeEnabled =
privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin" &&
(await license.hasTier(["personal", "tier2", "enterprise"]));
let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
if (pangolinCertModeEnabled) {
// create a list of all domains to get certs for
const domains = new Set<string>();
for (const resource of resourcesMap.values()) {
@@ -522,10 +530,7 @@ export async function getTraefikConfig(
);
let tls = {};
if (
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
if (!pangolinCertModeEnabled) {
tls = buildWildcardTls({
fullDomain,
hasSubdomain: !!resource.subdomain,
@@ -790,10 +795,7 @@ export async function getTraefikConfig(
domainCertResolver,
preferWildcardCert
}) => {
if (
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
if (!pangolinCertModeEnabled) {
return buildWildcardTls({
fullDomain,
hasSubdomain,
@@ -834,10 +836,7 @@ export async function getTraefikConfig(
maintenancePageUiUrl,
redirectHttpsMiddlewareName,
resolveTls: (fullDomain) => {
if (
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
if (!pangolinCertModeEnabled) {
// siteResource aliases don't have a per-domain cert
// resolver stored, so always fall back to the global
// defaults.
@@ -928,10 +927,7 @@ export async function getTraefikConfig(
const rule = buildHostRule(fullDomain, ir.wildcard);
let tls: any = {};
if (
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
if (!pangolinCertModeEnabled) {
tls = buildWildcardTls({
fullDomain,
hasSubdomain: !!ir.subdomain,
@@ -1011,10 +1007,7 @@ export async function getTraefikConfig(
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
let tls: any = {};
if (
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
if (!pangolinCertModeEnabled) {
// siteResource aliases don't have a per-domain cert
// resolver stored, so always fall back to the global
// defaults.
@@ -1088,7 +1081,7 @@ export async function getTraefikConfig(
.where(eq(exitNodes.exitNodeId, exitNodeId));
let validCertsLoginPages: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
if (pangolinCertModeEnabled) {
// create a list of all domains to get certs for
const domains = new Set<string>();
for (const lp of exitNodeLoginPages) {
@@ -1133,10 +1126,7 @@ export async function getTraefikConfig(
}
const tls = {};
if (
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
if (!pangolinCertModeEnabled) {
// TODO: we need to add the wildcard logic here too
} else {
// find a cert that matches the full domain, if not continue
+19 -2
View File
@@ -26,6 +26,7 @@ import {
LicenseStatus
} from "@server/license/license";
import { setHostMeta } from "@server/lib/hostMeta";
import { build } from "@server/build";
type ActivateLicenseKeyAPIResponse = {
data: {
@@ -119,6 +120,9 @@ LQIDAQAB
}
public async isUnlocked(): Promise<boolean> {
if (build == "saas") {
return true;
}
const status = await this.check();
if (status.isHostLicensed) {
if (status.isLicenseValid) {
@@ -128,6 +132,20 @@ LQIDAQAB
return false;
}
public async hasTier(tier: LicenseKeyTier[]): Promise<boolean> {
if (build == "saas") {
return true;
}
const status = await this.check();
if (status.isHostLicensed && status.isLicenseValid) {
return (
status.tier !== undefined &&
tier.includes(status.tier as LicenseKeyTier)
);
}
return false;
}
public async check(): Promise<LicenseStatus> {
// If a check is already in progress, return the last known status
if (this.checkInProgress) {
@@ -135,8 +153,7 @@ LQIDAQAB
"License check already in progress, returning last known status"
);
const lastStatus = this.statusCache.get(this.statusKey) as
| LicenseStatus
| undefined;
LicenseStatus | undefined;
if (lastStatus) {
return lastStatus;
}
@@ -222,15 +222,16 @@ export async function handleSubscriptionCreated(
let numUsers: number;
let numSites: number;
let tier = "enterprise";
if (subscriptionPriceId === priceSet[LicenseId.SMALL_LICENSE]) {
if (subscriptionPriceId === priceSet[LicenseId.TIER1]) {
numUsers = 25;
numSites = 25;
} else if (
subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE]
) {
tier = "tier1";
} else if (subscriptionPriceId === priceSet[LicenseId.TIER2]) {
numUsers = 50;
numSites = 100;
tier = "tier2";
} else {
logger.error(
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`
@@ -256,7 +257,8 @@ export async function handleSubscriptionCreated(
licenseId: parseInt(licenseId),
paidFor: true,
users: numUsers,
sites: numSites
sites: numSites,
tier: tier
})
}
);
@@ -64,14 +64,11 @@ export async function generateNewEnterpriseLicense(
const licenseData = req.body;
if (
licenseData.tier != "big_license" &&
licenseData.tier != "small_license"
) {
if (licenseData.tier != "tier2" && licenseData.tier != "tier1") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid tier specified. Must be either 'big_license' or 'small_license'."
"Invalid tier specified. Must be either 'tier2' or 'tier1'."
)
);
}
@@ -118,9 +115,7 @@ export async function generateNewEnterpriseLicense(
}
const tier =
licenseData.tier === "big_license"
? LicenseId.BIG_LICENSE
: LicenseId.SMALL_LICENSE;
licenseData.tier === "tier2" ? LicenseId.TIER2 : LicenseId.TIER1;
const tierPrice = getLicensePriceSet()[tier];
const session = await stripe!.checkout.sessions.create({
+14 -13
View File
@@ -252,9 +252,10 @@ export default function GenerateLicenseKeyForm({
try {
// Check if this is a business/enterprise license request
if (payload.useCaseType === "business") {
const response = await api.put<
AxiosResponse<string>
>(`/org/${orgId}/license/enterprise`, { ...payload, tier: "big_license" } );
const response = await api.put<AxiosResponse<string>>(
`/org/${orgId}/license/enterprise`,
{ ...payload, tier: "tier2" }
);
console.log("Checkout session response:", response.data);
const checkoutUrl = response.data.data;
@@ -1087,16 +1088,16 @@ export default function GenerateLicenseKeyForm({
)}
{!generatedKey && useCaseType === "business" && (
<Button
type="submit"
form="generate-license-business-form"
disabled={loading}
loading={loading}
>
{t(
"generateLicenseKeyForm.buttons.generateLicenseKey"
)}
</Button>
<Button
type="submit"
form="generate-license-business-form"
disabled={loading}
loading={loading}
>
{t(
"generateLicenseKeyForm.buttons.generateLicenseKey"
)}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
+10 -3
View File
@@ -201,9 +201,16 @@ export default function GenerateLicenseKeysTable({
},
cell: ({ row }) => {
const tier = row.original.tier;
return tier === "enterprise"
? t("licenseTierEnterprise")
: t("licenseTierPersonal");
switch (tier) {
case "enterprise":
return t("licenseTierEnterprise");
case "tier1":
return t("licenseTierTier1");
case "tier2":
return t("licenseTierTier2");
default:
return t("licenseTierPersonal");
}
}
},
{
+10 -3
View File
@@ -100,9 +100,16 @@ export function LicenseKeysDataTable({
},
cell: ({ row }) => {
const tier = row.original.tier;
return tier === "enterprise"
? t("licenseTierEnterprise")
: t("licenseTierPersonal");
switch (tier) {
case "enterprise":
return t("licenseTierEnterprise");
case "tier1":
return t("licenseTierTier1");
case "tier2":
return t("licenseTierTier2");
default:
return t("licenseTierPersonal");
}
}
},
{
+2 -2
View File
@@ -40,8 +40,8 @@ import { InfoIcon } from "lucide-react";
import { useUserContext } from "@app/hooks/useUserContext";
const TIER_TO_LICENSE_ID = {
starter: "small_license",
scale: "big_license"
starter: "tier1",
scale: "tier2"
} as const;
type FormProps = {