Make easier to run in dev - fix a couple of things

This commit is contained in:
Owen
2025-10-12 16:23:38 -07:00
parent f17a957058
commit a50c0d84e9
10 changed files with 265 additions and 123 deletions

View File

@@ -4,7 +4,7 @@ import { build } from "@server/build";
const schema = [ const schema = [
path.join("server", "db", "pg", "schema.ts"), path.join("server", "db", "pg", "schema.ts"),
path.join("server", "db", "pg", "pSchema.ts") path.join("server", "db", "pg", "privateSchema.ts")
]; ];
export default defineConfig({ export default defineConfig({

View File

@@ -5,7 +5,7 @@ import path from "path";
const schema = [ const schema = [
path.join("server", "db", "sqlite", "schema.ts"), path.join("server", "db", "sqlite", "schema.ts"),
path.join("server", "db", "sqlite", "pSchema.ts") path.join("server", "db", "sqlite", "privateSchema.ts")
]; ];
export default defineConfig({ export default defineConfig({

View File

@@ -1542,8 +1542,8 @@
"autoLoginError": "Auto Login Error", "autoLoginError": "Auto Login Error",
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.", "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
"remoteExitNodeManageRemoteExitNodes": "Managed Nodes", "remoteExitNodeManageRemoteExitNodes": "Remote Nodes",
"remoteExitNodeDescription": "Self-host one or more nodes for tunnel exit servers", "remoteExitNodeDescription": "Self-host one or more remote nodes for tunnel exit servers",
"remoteExitNodes": "Nodes", "remoteExitNodes": "Nodes",
"searchRemoteExitNodes": "Search nodes...", "searchRemoteExitNodes": "Search nodes...",
"remoteExitNodeAdd": "Add Node", "remoteExitNodeAdd": "Add Node",
@@ -1553,7 +1553,7 @@
"remoteExitNodeMessageConfirm": "To confirm, please type the name of the node below.", "remoteExitNodeMessageConfirm": "To confirm, please type the name of the node below.",
"remoteExitNodeConfirmDelete": "Confirm Delete Node", "remoteExitNodeConfirmDelete": "Confirm Delete Node",
"remoteExitNodeDelete": "Delete Node", "remoteExitNodeDelete": "Delete Node",
"sidebarRemoteExitNodes": "Nodes", "sidebarRemoteExitNodes": "Remote Nodes",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Create Node", "title": "Create Node",
"description": "Create a new node to extend your network connectivity", "description": "Create a new node to extend your network connectivity",

View File

@@ -44,27 +44,25 @@ export function createApiServer() {
} }
const corsConfig = config.getRawConfig().server.cors; const corsConfig = config.getRawConfig().server.cors;
const options = {
...(corsConfig?.origins
? { origin: corsConfig.origins }
: {
origin: (origin: any, callback: any) => {
callback(null, true);
}
}),
...(corsConfig?.methods && { methods: corsConfig.methods }),
...(corsConfig?.allowed_headers && {
allowedHeaders: corsConfig.allowed_headers
}),
credentials: !(corsConfig?.credentials === false)
};
if (build == "oss") { if (build == "oss" || !corsConfig) {
const options = {
...(corsConfig?.origins
? { origin: corsConfig.origins }
: {
origin: (origin: any, callback: any) => {
callback(null, true);
}
}),
...(corsConfig?.methods && { methods: corsConfig.methods }),
...(corsConfig?.allowed_headers && {
allowedHeaders: corsConfig.allowed_headers
}),
credentials: !(corsConfig?.credentials === false)
};
logger.debug("Using CORS options", options); logger.debug("Using CORS options", options);
apiServer.use(cors(options)); apiServer.use(cors(options));
} else { } else if (corsConfig) {
// Use the custom CORS middleware with loginPage support // Use the custom CORS middleware with loginPage support
apiServer.use(corsWithLoginPageSupport(corsConfig)); apiServer.use(corsWithLoginPageSupport(corsConfig));
} }

View File

@@ -31,6 +31,17 @@ interface StripeEvent {
}; };
} }
export function noop() {
if (
build !== "saas" ||
!process.env.S3_BUCKET ||
!process.env.LOCAL_FILE_PATH
) {
return true;
}
return false;
}
export class UsageService { export class UsageService {
private cache: NodeCache; private cache: NodeCache;
private bucketName: string | undefined; private bucketName: string | undefined;
@@ -41,7 +52,7 @@ export class UsageService {
constructor() { constructor() {
this.cache = new NodeCache({ stdTTL: 300 }); // 5 minute TTL this.cache = new NodeCache({ stdTTL: 300 }); // 5 minute TTL
if (build !== "saas") { if (noop()) {
return; return;
} }
// this.bucketName = privateConfig.getRawPrivateConfig().stripe?.s3Bucket; // this.bucketName = privateConfig.getRawPrivateConfig().stripe?.s3Bucket;
@@ -71,7 +82,9 @@ export class UsageService {
private async initializeEventsDirectory(): Promise<void> { private async initializeEventsDirectory(): Promise<void> {
if (!this.eventsDir) { if (!this.eventsDir) {
logger.warn("Stripe local file path is not configured, skipping events directory initialization."); logger.warn(
"Stripe local file path is not configured, skipping events directory initialization."
);
return; return;
} }
try { try {
@@ -83,7 +96,9 @@ export class UsageService {
private async uploadPendingEventFilesOnStartup(): Promise<void> { private async uploadPendingEventFilesOnStartup(): Promise<void> {
if (!this.eventsDir || !this.bucketName) { if (!this.eventsDir || !this.bucketName) {
logger.warn("Stripe local file path or bucket name is not configured, skipping leftover event file upload."); logger.warn(
"Stripe local file path or bucket name is not configured, skipping leftover event file upload."
);
return; return;
} }
try { try {
@@ -106,15 +121,17 @@ export class UsageService {
ContentType: "application/json" ContentType: "application/json"
}); });
await s3Client.send(uploadCommand); await s3Client.send(uploadCommand);
// Check if file still exists before unlinking // Check if file still exists before unlinking
try { try {
await fs.access(filePath); await fs.access(filePath);
await fs.unlink(filePath); await fs.unlink(filePath);
} catch (unlinkError) { } catch (unlinkError) {
logger.debug(`Startup file ${file} was already deleted`); logger.debug(
`Startup file ${file} was already deleted`
);
} }
logger.info( logger.info(
`Uploaded leftover event file ${file} to S3 with ${events.length} events` `Uploaded leftover event file ${file} to S3 with ${events.length} events`
); );
@@ -124,7 +141,9 @@ export class UsageService {
await fs.access(filePath); await fs.access(filePath);
await fs.unlink(filePath); await fs.unlink(filePath);
} catch (unlinkError) { } catch (unlinkError) {
logger.debug(`Empty startup file ${file} was already deleted`); logger.debug(
`Empty startup file ${file} was already deleted`
);
} }
} }
} catch (err) { } catch (err) {
@@ -135,8 +154,8 @@ export class UsageService {
} }
} }
} }
} catch (err) { } catch (error) {
logger.error("Failed to scan for leftover event files:", err); logger.error("Failed to scan for leftover event files");
} }
} }
@@ -146,17 +165,17 @@ export class UsageService {
value: number, value: number,
transaction: any = null transaction: any = null
): Promise<Usage | null> { ): Promise<Usage | null> {
if (build !== "saas") { if (noop()) {
return null; return null;
} }
// Truncate value to 11 decimal places // Truncate value to 11 decimal places
value = this.truncateValue(value); value = this.truncateValue(value);
// Implement retry logic for deadlock handling // Implement retry logic for deadlock handling
const maxRetries = 3; const maxRetries = 3;
let attempt = 0; let attempt = 0;
while (attempt <= maxRetries) { while (attempt <= maxRetries) {
try { try {
// Get subscription data for this org (with caching) // Get subscription data for this org (with caching)
@@ -179,7 +198,12 @@ export class UsageService {
); );
} else { } else {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
usage = await this.internalAddUsage(orgId, featureId, value, trx); usage = await this.internalAddUsage(
orgId,
featureId,
value,
trx
);
}); });
} }
@@ -189,25 +213,26 @@ export class UsageService {
return usage || null; return usage || null;
} catch (error: any) { } catch (error: any) {
// Check if this is a deadlock error // Check if this is a deadlock error
const isDeadlock = error?.code === '40P01' || const isDeadlock =
error?.cause?.code === '40P01' || error?.code === "40P01" ||
(error?.message && error.message.includes('deadlock')); error?.cause?.code === "40P01" ||
(error?.message && error.message.includes("deadlock"));
if (isDeadlock && attempt < maxRetries) { if (isDeadlock && attempt < maxRetries) {
attempt++; attempt++;
// Exponential backoff with jitter: 50-150ms, 100-300ms, 200-600ms // Exponential backoff with jitter: 50-150ms, 100-300ms, 200-600ms
const baseDelay = Math.pow(2, attempt - 1) * 50; const baseDelay = Math.pow(2, attempt - 1) * 50;
const jitter = Math.random() * baseDelay; const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter; const delay = baseDelay + jitter;
logger.warn( logger.warn(
`Deadlock detected for ${orgId}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms` `Deadlock detected for ${orgId}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`
); );
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
continue; continue;
} }
logger.error( logger.error(
`Failed to add usage for ${orgId}/${featureId} after ${attempt} attempts:`, `Failed to add usage for ${orgId}/${featureId} after ${attempt} attempts:`,
error error
@@ -227,10 +252,10 @@ export class UsageService {
): Promise<Usage> { ): Promise<Usage> {
// Truncate value to 11 decimal places // Truncate value to 11 decimal places
value = this.truncateValue(value); value = this.truncateValue(value);
const usageId = `${orgId}-${featureId}`; const usageId = `${orgId}-${featureId}`;
const meterId = getFeatureMeterId(featureId); const meterId = getFeatureMeterId(featureId);
// Use upsert: insert if not exists, otherwise increment // Use upsert: insert if not exists, otherwise increment
const [returnUsage] = await trx const [returnUsage] = await trx
.insert(usage) .insert(usage)
@@ -247,7 +272,8 @@ export class UsageService {
set: { set: {
latestValue: sql`${usage.latestValue} + ${value}` latestValue: sql`${usage.latestValue} + ${value}`
} }
}).returning(); })
.returning();
return returnUsage; return returnUsage;
} }
@@ -268,7 +294,7 @@ export class UsageService {
value?: number, value?: number,
customerId?: string customerId?: string
): Promise<void> { ): Promise<void> {
if (build !== "saas") { if (noop()) {
return; return;
} }
try { try {
@@ -339,7 +365,7 @@ export class UsageService {
.set({ .set({
latestValue: newRunningTotal, latestValue: newRunningTotal,
instantaneousValue: value, instantaneousValue: value,
updatedAt: Math.floor(Date.now() / 1000) updatedAt: Math.floor(Date.now() / 1000)
}) })
.where(eq(usage.usageId, usageId)); .where(eq(usage.usageId, usageId));
} }
@@ -354,7 +380,7 @@ export class UsageService {
meterId, meterId,
instantaneousValue: truncatedValue, instantaneousValue: truncatedValue,
latestValue: truncatedValue, latestValue: truncatedValue,
updatedAt: Math.floor(Date.now() / 1000) updatedAt: Math.floor(Date.now() / 1000)
}); });
} }
}); });
@@ -415,7 +441,7 @@ export class UsageService {
): Promise<void> { ): Promise<void> {
// Truncate value to 11 decimal places before sending to Stripe // Truncate value to 11 decimal places before sending to Stripe
const truncatedValue = this.truncateValue(value); const truncatedValue = this.truncateValue(value);
const event: StripeEvent = { const event: StripeEvent = {
identifier: uuidv4(), identifier: uuidv4(),
timestamp: Math.floor(new Date().getTime() / 1000), timestamp: Math.floor(new Date().getTime() / 1000),
@@ -432,7 +458,9 @@ export class UsageService {
private async writeEventToFile(event: StripeEvent): Promise<void> { private async writeEventToFile(event: StripeEvent): Promise<void> {
if (!this.eventsDir || !this.bucketName) { if (!this.eventsDir || !this.bucketName) {
logger.warn("Stripe local file path or bucket name is not configured, skipping event file write."); logger.warn(
"Stripe local file path or bucket name is not configured, skipping event file write."
);
return; return;
} }
if (!this.currentEventFile) { if (!this.currentEventFile) {
@@ -481,7 +509,9 @@ export class UsageService {
private async uploadFileToS3(): Promise<void> { private async uploadFileToS3(): Promise<void> {
if (!this.bucketName || !this.eventsDir) { if (!this.bucketName || !this.eventsDir) {
logger.warn("Stripe local file path or bucket name is not configured, skipping S3 upload."); logger.warn(
"Stripe local file path or bucket name is not configured, skipping S3 upload."
);
return; return;
} }
if (!this.currentEventFile) { if (!this.currentEventFile) {
@@ -493,7 +523,9 @@ export class UsageService {
// Check if this file is already being uploaded // Check if this file is already being uploaded
if (this.uploadingFiles.has(fileName)) { if (this.uploadingFiles.has(fileName)) {
logger.debug(`File ${fileName} is already being uploaded, skipping`); logger.debug(
`File ${fileName} is already being uploaded, skipping`
);
return; return;
} }
@@ -505,7 +537,9 @@ export class UsageService {
try { try {
await fs.access(filePath); await fs.access(filePath);
} catch (error) { } catch (error) {
logger.debug(`File ${fileName} does not exist, may have been already processed`); logger.debug(
`File ${fileName} does not exist, may have been already processed`
);
this.uploadingFiles.delete(fileName); this.uploadingFiles.delete(fileName);
// Reset current file if it was this file // Reset current file if it was this file
if (this.currentEventFile === fileName) { if (this.currentEventFile === fileName) {
@@ -525,7 +559,9 @@ export class UsageService {
await fs.unlink(filePath); await fs.unlink(filePath);
} catch (unlinkError) { } catch (unlinkError) {
// File may have been already deleted // File may have been already deleted
logger.debug(`File ${fileName} was already deleted during cleanup`); logger.debug(
`File ${fileName} was already deleted during cleanup`
);
} }
this.currentEventFile = null; this.currentEventFile = null;
this.uploadingFiles.delete(fileName); this.uploadingFiles.delete(fileName);
@@ -548,7 +584,9 @@ export class UsageService {
await fs.unlink(filePath); await fs.unlink(filePath);
} catch (unlinkError) { } catch (unlinkError) {
// File may have been already deleted by another process // File may have been already deleted by another process
logger.debug(`File ${fileName} was already deleted during upload`); logger.debug(
`File ${fileName} was already deleted during upload`
);
} }
logger.info( logger.info(
@@ -559,10 +597,7 @@ export class UsageService {
this.currentEventFile = null; this.currentEventFile = null;
this.currentFileStartTime = 0; this.currentFileStartTime = 0;
} catch (error) { } catch (error) {
logger.error( logger.error(`Failed to upload ${fileName} to S3:`, error);
`Failed to upload ${fileName} to S3:`,
error
);
} finally { } finally {
// Always remove from uploading set // Always remove from uploading set
this.uploadingFiles.delete(fileName); this.uploadingFiles.delete(fileName);
@@ -579,7 +614,7 @@ export class UsageService {
orgId: string, orgId: string,
featureId: FeatureId featureId: FeatureId
): Promise<Usage | null> { ): Promise<Usage | null> {
if (build !== "saas") { if (noop()) {
return null; return null;
} }
@@ -598,7 +633,7 @@ export class UsageService {
`Creating new usage record for ${orgId}/${featureId}` `Creating new usage record for ${orgId}/${featureId}`
); );
const meterId = getFeatureMeterId(featureId); const meterId = getFeatureMeterId(featureId);
try { try {
const [newUsage] = await db const [newUsage] = await db
.insert(usage) .insert(usage)
@@ -653,7 +688,7 @@ export class UsageService {
orgId: string, orgId: string,
featureId: FeatureId featureId: FeatureId
): Promise<Usage | null> { ): Promise<Usage | null> {
if (build !== "saas") { if (noop()) {
return null; return null;
} }
await this.updateDaily(orgId, featureId); // Ensure daily usage is updated await this.updateDaily(orgId, featureId); // Ensure daily usage is updated
@@ -673,7 +708,9 @@ export class UsageService {
*/ */
private async uploadOldEventFiles(): Promise<void> { private async uploadOldEventFiles(): Promise<void> {
if (!this.eventsDir || !this.bucketName) { if (!this.eventsDir || !this.bucketName) {
logger.warn("Stripe local file path or bucket name is not configured, skipping old event file upload."); logger.warn(
"Stripe local file path or bucket name is not configured, skipping old event file upload."
);
return; return;
} }
try { try {
@@ -681,15 +718,17 @@ export class UsageService {
const now = Date.now(); const now = Date.now();
for (const file of files) { for (const file of files) {
if (!file.endsWith(".json")) continue; if (!file.endsWith(".json")) continue;
// Skip files that are already being uploaded // Skip files that are already being uploaded
if (this.uploadingFiles.has(file)) { if (this.uploadingFiles.has(file)) {
logger.debug(`Skipping file ${file} as it's already being uploaded`); logger.debug(
`Skipping file ${file} as it's already being uploaded`
);
continue; continue;
} }
const filePath = path.join(this.eventsDir, file); const filePath = path.join(this.eventsDir, file);
try { try {
// Check if file still exists before processing // Check if file still exists before processing
try { try {
@@ -704,7 +743,7 @@ export class UsageService {
if (age >= 90000) { if (age >= 90000) {
// 1.5 minutes - Mark as being uploaded // 1.5 minutes - Mark as being uploaded
this.uploadingFiles.add(file); this.uploadingFiles.add(file);
try { try {
const fileContent = await fs.readFile( const fileContent = await fs.readFile(
filePath, filePath,
@@ -720,15 +759,17 @@ export class UsageService {
ContentType: "application/json" ContentType: "application/json"
}); });
await s3Client.send(uploadCommand); await s3Client.send(uploadCommand);
// Check if file still exists before unlinking // Check if file still exists before unlinking
try { try {
await fs.access(filePath); await fs.access(filePath);
await fs.unlink(filePath); await fs.unlink(filePath);
} catch (unlinkError) { } catch (unlinkError) {
logger.debug(`File ${file} was already deleted during interval upload`); logger.debug(
`File ${file} was already deleted during interval upload`
);
} }
logger.info( logger.info(
`Interval: Uploaded event file ${file} to S3 with ${events.length} events` `Interval: Uploaded event file ${file} to S3 with ${events.length} events`
); );
@@ -743,7 +784,9 @@ export class UsageService {
await fs.access(filePath); await fs.access(filePath);
await fs.unlink(filePath); await fs.unlink(filePath);
} catch (unlinkError) { } catch (unlinkError) {
logger.debug(`Empty file ${file} was already deleted`); logger.debug(
`Empty file ${file} was already deleted`
);
} }
} }
} finally { } finally {
@@ -765,12 +808,17 @@ export class UsageService {
} }
} }
public async checkLimitSet(orgId: string, kickSites = false, featureId?: FeatureId, usage?: Usage): Promise<boolean> { public async checkLimitSet(
if (build !== "saas") { orgId: string,
kickSites = false,
featureId?: FeatureId,
usage?: Usage
): Promise<boolean> {
if (noop()) {
return false; return false;
} }
// This method should check the current usage against the limits set for the organization // This method should check the current usage against the limits set for the organization
// and kick out all of the sites on the org // and kick out all of the sites on the org
let hasExceededLimits = false; let hasExceededLimits = false;
try { try {
@@ -805,16 +853,30 @@ export class UsageService {
if (usage) { if (usage) {
currentUsage = usage; currentUsage = usage;
} else { } else {
currentUsage = await this.getUsage(orgId, limit.featureId as FeatureId); currentUsage = await this.getUsage(
orgId,
limit.featureId as FeatureId
);
} }
const usageValue = currentUsage?.instantaneousValue || currentUsage?.latestValue || 0; const usageValue =
logger.debug(`Current usage for org ${orgId} on feature ${limit.featureId}: ${usageValue}`); currentUsage?.instantaneousValue ||
logger.debug(`Limit for org ${orgId} on feature ${limit.featureId}: ${limit.value}`); currentUsage?.latestValue ||
if (currentUsage && limit.value !== null && usageValue > limit.value) { 0;
logger.debug(
`Current usage for org ${orgId} on feature ${limit.featureId}: ${usageValue}`
);
logger.debug(
`Limit for org ${orgId} on feature ${limit.featureId}: ${limit.value}`
);
if (
currentUsage &&
limit.value !== null &&
usageValue > limit.value
) {
logger.debug( logger.debug(
`Org ${orgId} has exceeded limit for ${limit.featureId}: ` + `Org ${orgId} has exceeded limit for ${limit.featureId}: ` +
`${usageValue} > ${limit.value}` `${usageValue} > ${limit.value}`
); );
hasExceededLimits = true; hasExceededLimits = true;
break; // Exit early if any limit is exceeded break; // Exit early if any limit is exceeded
@@ -823,7 +885,9 @@ export class UsageService {
// If any limits are exceeded, disconnect all sites for this organization // If any limits are exceeded, disconnect all sites for this organization
if (hasExceededLimits && kickSites) { if (hasExceededLimits && kickSites) {
logger.warn(`Disconnecting all sites for org ${orgId} due to exceeded limits`); logger.warn(
`Disconnecting all sites for org ${orgId} due to exceeded limits`
);
// Get all sites for this organization // Get all sites for this organization
const orgSites = await db const orgSites = await db
@@ -832,7 +896,7 @@ export class UsageService {
.where(eq(sites.orgId, orgId)); .where(eq(sites.orgId, orgId));
// Mark all sites as offline and send termination messages // Mark all sites as offline and send termination messages
const siteUpdates = orgSites.map(site => site.siteId); const siteUpdates = orgSites.map((site) => site.siteId);
if (siteUpdates.length > 0) { if (siteUpdates.length > 0) {
// Send termination messages to newt sites // Send termination messages to newt sites
@@ -853,17 +917,21 @@ export class UsageService {
}; };
// Don't await to prevent blocking // Don't await to prevent blocking
sendToClient(newt.newtId, payload).catch((error: any) => { sendToClient(newt.newtId, payload).catch(
logger.error( (error: any) => {
`Failed to send termination message to newt ${newt.newtId}:`, logger.error(
error `Failed to send termination message to newt ${newt.newtId}:`,
); error
}); );
}
);
} }
} }
} }
logger.info(`Disconnected ${orgSites.length} sites for org ${orgId} due to exceeded limits`); logger.info(
`Disconnected ${orgSites.length} sites for org ${orgId} due to exceeded limits`
);
} }
} }
} catch (error) { } catch (error) {

View File

@@ -14,10 +14,10 @@
import Stripe from "stripe"; import Stripe from "stripe";
import privateConfig from "#private/lib/config"; import privateConfig from "#private/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { build } from "@server/build"; import { noop } from "@server/lib/billing/usageService";
let stripe: Stripe | undefined = undefined; let stripe: Stripe | undefined = undefined;
if (build == "saas") { if (!noop()) {
const stripeApiKey = privateConfig.getRawPrivateConfig().stripe?.secret_key; const stripeApiKey = privateConfig.getRawPrivateConfig().stripe?.secret_key;
if (!stripeApiKey) { if (!stripeApiKey) {
logger.error("Stripe secret key is not configured"); logger.error("Stripe secret key is not configured");

View File

@@ -33,10 +33,11 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { unauthenticated as ua, authenticated as a } from "@server/routers/external"; import { unauthenticated as ua, authenticated as a, authRouter as aa } from "@server/routers/external";
export const authenticated = a; export const authenticated = a;
export const unauthenticated = ua; export const unauthenticated = ua;
export const authRouter = aa;
unauthenticated.post( unauthenticated.post(
"/quick-start", "/quick-start",
@@ -227,8 +228,6 @@ authenticated.get(
loginPage.getLoginPage loginPage.getLoginPage
); );
export const authRouter = Router();
authRouter.post( authRouter.post(
"/remoteExitNode/get-token", "/remoteExitNode/get-token",
rateLimit({ rateLimit({

View File

@@ -68,10 +68,11 @@ import { decryptData } from "@server/lib/encryption";
import config from "@server/lib/config"; import config from "@server/lib/config";
import privateConfig from "#private/lib/config"; import privateConfig from "#private/lib/config";
import * as fs from "fs"; import * as fs from "fs";
import { exchangeSession } from "@server/routers/badger"; import { exchangeSession } from "@server/routers/badger";
import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
import { maxmindLookup } from "@server/db/maxmind"; import { maxmindLookup } from "@server/db/maxmind";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
// Zod schemas for request validation // Zod schemas for request validation
const getResourceByDomainParamsSchema = z const getResourceByDomainParamsSchema = z
@@ -162,6 +163,14 @@ const validateResourceSessionTokenBodySchema = z
}) })
.strict(); .strict();
const validateResourceAccessTokenBodySchema = z
.object({
accessTokenId: z.string().optional(),
resourceId: z.number().optional(),
accessToken: z.string()
})
.strict();
// Certificates by domains query validation // Certificates by domains query validation
const getCertificatesByDomainsQuerySchema = z const getCertificatesByDomainsQuerySchema = z
.object({ .object({
@@ -215,6 +224,33 @@ export type UserSessionWithUser = {
export const hybridRouter = Router(); export const hybridRouter = Router();
hybridRouter.use(verifySessionRemoteExitNodeMiddleware); hybridRouter.use(verifySessionRemoteExitNodeMiddleware);
hybridRouter.get(
"/general-config",
async (req: Request, res: Response, next: NextFunction) => {
return response(res, {
data: {
resource_session_request_param:
config.getRawConfig().server.resource_session_request_param,
resource_access_token_headers:
config.getRawConfig().server.resource_access_token_headers,
resource_access_token_param:
config.getRawConfig().server.resource_access_token_param,
session_cookie_name:
config.getRawConfig().server.session_cookie_name,
require_email_verification:
config.getRawConfig().flags?.require_email_verification ||
false,
resource_session_length_hours:
config.getRawConfig().server.resource_session_length_hours
},
success: true,
error: false,
message: "General config retrieved successfully",
status: HttpCode.OK
});
}
);
hybridRouter.get( hybridRouter.get(
"/traefik-config", "/traefik-config",
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -1101,6 +1137,52 @@ hybridRouter.post(
} }
); );
// Validate resource session token
hybridRouter.post(
"/resource/:resourceId/access-token/verify",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedBody = validateResourceAccessTokenBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { accessToken, resourceId, accessTokenId } = parsedBody.data;
const result = await verifyResourceAccessToken({
accessTokenId,
accessToken,
resourceId
});
return response(res, {
data: result,
success: true,
error: false,
message: result.valid
? "Resource access token is valid"
: "Resource access token is invalid or expired",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to validate resource session token"
)
);
}
}
);
const geoIpLookupParamsSchema = z.object({ const geoIpLookupParamsSchema = z.object({
ip: z.string().ip() ip: z.string().ip()
}); });
@@ -1489,4 +1571,4 @@ hybridRouter.post(
); );
} }
} }
); );

View File

@@ -84,30 +84,25 @@ export async function createRemoteExitNode(
orgId, orgId,
FeatureId.REMOTE_EXIT_NODES FeatureId.REMOTE_EXIT_NODES
); );
if (!usage) { if (usage) {
return next( const rejectRemoteExitNodes = await usageService.checkLimitSet(
createHttpError( orgId,
HttpCode.NOT_FOUND, false,
"No usage data found for this organization" FeatureId.REMOTE_EXIT_NODES,
) {
); ...usage,
} instantaneousValue: (usage.instantaneousValue || 0) + 1
const rejectRemoteExitNodes = await usageService.checkLimitSet( } // We need to add one to know if we are violating the limit
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"
)
); );
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 secretHash = await hashPassword(secret);

View File

@@ -22,7 +22,7 @@
"#private/*": ["../server/private/*"], "#private/*": ["../server/private/*"],
"#open/*": ["../server/*"], "#open/*": ["../server/*"],
"#closed/*": ["../server/private/*"], "#closed/*": ["../server/private/*"],
"#dynamic/*": ["../server/*"] "#dynamic/*": ["../server/private/*"]
}, },
"plugins": [ "plugins": [
{ {