add enterprise license system

This commit is contained in:
miloschwartz
2025-10-13 10:41:10 -07:00
parent 6b125bba7c
commit 37ceabdf5d
76 changed files with 3886 additions and 1931 deletions

View File

@@ -12,11 +12,8 @@
*/
import { z } from "zod";
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
import { db } from "@server/db";
import { SupporterKey, supporterKey } from "@server/db";
import { eq } from "drizzle-orm";
import { license } from "@server/license/license";
import { __DIRNAME } from "@server/lib/consts";
import { SupporterKey } from "@server/db";
import { fromError } from "zod-validation-error";
import {
privateConfigSchema,
@@ -143,7 +140,8 @@ export class PrivateConfig {
process.env.S3_BUCKET = parsedPrivateConfig.stripe.s3Bucket;
}
if (parsedPrivateConfig.stripe?.localFilePath) {
process.env.LOCAL_FILE_PATH = parsedPrivateConfig.stripe.localFilePath;
process.env.LOCAL_FILE_PATH =
parsedPrivateConfig.stripe.localFilePath;
}
if (parsedPrivateConfig.stripe?.s3Region) {
process.env.S3_REGION = parsedPrivateConfig.stripe.s3Region;

View File

@@ -36,6 +36,7 @@ export const privateConfigSchema = z
.pipe(z.string().min(8)),
resend_api_key: z.string().optional(),
reo_client_id: z.string().optional(),
fossorial_api_key: z.string().optional()
}).optional().default({
encryption_key_path: "./config/encryption.pem"
}),
@@ -164,6 +165,9 @@ export function readPrivateConfigFile() {
const loadConfig = (configPath: string) => {
try {
const yamlContent = fs.readFileSync(configPath, "utf8");
if (yamlContent.trim() === "") {
return {};
}
const config = yaml.load(yamlContent);
return config;
} catch (error) {
@@ -176,7 +180,7 @@ export function readPrivateConfigFile() {
}
};
let environment: any;
let environment: any = {};
if (fs.existsSync(privateConfigFilePath1)) {
environment = loadConfig(privateConfigFilePath1);
}

View File

@@ -0,0 +1,459 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, HostMeta } from "@server/db";
import { hostMeta, licenseKey } from "@server/db";
import logger from "@server/logger";
import NodeCache from "node-cache";
import { validateJWT } from "./licenseJwt";
import { eq } from "drizzle-orm";
import moment from "moment";
import { encrypt, decrypt } from "@server/lib/crypto";
import {
LicenseKeyCache,
LicenseKeyTier,
LicenseKeyType,
LicenseStatus
} from "@server/license/license";
import { setHostMeta } from "@server/lib/hostMeta";
type ActivateLicenseKeyAPIResponse = {
data: {
instanceId: string;
};
success: boolean;
error: string;
message: string;
status: number;
};
type ValidateLicenseAPIResponse = {
data: {
licenseKeys: {
[key: string]: string;
};
};
success: boolean;
error: string;
message: string;
status: number;
};
type TokenPayload = {
valid: boolean;
type: LicenseKeyType;
tier: LicenseKeyTier;
quantity: number;
terminateAt: string; // ISO
iat: number; // Issued at
};
export class License {
private phoneHomeInterval = 6 * 60 * 60; // 6 hours = 6 * 60 * 60 = 21600 seconds
private serverBaseUrl = "https://api.fossorial.io";
private validationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/validate`;
private activationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/activate`;
private statusCache = new NodeCache({ stdTTL: this.phoneHomeInterval });
private licenseKeyCache = new NodeCache();
private statusKey = "status";
private serverSecret!: string;
private publicKey = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF
FNkRDggQfYO6Ae+EWHGujZ9WYAZ10spLh9F/zoLhhr3XhsjpoRXwMfgNuO5HstWf
CYM20I0l7EUUMWEyWd4tZLd+5XQ4jY5xWOCWyFJAGQSp7flcRmxdfde+l+xg9eKl
apbY84aVp09/GqM96hCS+CsQZrhohu/aOqYVB/eAhF01qsbmiZ7Y3WtdhTldveYt
h4mZWGmjf8d/aEgePf/tk1gp0BUxf+Ae5yqoAqU+6aiFbjJ7q1kgxc18PWFGfE9y
zSk+OZk887N5ThQ52154+oOUCMMR2Y3t5OH1hVZod51vuY2u5LsQXsf+87PwB91y
LQIDAQAB
-----END PUBLIC KEY-----`;
constructor(private hostMeta: HostMeta) {
setInterval(
async () => {
await this.check();
},
1000 * 60 * 60
);
}
public listKeys(): LicenseKeyCache[] {
const keys = this.licenseKeyCache.keys();
return keys.map((key) => {
return this.licenseKeyCache.get<LicenseKeyCache>(key)!;
});
}
public setServerSecret(secret: string) {
this.serverSecret = secret;
}
public async forceRecheck() {
this.statusCache.flushAll();
this.licenseKeyCache.flushAll();
return await this.check();
}
public async isUnlocked(): Promise<boolean> {
const status = await this.check();
if (status.isHostLicensed) {
if (status.isLicenseValid) {
return true;
}
}
return false;
}
public async check(): Promise<LicenseStatus> {
const status: LicenseStatus = {
hostId: this.hostMeta.hostMetaId,
isHostLicensed: true,
isLicenseValid: false
};
try {
if (this.statusCache.has(this.statusKey)) {
const res = this.statusCache.get("status") as LicenseStatus;
return res;
}
// Invalidate all
this.licenseKeyCache.flushAll();
const allKeysRes = await db.select().from(licenseKey);
if (allKeysRes.length === 0) {
status.isHostLicensed = false;
return status;
}
let foundHostKey = false;
// Validate stored license keys
for (const key of allKeysRes) {
try {
// Decrypt the license key and token
const decryptedKey = decrypt(
key.licenseKeyId,
this.serverSecret
);
const decryptedToken = decrypt(
key.token,
this.serverSecret
);
const payload = validateJWT<TokenPayload>(
decryptedToken,
this.publicKey
);
this.licenseKeyCache.set<LicenseKeyCache>(decryptedKey, {
licenseKey: decryptedKey,
licenseKeyEncrypted: key.licenseKeyId,
valid: payload.valid,
type: payload.type,
tier: payload.tier,
iat: new Date(payload.iat * 1000),
terminateAt: new Date(payload.terminateAt)
});
if (payload.type === "host") {
foundHostKey = true;
}
} catch (e) {
logger.error(
`Error validating license key: ${key.licenseKeyId}`
);
logger.error(e);
this.licenseKeyCache.set<LicenseKeyCache>(
key.licenseKeyId,
{
licenseKey: key.licenseKeyId,
licenseKeyEncrypted: key.licenseKeyId,
valid: false
}
);
}
}
if (!foundHostKey && allKeysRes.length) {
logger.debug("No host license key found");
status.isHostLicensed = false;
}
const keys = allKeysRes.map((key) => ({
licenseKey: decrypt(key.licenseKeyId, this.serverSecret),
instanceId: decrypt(key.instanceId, this.serverSecret)
}));
let apiResponse: ValidateLicenseAPIResponse | undefined;
try {
// Phone home to validate license keys
apiResponse = await this.phoneHome(keys, false);
if (!apiResponse?.success) {
throw new Error(apiResponse?.error);
}
} catch (e) {
logger.error("Error communicating with license server:");
logger.error(e);
}
// Check and update all license keys with server response
for (const key of keys) {
try {
const cached = this.licenseKeyCache.get<LicenseKeyCache>(
key.licenseKey
)!;
const licenseKeyRes =
apiResponse?.data?.licenseKeys[key.licenseKey];
if (!apiResponse || !licenseKeyRes) {
logger.debug(
`No response from server for license key: ${key.licenseKey}`
);
if (cached.iat) {
const exp = moment(cached.iat)
.add(7, "days")
.toDate();
if (exp > new Date()) {
logger.debug(
`Using cached license key: ${key.licenseKey}, valid ${cached.valid}`
);
continue;
}
}
logger.debug(
`Can't trust license key: ${key.licenseKey}`
);
cached.valid = false;
this.licenseKeyCache.set<LicenseKeyCache>(
key.licenseKey,
cached
);
continue;
}
const payload = validateJWT<TokenPayload>(
licenseKeyRes,
this.publicKey
);
cached.valid = payload.valid;
cached.type = payload.type;
cached.tier = payload.tier;
cached.iat = new Date(payload.iat * 1000);
// Encrypt the updated token before storing
const encryptedKey = encrypt(
key.licenseKey,
this.serverSecret
);
const encryptedToken = encrypt(
licenseKeyRes,
this.serverSecret
);
await db
.update(licenseKey)
.set({
token: encryptedToken
})
.where(eq(licenseKey.licenseKeyId, encryptedKey));
this.licenseKeyCache.set<LicenseKeyCache>(
key.licenseKey,
cached
);
} catch (e) {
logger.error(`Error validating license key: ${key}`);
logger.error(e);
}
}
// Compute host status
for (const key of keys) {
const cached = this.licenseKeyCache.get<LicenseKeyCache>(
key.licenseKey
)!;
if (cached.type === "host") {
status.isLicenseValid = cached.valid;
status.tier = cached.tier;
}
if (!cached.valid) {
continue;
}
}
} catch (error) {
logger.error("Error checking license status:");
logger.error(error);
}
this.statusCache.set(this.statusKey, status);
return status;
}
public async activateLicenseKey(key: string) {
// Encrypt the license key before storing
const encryptedKey = encrypt(key, this.serverSecret);
const [existingKey] = await db
.select()
.from(licenseKey)
.where(eq(licenseKey.licenseKeyId, encryptedKey))
.limit(1);
if (existingKey) {
throw new Error("License key already exists");
}
let instanceId: string | undefined;
try {
// Call activate
const apiResponse = await fetch(this.activationServerUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseKey: key,
instanceName: this.hostMeta.hostMetaId
})
});
const data = await apiResponse.json();
if (!data.success) {
throw new Error(`${data.message || data.error}`);
}
const response = data as ActivateLicenseKeyAPIResponse;
if (!response.data) {
throw new Error("No response from server");
}
if (!response.data.instanceId) {
throw new Error("No instance ID in response");
}
logger.debug("Activated license key, instance ID:", {
instanceId: response.data.instanceId
});
instanceId = response.data.instanceId;
} catch (error) {
throw Error(`Error activating license key: ${error}`);
}
// Phone home to validate license key
const keys = [
{
licenseKey: key,
instanceId: instanceId!
}
];
let validateResponse: ValidateLicenseAPIResponse;
try {
validateResponse = await this.phoneHome(keys, false);
if (!validateResponse) {
throw new Error("No response from server");
}
if (!validateResponse.success) {
throw new Error(validateResponse.error);
}
// Validate the license key
const licenseKeyRes = validateResponse.data.licenseKeys[key];
if (!licenseKeyRes) {
throw new Error("Invalid license key");
}
const payload = validateJWT<TokenPayload>(
licenseKeyRes,
this.publicKey
);
if (!payload.valid) {
throw new Error("Invalid license key");
}
const encryptedToken = encrypt(licenseKeyRes, this.serverSecret);
// Encrypt the instanceId before storing
const encryptedInstanceId = encrypt(instanceId!, this.serverSecret);
// Store the license key in the database
await db.insert(licenseKey).values({
licenseKeyId: encryptedKey,
token: encryptedToken,
instanceId: encryptedInstanceId
});
} catch (error) {
throw Error(`Error validating license key: ${error}`);
}
// Invalidate the cache and re-compute the status
return await this.forceRecheck();
}
private async phoneHome(
keys: {
licenseKey: string;
instanceId: string;
}[],
doDecrypt = true
): Promise<ValidateLicenseAPIResponse> {
// Decrypt the instanceIds before sending to the server
const decryptedKeys = keys.map((key) => ({
licenseKey: key.licenseKey,
instanceId:
key.instanceId && doDecrypt
? decrypt(key.instanceId, this.serverSecret)
: key.instanceId
}));
const response = await fetch(this.validationServerUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseKeys: decryptedKeys,
instanceName: this.hostMeta.hostMetaId
})
});
const data = await response.json();
return data as ValidateLicenseAPIResponse;
}
}
await setHostMeta();
const [info] = await db.select().from(hostMeta).limit(1);
if (!info) {
throw new Error("Host information not found");
}
export const license = new License(info);
export default license;

View File

@@ -0,0 +1,122 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import * as crypto from "crypto";
/**
* Validates a JWT using a public key
* @param token - The JWT to validate
* @param publicKey - The public key used for verification (PEM format)
* @returns The decoded payload if validation succeeds, throws an error otherwise
*/
function validateJWT<Payload>(
token: string,
publicKey: string
): Payload {
// Split the JWT into its three parts
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT format");
}
const [encodedHeader, encodedPayload, signature] = parts;
// Decode the header to get the algorithm
const header = JSON.parse(Buffer.from(encodedHeader, "base64").toString());
const algorithm = header.alg;
// Verify the signature
const signatureInput = `${encodedHeader}.${encodedPayload}`;
const isValid = verify(signatureInput, signature, publicKey, algorithm);
if (!isValid) {
throw new Error("Invalid signature");
}
// Decode the payload
const payload = JSON.parse(
Buffer.from(encodedPayload, "base64").toString()
);
// Check if the token has expired
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
throw new Error("Token has expired");
}
return payload;
}
/**
* Verifies the signature of a JWT
*/
function verify(
input: string,
signature: string,
publicKey: string,
algorithm: string
): boolean {
let verifyAlgorithm: string;
// Map JWT algorithm name to Node.js crypto algorithm name
switch (algorithm) {
case "RS256":
verifyAlgorithm = "RSA-SHA256";
break;
case "RS384":
verifyAlgorithm = "RSA-SHA384";
break;
case "RS512":
verifyAlgorithm = "RSA-SHA512";
break;
case "ES256":
verifyAlgorithm = "SHA256";
break;
case "ES384":
verifyAlgorithm = "SHA384";
break;
case "ES512":
verifyAlgorithm = "SHA512";
break;
default:
throw new Error(`Unsupported algorithm: ${algorithm}`);
}
// Convert base64url signature to standard base64
const base64Signature = base64URLToBase64(signature);
// Verify the signature
const verifier = crypto.createVerify(verifyAlgorithm);
verifier.update(input);
return verifier.verify(publicKey, base64Signature, "base64");
}
/**
* Converts base64url format to standard base64
*/
function base64URLToBase64(base64url: string): string {
// Add padding if needed
let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const pad = base64.length % 4;
if (pad) {
if (pad === 1) {
throw new Error("Invalid base64url string");
}
base64 += "=".repeat(4 - pad);
}
return base64;
}
export { validateJWT };

View File

@@ -0,0 +1,33 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import license from "#private/license/license";
import { build } from "@server/build";
export async function verifyValidLicense(
req: Request,
res: Response,
next: NextFunction
) {
try {
if (build !== "saas") {
return next();
}
const unlocked = await license.isUnlocked();
if (!unlocked) {
return next(
createHttpError(HttpCode.FORBIDDEN, "License is not valid")
);
}
return next();
} catch (e) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying license"
)
);
}
}

View File

@@ -19,9 +19,16 @@ import * as loginPage from "#private/routers/loginPage";
import * as orgIdp from "#private/routers/orgIdp";
import * as domain from "#private/routers/domain";
import * as auth from "#private/routers/auth";
import * as license from "#private/routers/license";
import * as generateLicense from "./generatedLicense";
import { Router } from "express";
import { verifyOrgAccess, verifySessionUserMiddleware, verifyUserHasAction } from "@server/middlewares";
import {
verifyOrgAccess,
verifyUserHasAction,
verifyUserIsOrgOwner,
verifyUserIsServerAdmin
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import {
verifyCertificateAccess,
@@ -33,28 +40,19 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { unauthenticated as ua, authenticated as a } from "@server/routers/external";
import {
unauthenticated as ua,
authenticated as a
} from "@server/routers/external";
import { verifyValidLicense } from "../middlewares/verifyValidLicense";
import { build } from "@server/build";
export const authenticated = a;
export const unauthenticated = ua;
unauthenticated.post(
"/quick-start",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
keyGenerator: (req) => req.path,
handler: (req, res, next) => {
const message = `We're too busy right now. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
},
store: createStore()
}),
auth.quickStart
);
unauthenticated.post(
"/remote-exit-node/quick-start",
verifyValidLicense,
rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
@@ -68,9 +66,9 @@ unauthenticated.post(
remoteExitNode.quickStartRemoteExitNode
);
authenticated.put(
"/org/:orgId/idp/oidc",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createIdp),
orgIdp.createOrgOidcIdp
@@ -78,6 +76,7 @@ authenticated.put(
authenticated.post(
"/org/:orgId/idp/:idpId/oidc",
verifyValidLicense,
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.updateIdp),
@@ -86,6 +85,7 @@ authenticated.post(
authenticated.delete(
"/org/:orgId/idp/:idpId",
verifyValidLicense,
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.deleteIdp),
@@ -94,6 +94,7 @@ authenticated.delete(
authenticated.get(
"/org/:orgId/idp/:idpId",
verifyValidLicense,
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.getIdp),
@@ -102,6 +103,7 @@ authenticated.get(
authenticated.get(
"/org/:orgId/idp",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listIdps),
orgIdp.listOrgIdps
@@ -111,6 +113,7 @@ authenticated.get("/org/:orgId/idp", orgIdp.listOrgIdps); // anyone can see this
authenticated.get(
"/org/:orgId/certificate/:domainId/:domain",
verifyValidLicense,
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.getCertificate),
@@ -119,49 +122,87 @@ authenticated.get(
authenticated.post(
"/org/:orgId/certificate/:certId/restart",
verifyValidLicense,
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.restartCertificate),
certificates.restartCertificate
);
authenticated.post(
"/org/:orgId/billing/create-checkout-session",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.createCheckoutSession
);
if (build === "saas") {
unauthenticated.post(
"/quick-start",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
keyGenerator: (req) => req.path,
handler: (req, res, next) => {
const message = `We're too busy right now. Please try again later.`;
return next(
createHttpError(HttpCode.TOO_MANY_REQUESTS, message)
);
},
store: createStore()
}),
auth.quickStart
);
authenticated.post(
"/org/:orgId/billing/create-portal-session",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.createPortalSession
);
authenticated.post(
"/org/:orgId/billing/create-checkout-session",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.createCheckoutSession
);
authenticated.post(
"/org/:orgId/billing/create-portal-session",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.createPortalSession
);
authenticated.get(
"/org/:orgId/billing/subscription",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.getOrgSubscription
);
authenticated.get(
"/org/:orgId/billing/usage",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.getOrgUsage
);
authenticated.get(
"/org/:orgId/license",
verifyOrgAccess,
generateLicense.listSaasLicenseKeys
);
authenticated.put(
"/org/:orgId/license",
verifyOrgAccess,
generateLicense.generateNewLicense
);
}
authenticated.get(
"/org/:orgId/billing/subscription",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.getOrgSubscription
"/domain/namespaces",
verifyValidLicense,
domain.listDomainNamespaces
);
authenticated.get(
"/org/:orgId/billing/usage",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.getOrgUsage
);
authenticated.get("/domain/namespaces", domain.listDomainNamespaces);
authenticated.get(
"/domain/check-namespace-availability",
verifyValidLicense,
domain.checkDomainNamespaceAvailability
);
authenticated.put(
"/org/:orgId/remote-exit-node",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
remoteExitNode.createRemoteExitNode
@@ -169,6 +210,7 @@ authenticated.put(
authenticated.get(
"/org/:orgId/remote-exit-nodes",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listRemoteExitNode),
remoteExitNode.listRemoteExitNodes
@@ -176,6 +218,7 @@ authenticated.get(
authenticated.get(
"/org/:orgId/remote-exit-node/:remoteExitNodeId",
verifyValidLicense,
verifyOrgAccess,
verifyRemoteExitNodeAccess,
verifyUserHasAction(ActionsEnum.getRemoteExitNode),
@@ -184,6 +227,7 @@ authenticated.get(
authenticated.get(
"/org/:orgId/pick-remote-exit-node-defaults",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
remoteExitNode.pickRemoteExitNodeDefaults
@@ -191,6 +235,7 @@ authenticated.get(
authenticated.delete(
"/org/:orgId/remote-exit-node/:remoteExitNodeId",
verifyValidLicense,
verifyOrgAccess,
verifyRemoteExitNodeAccess,
verifyUserHasAction(ActionsEnum.deleteRemoteExitNode),
@@ -199,6 +244,7 @@ authenticated.delete(
authenticated.put(
"/org/:orgId/login-page",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createLoginPage),
loginPage.createLoginPage
@@ -206,6 +252,7 @@ authenticated.put(
authenticated.post(
"/org/:orgId/login-page/:loginPageId",
verifyValidLicense,
verifyOrgAccess,
verifyLoginPageAccess,
verifyUserHasAction(ActionsEnum.updateLoginPage),
@@ -214,6 +261,7 @@ authenticated.post(
authenticated.delete(
"/org/:orgId/login-page/:loginPageId",
verifyValidLicense,
verifyOrgAccess,
verifyLoginPageAccess,
verifyUserHasAction(ActionsEnum.deleteLoginPage),
@@ -222,6 +270,7 @@ authenticated.delete(
authenticated.get(
"/org/:orgId/login-page",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getLoginPage),
loginPage.getLoginPage
@@ -231,6 +280,7 @@ export const authRouter = Router();
authRouter.post(
"/remoteExitNode/get-token",
verifyValidLicense,
rateLimit({
windowMs: 15 * 60 * 1000,
max: 900,
@@ -247,6 +297,7 @@ authRouter.post(
authRouter.post(
"/transfer-session-token",
verifyValidLicense,
rateLimit({
windowMs: 1 * 60 * 1000,
max: 60,
@@ -259,4 +310,28 @@ authRouter.post(
store: createStore()
}),
auth.transferSession
);
);
authenticated.post(
"/license/activate",
verifyUserIsServerAdmin,
license.activateLicense
);
authenticated.get(
"/license/keys",
verifyUserIsServerAdmin,
license.listLicenseKeys
);
authenticated.delete(
"/license/:licenseKey",
verifyUserIsServerAdmin,
license.deleteLicenseKey
);
authenticated.post(
"/license/recheck",
verifyUserIsServerAdmin,
license.recheckStatus
);

View File

@@ -0,0 +1,91 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import privateConfig from "@server/private/lib/config";
export type NewLicenseKey = {
licenseKey: {
id: number;
instanceName: string | null;
instanceId: string;
licenseKey: string;
tier: string;
type: string;
quantity: number;
isValid: boolean;
updatedAt: string;
createdAt: string;
expiresAt: string;
orgId: string;
};
};
export type GenerateNewLicenseResponse = NewLicenseKey;
async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
try {
const response = await fetch(
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`,
{
method: "PUT",
headers: {
"api-key":
privateConfig.getRawPrivateConfig().server
.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify(licenseData)
}
);
const data = await response.json();
logger.debug("Fossorial API response:", {data});
return data;
} catch (error) {
console.error("Error creating new license:", error);
throw error;
}
}
export async function generateNewLicense(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const { orgId } = req.params;
if (!orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization ID is required"
)
);
}
logger.debug(`Generating new license for orgId: ${orgId}`);
const licenseData = req.body;
const apiResponse = await createNewLicense(orgId, licenseData);
return sendResponse<GenerateNewLicenseResponse>(res, {
data: apiResponse.data,
success: apiResponse.success,
error: apiResponse.error,
message: apiResponse.message,
status: apiResponse.status
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while generating new license"
)
);
}
}

View File

@@ -0,0 +1,2 @@
export * from "./listGeneratedLicenses";
export * from "./generateNewLicense";

View File

@@ -0,0 +1,83 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import privateConfig from "@server/private/lib/config";
export type GeneratedLicenseKey = {
instanceName: string | null;
licenseKey: string;
expiresAt: string;
isValid: boolean;
createdAt: string;
tier: string;
type: string;
};
export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[];
async function fetchLicenseKeys(orgId: string): Promise<any> {
try {
const response = await fetch(
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`,
{
method: "GET",
headers: {
"api-key":
privateConfig.getRawPrivateConfig().server
.fossorial_api_key!,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching license keys:", error);
throw error;
}
}
export async function listSaasLicenseKeys(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const { orgId } = req.params;
if (!orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization ID is required"
)
);
}
const apiResponse = await fetchLicenseKeys(orgId);
const keys: GeneratedLicenseKey[] = apiResponse.data.licenseKeys || [];
return sendResponse<ListGeneratedLicenseKeysResponse>(res, {
data: keys,
success: true,
error: false,
message: "Successfully retrieved license keys",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while fetching license keys"
)
);
}
}

View File

@@ -15,6 +15,7 @@ import * as loginPage from "#private/routers/loginPage";
import * as auth from "#private/routers/auth";
import * as orgIdp from "#private/routers/orgIdp";
import * as billing from "#private/routers/billing";
import * as license from "#private/routers/license";
import { Router } from "express";
import { verifySessionUserMiddleware } from "@server/middlewares";
@@ -34,3 +35,5 @@ internalRouter.post(
verifySessionUserMiddleware,
auth.getSessionTransferToken
);
internalRouter.get(`/license/status`, license.getLicenseStatus);

View File

@@ -0,0 +1,58 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import license from "#private/license/license";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { LicenseStatus } from "@server/license/license";
const bodySchema = z
.object({
licenseKey: z.string().min(1).max(255)
})
.strict();
export type ActivateLicenseStatus = LicenseStatus;
export async function activateLicense(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { licenseKey } = parsedBody.data;
try {
const status = await license.activateLicenseKey(licenseKey);
return sendResponse(res, {
data: status,
success: true,
error: false,
message: "License key activated successfully",
status: HttpCode.OK
});
} catch (e) {
logger.error(e);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`)
);
}
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,72 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import { licenseKey } from "@server/db";
import license from "#private/license/license";
import { LicenseStatus } from "@server/license/license";
const paramsSchema = z
.object({
licenseKey: z.string().min(1).max(255)
})
.strict();
export type DeleteLicenseKeyResponse = LicenseStatus;
export async function deleteLicenseKey(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { licenseKey: key } = parsedParams.data;
const [existing] = await db
.select()
.from(licenseKey)
.where(eq(licenseKey.licenseKeyId, key))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`License key ${key} not found`
)
);
}
await db.delete(licenseKey).where(eq(licenseKey.licenseKeyId, key));
const status = await license.forceRecheck();
return sendResponse(res, {
data: status,
success: true,
error: false,
message: "License key deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,32 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import license from "#private/license/license";
import { LicenseStatus } from "@server/license/license";
export type GetLicenseStatusResponse = LicenseStatus;
export async function getLicenseStatus(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const status = await license.check();
return sendResponse<GetLicenseStatusResponse>(res, {
data: status,
success: true,
error: false,
message: "Got status",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,5 @@
export * from "./getLicenseStatus";
export * from "./activateLicense";
export * from "./listLicenseKeys";
export * from "./deleteLicenseKey";
export * from "./recheckStatus";

View File

@@ -0,0 +1,32 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import license from "#private/license/license";
import { LicenseKeyCache } from "@server/license/license";
export type ListLicenseKeysResponse = LicenseKeyCache[];
export async function listLicenseKeys(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const keys = license.listKeys();
return sendResponse<ListLicenseKeysResponse>(res, {
data: keys,
success: true,
error: false,
message: "Successfully retrieved license keys",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,38 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import license from "#private/license/license";
import { LicenseStatus } from "@server/license/license";
export type RecheckStatusResponse = LicenseStatus;
export async function recheckStatus(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
try {
const status = await license.forceRecheck();
return sendResponse(res, {
data: status,
success: true,
error: false,
message: "License status rechecked successfully",
status: HttpCode.OK
});
} catch (e) {
logger.error(e);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`)
);
}
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}