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

@@ -0,0 +1,278 @@
/*
* 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 "#private/auth/sessions/remoteExitNode";
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/billing/usageService";
import { FeatureId } from "@server/lib/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

@@ -0,0 +1,131 @@
/*
* 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/billing/usageService";
import { FeatureId } from "@server/lib/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

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

@@ -0,0 +1,130 @@
/*
* 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 "#private/auth/sessions/remoteExitNode";
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

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

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

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

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

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

@@ -0,0 +1,170 @@
/*
* 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}`
};
}
};