diff --git a/messages/en-US.json b/messages/en-US.json index c6ac24e6d..3095c539a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", diff --git a/server/lib/billing/licenses.ts b/server/lib/billing/licenses.ts index ff942d11b..fbe5d828e 100644 --- a/server/lib/billing/licenses.ts +++ b/server/lib/billing/licenses.ts @@ -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( diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index a3bfd96c2..fa742e335 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -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(); diff --git a/server/license/license.ts b/server/license/license.ts index 7c9609847..d39842f44 100644 --- a/server/license/license.ts +++ b/server/license/license.ts @@ -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 { return { @@ -50,6 +50,10 @@ export class License { public async isUnlocked() { return false; } + + public async hasTier(tier: LicenseKeyTier[]) { + return false; + } } await setHostMeta(); diff --git a/server/private/lib/certificates/scheduler.ts b/server/private/lib/certificates/scheduler.ts index ee9111a6b..c46463008 100644 --- a/server/private/lib/certificates/scheduler.ts +++ b/server/private/lib/certificates/scheduler.ts @@ -45,9 +45,9 @@ export class JobScheduler { label: string ): () => Promise { 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; } diff --git a/server/private/lib/dns/server.ts b/server/private/lib/dns/server.ts index 7a1ef2b07..10c46c358 100644 --- a/server/private/lib/dns/server.ts +++ b/server/private/lib/dns/server.ts @@ -63,10 +63,11 @@ export class AuthoritativeDNSServer { private allDomains: Set = 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 { 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; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index cf12bb001..70a42b42f 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -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(); 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(); 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 diff --git a/server/private/license/license.ts b/server/private/license/license.ts index 4442a40c2..61ea23b48 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -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 { + 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 { + 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 { // 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; } diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 947f28c14..43da97c28 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -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 }) } ); diff --git a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts index 05b363d75..2951a7b6e 100644 --- a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts +++ b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts @@ -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({ diff --git a/src/components/GenerateLicenseKeyForm.tsx b/src/components/GenerateLicenseKeyForm.tsx index 33bf4d968..c6801f96e 100644 --- a/src/components/GenerateLicenseKeyForm.tsx +++ b/src/components/GenerateLicenseKeyForm.tsx @@ -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 - >(`/org/${orgId}/license/enterprise`, { ...payload, tier: "big_license" } ); + const response = await api.put>( + `/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" && ( - + )} diff --git a/src/components/GenerateLicenseKeysTable.tsx b/src/components/GenerateLicenseKeysTable.tsx index e4555a4de..300163e8a 100644 --- a/src/components/GenerateLicenseKeysTable.tsx +++ b/src/components/GenerateLicenseKeysTable.tsx @@ -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"); + } } }, { diff --git a/src/components/LicenseKeysDataTable.tsx b/src/components/LicenseKeysDataTable.tsx index a3e6f3ce5..4b63a7b28 100644 --- a/src/components/LicenseKeysDataTable.tsx +++ b/src/components/LicenseKeysDataTable.tsx @@ -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"); + } } }, { diff --git a/src/components/NewPricingLicenseForm.tsx b/src/components/NewPricingLicenseForm.tsx index 7972e0e19..14a05e7e1 100644 --- a/src/components/NewPricingLicenseForm.tsx +++ b/src/components/NewPricingLicenseForm.tsx @@ -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 = {