Chungus 2.0

This commit is contained in:
Owen
2025-10-10 11:27:15 -07:00
parent f64a477c3d
commit d92b87b7c8
224 changed files with 1507 additions and 1586 deletions

View File

@@ -10,10 +10,7 @@ export * from "./resetPassword";
export * from "./requestPasswordReset";
export * from "./setServerAdmin";
export * from "./initialSetupComplete";
export * from "./privateQuickStart";
export * from "./validateSetupToken";
export * from "./changePassword";
export * from "./checkResourceSession";
export * from "./securityKey";
export * from "./privateGetSessionTransferToken";
export * from "./privateTransferSession";
export * from "./securityKey";

View File

@@ -1,97 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, sessionTransferToken } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import {
generateSessionToken,
SESSION_COOKIE_NAME
} from "@server/auth/sessions/app";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { response } from "@server/lib/response";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const paramsSchema = z.object({}).strict();
export type GetSessionTransferTokenRenponse = {
token: string;
};
export async function getSessionTransferToken(
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 { user, session } = req;
if (!user || !session) {
return next(createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized"));
}
const tokenRaw = generateSessionToken();
const token = encodeHexLowerCase(
sha256(new TextEncoder().encode(tokenRaw))
);
const rawSessionId = req.cookies[SESSION_COOKIE_NAME];
if (!rawSessionId) {
return next(createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized"));
}
const encryptedSession = encrypt(
rawSessionId,
config.getRawConfig().server.secret!
);
await db.insert(sessionTransferToken).values({
encryptedSession,
token,
sessionId: session.sessionId,
expiresAt: Date.now() + 30 * 1000 // Token valid for 30 seconds
});
return response<GetSessionTransferTokenRenponse>(res, {
data: {
token: tokenRaw
},
success: true,
error: false,
message: "Transfer token created successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,579 +0,0 @@
/*
* 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 { NextFunction, Request, Response } from "express";
import {
account,
db,
domainNamespaces,
domains,
exitNodes,
newts,
newtSessions,
orgs,
passwordResetTokens,
Resource,
resourcePassword,
resourcePincode,
resources,
resourceWhitelist,
roleResources,
roles,
roleSites,
sites,
targetHealthCheck,
targets,
userResources,
userSites
} from "@server/db";
import HttpCode from "@server/types/HttpCode";
import { z } from "zod";
import { users } from "@server/db";
import { fromError } from "zod-validation-error";
import createHttpError from "http-errors";
import response from "@server/lib/response";
import { SqliteError } from "better-sqlite3";
import { eq, and, sql } from "drizzle-orm";
import moment from "moment";
import { generateId } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
import { UserType } from "@server/types/UserTypes";
import { createUserAccountOrg } from "@server/lib/private/createUserAccountOrg";
import { sendEmail } from "@server/emails";
import WelcomeQuickStart from "@server/emails/templates/WelcomeQuickStart";
import { alphabet, generateRandomString } from "oslo/crypto";
import { createDate, TimeSpan } from "oslo";
import { getUniqueResourceName, getUniqueSiteName } from "@server/db/names";
import { pickPort } from "../target/helpers";
import { addTargets } from "../newt/targets";
import { isTargetValid } from "@server/lib/validators";
import { listExitNodes } from "@server/lib/exitNodes";
const bodySchema = z.object({
email: z.string().toLowerCase().email(),
ip: z.string().refine(isTargetValid),
method: z.enum(["http", "https"]),
port: z.number().int().min(1).max(65535),
pincode: z
.string()
.regex(/^\d{6}$/)
.optional(),
password: z.string().min(4).max(100).optional(),
enableWhitelist: z.boolean().optional().default(true),
animalId: z.string() // This is actually the secret key for the backend
});
export type QuickStartBody = z.infer<typeof bodySchema>;
export type QuickStartResponse = {
newtId: string;
newtSecret: string;
resourceUrl: string;
completeSignUpLink: string;
};
const DEMO_UBO_KEY = "b460293f-347c-4b30-837d-4e06a04d5a22";
export async function quickStart(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const {
email,
ip,
method,
port,
pincode,
password,
enableWhitelist,
animalId
} = parsedBody.data;
try {
const tokenValidation = validateTokenOnApi(animalId);
if (!tokenValidation.isValid) {
logger.warn(
`Quick start failed for ${email} token ${animalId}: ${tokenValidation.message}`
);
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid or expired token"
)
);
}
if (animalId === DEMO_UBO_KEY) {
if (email !== "mehrdad@getubo.com") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid email for demo Ubo key"
)
);
}
const [existing] = await db
.select()
.from(users)
.where(
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
);
if (existing) {
// delete the user if it already exists
await db.delete(users).where(eq(users.userId, existing.userId));
const orgId = `org_${existing.userId}`;
await db.delete(orgs).where(eq(orgs.orgId, orgId));
}
}
const tempPassword = generateId(15);
const passwordHash = await hashPassword(tempPassword);
const userId = generateId(15);
// TODO: see if that user already exists?
// Create the sandbox user
const existing = await db
.select()
.from(users)
.where(
and(eq(users.email, email), eq(users.type, UserType.Internal))
);
if (existing && existing.length > 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email address already exists"
)
);
}
let newtId: string;
let secret: string;
let fullDomain: string;
let resource: Resource;
let completeSignUpLink: string;
await db.transaction(async (trx) => {
await trx.insert(users).values({
userId: userId,
type: UserType.Internal,
username: email,
email: email,
passwordHash,
dateCreated: moment().toISOString()
});
// create user"s account
await trx.insert(account).values({
userId
});
});
const { success, error, org } = await createUserAccountOrg(
userId,
email
);
if (!success) {
if (error) {
throw new Error(error);
}
throw new Error("Failed to create user account and organization");
}
if (!org) {
throw new Error("Failed to create user account and organization");
}
const orgId = org.orgId;
await db.transaction(async (trx) => {
const token = generateRandomString(
8,
alphabet("0-9", "A-Z", "a-z")
);
await trx
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.userId, userId));
const tokenHash = await hashPassword(token);
await trx.insert(passwordResetTokens).values({
userId: userId,
email: email,
tokenHash,
expiresAt: createDate(new TimeSpan(7, "d")).getTime()
});
// // Create the sandbox newt
// const newClientAddress = await getNextAvailableClientSubnet(orgId);
// if (!newClientAddress) {
// throw new Error("No available subnet found");
// }
// const clientAddress = newClientAddress.split("/")[0];
newtId = generateId(15);
secret = generateId(48);
// Create the sandbox site
const siteNiceId = await getUniqueSiteName(orgId);
const siteName = `First Site`;
// pick a random exit node
const exitNodesList = await listExitNodes(orgId);
// select a random exit node
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
if (!randomExitNode) {
throw new Error("No exit nodes available");
}
const [newSite] = await trx
.insert(sites)
.values({
orgId,
exitNodeId: randomExitNode.exitNodeId,
name: siteName,
niceId: siteNiceId,
// address: clientAddress,
type: "newt",
dockerSocketEnabled: true
})
.returning();
const siteId = newSite.siteId;
const adminRole = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (adminRole.length === 0) {
throw new Error("Admin role not found");
}
await trx.insert(roleSites).values({
roleId: adminRole[0].roleId,
siteId: newSite.siteId
});
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
// make sure the user can access the site
await trx.insert(userSites).values({
userId: req.user?.userId!,
siteId: newSite.siteId
});
}
// add the peer to the exit node
const secretHash = await hashPassword(secret!);
await trx.insert(newts).values({
newtId: newtId!,
secretHash,
siteId: newSite.siteId,
dateCreated: moment().toISOString()
});
const [randomNamespace] = await trx
.select()
.from(domainNamespaces)
.orderBy(sql`RANDOM()`)
.limit(1);
if (!randomNamespace) {
throw new Error("No domain namespace available");
}
const [randomNamespaceDomain] = await trx
.select()
.from(domains)
.where(eq(domains.domainId, randomNamespace.domainId))
.limit(1);
if (!randomNamespaceDomain) {
throw new Error("No domain found for the namespace");
}
const resourceNiceId = await getUniqueResourceName(orgId);
// Create sandbox resource
const subdomain = `${resourceNiceId}-${generateId(5)}`;
fullDomain = `${subdomain}.${randomNamespaceDomain.baseDomain}`;
const resourceName = `First Resource`;
const newResource = await trx
.insert(resources)
.values({
niceId: resourceNiceId,
fullDomain,
domainId: randomNamespaceDomain.domainId,
orgId,
name: resourceName,
subdomain,
http: true,
protocol: "tcp",
ssl: true,
sso: false,
emailWhitelistEnabled: enableWhitelist
})
.returning();
await trx.insert(roleResources).values({
roleId: adminRole[0].roleId,
resourceId: newResource[0].resourceId
});
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
// make sure the user can access the resource
await trx.insert(userResources).values({
userId: req.user?.userId!,
resourceId: newResource[0].resourceId
});
}
resource = newResource[0];
// Create the sandbox target
const { internalPort, targetIps } = await pickPort(siteId!, trx);
if (!internalPort) {
throw new Error("No available internal port");
}
const newTarget = await trx
.insert(targets)
.values({
resourceId: resource.resourceId,
siteId: siteId!,
internalPort,
ip,
method,
port,
enabled: true
})
.returning();
const newHealthcheck = await trx
.insert(targetHealthCheck)
.values({
targetId: newTarget[0].targetId,
hcEnabled: false
}).returning();
// add the new target to the targetIps array
targetIps.push(`${ip}/32`);
const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, siteId!))
.limit(1);
await addTargets(newt.newtId, newTarget, newHealthcheck, resource.protocol);
// Set resource pincode if provided
if (pincode) {
await trx
.delete(resourcePincode)
.where(
eq(resourcePincode.resourceId, resource!.resourceId)
);
const pincodeHash = await hashPassword(pincode);
await trx.insert(resourcePincode).values({
resourceId: resource!.resourceId,
pincodeHash,
digitLength: 6
});
}
// Set resource password if provided
if (password) {
await trx
.delete(resourcePassword)
.where(
eq(resourcePassword.resourceId, resource!.resourceId)
);
const passwordHash = await hashPassword(password);
await trx.insert(resourcePassword).values({
resourceId: resource!.resourceId,
passwordHash
});
}
// Set resource OTP if whitelist is enabled
if (enableWhitelist) {
await trx.insert(resourceWhitelist).values({
email,
resourceId: resource!.resourceId
});
}
completeSignUpLink = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}&token=${token}`;
// Store token for email outside transaction
await sendEmail(
WelcomeQuickStart({
username: email,
link: completeSignUpLink,
fallbackLink: `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}`,
resourceMethod: method,
resourceHostname: ip,
resourcePort: port,
resourceUrl: `https://${fullDomain}`,
cliCommand: `newt --id ${newtId} --secret ${secret}`
}),
{
to: email,
from: config.getNoReplyEmail(),
subject: `Access your Pangolin dashboard and resources`
}
);
});
return response<QuickStartResponse>(res, {
data: {
newtId: newtId!,
newtSecret: secret!,
resourceUrl: `https://${fullDomain!}`,
completeSignUpLink: completeSignUpLink!
},
success: true,
error: false,
message: "Quick start completed successfully",
status: HttpCode.OK
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Account already exists with that email. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email address already exists"
)
);
} else {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to do quick start"
)
);
}
}
}
const BACKEND_SECRET_KEY = "4f9b6000-5d1a-11f0-9de7-ff2cc032f501";
/**
* Validates a token received from the frontend.
* @param {string} token The validation token from the request.
* @returns {{ isValid: boolean; message: string }} An object indicating if the token is valid.
*/
const validateTokenOnApi = (
token: string
): { isValid: boolean; message: string } => {
if (token === DEMO_UBO_KEY) {
// Special case for demo UBO key
return { isValid: true, message: "Demo UBO key is valid." };
}
if (!token) {
return { isValid: false, message: "Error: No token provided." };
}
try {
// 1. Decode the base64 string
const decodedB64 = atob(token);
// 2. Reverse the character code manipulation
const deobfuscated = decodedB64
.split("")
.map((char) => String.fromCharCode(char.charCodeAt(0) - 5)) // Reverse the shift
.join("");
// 3. Split the data to get the original secret and timestamp
const parts = deobfuscated.split("|");
if (parts.length !== 2) {
throw new Error("Invalid token format.");
}
const receivedKey = parts[0];
const tokenTimestamp = parseInt(parts[1], 10);
// 4. Check if the secret key matches
if (receivedKey !== BACKEND_SECRET_KEY) {
return { isValid: false, message: "Invalid token: Key mismatch." };
}
// 5. Check if the timestamp is recent (e.g., within 30 seconds) to prevent replay attacks
const now = Date.now();
const timeDifference = now - tokenTimestamp;
if (timeDifference > 30000) {
// 30 seconds
return { isValid: false, message: "Invalid token: Expired." };
}
if (timeDifference < 0) {
// Timestamp is in the future
return {
isValid: false,
message: "Invalid token: Timestamp is in the future."
};
}
// If all checks pass, the token is valid
return { isValid: true, message: "Token is valid!" };
} catch (error) {
// This will catch errors from atob (if not valid base64) or other issues.
return {
isValid: false,
message: `Error: ${(error as Error).message}`
};
}
};

View File

@@ -1,128 +0,0 @@
/*
* 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 HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { sessions, sessionTransferToken } from "@server/db";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import { response } from "@server/lib/response";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { serializeSessionCookie } from "@server/auth/sessions/app";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const bodySchema = z.object({
token: z.string()
});
export type TransferSessionBodySchema = z.infer<typeof bodySchema>;
export type TransferSessionResponse = {
valid: boolean;
cookie?: string;
};
export async function transferSession(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
try {
const { token } = parsedBody.data;
const tokenRaw = encodeHexLowerCase(
sha256(new TextEncoder().encode(token))
);
const [existing] = await db
.select()
.from(sessionTransferToken)
.where(eq(sessionTransferToken.token, tokenRaw))
.innerJoin(
sessions,
eq(sessions.sessionId, sessionTransferToken.sessionId)
)
.limit(1);
if (!existing) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid transfer token")
);
}
const transferToken = existing.sessionTransferToken;
const session = existing.session;
if (!transferToken) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid transfer token")
);
}
await db
.delete(sessionTransferToken)
.where(eq(sessionTransferToken.token, tokenRaw));
if (Date.now() > transferToken.expiresAt) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Transfer token expired")
);
}
const rawSession = decrypt(
transferToken.encryptedSession,
config.getRawConfig().server.secret!
);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(
rawSession,
isSecure,
new Date(session.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
return response<TransferSessionResponse>(res, {
data: { valid: true, cookie },
success: true,
error: false,
message: "Session exchanged successfully",
status: HttpCode.OK
});
} catch (e) {
console.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to exchange session"
)
);
}
}

View File

@@ -110,10 +110,12 @@ export async function requestTotpSecret(
);
}
const appName = process.env.BRANDING_APP_NAME || "Pangolin"; // From the private config loading into env vars to seperate away the private config
const hex = crypto.getRandomValues(new Uint8Array(20));
const secret = encodeHex(hex);
const uri = createTOTPKeyURI(
config.getRawPrivateConfig().branding?.app_name || "Pangolin",
appName,
user.email!,
hex
);

View File

@@ -21,12 +21,12 @@ import { hashPassword } from "@server/auth/password";
import { checkValidInvite } from "@server/auth/checkValidInvite";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
import { createUserAccountOrg } from "@server/lib/private/createUserAccountOrg";
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
import { build } from "@server/build";
import resend, {
AudienceIds,
moveEmailToAudience
} from "@server/lib/private/resend";
} from "#dynamic/lib/resend";
export const signupBodySchema = z.object({
email: z.string().toLowerCase().email(),

View File

@@ -10,7 +10,7 @@ import { eq } from "drizzle-orm";
import { isWithinExpirationDate } from "oslo";
import config from "@server/lib/config";
import logger from "@server/logger";
import { freeLimitSet, limitsService } from "@server/lib/private/billing";
import { freeLimitSet, limitsService } from "@server/lib/billing";
import { build } from "@server/build";
export const verifyEmailBody = z

View File

@@ -34,8 +34,8 @@ import NodeCache from "node-cache";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { getCountryCodeForIp, remoteGetCountryCodeForIp } from "@server/lib/geoip";
import { getOrgTierData } from "@server/routers/private/billing";
import { TierId } from "@server/lib/private/billing/tiers";
import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { verifyPassword } from "@server/auth/password";
// We'll see if this speeds anything up

View File

@@ -0,0 +1,14 @@
import createHttpError from "http-errors";
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
export async function billingWebhookHandler(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
// return not found
return next(
createHttpError(HttpCode.NOT_FOUND, "This endpoint is not in use")
);
}

View File

@@ -0,0 +1,5 @@
import { db, Transaction } from "@server/db";
export async function createCertificate(domainId: string, domain: string, trx: Transaction | typeof db) {
return;
}

View File

@@ -24,7 +24,7 @@ import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip";
import { OpenAPITags, registry } from "@server/openApi";
import { listExitNodes } from "@server/lib/exitNodes";
import { listExitNodes } from "#dynamic/lib/exitNodes";
const createClientParamsSchema = z
.object({

View File

@@ -1,4 +1,4 @@
import { sendToClient } from "../ws";
import { sendToClient } from "#dynamic/routers/ws";
export async function addTargets(
newtId: string,

View File

@@ -17,7 +17,7 @@ import {
addPeer as olmAddPeer,
deletePeer as olmDeletePeer
} from "../olm/peers";
import { sendToExitNode } from "@server/lib/exitNodes";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
const updateClientParamsSchema = z
.object({

View File

@@ -9,8 +9,8 @@ import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/lib/schemas";
import { generateId } from "@server/auth/sessions/app";
import { eq, and } from "drizzle-orm";
import { usageService } from "@server/lib/private/billing/usageService";
import { FeatureId } from "@server/lib/private/billing";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators";
import { build } from "@server/build";
import config from "@server/lib/config";

View File

@@ -7,8 +7,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { and, eq } from "drizzle-orm";
import { usageService } from "@server/lib/private/billing/usageService";
import { FeatureId } from "@server/lib/private/billing";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
const paramsSchema = z
.object({

View File

@@ -1,6 +1,4 @@
export * from "./listDomains";
export * from "./createOrgDomain";
export * from "./deleteOrgDomain";
export * from "./privateListDomainNamespaces";
export * from "./privateCheckDomainNamespaceAvailability";
export * from "./restartOrgDomain";

View File

@@ -1,127 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { db, domainNamespaces, resources } from "@server/db";
import { inArray } from "drizzle-orm";
const paramsSchema = z.object({}).strict();
const querySchema = z
.object({
subdomain: z.string()
})
.strict();
export type CheckDomainAvailabilityResponse = {
available: boolean;
options: {
domainNamespaceId: string;
domainId: string;
fullDomain: string;
}[];
};
registry.registerPath({
method: "get",
path: "/domain/check-namespace-availability",
description: "Check if a domain namespace is available based on subdomain",
tags: [OpenAPITags.Domain],
request: {
params: paramsSchema,
query: querySchema
},
responses: {}
});
export async function checkDomainNamespaceAvailability(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { subdomain } = parsedQuery.data;
const namespaces = await db.select().from(domainNamespaces);
let possibleDomains = namespaces.map((ns) => {
const desired = `${subdomain}.${ns.domainNamespaceId}`;
return {
fullDomain: desired,
domainId: ns.domainId,
domainNamespaceId: ns.domainNamespaceId
};
});
if (!possibleDomains.length) {
return response<CheckDomainAvailabilityResponse>(res, {
data: {
available: false,
options: []
},
success: true,
error: false,
message: "No domain namespaces available",
status: HttpCode.OK
});
}
const existingResources = await db
.select()
.from(resources)
.where(
inArray(
resources.fullDomain,
possibleDomains.map((d) => d.fullDomain)
)
);
possibleDomains = possibleDomains.filter(
(domain) =>
!existingResources.some(
(resource) => resource.fullDomain === domain.fullDomain
)
);
return response<CheckDomainAvailabilityResponse>(res, {
data: {
available: possibleDomains.length > 0,
options: possibleDomains
},
success: true,
error: false,
message: "Domain namespaces checked successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,130 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domainNamespaces } from "@server/db";
import { domains } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { eq, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.object({}).strict();
const querySchema = z
.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().nonnegative()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
})
.strict();
async function query(limit: number, offset: number) {
const res = await db
.select({
domainNamespaceId: domainNamespaces.domainNamespaceId,
domainId: domainNamespaces.domainId
})
.from(domainNamespaces)
.innerJoin(
domains,
eq(domains.domainId, domainNamespaces.domainNamespaceId)
)
.limit(limit)
.offset(offset);
return res;
}
export type ListDomainNamespacesResponse = {
domainNamespaces: NonNullable<Awaited<ReturnType<typeof query>>>;
pagination: { total: number; limit: number; offset: number };
};
registry.registerPath({
method: "get",
path: "/domains/namepaces",
description: "List all domain namespaces in the system",
tags: [OpenAPITags.Domain],
request: {
query: querySchema
},
responses: {}
});
export async function listDomainNamespaces(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { limit, offset } = parsedQuery.data;
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const domainNamespacesList = await query(limit, offset);
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(domainNamespaces);
return response<ListDomainNamespacesResponse>(res, {
data: {
domainNamespaces: domainNamespacesList,
pagination: {
total: count,
limit,
offset
}
},
success: true,
error: false,
message: "Namespaces retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -38,25 +38,13 @@ import {
verifyUserIsOrgOwner,
verifySiteResourceAccess
} from "@server/middlewares";
import {
verifyCertificateAccess,
verifyRemoteExitNodeAccess,
verifyIdpAccess,
verifyLoginPageAccess
} from "@server/middlewares/private";
import { createStore } from "@server/lib/private/rateLimitStore";
import { ActionsEnum } from "@server/auth/actions";
import { createNewt, getNewtToken } from "./newt";
import { getOlmToken } from "./olm";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import createHttpError from "http-errors";
import * as certificates from "./private/certificates";
import * as billing from "@server/routers/private/billing";
import { quickStart } from "./auth/privateQuickStart";
import { build } from "@server/build";
import * as remoteExitNode from "@server/routers/private/remoteExitNode";
import * as loginPage from "@server/routers/private/loginPage";
import * as orgIdp from "@server/routers/private/orgIdp";
import { createStore } from "#dynamic/lib/rateLimitStore";
// Root routes
export const unauthenticated = Router();
@@ -65,45 +53,6 @@ unauthenticated.get("/", (_, res) => {
res.status(HttpCode.OK).json({ message: "Healthy" });
});
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()
}),
quickStart
);
}
if (build !== "oss") {
unauthenticated.post(
"/remote-exit-node/quick-start",
rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
keyGenerator: (req) =>
`${req.path}:${ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only create 5 remote exit nodes every hour. Please try again later.`;
return next(
createHttpError(HttpCode.TOO_MANY_REQUESTS, message)
);
},
store: createStore()
}),
remoteExitNode.quickStartRemoteExitNode
);
}
// Authenticated Root routes
export const authenticated = Router();
authenticated.use(verifySessionUserMiddleware);
@@ -727,45 +676,7 @@ authenticated.post(
idp.updateOidcIdp
);
if (build !== "oss") {
authenticated.put(
"/org/:orgId/idp/oidc",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createIdp),
orgIdp.createOrgOidcIdp
);
authenticated.post(
"/org/:orgId/idp/:idpId/oidc",
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.updateIdp),
orgIdp.updateOrgOidcIdp
);
authenticated.delete(
"/org/:orgId/idp/:idpId",
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.deleteIdp),
idp.deleteIdp
);
authenticated.get(
"/org/:orgId/idp/:idpId",
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.getIdp),
orgIdp.getOrgIdp
);
authenticated.get(
"/org/:orgId/idp",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listIdps),
orgIdp.listOrgIdps
);
}
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
@@ -795,9 +706,7 @@ authenticated.get(
idp.listIdpOrgPolicies
);
if (build !== "oss") {
authenticated.get("/org/:orgId/idp", orgIdp.listOrgIdps); // anyone can see this; it's just a list of idp names and ids
}
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
@@ -930,126 +839,6 @@ authenticated.delete(
domain.deleteAccountDomain
);
if (build !== "oss") {
authenticated.get(
"/org/:orgId/certificate/:domainId/:domain",
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.getCertificate),
certificates.getCertificate
);
authenticated.post(
"/org/:orgId/certificate/:certId/restart",
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.restartCertificate),
certificates.restartCertificate
);
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("/domain/namespaces", domain.listDomainNamespaces);
authenticated.get(
"/domain/check-namespace-availability",
domain.checkDomainNamespaceAvailability
);
authenticated.put(
"/org/:orgId/remote-exit-node",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
remoteExitNode.createRemoteExitNode
);
authenticated.get(
"/org/:orgId/remote-exit-nodes",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listRemoteExitNode),
remoteExitNode.listRemoteExitNodes
);
authenticated.get(
"/org/:orgId/remote-exit-node/:remoteExitNodeId",
verifyOrgAccess,
verifyRemoteExitNodeAccess,
verifyUserHasAction(ActionsEnum.getRemoteExitNode),
remoteExitNode.getRemoteExitNode
);
authenticated.get(
"/org/:orgId/pick-remote-exit-node-defaults",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
remoteExitNode.pickRemoteExitNodeDefaults
);
authenticated.delete(
"/org/:orgId/remote-exit-node/:remoteExitNodeId",
verifyOrgAccess,
verifyRemoteExitNodeAccess,
verifyUserHasAction(ActionsEnum.deleteRemoteExitNode),
remoteExitNode.deleteRemoteExitNode
);
authenticated.put(
"/org/:orgId/login-page",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createLoginPage),
loginPage.createLoginPage
);
authenticated.post(
"/org/:orgId/login-page/:loginPageId",
verifyOrgAccess,
verifyLoginPageAccess,
verifyUserHasAction(ActionsEnum.updateLoginPage),
loginPage.updateLoginPage
);
authenticated.delete(
"/org/:orgId/login-page/:loginPageId",
verifyOrgAccess,
verifyLoginPageAccess,
verifyUserHasAction(ActionsEnum.deleteLoginPage),
loginPage.deleteLoginPage
);
authenticated.get(
"/org/:orgId/login-page",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getLoginPage),
loginPage.getLoginPage
);
}
// Auth routes
export const authRouter = Router();
unauthenticated.use("/auth", authRouter);
@@ -1129,26 +918,6 @@ authRouter.post(
getOlmToken
);
if (build !== "oss") {
authRouter.post(
"/remoteExitNode/get-token",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 900,
keyGenerator: (req) =>
`remoteExitNodeGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only request an remoteExitNodeToken token ${900} times every ${15} minutes. Please try again later.`;
return next(
createHttpError(HttpCode.TOO_MANY_REQUESTS, message)
);
},
store: createStore()
}),
remoteExitNode.getRemoteExitNodeToken
);
}
authRouter.post(
"/2fa/enable",
rateLimit({
@@ -1316,26 +1085,6 @@ authRouter.post(
resource.authWithWhitelist
);
if (build !== "oss") {
authRouter.post(
"/transfer-session-token",
rateLimit({
windowMs: 1 * 60 * 1000,
max: 60,
keyGenerator: (req) =>
`transferSessionToken:${ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only transfer a session token ${5} times every ${1} minute. Please try again later.`;
return next(
createHttpError(HttpCode.TOO_MANY_REQUESTS, message)
);
},
store: createStore()
}),
auth.transferSession
);
}
authRouter.post(
"/resource/:resourceId/access-token",
resource.authWithAccessToken

View File

@@ -1,19 +1,16 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { sites, resources, targets, exitNodes, ExitNode } from "@server/db";
import { sites, exitNodes, ExitNode } from "@server/db";
import { db } from "@server/db";
import { eq, isNotNull, and } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import config from "@server/lib/config";
import { getUniqueExitNodeEndpointName } from "../../db/names";
import { findNextAvailableCidr } from "@server/lib/ip";
import { fromError } from "zod-validation-error";
import { getAllowedIps } from "../target/helpers";
import { proxyToRemote } from "@server/lib/remoteProxy";
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
import { createExitNode } from "./privateCreateExitNode";
import { createExitNode } from "#dynamic/routers/gerbil/createExitNode";
// Define Zod schema for request validation
const getConfigSchema = z.object({

View File

@@ -4,9 +4,9 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { resolveExitNodes } from "@server/lib/exitNodes";
import config from "@server/lib/config";
import { resolveExitNodes } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
import config from "@server/lib/config";
// Define Zod schema for request validation
const getResolvedHostnameSchema = z.object({
@@ -36,7 +36,15 @@ export async function getResolvedHostname(
const { hostname, publicKey } = parsedParams.data;
const baseDomain = config.getRawPrivateConfig().app.base_domain;
const dashboardUrl = config.getRawConfig().app.dashboard_url;
// extract the domain removing the http and stuff
const baseDomain = dashboardUrl
? dashboardUrl
.replace("http://", "")
.replace("https://", "")
.split("/")[0]
: null;
// if the hostname ends with the base domain then send back a empty array
if (baseDomain && hostname.endsWith(baseDomain)) {
@@ -50,6 +58,13 @@ export async function getResolvedHostname(
publicKey
);
if (resourceExitNodes.length === 0) {
// no exit nodes found, return empty array to force local routing
return res.status(HttpCode.OK).send({
endpoints: [] // this should force to route locally
});
}
endpoints = resourceExitNodes.map((node) => node.endpoint);
}

View File

@@ -2,7 +2,7 @@ import logger from "@server/logger";
import { db } from "@server/db";
import { exitNodes } from "@server/db";
import { eq } from "drizzle-orm";
import { sendToExitNode } from "@server/lib/exitNodes";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
export async function addPeer(
exitNodeId: number,

View File

@@ -1,67 +0,0 @@
/*
* 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, ExitNode, exitNodes } from "@server/db";
import { getUniqueExitNodeEndpointName } from "@server/db/names";
import config from "@server/lib/config";
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
import logger from "@server/logger";
import { eq } from "drizzle-orm";
export async function createExitNode(
publicKey: string,
reachableAt: string | undefined
) {
// Fetch exit node
const [exitNodeQuery] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.publicKey, publicKey));
let exitNode: ExitNode;
if (!exitNodeQuery) {
const address = await getNextAvailableSubnet();
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
listenPort,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} else {
exitNode = exitNodeQuery;
}
return exitNode;
}

View File

@@ -1,13 +0,0 @@
/*
* 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.
*/

View File

@@ -6,9 +6,9 @@ import logger from "@server/logger";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { usageService } from "@server/lib/private/billing/usageService";
import { FeatureId } from "@server/lib/private/billing/features";
import { checkExitNodeOrg } from "@server/lib/exitNodes";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing/features";
import { checkExitNodeOrg } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
// Track sites that are already offline to avoid unnecessary queries

View File

@@ -18,8 +18,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { validateNewtSessionToken } from "@server/auth/sessions/newt";
import { validateOlmSessionToken } from "@server/auth/sessions/olm";
import axios from "axios";
import { checkExitNodeOrg } from "@server/lib/exitNodes";
import { checkExitNodeOrg } from "#dynamic/lib/exitNodes";
// Define Zod schema for request validation
const updateHolePunchSchema = z.object({

4
server/routers/hybrid.ts Normal file
View File

@@ -0,0 +1,4 @@
import { Router } from "express";
// Root routes
export const hybridRouter = Router();

View File

@@ -14,8 +14,8 @@ import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import { build } from "@server/build";
import { getOrgTierData } from "@server/routers/private/billing";
import { TierId } from "@server/lib/private/billing/tiers";
import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
const paramsSchema = z
.object({

View File

@@ -30,8 +30,8 @@ import {
} from "@server/auth/sessions/app";
import { decrypt } from "@server/lib/crypto";
import { UserType } from "@server/types/UserTypes";
import { FeatureId } from "@server/lib/private/billing";
import { usageService } from "@server/lib/private/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
const ensureTrailingSlash = (url: string): string => {
return url;

View File

@@ -555,13 +555,6 @@ authenticated.post(
idp.updateOidcIdp
);
authenticated.delete(
"/idp/:idpId",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.deleteIdp),
idp.deleteIdp
);
authenticated.get(
"/idp",
verifyApiKeyIsRoot,
@@ -604,15 +597,6 @@ authenticated.get(
idp.listIdpOrgPolicies
);
if (build == "saas") {
authenticated.post(
`/org/:orgId/send-usage-notification`,
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
verifyApiKeyHasAction(ActionsEnum.sendUsageNotification),
org.sendUsageNotification
);
}
authenticated.get(
"/org/:orgId/pick-client-defaults",
verifyClientsEnabled,

View File

@@ -7,7 +7,6 @@ import * as auth from "@server/routers/auth";
import * as supporterKey from "@server/routers/supporterKey";
import * as license from "@server/routers/license";
import * as idp from "@server/routers/idp";
import * as loginPage from "@server/routers/private/loginPage";
import { proxyToRemote } from "@server/lib/remoteProxy";
import config from "@server/lib/config";
import HttpCode from "@server/types/HttpCode";
@@ -15,12 +14,9 @@ import {
verifyResourceAccess,
verifySessionUserMiddleware
} from "@server/middlewares";
import { build } from "@server/build";
import * as billing from "@server/routers/private/billing";
import * as orgIdp from "@server/routers/private/orgIdp";
// Root routes
const internalRouter = Router();
export const internalRouter = Router();
internalRouter.get("/", (_, res) => {
res.status(HttpCode.OK).json({ message: "Healthy" });
@@ -51,12 +47,6 @@ internalRouter.get("/idp", idp.listIdps);
internalRouter.get("/idp/:idpId", idp.getIdp);
if (build !== "oss") {
internalRouter.get("/org/:orgId/idp", orgIdp.listOrgIdps);
internalRouter.get("/org/:orgId/billing/tier", billing.getOrgTier);
}
// Gerbil routes
const gerbilRouter = Router();
internalRouter.use("/gerbil", gerbilRouter);
@@ -106,16 +96,4 @@ if (config.isManagedMode()) {
);
} else {
badgerRouter.post("/exchange-session", badger.exchangeSession);
}
if (build !== "oss") {
internalRouter.get("/login-page", loginPage.loadLoginPage);
internalRouter.post(
"/get-session-transfer-token",
verifySessionUserMiddleware,
auth.getSessionTransferToken
);
}
export default internalRouter;
}

View File

@@ -1,5 +1,5 @@
import NodeCache from "node-cache";
import { sendToClient } from "../ws";
import { sendToClient } from "#dynamic/routers/ws";
export const dockerSocketCache = new NodeCache({
stdTTL: 3600 // seconds

View File

@@ -1,5 +1,5 @@
import { db, newts } from "@server/db";
import { MessageHandler } from "../ws";
import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db";
import { eq, and, sql, inArray } from "drizzle-orm";
import logger from "@server/logger";

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { MessageHandler } from "../ws";
import { MessageHandler } from "@server/routers/ws";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
@@ -14,7 +14,7 @@ import {
import { clients, clientSites, Newt, sites } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import { updatePeer } from "../olm/peers";
import { sendToExitNode } from "@server/lib/exitNodes";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
const inputSchema = z.object({
publicKey: z.string(),

View File

@@ -1,10 +1,9 @@
import { db, sites } from "@server/db";
import { MessageHandler } from "../ws";
import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt } from "@server/db";
import logger from "@server/logger";
import config from "@server/lib/config";
import { ne, eq, or, and, count } from "drizzle-orm";
import { listExitNodes } from "@server/lib/exitNodes";
import { listExitNodes } from "#dynamic/lib/exitNodes";
export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;

View File

@@ -1,5 +1,5 @@
import { db, exitNodeOrgs, newts } from "@server/db";
import { MessageHandler } from "../ws";
import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db";
import { targetHealthCheck } from "@server/db";
import { eq, and, sql, inArray } from "drizzle-orm";
@@ -10,12 +10,12 @@ import {
findNextAvailableCidr,
getNextAvailableClientSubnet
} from "@server/lib/ip";
import { usageService } from "@server/lib/private/billing/usageService";
import { FeatureId } from "@server/lib/private/billing";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import {
selectBestExitNode,
verifyExitNodeOrgAccess
} from "@server/lib/exitNodes";
} from "#dynamic/lib/exitNodes";
import { fetchContainers } from "./dockerSocket";
export type ExitNodePingResult = {

View File

@@ -1,5 +1,5 @@
import { db } from "@server/db";
import { MessageHandler } from "../ws";
import { MessageHandler } from "@server/routers/ws";
import { clients, Newt } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";

View File

@@ -1,4 +1,4 @@
import { MessageHandler } from "../ws";
import { MessageHandler } from "@server/routers/ws";
import logger from "@server/logger";
import { dockerSocketCache } from "./dockerSocket";
import { Newt } from "@server/db";

View File

@@ -1,7 +1,7 @@
import { db } from "@server/db";
import { newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import { sendToClient } from "../ws";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
export async function addPeer(

View File

@@ -1,5 +1,5 @@
import { Target, TargetHealthCheck, db, targetHealthCheck } from "@server/db";
import { sendToClient } from "../ws";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import { eq, inArray } from "drizzle-orm";

View File

@@ -1,5 +1,5 @@
import { db } from "@server/db";
import { MessageHandler } from "../ws";
import { MessageHandler } from "@server/routers/ws";
import { clients, Olm } from "@server/db";
import { eq, lt, isNull, and, or } from "drizzle-orm";
import logger from "@server/logger";

View File

@@ -1,10 +1,10 @@
import { db, ExitNode } from "@server/db";
import { MessageHandler } from "../ws";
import { MessageHandler } from "@server/routers/ws";
import { clients, clientSites, exitNodes, Olm, olms, sites } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger";
import { listExitNodes } from "@server/lib/exitNodes";
import { listExitNodes } from "#dynamic/lib/exitNodes";
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
logger.info("Handling register olm message!");

View File

@@ -1,5 +1,5 @@
import { db, exitNodes, sites } from "@server/db";
import { MessageHandler } from "../ws";
import { MessageHandler } from "@server/routers/ws";
import { clients, clientSites, Olm } from "@server/db";
import { and, eq } from "drizzle-orm";
import { updatePeer } from "../newt/peers";

View File

@@ -1,7 +1,7 @@
import { db } from "@server/db";
import { clients, olms, newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import { sendToClient } from "../ws";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
export async function addPeer(

View File

@@ -3,8 +3,6 @@ import { z } from "zod";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import {
apiKeyOrg,
apiKeys,
domains,
Org,
orgDomains,
@@ -24,9 +22,9 @@ import { fromError } from "zod-validation-error";
import { defaultRoleAllowedActions } from "../role";
import { OpenAPITags, registry } from "@server/openApi";
import { isValidCIDR } from "@server/lib/validators";
import { createCustomer } from "@server/routers/private/billing/createCustomer";
import { usageService } from "@server/lib/private/billing/usageService";
import { FeatureId } from "@server/lib/private/billing";
import { createCustomer } from "#dynamic/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { build } from "@server/build";
const createOrgSchema = z

View File

@@ -9,7 +9,7 @@ import createHttpError from "http-errors";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { sendToClient } from "../ws";
import { sendToClient } from "#dynamic/routers/ws";
import { deletePeer } from "../gerbil/peers";
import { OpenAPITags, registry } from "@server/openApi";

View File

@@ -7,5 +7,4 @@ export * from "./checkId";
export * from "./getOrgOverview";
export * from "./listOrgs";
export * from "./pickOrgDefaults";
export * from "./privateSendUsageNotifications";
export * from "./applyBlueprint";

View File

@@ -1,249 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userOrgs, users, roles, orgs } from "@server/db";
import { eq, and, or } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { sendEmail } from "@server/emails";
import NotifyUsageLimitApproaching from "@server/emails/templates/PrivateNotifyUsageLimitApproaching";
import NotifyUsageLimitReached from "@server/emails/templates/PrivateNotifyUsageLimitReached";
import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi";
const sendUsageNotificationParamsSchema = z.object({
orgId: z.string()
});
const sendUsageNotificationBodySchema = z.object({
notificationType: z.enum(["approaching_70", "approaching_90", "reached"]),
limitName: z.string(),
currentUsage: z.number(),
usageLimit: z.number(),
});
type SendUsageNotificationRequest = z.infer<typeof sendUsageNotificationBodySchema>;
export type SendUsageNotificationResponse = {
success: boolean;
emailsSent: number;
adminEmails: string[];
};
// WE SHOULD NOT REGISTER THE PATH IN SAAS
// registry.registerPath({
// method: "post",
// path: "/org/{orgId}/send-usage-notification",
// description: "Send usage limit notification emails to all organization admins.",
// tags: [OpenAPITags.Org],
// request: {
// params: sendUsageNotificationParamsSchema,
// body: {
// content: {
// "application/json": {
// schema: sendUsageNotificationBodySchema
// }
// }
// }
// },
// responses: {
// 200: {
// description: "Usage notifications sent successfully",
// content: {
// "application/json": {
// schema: z.object({
// success: z.boolean(),
// emailsSent: z.number(),
// adminEmails: z.array(z.string())
// })
// }
// }
// }
// }
// });
async function getOrgAdmins(orgId: string) {
// Get all users in the organization who are either:
// 1. Organization owners (isOwner = true)
// 2. Have admin roles (role.isAdmin = true)
const admins = await db
.select({
userId: users.userId,
email: users.email,
name: users.name,
isOwner: userOrgs.isOwner,
roleName: roles.name,
isAdminRole: roles.isAdmin
})
.from(userOrgs)
.innerJoin(users, eq(userOrgs.userId, users.userId))
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.where(
and(
eq(userOrgs.orgId, orgId),
or(
eq(userOrgs.isOwner, true),
eq(roles.isAdmin, true)
)
)
);
// Filter to only include users with verified emails
const orgAdmins = admins.filter(admin =>
admin.email &&
admin.email.length > 0
);
return orgAdmins;
}
export async function sendUsageNotification(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = sendUsageNotificationParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = sendUsageNotificationBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const {
notificationType,
limitName,
currentUsage,
usageLimit,
} = parsedBody.data;
// Verify organization exists
const org = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (org.length === 0) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
);
}
// Get all admin users for this organization
const orgAdmins = await getOrgAdmins(orgId);
if (orgAdmins.length === 0) {
logger.warn(`No admin users found for organization ${orgId}`);
return response<SendUsageNotificationResponse>(res, {
data: {
success: true,
emailsSent: 0,
adminEmails: []
},
success: true,
error: false,
message: "No admin users found to notify",
status: HttpCode.OK
});
}
// Default billing link if not provided
const defaultBillingLink = `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`;
let emailsSent = 0;
const adminEmails: string[] = [];
// Send emails to all admin users
for (const admin of orgAdmins) {
if (!admin.email) continue;
try {
let template;
let subject;
if (notificationType === "approaching_70" || notificationType === "approaching_90") {
template = NotifyUsageLimitApproaching({
email: admin.email,
limitName,
currentUsage,
usageLimit,
billingLink: defaultBillingLink
});
subject = `Usage limit warning for ${limitName}`;
} else {
template = NotifyUsageLimitReached({
email: admin.email,
limitName,
currentUsage,
usageLimit,
billingLink: defaultBillingLink
});
subject = `URGENT: Usage limit reached for ${limitName}`;
}
await sendEmail(template, {
to: admin.email,
from: config.getNoReplyEmail(),
subject
});
emailsSent++;
adminEmails.push(admin.email);
logger.info(`Usage notification sent to admin ${admin.email} for org ${orgId}`);
} catch (emailError) {
logger.error(`Failed to send usage notification to ${admin.email}:`, emailError);
// Continue with other admins even if one fails
}
}
return response<SendUsageNotificationResponse>(res, {
data: {
success: true,
emailsSent,
adminEmails
},
success: true,
error: false,
message: `Usage notifications sent to ${emailsSent} administrators`,
status: HttpCode.OK
});
} catch (error) {
logger.error("Error sending usage notifications:", error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to send usage notifications")
);
}
}

View File

@@ -1,101 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { customers, db } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import config from "@server/lib/config";
import { fromError } from "zod-validation-error";
import stripe from "@server/lib/private/stripe";
import { getLineItems, getStandardFeaturePriceSet } from "@server/lib/private/billing";
import { getTierPriceSet, TierId } from "@server/lib/private/billing/tiers";
const createCheckoutSessionSchema = z
.object({
orgId: z.string()
})
.strict();
export async function createCheckoutSession(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = createCheckoutSessionSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
// check if we already have a customer for this org
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
// If we don't have a customer, create one
if (!customer) {
// error
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No customer found for this organization"
)
);
}
const standardTierPrice = getTierPriceSet()[TierId.STANDARD];
const session = await stripe!.checkout.sessions.create({
client_reference_id: orgId, // So we can look it up the org later on the webhook
billing_address_collection: "required",
line_items: [
{
price: standardTierPrice, // Use the standard tier
quantity: 1
},
...getLineItems(getStandardFeaturePriceSet())
], // Start with the standard feature set that matches the free limits
customer: customer.customerId,
mode: "subscription",
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?canceled=true`
});
return response<string>(res, {
data: session.url,
success: true,
error: false,
message: "Organization created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,48 +0,0 @@
/*
* 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 { customers, db } from "@server/db";
import { eq } from "drizzle-orm";
import stripe from "@server/lib/private/stripe";
import { build } from "@server/build";
export async function createCustomer(
orgId: string,
email: string | null | undefined
): Promise<string | undefined> {
if (build !== "saas") {
return;
}
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
let customerId: string;
// If we don't have a customer, create one
if (!customer) {
const newCustomer = await stripe!.customers.create({
metadata: {
orgId: orgId
},
email: email || undefined
});
customerId = newCustomer.id;
// It will get inserted into the database by the webhook
} else {
customerId = customer.customerId;
}
return customerId;
}

View File

@@ -1,89 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { account, customers, db } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import config from "@server/lib/config";
import { fromError } from "zod-validation-error";
import stripe from "@server/lib/private/stripe";
const createPortalSessionSchema = z
.object({
orgId: z.string()
})
.strict();
export async function createPortalSession(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = createPortalSessionSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
// check if we already have a customer for this org
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
let customerId: string;
// If we don't have a customer, create one
if (!customer) {
// error
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No customer found for this organization"
)
);
} else {
// If we have a customer, use the existing customer ID
customerId = customer.customerId;
}
const portalSession = await stripe!.billingPortal.sessions.create({
customer: customerId,
return_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`
});
return response<string>(res, {
data: portalSession.url,
success: true,
error: false,
message: "Organization created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,157 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { Org, orgs } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
// Import tables for billing
import {
customers,
subscriptions,
subscriptionItems,
Subscription,
SubscriptionItem
} from "@server/db";
const getOrgSchema = z
.object({
orgId: z.string()
})
.strict();
export type GetOrgSubscriptionResponse = {
subscription: Subscription | null;
items: SubscriptionItem[];
};
registry.registerPath({
method: "get",
path: "/org/{orgId}/billing/subscription",
description: "Get an organization",
tags: [OpenAPITags.Org],
request: {
params: getOrgSchema
},
responses: {}
});
export async function getOrgSubscription(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getOrgSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
let subscriptionData = null;
let itemsData: SubscriptionItem[] = [];
try {
const { subscription, items } = await getOrgSubscriptionData(orgId);
subscriptionData = subscription;
itemsData = items;
} catch (err) {
if ((err as Error).message === "Not found") {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
throw err;
}
return response<GetOrgSubscriptionResponse>(res, {
data: {
subscription: subscriptionData,
items: itemsData
},
success: true,
error: false,
message: "Organization and subscription retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
export async function getOrgSubscriptionData(
orgId: string
): Promise<{ subscription: Subscription | null; items: SubscriptionItem[] }> {
const org = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (org.length === 0) {
throw new Error(`Not found`);
}
// Get customer for org
const customer = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
let subscription = null;
let items: SubscriptionItem[] = [];
if (customer.length > 0) {
// Get subscription for customer
const subs = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.customerId, customer[0].customerId))
.limit(1);
if (subs.length > 0) {
subscription = subs[0];
// Get subscription items
items = await db
.select()
.from(subscriptionItems)
.where(
eq(
subscriptionItems.subscriptionId,
subscription.subscriptionId
)
);
}
}
return { subscription, items };
}

View File

@@ -1,129 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { orgs } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { Limit, limits, Usage, usage } from "@server/db";
import { usageService } from "@server/lib/private/billing/usageService";
import { FeatureId } from "@server/lib/private/billing";
const getOrgSchema = z
.object({
orgId: z.string()
})
.strict();
export type GetOrgUsageResponse = {
usage: Usage[];
limits: Limit[];
};
registry.registerPath({
method: "get",
path: "/org/{orgId}/billing/usage",
description: "Get an organization's billing usage",
tags: [OpenAPITags.Org],
request: {
params: getOrgSchema
},
responses: {}
});
export async function getOrgUsage(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getOrgSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
const org = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (org.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
// Get usage for org
const usageData = [];
const siteUptime = await usageService.getUsage(orgId, FeatureId.SITE_UPTIME);
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
const domains = await usageService.getUsageDaily(orgId, FeatureId.DOMAINS);
const remoteExitNodes = await usageService.getUsageDaily(orgId, FeatureId.REMOTE_EXIT_NODES);
const egressData = await usageService.getUsage(orgId, FeatureId.EGRESS_DATA_MB);
if (siteUptime) {
usageData.push(siteUptime);
}
if (users) {
usageData.push(users);
}
if (egressData) {
usageData.push(egressData);
}
if (domains) {
usageData.push(domains);
}
if (remoteExitNodes) {
usageData.push(remoteExitNodes);
}
const orgLimits = await db.select()
.from(limits)
.where(eq(limits.orgId, orgId));
return response<GetOrgUsageResponse>(res, {
data: {
usage: usageData,
limits: orgLimits
},
success: true,
error: false,
message: "Organization usage retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,57 +0,0 @@
/*
* 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 Stripe from "stripe";
import { customers, db } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
export async function handleCustomerCreated(
customer: Stripe.Customer
): Promise<void> {
try {
const [existingCustomer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, customer.id))
.limit(1);
if (existingCustomer) {
logger.info(`Customer with ID ${customer.id} already exists.`);
return;
}
if (!customer.metadata.orgId) {
logger.error(
`Customer with ID ${customer.id} does not have an orgId in metadata.`
);
return;
}
await db.insert(customers).values({
customerId: customer.id,
orgId: customer.metadata.orgId,
email: customer.email || null,
name: customer.name || null,
createdAt: customer.created,
updatedAt: customer.created
});
logger.info(`Customer with ID ${customer.id} created successfully.`);
} catch (error) {
logger.error(
`Error handling customer created event for ID ${customer.id}:`,
error
);
}
return;
}

View File

@@ -1,44 +0,0 @@
/*
* 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 Stripe from "stripe";
import { customers, db } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
export async function handleCustomerDeleted(
customer: Stripe.Customer
): Promise<void> {
try {
const [existingCustomer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, customer.id))
.limit(1);
if (!existingCustomer) {
logger.info(`Customer with ID ${customer.id} does not exist.`);
return;
}
await db
.delete(customers)
.where(eq(customers.customerId, customer.id));
} catch (error) {
logger.error(
`Error handling customer created event for ID ${customer.id}:`,
error
);
}
return;
}

View File

@@ -1,54 +0,0 @@
/*
* 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 Stripe from "stripe";
import { customers, db } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
export async function handleCustomerUpdated(
customer: Stripe.Customer
): Promise<void> {
try {
const [existingCustomer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, customer.id))
.limit(1);
if (!existingCustomer) {
logger.info(`Customer with ID ${customer.id} does not exist.`);
return;
}
const newCustomer = {
customerId: customer.id,
orgId: customer.metadata.orgId,
email: customer.email || null,
name: customer.name || null,
updatedAt: Math.floor(Date.now() / 1000)
};
// Update the existing customer record
await db
.update(customers)
.set(newCustomer)
.where(eq(customers.customerId, customer.id));
} catch (error) {
logger.error(
`Error handling customer created event for ID ${customer.id}:`,
error
);
}
return;
}

View File

@@ -1,153 +0,0 @@
/*
* 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 Stripe from "stripe";
import {
customers,
subscriptions,
db,
subscriptionItems,
userOrgs,
users
} from "@server/db";
import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import stripe from "@server/lib/private/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "@server/lib/private/resend";
export async function handleSubscriptionCreated(
subscription: Stripe.Subscription
): Promise<void> {
try {
// Fetch the subscription from Stripe with expanded price.tiers
const fullSubscription = await stripe!.subscriptions.retrieve(
subscription.id,
{
expand: ["items.data.price.tiers"]
}
);
logger.info(JSON.stringify(fullSubscription, null, 2));
// Check if subscription already exists
const [existingSubscription] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.subscriptionId, subscription.id))
.limit(1);
if (existingSubscription) {
logger.info(
`Subscription with ID ${subscription.id} already exists.`
);
return;
}
const newSubscription = {
subscriptionId: subscription.id,
customerId: subscription.customer as string,
status: subscription.status,
canceledAt: subscription.canceled_at
? subscription.canceled_at
: null,
createdAt: subscription.created
};
await db.insert(subscriptions).values(newSubscription);
logger.info(
`Subscription with ID ${subscription.id} created successfully.`
);
// Insert subscription items
if (Array.isArray(fullSubscription.items?.data)) {
const itemsToInsertPromises = fullSubscription.items.data.map(
async (item) => {
// try to get the product name from stripe and add it to the item
let name = null;
if (item.price.product) {
const product = await stripe!.products.retrieve(
item.price.product as string
);
name = product.name || null;
}
return {
subscriptionId: subscription.id,
planId: item.plan.id,
priceId: item.price.id,
meterId: item.plan.meter,
unitAmount: item.price.unit_amount || 0,
currentPeriodStart: item.current_period_start,
currentPeriodEnd: item.current_period_end,
tiers: item.price.tiers
? JSON.stringify(item.price.tiers)
: null,
interval: item.plan.interval,
name
};
}
);
// wait for all items to be processed
const itemsToInsert = await Promise.all(itemsToInsertPromises);
if (itemsToInsert.length > 0) {
await db.insert(subscriptionItems).values(itemsToInsert);
logger.info(
`Inserted ${itemsToInsert.length} subscription items for subscription ${subscription.id}.`
);
}
}
// Lookup customer to get orgId
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, subscription.customer as string))
.limit(1);
if (!customer) {
logger.error(
`Customer with ID ${subscription.customer} not found for subscription ${subscription.id}.`
);
return;
}
await handleSubscriptionLifesycle(customer.orgId, subscription.status);
const [orgUserRes] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, customer.orgId),
eq(userOrgs.isOwner, true)
)
)
.innerJoin(users, eq(userOrgs.userId, users.userId));
if (orgUserRes) {
const email = orgUserRes.user.email;
if (email) {
moveEmailToAudience(email, AudienceIds.Subscribed);
}
}
} catch (error) {
logger.error(
`Error handling subscription created event for ID ${subscription.id}:`,
error
);
}
return;
}

View File

@@ -1,91 +0,0 @@
/*
* 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 Stripe from "stripe";
import { subscriptions, db, subscriptionItems, customers, userOrgs, users } from "@server/db";
import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "@server/lib/private/resend";
export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
): Promise<void> {
try {
const [existingSubscription] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.subscriptionId, subscription.id))
.limit(1);
if (!existingSubscription) {
logger.info(
`Subscription with ID ${subscription.id} does not exist.`
);
return;
}
await db
.delete(subscriptions)
.where(eq(subscriptions.subscriptionId, subscription.id));
await db
.delete(subscriptionItems)
.where(eq(subscriptionItems.subscriptionId, subscription.id));
// Lookup customer to get orgId
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, subscription.customer as string))
.limit(1);
if (!customer) {
logger.error(
`Customer with ID ${subscription.customer} not found for subscription ${subscription.id}.`
);
return;
}
await handleSubscriptionLifesycle(
customer.orgId,
subscription.status
);
const [orgUserRes] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, customer.orgId),
eq(userOrgs.isOwner, true)
)
)
.innerJoin(users, eq(userOrgs.userId, users.userId));
if (orgUserRes) {
const email = orgUserRes.user.email;
if (email) {
moveEmailToAudience(email, AudienceIds.Churned);
}
}
} catch (error) {
logger.error(
`Error handling subscription updated event for ID ${subscription.id}:`,
error
);
}
return;
}

View File

@@ -1,296 +0,0 @@
/*
* 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 Stripe from "stripe";
import {
subscriptions,
db,
subscriptionItems,
usage,
sites,
customers,
orgs
} from "@server/db";
import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import { getFeatureIdByMetricId } from "@server/lib/private/billing/features";
import stripe from "@server/lib/private/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription,
previousAttributes: Partial<Stripe.Subscription> | undefined
): Promise<void> {
try {
// Fetch the subscription from Stripe with expanded price.tiers
const fullSubscription = await stripe!.subscriptions.retrieve(
subscription.id,
{
expand: ["items.data.price.tiers"]
}
);
logger.info(JSON.stringify(fullSubscription, null, 2));
const [existingSubscription] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.subscriptionId, subscription.id))
.limit(1);
if (!existingSubscription) {
logger.info(
`Subscription with ID ${subscription.id} does not exist.`
);
return;
}
// get the customer
const [existingCustomer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, subscription.customer as string))
.limit(1);
await db
.update(subscriptions)
.set({
status: subscription.status,
canceledAt: subscription.canceled_at
? subscription.canceled_at
: null,
updatedAt: Math.floor(Date.now() / 1000),
billingCycleAnchor: subscription.billing_cycle_anchor
})
.where(eq(subscriptions.subscriptionId, subscription.id));
await handleSubscriptionLifesycle(
existingCustomer.orgId,
subscription.status
);
// Upsert subscription items
if (Array.isArray(fullSubscription.items?.data)) {
const itemsToUpsert = fullSubscription.items.data.map((item) => ({
subscriptionId: subscription.id,
planId: item.plan.id,
priceId: item.price.id,
meterId: item.plan.meter,
unitAmount: item.price.unit_amount || 0,
currentPeriodStart: item.current_period_start,
currentPeriodEnd: item.current_period_end,
tiers: item.price.tiers
? JSON.stringify(item.price.tiers)
: null,
interval: item.plan.interval
}));
if (itemsToUpsert.length > 0) {
await db.transaction(async (trx) => {
await trx
.delete(subscriptionItems)
.where(
eq(
subscriptionItems.subscriptionId,
subscription.id
)
);
await trx.insert(subscriptionItems).values(itemsToUpsert);
});
logger.info(
`Updated ${itemsToUpsert.length} subscription items for subscription ${subscription.id}.`
);
}
// --- Detect cycled items and update usage ---
if (previousAttributes) {
// Only proceed if latest_invoice changed (per Stripe docs)
if ("latest_invoice" in previousAttributes) {
// If items array present in previous_attributes, check each item
if (Array.isArray(previousAttributes.items?.data)) {
for (const item of subscription.items.data) {
const prevItem = previousAttributes.items.data.find(
(pi: any) => pi.id === item.id
);
if (
prevItem &&
prevItem.current_period_end &&
item.current_period_start &&
prevItem.current_period_end ===
item.current_period_start &&
item.current_period_start >
prevItem.current_period_start
) {
logger.info(
`Subscription item ${item.id} has cycled. Resetting usage.`
);
} else {
continue;
}
// This item has cycled
const meterId = item.plan.meter;
if (!meterId) {
logger.warn(
`No meterId found for subscription item ${item.id}. Skipping usage reset.`
);
continue;
}
const featureId = getFeatureIdByMetricId(meterId);
if (!featureId) {
logger.warn(
`No featureId found for meterId ${meterId}. Skipping usage reset.`
);
continue;
}
const orgId = existingCustomer.orgId;
if (!orgId) {
logger.warn(
`No orgId found in subscription metadata for subscription ${subscription.id}. Skipping usage reset.`
);
continue;
}
await db.transaction(async (trx) => {
const [usageRow] = await trx
.select()
.from(usage)
.where(
eq(
usage.usageId,
`${orgId}-${featureId}`
)
)
.limit(1);
if (usageRow) {
// get the next rollover date
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
const lastRollover = usageRow.rolledOverAt
? new Date(usageRow.rolledOverAt * 1000)
: new Date();
const anchorDate = org.createdAt
? new Date(org.createdAt)
: new Date();
const nextRollover =
calculateNextRollOverDate(
lastRollover,
anchorDate
);
await trx
.update(usage)
.set({
previousValue: usageRow.latestValue,
latestValue:
usageRow.instantaneousValue ||
0,
updatedAt: Math.floor(
Date.now() / 1000
),
rolledOverAt: Math.floor(
Date.now() / 1000
),
nextRolloverAt: Math.floor(
nextRollover.getTime() / 1000
)
})
.where(
eq(usage.usageId, usageRow.usageId)
);
logger.info(
`Usage reset for org ${orgId}, meter ${featureId} on subscription item cycle.`
);
}
// Also reset the sites to 0
await trx
.update(sites)
.set({
megabytesIn: 0,
megabytesOut: 0
})
.where(eq(sites.orgId, orgId));
});
}
}
}
}
// --- end usage update ---
}
} catch (error) {
logger.error(
`Error handling subscription updated event for ID ${subscription.id}:`,
error
);
}
return;
}
/**
* Calculate the next billing date based on monthly billing cycle
* Handles end-of-month scenarios as described in the requirements
* Made public for testing
*/
function calculateNextRollOverDate(lastRollover: Date, anchorDate: Date): Date {
const rolloverDate = new Date(lastRollover);
const anchor = new Date(anchorDate);
// Get components from rollover date
const rolloverYear = rolloverDate.getUTCFullYear();
const rolloverMonth = rolloverDate.getUTCMonth();
// Calculate target month and year (next month)
let targetMonth = rolloverMonth + 1;
let targetYear = rolloverYear;
if (targetMonth > 11) {
targetMonth = 0;
targetYear++;
}
// Get anchor day for billing
const anchorDay = anchor.getUTCDate();
// Get the last day of the target month
const lastDayOfMonth = new Date(
Date.UTC(targetYear, targetMonth + 1, 0)
).getUTCDate();
// Use the anchor day or the last day of the month, whichever is smaller
const targetDay = Math.min(anchorDay, lastDayOfMonth);
// Create the next billing date using UTC
const nextBilling = new Date(
Date.UTC(
targetYear,
targetMonth,
targetDay,
anchor.getUTCHours(),
anchor.getUTCMinutes(),
anchor.getUTCSeconds(),
anchor.getUTCMilliseconds()
)
);
return nextBilling;
}

View File

@@ -1,18 +0,0 @@
/*
* 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.
*/
export * from "./createCheckoutSession";
export * from "./createPortalSession";
export * from "./getOrgSubscription";
export * from "./getOrgUsage";
export * from "./internalGetOrgTier";

View File

@@ -1,119 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { getTierPriceSet } from "@server/lib/private/billing/tiers";
import { getOrgSubscriptionData } from "./getOrgSubscription";
import { build } from "@server/build";
const getOrgSchema = z
.object({
orgId: z.string()
})
.strict();
export type GetOrgTierResponse = {
tier: string | null;
active: boolean;
};
export async function getOrgTier(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getOrgSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
let tierData = null;
let activeData = false;
try {
const { tier, active } = await getOrgTierData(orgId);
tierData = tier;
activeData = active;
} catch (err) {
if ((err as Error).message === "Not found") {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
throw err;
}
return response<GetOrgTierResponse>(res, {
data: {
tier: tierData,
active: activeData
},
success: true,
error: false,
message: "Organization and subscription retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
export async function getOrgTierData(
orgId: string
): Promise<{ tier: string | null; active: boolean }> {
let tier = null;
let active = false;
if (build !== "saas") {
return { tier, active };
}
const { subscription, items } = await getOrgSubscriptionData(orgId);
if (items && items.length > 0) {
const tierPriceSet = getTierPriceSet();
// Iterate through tiers in order (earlier keys are higher tiers)
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
// Check if any subscription item matches this tier's price ID
const matchingItem = items.find((item) => item.priceId === priceId);
if (matchingItem) {
tier = tierId;
break;
}
}
}
if (subscription && subscription.status === "active") {
active = true;
}
return { tier, active };
}

View File

@@ -1,45 +0,0 @@
/*
* 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 { freeLimitSet, limitsService, subscribedLimitSet } from "@server/lib/private/billing";
import { usageService } from "@server/lib/private/billing/usageService";
import logger from "@server/logger";
export async function handleSubscriptionLifesycle(orgId: string, status: string) {
switch (status) {
case "active":
await limitsService.applyLimitSetToOrg(orgId, subscribedLimitSet);
await usageService.checkLimitSet(orgId, true);
break;
case "canceled":
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
await usageService.checkLimitSet(orgId, true);
break;
case "past_due":
// Optionally handle past due status, e.g., notify customer
break;
case "unpaid":
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
await usageService.checkLimitSet(orgId, true);
break;
case "incomplete":
// Optionally handle incomplete status, e.g., notify customer
break;
case "incomplete_expired":
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
await usageService.checkLimitSet(orgId, true);
break;
default:
break;
}
}

View File

@@ -1,136 +0,0 @@
/*
* 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 stripe from "@server/lib/private/stripe";
import config from "@server/lib/config";
import logger from "@server/logger";
import createHttpError from "http-errors";
import { response } from "@server/lib/response";
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import Stripe from "stripe";
import { handleCustomerCreated } from "./hooks/handleCustomerCreated";
import { handleSubscriptionCreated } from "./hooks/handleSubscriptionCreated";
import { handleSubscriptionUpdated } from "./hooks/handleSubscriptionUpdated";
import { handleCustomerUpdated } from "./hooks/handleCustomerUpdated";
import { handleSubscriptionDeleted } from "./hooks/handleSubscriptionDeleted";
import { handleCustomerDeleted } from "./hooks/handleCustomerDeleted";
export async function stripeWebhookHandler(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
let event: Stripe.Event = req.body;
const endpointSecret = config.getRawPrivateConfig().stripe?.webhook_secret;
if (!endpointSecret) {
logger.warn("Stripe webhook secret is not configured. Webhook events will not be priocessed.");
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "")
);
}
// Only verify the event if you have an endpoint secret defined.
// Otherwise use the basic event deserialized with JSON.parse
if (endpointSecret) {
// Get the signature sent by Stripe
const signature = req.headers["stripe-signature"];
if (!signature) {
logger.info("No stripe signature found in headers.");
return next(
createHttpError(HttpCode.BAD_REQUEST, "No stripe signature found in headers")
);
}
try {
event = stripe!.webhooks.constructEvent(
req.body,
signature,
endpointSecret
);
} catch (err) {
logger.error(`Webhook signature verification failed.`, err);
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Webhook signature verification failed")
);
}
}
let subscription;
let previousAttributes;
// Handle the event
switch (event.type) {
case "customer.created":
const customer = event.data.object;
logger.info("Customer created: ", customer);
handleCustomerCreated(customer);
break;
case "customer.updated":
const customerUpdated = event.data.object;
logger.info("Customer updated: ", customerUpdated);
handleCustomerUpdated(customerUpdated);
break;
case "customer.deleted":
const customerDeleted = event.data.object;
logger.info("Customer deleted: ", customerDeleted);
handleCustomerDeleted(customerDeleted);
break;
case "customer.subscription.paused":
subscription = event.data.object;
previousAttributes = event.data.previous_attributes;
handleSubscriptionUpdated(subscription, previousAttributes);
break;
case "customer.subscription.resumed":
subscription = event.data.object;
previousAttributes = event.data.previous_attributes;
handleSubscriptionUpdated(subscription, previousAttributes);
break;
case "customer.subscription.deleted":
subscription = event.data.object;
handleSubscriptionDeleted(subscription);
break;
case "customer.subscription.created":
subscription = event.data.object;
handleSubscriptionCreated(subscription);
break;
case "customer.subscription.updated":
subscription = event.data.object;
previousAttributes = event.data.previous_attributes;
handleSubscriptionUpdated(subscription, previousAttributes);
break;
case "customer.subscription.trial_will_end":
subscription = event.data.object;
// Then define and call a method to handle the subscription trial ending.
// handleSubscriptionTrialEnding(subscription);
break;
case "entitlements.active_entitlement_summary.updated":
subscription = event.data.object;
logger.info(
`Active entitlement summary updated for ${subscription}.`
);
// Then define and call a method to handle active entitlement summary updated
// handleEntitlementUpdated(subscription);
break;
default:
// Unexpected event type
logger.info(`Unhandled event type ${event.type}.`);
}
// Return a 200 response to acknowledge receipt of the event
return response(res, {
data: null,
success: true,
error: false,
message: "Webhook event processed successfully",
status: HttpCode.CREATED
});
}

View File

@@ -1,85 +0,0 @@
/*
* 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 { Certificate, certificates, db, domains } from "@server/db";
import logger from "@server/logger";
import { Transaction } from "@server/db";
import { eq, or, and, like } from "drizzle-orm";
import { build } from "@server/build";
/**
* Checks if a certificate exists for the given domain.
* If not, creates a new certificate in 'pending' state.
* Wildcard certs cover subdomains.
*/
export async function createCertificate(domainId: string, domain: string, trx: Transaction | typeof db) {
if (build !== "saas") {
return;
}
const [domainRecord] = await trx
.select()
.from(domains)
.where(eq(domains.domainId, domainId))
.limit(1);
if (!domainRecord) {
throw new Error(`Domain with ID ${domainId} not found`);
}
let existing: Certificate[] = [];
if (domainRecord.type == "ns") {
const domainLevelDown = domain.split('.').slice(1).join('.');
existing = await trx
.select()
.from(certificates)
.where(
and(
eq(certificates.domainId, domainId),
eq(certificates.wildcard, true), // only NS domains can have wildcard certs
or(
eq(certificates.domain, domain),
eq(certificates.domain, domainLevelDown),
)
)
);
} else {
// For non-NS domains, we only match exact domain names
existing = await trx
.select()
.from(certificates)
.where(
and(
eq(certificates.domainId, domainId),
eq(certificates.domain, domain) // exact match for non-NS domains
)
);
}
if (existing.length > 0) {
logger.info(
`Certificate already exists for domain ${domain}`
);
return;
}
// No cert found, create a new one in pending state
await trx.insert(certificates).values({
domain,
domainId,
wildcard: domainRecord.type == "ns", // we can only create wildcard certs for NS domains
status: "pending",
updatedAt: Math.floor(Date.now() / 1000),
createdAt: Math.floor(Date.now() / 1000)
});
}

View File

@@ -1,167 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { certificates, db, domains } from "@server/db";
import { eq, and, or, like } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { registry } from "@server/openApi";
const getCertificateSchema = z
.object({
domainId: z.string(),
domain: z.string().min(1).max(255),
orgId: z.string()
})
.strict();
async function query(domainId: string, domain: string) {
const [domainRecord] = await db
.select()
.from(domains)
.where(eq(domains.domainId, domainId))
.limit(1);
if (!domainRecord) {
throw new Error(`Domain with ID ${domainId} not found`);
}
let existing: any[] = [];
if (domainRecord.type == "ns") {
const domainLevelDown = domain.split('.').slice(1).join('.');
existing = await db
.select({
certId: certificates.certId,
domain: certificates.domain,
wildcard: certificates.wildcard,
status: certificates.status,
expiresAt: certificates.expiresAt,
lastRenewalAttempt: certificates.lastRenewalAttempt,
createdAt: certificates.createdAt,
updatedAt: certificates.updatedAt,
errorMessage: certificates.errorMessage,
renewalCount: certificates.renewalCount
})
.from(certificates)
.where(
and(
eq(certificates.domainId, domainId),
eq(certificates.wildcard, true), // only NS domains can have wildcard certs
or(
eq(certificates.domain, domain),
eq(certificates.domain, domainLevelDown),
)
)
);
} else {
// For non-NS domains, we only match exact domain names
existing = await db
.select({
certId: certificates.certId,
domain: certificates.domain,
wildcard: certificates.wildcard,
status: certificates.status,
expiresAt: certificates.expiresAt,
lastRenewalAttempt: certificates.lastRenewalAttempt,
createdAt: certificates.createdAt,
updatedAt: certificates.updatedAt,
errorMessage: certificates.errorMessage,
renewalCount: certificates.renewalCount
})
.from(certificates)
.where(
and(
eq(certificates.domainId, domainId),
eq(certificates.domain, domain) // exact match for non-NS domains
)
);
}
return existing.length > 0 ? existing[0] : null;
}
export type GetCertificateResponse = {
certId: number;
domain: string;
domainId: string;
wildcard: boolean;
status: string; // pending, requested, valid, expired, failed
expiresAt: string | null;
lastRenewalAttempt: Date | null;
createdAt: string;
updatedAt: string;
errorMessage?: string | null;
renewalCount: number;
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/certificate/{domainId}/{domain}",
description: "Get a certificate by domain.",
tags: ["Certificate"],
request: {
params: z.object({
domainId: z
.string(),
domain: z.string().min(1).max(255),
orgId: z.string()
})
},
responses: {}
});
export async function getCertificate(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getCertificateSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { domainId, domain } = parsedParams.data;
const cert = await query(domainId, domain);
if (!cert) {
logger.warn(`Certificate not found for domain: ${domainId}`);
return next(createHttpError(HttpCode.NOT_FOUND, "Certificate not found"));
}
return response<GetCertificateResponse>(res, {
data: cert,
success: true,
error: false,
message: "Certificate retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,15 +0,0 @@
/*
* 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.
*/
export * from "./getCertificate";
export * from "./restartCertificate";

View File

@@ -1,116 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { certificates, db } from "@server/db";
import { sites } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const restartCertificateParamsSchema = z
.object({
certId: z.string().transform(stoi).pipe(z.number().int().positive()),
orgId: z.string()
})
.strict();
registry.registerPath({
method: "post",
path: "/certificate/{certId}",
description: "Restart a certificate by ID.",
tags: ["Certificate"],
request: {
params: z.object({
certId: z
.string()
.transform(stoi)
.pipe(z.number().int().positive()),
orgId: z.string()
})
},
responses: {}
});
export async function restartCertificate(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = restartCertificateParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { certId } = parsedParams.data;
// get the certificate by ID
const [cert] = await db
.select()
.from(certificates)
.where(eq(certificates.certId, certId))
.limit(1);
if (!cert) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Certificate not found")
);
}
if (cert.status != "failed" && cert.status != "expired") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Certificate is already valid, no need to restart"
)
);
}
// update the certificate status to 'pending'
await db
.update(certificates)
.set({
status: "pending",
errorMessage: null,
lastRenewalAttempt: Math.floor(Date.now() / 1000)
})
.where(eq(certificates.certId, certId));
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Certificate restarted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,225 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
exitNodes,
loginPage,
LoginPage,
loginPageOrg,
resources,
sites
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { createCertificate } from "@server/routers/private/certificates/createCertificate";
import { getOrgTierData } from "@server/routers/private/billing";
import { TierId } from "@server/lib/private/billing/tiers";
import { build } from "@server/build";
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
const bodySchema = z
.object({
subdomain: z.string().nullable().optional(),
domainId: z.string()
})
.strict();
export type CreateLoginPageBody = z.infer<typeof bodySchema>;
export type CreateLoginPageResponse = LoginPage;
export async function createLoginPage(
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 { domainId, subdomain } = parsedBody.data;
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const [existing] = await db
.select()
.from(loginPageOrg)
.where(eq(loginPageOrg.orgId, orgId));
if (existing) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A login page already exists for this organization"
)
);
}
const domainResult = await validateAndConstructDomain(
domainId,
orgId,
subdomain
);
if (!domainResult.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, domainResult.error)
);
}
const { fullDomain, subdomain: finalSubdomain } = domainResult;
logger.debug(`Full domain: ${fullDomain}`);
const existingResource = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, fullDomain));
if (existingResource.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
const existingLoginPages = await db
.select()
.from(loginPage)
.where(eq(loginPage.fullDomain, fullDomain));
if (existingLoginPages.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Login page with that domain already exists"
)
);
}
let returned: LoginPage | undefined;
await db.transaction(async (trx) => {
const orgSites = await trx
.select()
.from(sites)
.innerJoin(exitNodes, eq(exitNodes.exitNodeId, sites.exitNodeId))
.where(and(eq(sites.orgId, orgId), eq(exitNodes.type, "gerbil"), eq(exitNodes.online, true)))
.limit(10);
let exitNodesList = orgSites.map((s) => s.exitNodes);
if (exitNodesList.length === 0) {
exitNodesList = await trx
.select()
.from(exitNodes)
.where(and(eq(exitNodes.type, "gerbil"), eq(exitNodes.online, true)))
.limit(10);
}
// select a random exit node
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
if (!randomExitNode) {
throw new Error("No exit nodes available");
}
const [returnedLoginPage] = await db
.insert(loginPage)
.values({
subdomain: finalSubdomain,
fullDomain,
domainId,
exitNodeId: randomExitNode.exitNodeId
})
.returning();
await trx.insert(loginPageOrg).values({
orgId,
loginPageId: returnedLoginPage.loginPageId
});
returned = returnedLoginPage;
});
if (!returned) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create login page"
)
);
}
await createCertificate(domainId, fullDomain, db);
return response<LoginPage>(res, {
data: returned,
success: true,
error: false,
message: "Login page created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,106 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, loginPage, LoginPage, loginPageOrg } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
const paramsSchema = z
.object({
orgId: z.string(),
loginPageId: z.coerce.number()
})
.strict();
export type DeleteLoginPageResponse = LoginPage;
export async function deleteLoginPage(
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 [existingLoginPage] = await db
.select()
.from(loginPage)
.where(eq(loginPage.loginPageId, parsedParams.data.loginPageId))
.innerJoin(
loginPageOrg,
eq(loginPageOrg.orgId, parsedParams.data.orgId)
);
if (!existingLoginPage) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Login page not found")
);
}
await db
.delete(loginPageOrg)
.where(
and(
eq(loginPageOrg.orgId, parsedParams.data.orgId),
eq(loginPageOrg.loginPageId, parsedParams.data.loginPageId)
)
);
// const leftoverLinks = await db
// .select()
// .from(loginPageOrg)
// .where(eq(loginPageOrg.loginPageId, parsedParams.data.loginPageId))
// .limit(1);
// if (!leftoverLinks.length) {
await db
.delete(loginPage)
.where(
eq(loginPage.loginPageId, parsedParams.data.loginPageId)
);
await db
.delete(loginPageOrg)
.where(
eq(loginPageOrg.loginPageId, parsedParams.data.loginPageId)
);
// }
return response<LoginPage>(res, {
data: existingLoginPage.loginPage,
success: true,
error: false,
message: "Login page deleted successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,86 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, loginPage, loginPageOrg } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
async function query(orgId: string) {
const [res] = await db
.select()
.from(loginPageOrg)
.where(eq(loginPageOrg.orgId, orgId))
.innerJoin(
loginPage,
eq(loginPage.loginPageId, loginPageOrg.loginPageId)
)
.limit(1);
return res?.loginPage;
}
export type GetLoginPageResponse = NonNullable<
Awaited<ReturnType<typeof query>>
>;
export async function getLoginPage(
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 { orgId } = parsedParams.data;
const loginPage = await query(orgId);
if (!loginPage) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Login page not found")
);
}
return response<GetLoginPageResponse>(res, {
data: loginPage,
success: true,
error: false,
message: "Login page retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,19 +0,0 @@
/*
* 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.
*/
export * from "./createLoginPage";
export * from "./updateLoginPage";
export * from "./getLoginPage";
export * from "./loadLoginPage";
export * from "./updateLoginPage";
export * from "./deleteLoginPage";

View File

@@ -1,148 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idpOrg, loginPage, loginPageOrg, resources } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const querySchema = z.object({
resourceId: z.coerce.number().int().positive().optional(),
idpId: z.coerce.number().int().positive().optional(),
orgId: z.coerce.number().int().positive().optional(),
fullDomain: z.string().min(1)
});
async function query(orgId: string | undefined, fullDomain: string) {
if (!orgId) {
const [res] = await db
.select()
.from(loginPage)
.where(eq(loginPage.fullDomain, fullDomain))
.innerJoin(
loginPageOrg,
eq(loginPage.loginPageId, loginPageOrg.loginPageId)
)
.limit(1);
return {
...res.loginPage,
orgId: res.loginPageOrg.orgId
};
}
const [orgLink] = await db
.select()
.from(loginPageOrg)
.where(eq(loginPageOrg.orgId, orgId));
if (!orgLink) {
return null;
}
const [res] = await db
.select()
.from(loginPage)
.where(
and(
eq(loginPage.loginPageId, orgLink.loginPageId),
eq(loginPage.fullDomain, fullDomain)
)
)
.limit(1);
return {
...res,
orgId: orgLink.orgId
};
}
export type LoadLoginPageResponse = NonNullable<
Awaited<ReturnType<typeof query>>
> & { orgId: string };
export async function loadLoginPage(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { resourceId, idpId, fullDomain } = parsedQuery.data;
let orgId;
if (resourceId) {
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
orgId = resource.orgId;
} else if (idpId) {
const [idpOrgLink] = await db
.select()
.from(idpOrg)
.where(eq(idpOrg.idpId, idpId));
if (!idpOrgLink) {
return next(
createHttpError(HttpCode.NOT_FOUND, "IdP not found")
);
}
orgId = idpOrgLink.orgId;
} else if (parsedQuery.data.orgId) {
orgId = parsedQuery.data.orgId.toString();
}
const loginPage = await query(orgId, fullDomain);
if (!loginPage) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Login page not found")
);
}
return response<LoadLoginPageResponse>(res, {
data: loginPage,
success: true,
error: false,
message: "Login page retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,227 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, loginPage, LoginPage, loginPageOrg, resources } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { subdomainSchema } from "@server/lib/schemas";
import { createCertificate } from "@server/routers/private/certificates/createCertificate";
import { getOrgTierData } from "@server/routers/private/billing";
import { TierId } from "@server/lib/private/billing/tiers";
import { build } from "@server/build";
const paramsSchema = z
.object({
orgId: z.string(),
loginPageId: z.coerce.number()
})
.strict();
const bodySchema = z
.object({
subdomain: subdomainSchema.nullable().optional(),
domainId: z.string().optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update"
})
.refine(
(data) => {
if (data.subdomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{ message: "Invalid subdomain" }
);
export type UpdateLoginPageBody = z.infer<typeof bodySchema>;
export type UpdateLoginPageResponse = LoginPage;
export async function updateLoginPage(
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 updateData = parsedBody.data;
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { loginPageId, orgId } = parsedParams.data;
if (build === "saas"){
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const [existingLoginPage] = await db
.select()
.from(loginPage)
.where(eq(loginPage.loginPageId, loginPageId));
if (!existingLoginPage) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Login page not found")
);
}
const [orgLink] = await db
.select()
.from(loginPageOrg)
.where(
and(
eq(loginPageOrg.orgId, orgId),
eq(loginPageOrg.loginPageId, loginPageId)
)
);
if (!orgLink) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Login page not found for this organization"
)
);
}
if (updateData.domainId) {
const domainId = updateData.domainId;
// Validate domain and construct full domain
const domainResult = await validateAndConstructDomain(
domainId,
orgId,
updateData.subdomain
);
if (!domainResult.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, domainResult.error)
);
}
const { fullDomain, subdomain: finalSubdomain } = domainResult;
logger.debug(`Full domain: ${fullDomain}`);
if (fullDomain) {
const [existingDomain] = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, fullDomain));
if (existingDomain) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
const [existingLoginPage] = await db
.select()
.from(loginPage)
.where(eq(loginPage.fullDomain, fullDomain));
if (
existingLoginPage &&
existingLoginPage.loginPageId !== loginPageId
) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Login page with that domain already exists"
)
);
}
// update the full domain if it has changed
if (fullDomain && fullDomain !== existingLoginPage?.fullDomain) {
await db
.update(loginPage)
.set({ fullDomain })
.where(eq(loginPage.loginPageId, loginPageId));
}
await createCertificate(domainId, fullDomain, db);
}
updateData.subdomain = finalSubdomain;
}
const updatedLoginPage = await db
.update(loginPage)
.set({ ...updateData })
.where(eq(loginPage.loginPageId, loginPageId))
.returning();
if (updatedLoginPage.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Login page with ID ${loginPageId} not found`
)
);
}
return response<LoginPage>(res, {
data: updatedLoginPage[0],
success: true,
error: false,
message: "Login page created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,185 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { build } from "@server/build";
import { getOrgTierData } from "@server/routers/private/billing";
import { TierId } from "@server/lib/private/billing/tiers";
const paramsSchema = z.object({ orgId: z.string().nonempty() }).strict();
const bodySchema = z
.object({
name: z.string().nonempty(),
clientId: z.string().nonempty(),
clientSecret: z.string().nonempty(),
authUrl: z.string().url(),
tokenUrl: z.string().url(),
identifierPath: z.string().nonempty(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().nonempty(),
autoProvision: z.boolean().optional(),
variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"),
roleMapping: z.string().optional()
})
.strict();
export type CreateOrgIdpResponse = {
idpId: number;
redirectUrl: string;
};
// registry.registerPath({
// method: "put",
// path: "/idp/oidc",
// description: "Create an OIDC IdP.",
// tags: [OpenAPITags.Idp],
// request: {
// body: {
// content: {
// "application/json": {
// schema: bodySchema
// }
// }
// }
// },
// responses: {}
// });
export async function createOrgOidcIdp(
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 { orgId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const {
clientId,
clientSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath,
name,
autoProvision,
variant,
roleMapping
} = parsedBody.data;
if (build === "saas") {
const { tier, active } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const key = config.getRawConfig().server.secret!;
const encryptedSecret = encrypt(clientSecret, key);
const encryptedClientId = encrypt(clientId, key);
let idpId: number | undefined;
await db.transaction(async (trx) => {
const [idpRes] = await trx
.insert(idp)
.values({
name,
autoProvision,
type: "oidc"
})
.returning();
idpId = idpRes.idpId;
await trx.insert(idpOidcConfig).values({
idpId: idpRes.idpId,
clientId: encryptedClientId,
clientSecret: encryptedSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath,
variant
});
await trx.insert(idpOrg).values({
idpId: idpRes.idpId,
orgId: orgId,
roleMapping: roleMapping || null,
orgMapping: `'${orgId}'`
});
});
const redirectUrl = await generateOidcRedirectUrl(idpId as number, orgId);
return response<CreateOrgIdpResponse>(res, {
data: {
idpId: idpId as number,
redirectUrl
},
success: true,
error: false,
message: "Org Idp created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,117 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idpOrg, loginPage, loginPageOrg } from "@server/db";
import { idp, idpOidcConfig } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
const paramsSchema = z
.object({
orgId: z.string().nonempty(),
idpId: z.coerce.number()
})
.strict();
async function query(idpId: number, orgId: string) {
const [res] = await db
.select()
.from(idp)
.where(eq(idp.idpId, idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.leftJoin(
idpOrg,
and(eq(idpOrg.idpId, idp.idpId), eq(idpOrg.orgId, orgId))
)
.limit(1);
return res;
}
export type GetOrgIdpResponse = NonNullable<
Awaited<ReturnType<typeof query>>
> & { redirectUrl: string };
// registry.registerPath({
// method: "get",
// path: "/idp/{idpId}",
// description: "Get an IDP by its IDP ID.",
// tags: [OpenAPITags.Idp],
// request: {
// params: paramsSchema
// },
// responses: {}
// });
export async function getOrgIdp(
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 { idpId, orgId } = parsedParams.data;
const idpRes = await query(idpId, orgId);
if (!idpRes) {
return next(createHttpError(HttpCode.NOT_FOUND, "Idp not found"));
}
const key = config.getRawConfig().server.secret!;
if (idpRes.idp.type === "oidc") {
const clientSecret = idpRes.idpOidcConfig!.clientSecret;
const clientId = idpRes.idpOidcConfig!.clientId;
idpRes.idpOidcConfig!.clientSecret = decrypt(clientSecret, key);
idpRes.idpOidcConfig!.clientId = decrypt(clientId, key);
}
const redirectUrl = await generateOidcRedirectUrl(idpRes.idp.idpId, orgId);
return response<GetOrgIdpResponse>(res, {
data: {
...idpRes,
redirectUrl
},
success: true,
error: false,
message: "Org Idp retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,17 +0,0 @@
/*
* 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.
*/
export * from "./createOrgOidcIdp";
export * from "./getOrgIdp";
export * from "./listOrgIdps";
export * from "./updateOrgOidcIdp";

View File

@@ -1,142 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idpOidcConfig } from "@server/db";
import { idp, idpOrg } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { eq, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const querySchema = z
.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().nonnegative()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
})
.strict();
const paramsSchema = z
.object({
orgId: z.string().nonempty()
})
.strict();
async function query(orgId: string, limit: number, offset: number) {
const res = await db
.select({
idpId: idp.idpId,
orgId: idpOrg.orgId,
name: idp.name,
type: idp.type,
variant: idpOidcConfig.variant
})
.from(idpOrg)
.where(eq(idpOrg.orgId, orgId))
.innerJoin(idp, eq(idp.idpId, idpOrg.idpId))
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId))
.orderBy(sql`idp.name DESC`)
.limit(limit)
.offset(offset);
return res;
}
export type ListOrgIdpsResponse = {
idps: Awaited<ReturnType<typeof query>>;
pagination: {
total: number;
limit: number;
offset: number;
};
};
// registry.registerPath({
// method: "get",
// path: "/idp",
// description: "List all IDP in the system.",
// tags: [OpenAPITags.Idp],
// request: {
// query: querySchema
// },
// responses: {}
// });
export async function listOrgIdps(
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 { orgId } = parsedParams.data;
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { limit, offset } = parsedQuery.data;
const list = await query(orgId, limit, offset);
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(idp);
return response<ListOrgIdpsResponse>(res, {
data: {
idps: list,
pagination: {
total: count,
limit,
offset
}
},
success: true,
error: false,
message: "Org Idps retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,237 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idpOrg } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { idp, idpOidcConfig } from "@server/db";
import { eq, and } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import license from "@server/license/license";
import { build } from "@server/build";
import { getOrgTierData } from "@server/routers/private/billing";
import { TierId } from "@server/lib/private/billing/tiers";
const paramsSchema = z
.object({
orgId: z.string().nonempty(),
idpId: z.coerce.number()
})
.strict();
const bodySchema = z
.object({
name: z.string().optional(),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
authUrl: z.string().optional(),
tokenUrl: z.string().optional(),
identifierPath: z.string().optional(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().optional(),
autoProvision: z.boolean().optional(),
roleMapping: z.string().optional()
})
.strict();
export type UpdateOrgIdpResponse = {
idpId: number;
};
// registry.registerPath({
// method: "post",
// path: "/idp/{idpId}/oidc",
// description: "Update an OIDC IdP.",
// tags: [OpenAPITags.Idp],
// request: {
// params: paramsSchema,
// body: {
// content: {
// "application/json": {
// schema: bodySchema
// }
// }
// }
// },
// responses: {}
// });
export async function updateOrgOidcIdp(
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 parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { idpId, orgId } = parsedParams.data;
const {
clientId,
clientSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath,
name,
autoProvision,
roleMapping
} = parsedBody.data;
if (build === "saas") {
const { tier, active } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
// Check if IDP exists and is of type OIDC
const [existingIdp] = await db
.select()
.from(idp)
.where(eq(idp.idpId, idpId));
if (!existingIdp) {
return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found"));
}
const [existingIdpOrg] = await db
.select()
.from(idpOrg)
.where(and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId)));
if (!existingIdpOrg) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"IdP not found for this organization"
)
);
}
if (existingIdp.type !== "oidc") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IdP is not an OIDC provider"
)
);
}
const key = config.getRawConfig().server.secret!;
const encryptedSecret = clientSecret
? encrypt(clientSecret, key)
: undefined;
const encryptedClientId = clientId ? encrypt(clientId, key) : undefined;
await db.transaction(async (trx) => {
const idpData = {
name,
autoProvision
};
// only update if at least one key is not undefined
let keysToUpdate = Object.keys(idpData).filter(
(key) => idpData[key as keyof typeof idpData] !== undefined
);
if (keysToUpdate.length > 0) {
await trx.update(idp).set(idpData).where(eq(idp.idpId, idpId));
}
const configData = {
clientId: encryptedClientId,
clientSecret: encryptedSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath
};
keysToUpdate = Object.keys(configData).filter(
(key) =>
configData[key as keyof typeof configData] !== undefined
);
if (keysToUpdate.length > 0) {
// Update OIDC config
await trx
.update(idpOidcConfig)
.set(configData)
.where(eq(idpOidcConfig.idpId, idpId));
}
if (roleMapping !== undefined) {
// Update IdP-org policy
await trx
.update(idpOrg)
.set({
roleMapping
})
.where(
and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))
);
}
});
return response<UpdateOrgIdpResponse>(res, {
data: {
idpId
},
success: true,
error: false,
message: "Org IdP updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,278 +0,0 @@
/*
* 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 { NextFunction, Request, Response } from "express";
import { db, exitNodes, exitNodeOrgs, ExitNode, ExitNodeOrg } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import { z } from "zod";
import { remoteExitNodes } from "@server/db";
import createHttpError from "http-errors";
import response from "@server/lib/response";
import { SqliteError } from "better-sqlite3";
import moment from "moment";
import { generateSessionToken } from "@server/auth/sessions/app";
import { createRemoteExitNodeSession } from "@server/auth/sessions/privateRemoteExitNode";
import { fromError } from "zod-validation-error";
import { hashPassword, verifyPassword } from "@server/auth/password";
import logger from "@server/logger";
import { and, eq } from "drizzle-orm";
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
import { usageService } from "@server/lib/private/billing/usageService";
import { FeatureId } from "@server/lib/private/billing";
export const paramsSchema = z.object({
orgId: z.string()
});
export type CreateRemoteExitNodeResponse = {
token: string;
remoteExitNodeId: string;
secret: string;
};
const bodySchema = z
.object({
remoteExitNodeId: z.string().length(15),
secret: z.string().length(48)
})
.strict();
export type CreateRemoteExitNodeBody = z.infer<typeof bodySchema>;
export async function createRemoteExitNode(
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 { orgId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { remoteExitNodeId, secret } = parsedBody.data;
if (req.user && !req.userOrgRoleId) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
}
const usage = await usageService.getUsage(
orgId,
FeatureId.REMOTE_EXIT_NODES
);
if (!usage) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No usage data found for this organization"
)
);
}
const rejectRemoteExitNodes = await usageService.checkLimitSet(
orgId,
false,
FeatureId.REMOTE_EXIT_NODES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectRemoteExitNodes) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Remote exit node limit exceeded. Please upgrade your plan or contact us at support@fossorial.io"
)
);
}
const secretHash = await hashPassword(secret);
// const address = await getNextAvailableSubnet();
const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES
const [existingRemoteExitNode] = await db
.select()
.from(remoteExitNodes)
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId));
if (existingRemoteExitNode) {
// validate the secret
const validSecret = await verifyPassword(
secret,
existingRemoteExitNode.secretHash
);
if (!validSecret) {
logger.info(
`Failed secret validation for remote exit node: ${remoteExitNodeId}`
);
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Invalid secret for remote exit node"
)
);
}
}
let existingExitNode: ExitNode | null = null;
if (existingRemoteExitNode?.exitNodeId) {
const [res] = await db
.select()
.from(exitNodes)
.where(
eq(exitNodes.exitNodeId, existingRemoteExitNode.exitNodeId)
);
existingExitNode = res;
}
let existingExitNodeOrg: ExitNodeOrg | null = null;
if (existingRemoteExitNode?.exitNodeId) {
const [res] = await db
.select()
.from(exitNodeOrgs)
.where(
and(
eq(
exitNodeOrgs.exitNodeId,
existingRemoteExitNode.exitNodeId
),
eq(exitNodeOrgs.orgId, orgId)
)
);
existingExitNodeOrg = res;
}
if (existingExitNodeOrg) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Remote exit node already exists in this organization"
)
);
}
let numExitNodeOrgs: ExitNodeOrg[] | undefined;
await db.transaction(async (trx) => {
if (!existingExitNode) {
const [res] = await trx
.insert(exitNodes)
.values({
name: remoteExitNodeId,
address,
endpoint: "",
publicKey: "",
listenPort: 0,
online: false,
type: "remoteExitNode"
})
.returning();
existingExitNode = res;
}
if (!existingRemoteExitNode) {
await trx.insert(remoteExitNodes).values({
remoteExitNodeId: remoteExitNodeId,
secretHash,
dateCreated: moment().toISOString(),
exitNodeId: existingExitNode.exitNodeId
});
} else {
// update the existing remote exit node
await trx
.update(remoteExitNodes)
.set({
exitNodeId: existingExitNode.exitNodeId
})
.where(
eq(
remoteExitNodes.remoteExitNodeId,
existingRemoteExitNode.remoteExitNodeId
)
);
}
if (!existingExitNodeOrg) {
await trx.insert(exitNodeOrgs).values({
exitNodeId: existingExitNode.exitNodeId,
orgId: orgId
});
}
numExitNodeOrgs = await trx
.select()
.from(exitNodeOrgs)
.where(eq(exitNodeOrgs.orgId, orgId));
});
if (numExitNodeOrgs) {
await usageService.updateDaily(
orgId,
FeatureId.REMOTE_EXIT_NODES,
numExitNodeOrgs.length
);
}
const token = generateSessionToken();
await createRemoteExitNodeSession(token, remoteExitNodeId);
return response<CreateRemoteExitNodeResponse>(res, {
data: {
remoteExitNodeId,
secret,
token
},
success: true,
error: false,
message: "RemoteExitNode created successfully",
status: HttpCode.OK
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A remote exit node with that ID already exists"
)
);
} else {
logger.error("Failed to create remoteExitNode", e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create remoteExitNode"
)
);
}
}
}

View File

@@ -1,131 +0,0 @@
/*
* 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 { NextFunction, Request, Response } from "express";
import { z } from "zod";
import { db, ExitNodeOrg, exitNodeOrgs, exitNodes } from "@server/db";
import { remoteExitNodes } from "@server/db";
import { and, count, eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { usageService } from "@server/lib/private/billing/usageService";
import { FeatureId } from "@server/lib/private/billing";
const paramsSchema = z
.object({
orgId: z.string().min(1),
remoteExitNodeId: z.string().min(1)
})
.strict();
export async function deleteRemoteExitNode(
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 { orgId, remoteExitNodeId } = parsedParams.data;
const [remoteExitNode] = await db
.select()
.from(remoteExitNodes)
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId));
if (!remoteExitNode) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Remote exit node with ID ${remoteExitNodeId} not found`
)
);
}
if (!remoteExitNode.exitNodeId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Remote exit node with ID ${remoteExitNodeId} does not have an exit node ID`
)
);
}
let numExitNodeOrgs: ExitNodeOrg[] | undefined;
await db.transaction(async (trx) => {
await trx
.delete(exitNodeOrgs)
.where(
and(
eq(exitNodeOrgs.orgId, orgId),
eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId!)
)
);
const [remainingExitNodeOrgs] = await trx
.select({ count: count() })
.from(exitNodeOrgs)
.where(eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId!));
if (remainingExitNodeOrgs.count === 0) {
await trx
.delete(remoteExitNodes)
.where(
eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)
);
await trx
.delete(exitNodes)
.where(
eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId!)
);
}
numExitNodeOrgs = await trx
.select()
.from(exitNodeOrgs)
.where(eq(exitNodeOrgs.orgId, orgId));
});
if (numExitNodeOrgs) {
await usageService.updateDaily(
orgId,
FeatureId.REMOTE_EXIT_NODES,
numExitNodeOrgs.length
);
}
return response(res, {
data: null,
success: true,
error: false,
message: "Remote exit node deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,99 +0,0 @@
/*
* 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 { NextFunction, Request, Response } from "express";
import { z } from "zod";
import { db, exitNodes } from "@server/db";
import { remoteExitNodes } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const getRemoteExitNodeSchema = z
.object({
orgId: z.string().min(1),
remoteExitNodeId: z.string().min(1)
})
.strict();
async function query(remoteExitNodeId: string) {
const [remoteExitNode] = await db
.select({
remoteExitNodeId: remoteExitNodes.remoteExitNodeId,
dateCreated: remoteExitNodes.dateCreated,
version: remoteExitNodes.version,
exitNodeId: remoteExitNodes.exitNodeId,
name: exitNodes.name,
address: exitNodes.address,
endpoint: exitNodes.endpoint,
online: exitNodes.online,
type: exitNodes.type
})
.from(remoteExitNodes)
.innerJoin(
exitNodes,
eq(exitNodes.exitNodeId, remoteExitNodes.exitNodeId)
)
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
.limit(1);
return remoteExitNode;
}
export type GetRemoteExitNodeResponse = Awaited<ReturnType<typeof query>>;
export async function getRemoteExitNode(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getRemoteExitNodeSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { remoteExitNodeId } = parsedParams.data;
const remoteExitNode = await query(remoteExitNodeId);
if (!remoteExitNode) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Remote exit node with ID ${remoteExitNodeId} not found`
)
);
}
return response<GetRemoteExitNodeResponse>(res, {
data: remoteExitNode,
success: true,
error: false,
message: "Remote exit node retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,130 +0,0 @@
/*
* 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 { generateSessionToken } from "@server/auth/sessions/app";
import { db } from "@server/db";
import { remoteExitNodes } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import {
createRemoteExitNodeSession,
validateRemoteExitNodeSessionToken
} from "@server/auth/sessions/privateRemoteExitNode";
import { verifyPassword } from "@server/auth/password";
import logger from "@server/logger";
import config from "@server/lib/config";
export const remoteExitNodeGetTokenBodySchema = z.object({
remoteExitNodeId: z.string(),
secret: z.string(),
token: z.string().optional()
});
export type RemoteExitNodeGetTokenBody = z.infer<typeof remoteExitNodeGetTokenBodySchema>;
export async function getRemoteExitNodeToken(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = remoteExitNodeGetTokenBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { remoteExitNodeId, secret, token } = parsedBody.data;
try {
if (token) {
const { session, remoteExitNode } = await validateRemoteExitNodeSessionToken(token);
if (session) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`RemoteExitNode session already valid. RemoteExitNode ID: ${remoteExitNodeId}. IP: ${req.ip}.`
);
}
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Token session already valid",
status: HttpCode.OK
});
}
}
const existingRemoteExitNodeRes = await db
.select()
.from(remoteExitNodes)
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId));
if (!existingRemoteExitNodeRes || !existingRemoteExitNodeRes.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No remoteExitNode found with that remoteExitNodeId"
)
);
}
const existingRemoteExitNode = existingRemoteExitNodeRes[0];
const validSecret = await verifyPassword(
secret,
existingRemoteExitNode.secretHash
);
if (!validSecret) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`RemoteExitNode id or secret is incorrect. RemoteExitNode: ID ${remoteExitNodeId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
);
}
const resToken = generateSessionToken();
await createRemoteExitNodeSession(resToken, existingRemoteExitNode.remoteExitNodeId);
// logger.debug(`Created RemoteExitNode token response: ${JSON.stringify(resToken)}`);
return response<{ token: string }>(res, {
data: {
token: resToken
},
success: true,
error: false,
message: "Token created successfully",
status: HttpCode.OK
});
} catch (e) {
console.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to authenticate remoteExitNode"
)
);
}
}

View File

@@ -1,140 +0,0 @@
/*
* 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, exitNodes, sites } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { clients, RemoteExitNode } from "@server/db";
import { eq, lt, isNull, and, or, inArray } from "drizzle-orm";
import logger from "@server/logger";
// Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null;
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
/**
* Starts the background interval that checks for clients that haven't pinged recently
* and marks them as offline
*/
export const startRemoteExitNodeOfflineChecker = (): void => {
if (offlineCheckerInterval) {
return; // Already running
}
offlineCheckerInterval = setInterval(async () => {
try {
const twoMinutesAgo = Math.floor((Date.now() - OFFLINE_THRESHOLD_MS) / 1000);
// Find clients that haven't pinged in the last 2 minutes and mark them as offline
const newlyOfflineNodes = await db
.update(exitNodes)
.set({ online: false })
.where(
and(
eq(exitNodes.online, true),
eq(exitNodes.type, "remoteExitNode"),
or(
lt(exitNodes.lastPing, twoMinutesAgo),
isNull(exitNodes.lastPing)
)
)
).returning();
// Update the sites to offline if they have not pinged either
const exitNodeIds = newlyOfflineNodes.map(node => node.exitNodeId);
const sitesOnNode = await db
.select()
.from(sites)
.where(
and(
eq(sites.online, true),
inArray(sites.exitNodeId, exitNodeIds)
)
);
// loop through the sites and process their lastBandwidthUpdate as an iso string and if its more than 1 minute old then mark the site offline
for (const site of sitesOnNode) {
if (!site.lastBandwidthUpdate) {
continue;
}
const lastBandwidthUpdate = new Date(site.lastBandwidthUpdate);
if (Date.now() - lastBandwidthUpdate.getTime() > 60 * 1000) {
await db
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, site.siteId));
}
}
} catch (error) {
logger.error("Error in offline checker interval", { error });
}
}, OFFLINE_CHECK_INTERVAL);
logger.info("Started offline checker interval");
};
/**
* Stops the background interval that checks for offline clients
*/
export const stopRemoteExitNodeOfflineChecker = (): void => {
if (offlineCheckerInterval) {
clearInterval(offlineCheckerInterval);
offlineCheckerInterval = null;
logger.info("Stopped offline checker interval");
}
};
/**
* Handles ping messages from clients and responds with pong
*/
export const handleRemoteExitNodePingMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context;
const remoteExitNode = c as RemoteExitNode;
if (!remoteExitNode) {
logger.debug("RemoteExitNode not found");
return;
}
if (!remoteExitNode.exitNodeId) {
logger.debug("RemoteExitNode has no exit node ID!"); // this can happen if the exit node is created but not adopted yet
return;
}
try {
// Update the exit node's last ping timestamp
await db
.update(exitNodes)
.set({
lastPing: Math.floor(Date.now() / 1000),
online: true,
})
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
} catch (error) {
logger.error("Error handling ping message", { error });
}
return {
message: {
type: "pong",
data: {
timestamp: new Date().toISOString(),
}
},
broadcast: false,
excludeSender: false
};
};

View File

@@ -1,49 +0,0 @@
/*
* 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, RemoteExitNode, remoteExitNodes } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
export const handleRemoteExitNodeRegisterMessage: MessageHandler = async (
context
) => {
const { message, client, sendToClient } = context;
const remoteExitNode = client as RemoteExitNode;
logger.debug("Handling register remoteExitNode message!");
if (!remoteExitNode) {
logger.warn("Remote exit node not found");
return;
}
const { remoteExitNodeVersion } = message.data;
if (!remoteExitNodeVersion) {
logger.warn("Remote exit node version not found");
return;
}
// update the version
await db
.update(remoteExitNodes)
.set({ version: remoteExitNodeVersion })
.where(
eq(
remoteExitNodes.remoteExitNodeId,
remoteExitNode.remoteExitNodeId
)
);
};

View File

@@ -1,23 +0,0 @@
/*
* 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.
*/
export * from "./createRemoteExitNode";
export * from "./getRemoteExitNode";
export * from "./listRemoteExitNodes";
export * from "./getRemoteExitNodeToken";
export * from "./handleRemoteExitNodeRegisterMessage";
export * from "./handleRemoteExitNodePingMessage";
export * from "./deleteRemoteExitNode";
export * from "./listRemoteExitNodes";
export * from "./pickRemoteExitNodeDefaults";
export * from "./quickStartRemoteExitNode";

View File

@@ -1,147 +0,0 @@
/*
* 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 { NextFunction, Request, Response } from "express";
import { z } from "zod";
import { db, exitNodeOrgs, exitNodes } from "@server/db";
import { remoteExitNodes } from "@server/db";
import { eq, and, count } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const listRemoteExitNodesParamsSchema = z
.object({
orgId: z.string()
})
.strict();
const listRemoteExitNodesSchema = z.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
function queryRemoteExitNodes(orgId: string) {
return db
.select({
remoteExitNodeId: remoteExitNodes.remoteExitNodeId,
dateCreated: remoteExitNodes.dateCreated,
version: remoteExitNodes.version,
exitNodeId: remoteExitNodes.exitNodeId,
name: exitNodes.name,
address: exitNodes.address,
endpoint: exitNodes.endpoint,
online: exitNodes.online,
type: exitNodes.type
})
.from(exitNodeOrgs)
.where(eq(exitNodeOrgs.orgId, orgId))
.innerJoin(exitNodes, eq(exitNodes.exitNodeId, exitNodeOrgs.exitNodeId))
.innerJoin(
remoteExitNodes,
eq(remoteExitNodes.exitNodeId, exitNodeOrgs.exitNodeId)
);
}
export type ListRemoteExitNodesResponse = {
remoteExitNodes: Awaited<ReturnType<typeof queryRemoteExitNodes>>;
pagination: { total: number; limit: number; offset: number };
};
export async function listRemoteExitNodes(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listRemoteExitNodesSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { limit, offset } = parsedQuery.data;
const parsedParams = listRemoteExitNodesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const baseQuery = queryRemoteExitNodes(orgId);
const countQuery = db
.select({ count: count() })
.from(remoteExitNodes)
.innerJoin(
exitNodes,
eq(exitNodes.exitNodeId, remoteExitNodes.exitNodeId)
)
.where(eq(exitNodes.type, "remoteExitNode"));
const remoteExitNodesList = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
return response<ListRemoteExitNodesResponse>(res, {
data: {
remoteExitNodes: remoteExitNodesList,
pagination: {
total: totalCount,
limit,
offset
}
},
success: true,
error: false,
message: "Remote exit nodes retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,71 +0,0 @@
/*
* 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 { Request, Response, NextFunction } from "express";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { generateId } from "@server/auth/sessions/app";
import { fromError } from "zod-validation-error";
import { z } from "zod";
export type PickRemoteExitNodeDefaultsResponse = {
remoteExitNodeId: string;
secret: string;
};
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
export async function pickRemoteExitNodeDefaults(
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 { orgId } = parsedParams.data;
const remoteExitNodeId = generateId(15);
const secret = generateId(48);
return response<PickRemoteExitNodeDefaultsResponse>(res, {
data: {
remoteExitNodeId,
secret
},
success: true,
error: false,
message: "Organization retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,170 +0,0 @@
/*
* 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 { NextFunction, Request, Response } from "express";
import { db, exitNodes, exitNodeOrgs } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import { remoteExitNodes } from "@server/db";
import createHttpError from "http-errors";
import response from "@server/lib/response";
import { SqliteError } from "better-sqlite3";
import moment from "moment";
import { generateId } from "@server/auth/sessions/app";
import { hashPassword } from "@server/auth/password";
import logger from "@server/logger";
import z from "zod";
import { fromError } from "zod-validation-error";
export type QuickStartRemoteExitNodeResponse = {
remoteExitNodeId: string;
secret: string;
};
const INSTALLER_KEY = "af4e4785-7e09-11f0-b93a-74563c4e2a7e";
const quickStartRemoteExitNodeBodySchema = z.object({
token: z.string()
});
export async function quickStartRemoteExitNode(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = quickStartRemoteExitNodeBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { token } = parsedBody.data;
const tokenValidation = validateTokenOnApi(token);
if (!tokenValidation.isValid) {
logger.info(`Failed token validation: ${tokenValidation.message}`);
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
fromError(tokenValidation.message).toString()
)
);
}
const remoteExitNodeId = generateId(15);
const secret = generateId(48);
const secretHash = await hashPassword(secret);
await db.insert(remoteExitNodes).values({
remoteExitNodeId,
secretHash,
dateCreated: moment().toISOString()
});
return response<QuickStartRemoteExitNodeResponse>(res, {
data: {
remoteExitNodeId,
secret
},
success: true,
error: false,
message: "Remote exit node created successfully",
status: HttpCode.OK
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A remote exit node with that ID already exists"
)
);
} else {
logger.error("Failed to create remoteExitNode", e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create remoteExitNode"
)
);
}
}
}
/**
* Validates a token received from the frontend.
* @param {string} token The validation token from the request.
* @returns {{ isValid: boolean; message: string }} An object indicating if the token is valid.
*/
const validateTokenOnApi = (
token: string
): { isValid: boolean; message: string } => {
if (!token) {
return { isValid: false, message: "Error: No token provided." };
}
try {
// 1. Decode the base64 string
const decodedB64 = atob(token);
// 2. Reverse the character code manipulation
const deobfuscated = decodedB64
.split("")
.map((char) => String.fromCharCode(char.charCodeAt(0) - 5)) // Reverse the shift
.join("");
// 3. Split the data to get the original secret and timestamp
const parts = deobfuscated.split("|");
if (parts.length !== 2) {
throw new Error("Invalid token format.");
}
const receivedKey = parts[0];
const tokenTimestamp = parseInt(parts[1], 10);
// 4. Check if the secret key matches
if (receivedKey !== INSTALLER_KEY) {
logger.info(`Token key mismatch. Received: ${receivedKey}`);
return { isValid: false, message: "Invalid token: Key mismatch." };
}
// 5. Check if the timestamp is recent (e.g., within 30 seconds) to prevent replay attacks
const now = Date.now();
const timeDifference = now - tokenTimestamp;
if (timeDifference > 30000) {
// 30 seconds
return { isValid: false, message: "Invalid token: Expired." };
}
if (timeDifference < 0) {
// Timestamp is in the future
return {
isValid: false,
message: "Invalid token: Timestamp is in the future."
};
}
// If all checks pass, the token is valid
return { isValid: true, message: "Token is valid!" };
} catch (error) {
// This will catch errors from atob (if not valid base64) or other issues.
return {
isValid: false,
message: `Error: ${(error as Error).message}`
};
}
};

View File

@@ -21,7 +21,7 @@ import { subdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { createCertificate } from "../private/certificates/createCertificate";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { getUniqueResourceName } from "@server/db/names";
import { validateAndConstructDomain } from "@server/lib/domainUtils";

View File

@@ -20,7 +20,7 @@ import { tlsNameSchema } from "@server/lib/schemas";
import { subdomainSchema } from "@server/lib/schemas";
import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "../private/certificates/createCertificate";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { validateHeaders } from "@server/lib/validators";
import { build } from "@server/build";

View File

@@ -16,8 +16,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { hashPassword } from "@server/auth/password";
import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip";
import config from "@server/lib/config";
import { verifyExitNodeOrgAccess } from "@server/lib/exitNodes";
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
const createSiteParamsSchema = z
.object({

View File

@@ -9,7 +9,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { deletePeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error";
import { sendToClient } from "../ws";
import { sendToClient } from "#dynamic/routers/ws";
import { OpenAPITags, registry } from "@server/openApi";
const deleteSiteSchema = z

View File

@@ -15,7 +15,7 @@ import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi";
import { fromError } from "zod-validation-error";
import { z } from "zod";
import { listExitNodes } from "@server/lib/exitNodes";
import { listExitNodes } from "#dynamic/lib/exitNodes";
export type PickSiteDefaultsResponse = {
exitNodeId: number;

View File

@@ -9,7 +9,7 @@ import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import stoi from "@server/lib/stoi";
import { sendToClient } from "../ws";
import { sendToClient } from "#dynamic/routers/ws";
import {
fetchContainers,
dockerSocketCache,

View File

@@ -3,6 +3,7 @@ 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 "#private/lib/config";
import config from "@server/lib/config";
import { db } from "@server/db";
import { count } from "drizzle-orm";
@@ -45,7 +46,7 @@ export async function isSupporterKeyVisible(
}
}
if (config.getRawPrivateConfig().flags?.hide_supporter_key && build != "oss") {
if (build != "oss") {
visible = false;
}

View File

@@ -1,5 +1,5 @@
import { db, targets, resources, sites, targetHealthCheck } from "@server/db";
import { MessageHandler } from "../ws";
import { MessageHandler } from "@server/routers/ws";
import { Newt } from "@server/db";
import { eq, and } from "drizzle-orm";
import logger from "@server/logger";

View File

@@ -3,7 +3,7 @@ import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config";
import { build } from "@server/build";
import { getTraefikConfig } from "@server/lib/traefik";
import { getTraefikConfig } from "#dynamic/lib/traefik";
import { getCurrentExitNodeId } from "@server/lib/exitNodes";
const badgerMiddlewareName = "badger";

View File

@@ -10,8 +10,8 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { checkValidInvite } from "@server/auth/checkValidInvite";
import { verifySession } from "@server/auth/sessions/verifySession";
import { usageService } from "@server/lib/private/billing/usageService";
import { FeatureId } from "@server/lib/private/billing";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
const acceptInviteBodySchema = z
.object({

View File

@@ -10,11 +10,11 @@ import { db, UserOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
import { generateId } from "@server/auth/sessions/app";
import { usageService } from "@server/lib/private/billing/usageService";
import { FeatureId } from "@server/lib/private/billing";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { build } from "@server/build";
import { getOrgTierData } from "@server/routers/private/billing";
import { TierId } from "@server/lib/private/billing/tiers";
import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
const paramsSchema = z
.object({

Some files were not shown because too many files have changed in this diff Show More