diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index ae73b97ac..cbe8a4039 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -2,6 +2,7 @@ import { pgTable, serial, varchar, + unique, boolean, integer, bigint, @@ -19,12 +20,13 @@ import { roles, users, exitNodes, - sessions, - clients, resources, siteResources, targetHealthCheck, - sites + sites, + clients, + sessions, + labels } from "./schema"; export const certificates = pgTable("certificates", { @@ -197,6 +199,42 @@ 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 remoteExitNodePreferenceLabels = pgTable( + // this controls what sites are enforced to connect to this node + "remoteExitNodePreferenceLabels", + { + remoteExitNodePreferenceLabelId: serial( + "remoteExitNodePreferenceLabelId" + ).primaryKey(), + remoteExitNodeId: varchar("remoteExitNodeId") + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [ + unique("remote_exit_node_preference_label_uniq").on( + t.remoteExitNodeId, + t.labelId + ) + ] +); + 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 ad5f386eb..b75836e29 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -12,6 +12,7 @@ import { clients, domains, exitNodes, + labels, orgs, resources, roles, @@ -192,6 +193,44 @@ 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 remoteExitNodePreferenceLabels = sqliteTable( + // this controls what sites are enforced to connect to this node + "remoteExitNodePreferenceLabels", + { + remoteExitNodePreferenceLabelId: integer( + "remoteExitNodePreferenceLabelId" + ).primaryKey({ autoIncrement: true }), + remoteExitNodeId: text("remoteExitNodeId") + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [ + uniqueIndex("remote_exit_node_preference_label_uniq").on( + t.remoteExitNodeId, + t.labelId + ) + ] +); + 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 a239cc719..413bdf9a3 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -276,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) @@ -340,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( @@ -384,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/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index f6417dae2..1f9517725 100644 --- a/server/private/lib/exitNodes/exitNodes.ts +++ b/server/private/lib/exitNodes/exitNodes.ts @@ -18,12 +18,15 @@ import { resources, targets, sites, + siteLabels, + remoteExitNodes, + remoteExitNodePreferenceLabels, targetHealthCheck, Transaction } from "@server/db"; import logger from "@server/logger"; import { ExitNodePingResult } from "@server/routers/newt"; -import { eq, and, or, ne, isNull } from "drizzle-orm"; +import { eq, and, or, ne, isNull, inArray } from "drizzle-orm"; import axios from "axios"; import config from "../config"; @@ -150,7 +153,8 @@ export async function verifyExitNodeOrgAccess( export async function listExitNodes( orgId: string, filterOnline = false, - noCloud = false + noCloud = false, + siteId?: number ) { const allExitNodes = await db .select({ @@ -237,7 +241,7 @@ export async function listExitNodes( // }) // ); - const remoteExitNodes = allExitNodes.filter( + let remoteExitNodesList = allExitNodes.filter( (node) => node.type === "remoteExitNode" && (!filterOnline || node.online) ); @@ -246,9 +250,82 @@ export async function listExitNodes( node.type === "gerbil" && (!filterOnline || node.online) && !noCloud ); + // Apply label-based filtering to remote exit nodes if siteId is provided + if (siteId !== undefined && remoteExitNodesList.length > 0) { + // Get the site's labels + const siteLabelRows = await db + .select({ labelId: siteLabels.labelId }) + .from(siteLabels) + .where(eq(siteLabels.siteId, siteId)); + const siteLabelIds = new Set(siteLabelRows.map((r) => r.labelId)); + + // Get the remoteExitNode records for these exit nodes so we have the remoteExitNodeId + const exitNodeIds = remoteExitNodesList.map((n) => n.exitNodeId); + const remoteNodeRows = await db + .select({ + exitNodeId: remoteExitNodes.exitNodeId, + remoteExitNodeId: remoteExitNodes.remoteExitNodeId + }) + .from(remoteExitNodes) + .where(inArray(remoteExitNodes.exitNodeId, exitNodeIds)); + + const exitNodeIdToRemoteId = new Map( + remoteNodeRows + .filter((r) => r.exitNodeId !== null) + .map((r) => [r.exitNodeId!, r.remoteExitNodeId]) + ); + + // Get preference labels for all remote exit nodes + const remoteExitNodeIds = remoteNodeRows.map((r) => r.remoteExitNodeId); + const prefLabelRows = + remoteExitNodeIds.length > 0 + ? await db + .select({ + remoteExitNodeId: + remoteExitNodePreferenceLabels.remoteExitNodeId, + labelId: remoteExitNodePreferenceLabels.labelId + }) + .from(remoteExitNodePreferenceLabels) + .where( + inArray( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeIds + ) + ) + : []; + + // Build a map of remoteExitNodeId -> Set of labelIds + const prefLabelsMap = new Map>(); + for (const row of prefLabelRows) { + if (!prefLabelsMap.has(row.remoteExitNodeId)) { + prefLabelsMap.set(row.remoteExitNodeId, new Set()); + } + prefLabelsMap.get(row.remoteExitNodeId)!.add(row.labelId); + } + + // Filter: include node if it has no preference labels, or if site shares at least one label + const filtered = remoteExitNodesList.filter((node) => { + const remoteId = exitNodeIdToRemoteId.get(node.exitNodeId); + if (!remoteId) return true; // no remoteExitNode record, don't filter + const prefLabels = prefLabelsMap.get(remoteId); + if (!prefLabels || prefLabels.size === 0) return true; // no preference labels, include + // include only if site has at least one matching label + for (const labelId of siteLabelIds) { + if (prefLabels.has(labelId)) return true; + } + return false; + }); + + // Only apply the filtered list if at least one remote node remains; + // otherwise fall through to the gerbil fallback below + if (filtered.length > 0 || remoteExitNodesList.length === 0) { + remoteExitNodesList = filtered; + } + } + // THIS PROVIDES THE FALL const exitNodesList = - remoteExitNodes.length > 0 ? remoteExitNodes : gerbilExitNodes; + remoteExitNodesList.length > 0 ? remoteExitNodesList : gerbilExitNodes; return exitNodesList; } diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index ac18861ca..c6ee3de55 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -329,6 +329,44 @@ 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.get( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.getRemoteExitNode), + remoteExitNode.listRemoteExitNodePreferenceLabels +); + +authenticated.post( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.updateRemoteExitNode), + logActionAudit(ActionsEnum.updateRemoteExitNode), + remoteExitNode.setRemoteExitNodePreferenceLabels +); + authenticated.put( "/org/:orgId/login-page", verifyValidLicense, diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index 953ccba88..244ef17de 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -23,3 +23,7 @@ export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; export * from "./offlineChecker"; export * from "./exitNodeReconnectScheduler"; +export * from "./listRemoteExitNodeResources"; +export * from "./setRemoteExitNodeResources"; +export * from "./listRemoteExitNodePreferenceLabels"; +export * from "./setRemoteExitNodePreferenceLabels"; diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts b/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts new file mode 100644 index 000000000..47ff11c48 --- /dev/null +++ b/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts @@ -0,0 +1,110 @@ +/* + * 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, + labels, + remoteExitNodePreferenceLabels, + 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 ListRemoteExitNodePreferenceLabelsResponse = { + labels: { + remoteExitNodePreferenceLabelId: number; + labelId: number; + name: string; + color: string; + }[]; +}; + +export async function listRemoteExitNodePreferenceLabels( + 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 rows = await db + .select({ + remoteExitNodePreferenceLabelId: + remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId, + labelId: remoteExitNodePreferenceLabels.labelId, + name: labels.name, + color: labels.color + }) + .from(remoteExitNodePreferenceLabels) + .innerJoin( + labels, + eq(labels.labelId, remoteExitNodePreferenceLabels.labelId) + ) + .where( + eq( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeId + ) + ); + + return response(res, { + data: { labels: rows }, + success: true, + error: false, + message: + "Remote exit node preference labels 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/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/setRemoteExitNodePreferenceLabels.ts b/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts new file mode 100644 index 000000000..3185e5a99 --- /dev/null +++ b/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts @@ -0,0 +1,168 @@ +/* + * 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, + labels, + remoteExitNodePreferenceLabels, + remoteExitNodes +} from "@server/db"; +import { and, eq, inArray } 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 bodySchema = z.strictObject({ + labelIds: z.array(z.number().int().positive()) +}); + +export type SetRemoteExitNodePreferenceLabelsBody = z.infer; + +export type SetRemoteExitNodePreferenceLabelsResponse = { + labels: { + remoteExitNodePreferenceLabelId: number; + labelId: number; + name: string; + color: string; + }[]; +}; + +export async function setRemoteExitNodePreferenceLabels( + 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 { orgId, remoteExitNodeId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { labelIds } = 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` + ) + ); + } + + // Validate all provided labelIds belong to this org + if (labelIds.length > 0) { + const existingLabels = await db + .select({ labelId: labels.labelId }) + .from(labels) + .where( + and( + eq(labels.orgId, orgId), + inArray(labels.labelId, labelIds) + ) + ); + + if (existingLabels.length !== labelIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more label IDs are invalid or do not belong to this organization" + ) + ); + } + } + + // Replace all preference labels atomically + await db + .delete(remoteExitNodePreferenceLabels) + .where( + eq( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeId + ) + ); + + if (labelIds.length > 0) { + await db.insert(remoteExitNodePreferenceLabels).values( + labelIds.map((labelId) => ({ + remoteExitNodeId, + labelId + })) + ); + } + + const rows = await db + .select({ + remoteExitNodePreferenceLabelId: + remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId, + labelId: remoteExitNodePreferenceLabels.labelId, + name: labels.name, + color: labels.color + }) + .from(remoteExitNodePreferenceLabels) + .innerJoin( + labels, + eq(labels.labelId, remoteExitNodePreferenceLabels.labelId) + ) + .where( + eq( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeId + ) + ); + + return response(res, { + data: { labels: rows }, + success: true, + error: false, + message: "Remote exit node preference labels updated 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..8416f0169 --- /dev/null +++ b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts @@ -0,0 +1,155 @@ +/* + * 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, + newts, + remoteExitNodeResources, + remoteExitNodes, + sites +} 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"; +import { sendToClientsBatch } from "#private/routers/ws"; + +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) + ); + + // Notify all newts connected to this remote exit node's exit node + if (remoteExitNode.exitNodeId) { + const connectedNewts = await db + .select({ newtId: newts.newtId }) + .from(newts) + .innerJoin(sites, eq(newts.siteId, sites.siteId)) + .where(eq(sites.exitNodeId, remoteExitNode.exitNodeId)); + + await sendToClientsBatch( + connectedNewts.map(({ newtId }) => ({ + clientId: newtId, + message: { + type: "newt/wg/subnets/update", + data: { subnets: destinations } + } + })) + ); + } + + 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/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 154ab38cb..5083a6c56 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -5,6 +5,7 @@ import { db, ExitNode, networks, + remoteExitNodeResources, resources, Site, siteNetworks, @@ -223,7 +224,8 @@ export async function buildClientConfigurationForNewtClient( export async function buildTargetConfigurationForNewtClient( siteId: number, - version?: string | null + version?: string | null, + remoteExitNodeId?: string ) { // Get all enabled targets with their resource mode information const allTargets = await db @@ -379,10 +381,24 @@ export async function buildTargetConfigurationForNewtClient( }; }); + let remoteExitNodeSubnets: string[] = []; + if (remoteExitNodeId) { + const remoteNodeResources = await db + .select() + .from(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + // filter through these and provide the subnets + remoteExitNodeSubnets = remoteNodeResources.map((r) => r.destination); + } + return { validHealthCheckTargets, tcpTargets, udpTargets, - browserGatewayTargets + browserGatewayTargets, + remoteExitNodeSubnets }; } diff --git a/server/routers/newt/handleNewtPingRequestMessage.ts b/server/routers/newt/handleNewtPingRequestMessage.ts index 8f6df4bec..f239dc4de 100644 --- a/server/routers/newt/handleNewtPingRequestMessage.ts +++ b/server/routers/newt/handleNewtPingRequestMessage.ts @@ -38,7 +38,8 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => { const exitNodesList = await listExitNodes( site.orgId, true, - noCloud || false + noCloud || false, + newt.siteId ); // filter for only the online ones let lastExitNodeId = null; diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index bd4aaacb3..0dc8380c8 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -1,4 +1,4 @@ -import { db, ExitNode, newts, Transaction } from "@server/db"; +import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { exitNodes, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; @@ -196,12 +196,29 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .where(eq(newts.newtId, newt.newtId)); } + let remoteExitNodeId: string | undefined; + if (exitNode.type == "remoteExitNode") { + // get the remote exit node ID associated with this exit node + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.exitNodeId, exitNode.exitNodeId)) + .limit(1); + + remoteExitNodeId = remoteExitNode?.remoteExitNodeId; + } + const { tcpTargets, udpTargets, validHealthCheckTargets, - browserGatewayTargets - } = await buildTargetConfigurationForNewtClient(siteId, newtVersion); + browserGatewayTargets, + remoteExitNodeSubnets + } = await buildTargetConfigurationForNewtClient( + siteId, + newtVersion, + remoteExitNodeId // this is for the remote node resources + ); logger.debug( `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` @@ -222,6 +239,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { }, healthCheckTargets: validHealthCheckTargets, browserGatewayTargets: browserGatewayTargets, + remoteExitNodeSubnets: remoteExitNodeSubnets, chainId: chainId } }, 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..a0da030ff --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useEffect, useMemo, 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 { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input"; +import type { TagValue } from "@app/components/multi-select/multi-select-content"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useDebounce } from "use-debounce"; +import type { ListRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/listRemoteExitNodeResources"; +import type { SetRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodeResources"; +import type { ListRemoteExitNodePreferenceLabelsResponse } from "@server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels"; +import type { SetRemoteExitNodePreferenceLabelsResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels"; + +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(); + + // Subnets state + const [subnets, setSubnets] = useState([]); + const [activeTagIndex, setActiveTagIndex] = useState(null); + const [loadingSubnets, setLoadingSubnets] = useState(true); + const [savingSubnets, setSavingSubnets] = useState(false); + + // Labels state + const [selectedLabels, setSelectedLabels] = useState([]); + const [labelSearchQuery, setLabelSearchQuery] = useState(""); + const [loadingLabels, setLoadingLabels] = useState(true); + const [savingLabels, setSavingLabels] = useState(false); + + const [debouncedLabelQuery] = useDebounce(labelSearchQuery, 150); + + const { data: availableLabels = [] } = useQuery( + orgQueries.labels({ orgId, query: debouncedLabelQuery, perPage: 10 }) + ); + + const labelsShown = useMemo(() => { + const base: TagValue[] = availableLabels.map((l) => ({ + id: l.labelId.toString(), + text: l.name, + color: l.color + })); + if (debouncedLabelQuery.trim().length === 0) { + for (const sel of selectedLabels) { + if (!base.find((b) => b.id === sel.id)) { + base.unshift(sel); + } + } + } + return base; + }, [availableLabels, selectedLabels, debouncedLabelQuery]); + + useEffect(() => { + async function loadSubnets() { + try { + const res = await api.get< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources` + ); + setSubnets( + res.data.data.resources.map((r) => ({ + id: r.destination, + text: r.destination + })) + ); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: + formatAxiosError(error) || "Failed to load subnets" + }); + } finally { + setLoadingSubnets(false); + } + } + + async function loadLabels() { + try { + const res = await api.get< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels` + ); + setSelectedLabels( + res.data.data.labels.map((l) => ({ + id: l.labelId.toString(), + text: l.name, + color: l.color + })) + ); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: + formatAxiosError(error) || "Failed to load labels" + }); + } finally { + setLoadingLabels(false); + } + } + + loadSubnets(); + loadLabels(); + }, [remoteExitNode.remoteExitNodeId]); + + const handleSaveSubnets = async () => { + setSavingSubnets(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 { + setSavingSubnets(false); + } + }; + + const handleSaveLabels = async () => { + setSavingLabels(true); + try { + await api.post< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels`, + { labelIds: selectedLabels.map((l) => parseInt(l.id)) } + ); + toast({ + title: "Labels saved", + description: "Preference labels have been updated successfully." + }); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: formatAxiosError(error) || "Failed to save labels" + }); + } finally { + setSavingLabels(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={loadingSubnets} + allowDuplicates={false} + inlineTags={true} + /> + + + + + + + + + + Preference Labels + + + Sites with these labels will be enforced to connect + through this remote exit node. + + + + + + + + + + + ); +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx index a368ec687..4df0f0797 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx @@ -10,6 +10,6 @@ export default async function RemoteExitNodePage(props: { }) { const params = await props.params; redirect( - `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/credentials` + `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/networking` ); } 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"; diff --git a/src/components/multi-select/multi-select-content.tsx b/src/components/multi-select/multi-select-content.tsx index 15b23827f..659e16d5c 100644 --- a/src/components/multi-select/multi-select-content.tsx +++ b/src/components/multi-select/multi-select-content.tsx @@ -12,7 +12,12 @@ import { CheckIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { Checkbox } from "../ui/checkbox"; -export type TagValue = { text: string; id: string; isAdmin?: boolean }; +export type TagValue = { + text: string; + id: string; + isAdmin?: boolean; + color?: string; +}; export type MultiSelectTagsProps = { emptyPlaceholder?: string; @@ -77,6 +82,14 @@ export function MultiSelectContent({ aria-hidden tabIndex={-1} /> + {option.color && ( + + )} {`${option.text}`} ); diff --git a/src/components/multi-select/multi-select-tag-input.tsx b/src/components/multi-select/multi-select-tag-input.tsx index bde1a9b05..dc75311c2 100644 --- a/src/components/multi-select/multi-select-tag-input.tsx +++ b/src/components/multi-select/multi-select-tag-input.tsx @@ -66,7 +66,17 @@ export function MultiSelectTagInput({ )} onClick={(e) => e.stopPropagation()} > - {option.text} + {option.color && ( + + )} + + {option.text} + {isLocked ? (