diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 5040808a9..848e3fb98 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -19,12 +19,12 @@ import { roles, users, exitNodes, - sessions, - clients, resources, siteResources, targetHealthCheck, - sites + sites, + clients, + sessions } from "./schema"; export const certificates = pgTable("certificates", { @@ -197,6 +197,16 @@ export const remoteExitNodes = pgTable("remoteExitNode", { }) }); +export const remoteExitNodeResources = pgTable("remoteExitNodeResources", { + remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(), + remoteExitNodeId: varchar("remoteExitNodeId") + .notNull() + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }), + destination: varchar("destination").notNull() // a cidr range +}); + export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", { sessionId: varchar("id").primaryKey(), remoteExitNodeId: varchar("remoteExitNodeId") diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index b235d26d5..d5fe7135a 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -195,6 +195,18 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", { }) }); +export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", { + remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({ + autoIncrement: true + }), + remoteExitNodeId: text("remoteExitNodeId") + .notNull() + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }), + destination: text("destination").notNull() // a cidr range +}); + export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", { sessionId: text("id").primaryKey(), remoteExitNodeId: text("remoteExitNodeId") diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 9cb24bbeb..863d9f30a 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -22,7 +22,6 @@ export function noop() { } export class UsageService { - constructor() { if (noop()) { return; @@ -57,7 +56,10 @@ export class UsageService { try { let usage; if (transaction) { - const orgIdToUse = await this.getBillingOrg(orgId, transaction); + const orgIdToUse = await this.getBillingOrg( + orgId, + transaction + ); usage = await this.internalAddUsage( orgIdToUse, featureId, @@ -274,11 +276,12 @@ export class UsageService { return null; } - const orgIdToUse = await this.getBillingOrg(orgId, trx); - - const usageId = `${orgIdToUse}-${featureId}`; - + let orgIdToUse = orgId; try { + orgIdToUse = await this.getBillingOrg(orgId, trx); + + const usageId = `${orgIdToUse}-${featureId}`; + const [result] = await trx .select() .from(usage) @@ -338,8 +341,12 @@ export class UsageService { `Failed to get usage for ${orgIdToUse}/${featureId}:`, error ); - throw error; + if (process.env.NODE_ENV !== "development") { + throw error; + } } + + return null; } public async getBillingOrg( @@ -382,13 +389,13 @@ export class UsageService { return false; } - const orgIdToUse = await this.getBillingOrg(orgId, trx); - // This method should check the current usage against the limits set for the organization // and kick out all of the sites on the org let hasExceededLimits = false; - + let orgIdToUse = orgId; try { + orgIdToUse = await this.getBillingOrg(orgId, trx); + let orgLimits: Limit[] = []; if (featureId) { // Get all limits set for this organization diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0598a1514..31ee163fb 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -330,6 +330,25 @@ authenticated.delete( remoteExitNode.deleteRemoteExitNode ); +authenticated.get( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/resources", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.getRemoteExitNode), + remoteExitNode.listRemoteExitNodeResources +); + +authenticated.post( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/resources", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.updateRemoteExitNode), + logActionAudit(ActionsEnum.updateRemoteExitNode), + remoteExitNode.setRemoteExitNodeResources +); + authenticated.put( "/org/:orgId/login-page", verifyValidLicense, diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index 730f6b693..674a663ed 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -22,3 +22,5 @@ export * from "./listRemoteExitNodes"; export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; export * from "./offlineChecker"; +export * from "./listRemoteExitNodeResources"; +export * from "./setRemoteExitNodeResources"; diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts b/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts new file mode 100644 index 000000000..5f91fd0b3 --- /dev/null +++ b/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts @@ -0,0 +1,90 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 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, remoteExitNodeResources, 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 paramsSchema = z.strictObject({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) +}); + +export type ListRemoteExitNodeResourcesResponse = { + resources: { + remoteExitNodeResourceId: number; + remoteExitNodeId: string; + destination: string; + }[]; +}; + +export async function listRemoteExitNodeResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { remoteExitNodeId } = parsedParams.data; + + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + const resources = await db + .select() + .from(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + return response(res, { + data: { resources }, + success: true, + error: false, + message: "Remote exit node resources retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts new file mode 100644 index 000000000..b262bf9a1 --- /dev/null +++ b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts @@ -0,0 +1,129 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 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, remoteExitNodeResources, 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 paramsSchema = z.strictObject({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) +}); + +const cidrRegex = + /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/; + +const bodySchema = z.strictObject({ + destinations: z.array( + z.string().regex(cidrRegex, "Must be a valid CIDR range") + ) +}); + +export type SetRemoteExitNodeResourcesBody = z.infer; + +export type SetRemoteExitNodeResourcesResponse = { + resources: { + remoteExitNodeResourceId: number; + remoteExitNodeId: string; + destination: string; + }[]; +}; + +export async function setRemoteExitNodeResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { remoteExitNodeId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { destinations } = parsedBody.data; + + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + // Replace all resources atomically + await db + .delete(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + if (destinations.length > 0) { + await db.insert(remoteExitNodeResources).values( + destinations.map((destination) => ({ + remoteExitNodeId, + destination + })) + ); + } + + const resources = await db + .select() + .from(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + return response(res, { + data: { resources }, + success: true, + error: false, + message: "Remote exit node resources updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx index ec1dea837..cac55907f 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx @@ -34,6 +34,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const t = await getTranslations(); const navItems = [ + { + title: "Networking", + href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/networking" + }, { title: t("credentials"), href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials" diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx new file mode 100644 index 000000000..bf220714b --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useParams } from "next/navigation"; +import { AxiosResponse } from "axios"; +import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import type { ListRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/listRemoteExitNodeResources"; +import type { SetRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodeResources"; + +const cidrRegex = + /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/; + +export default function NetworkingPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams<{ + orgId: string; + remoteExitNodeId: string; + }>(); + const { remoteExitNode } = useRemoteExitNodeContext(); + + const [subnets, setSubnets] = useState([]); + const [activeTagIndex, setActiveTagIndex] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + async function loadResources() { + try { + const res = await api.get< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources` + ); + const resources = res.data.data.resources; + setSubnets( + resources.map((r) => ({ + id: r.destination, + text: r.destination + })) + ); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: + formatAxiosError(error) || "Failed to load subnets" + }); + } finally { + setLoading(false); + } + } + + loadResources(); + }, [remoteExitNode.remoteExitNodeId]); + + const handleSave = async () => { + setSaving(true); + try { + await api.post>( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`, + { + destinations: subnets.map((s) => s.text) + } + ); + toast({ + title: "Subnets saved", + description: "Remote subnets have been updated successfully." + }); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: formatAxiosError(error) || "Failed to save subnets" + }); + } finally { + setSaving(false); + } + }; + + return ( + + + + Remote Subnets + + Define the CIDR ranges that this remote exit node will + route traffic to. Type a valid CIDR (e.g.{" "} + 10.0.0.0/8) and press Enter to add. + + + + cidrRegex.test(tag.trim())} + activeTagIndex={activeTagIndex} + setActiveTagIndex={setActiveTagIndex} + disabled={loading} + allowDuplicates={false} + inlineTags={true} + /> + + + + + + + ); +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx index 0e68c3791..13c62a3e7 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx @@ -35,8 +35,6 @@ import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon } from "lucide-react"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { StrategySelect } from "@app/components/StrategySelect";