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