mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-15 23:30:17 +02:00
Add license tiers
This commit is contained in:
+4
-2
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user