diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 848e3fb98..0b028b42c 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, @@ -24,7 +25,8 @@ import { targetHealthCheck, sites, clients, - sessions + sessions, + labels } from "./schema"; export const certificates = pgTable("certificates", { @@ -207,6 +209,32 @@ export const remoteExitNodeResources = pgTable("remoteExitNodeResources", { 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(), + remoteExitNode: integer("remoteExitNode") + .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.remoteExitNode, + 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 d5fe7135a..9a49daf63 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, @@ -21,9 +22,6 @@ import { targetHealthCheck, users } from "./schema"; -import { serial, varchar } from "drizzle-orm/mysql-core"; -import { pgTable } from "drizzle-orm/pg-core"; -import { bigint } from "zod"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -207,6 +205,32 @@ export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", { 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/private/routers/external.ts b/server/private/routers/external.ts index 31ee163fb..e381fb618 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -349,6 +349,25 @@ authenticated.post( 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 674a663ed..254cbd5b5 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -24,3 +24,5 @@ export * from "./quickStartRemoteExitNode"; export * from "./offlineChecker"; 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/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/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 index bf220714b..a0da030ff 100644 --- 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 @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { SettingsContainer, SettingsSection, @@ -18,8 +18,15 @@ 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]))$/; @@ -33,22 +40,50 @@ export default function NetworkingPage() { }>(); const { remoteExitNode } = useRemoteExitNodeContext(); + // Subnets state const [subnets, setSubnets] = useState([]); const [activeTagIndex, setActiveTagIndex] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); + 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 loadResources() { + async function loadSubnets() { 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) => ({ + res.data.data.resources.map((r) => ({ id: r.destination, text: r.destination })) @@ -61,21 +96,46 @@ export default function NetworkingPage() { formatAxiosError(error) || "Failed to load subnets" }); } finally { - setLoading(false); + setLoadingSubnets(false); } } - loadResources(); + 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 handleSave = async () => { - setSaving(true); + const handleSaveSubnets = async () => { + setSavingSubnets(true); try { await api.post>( `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`, - { - destinations: subnets.map((s) => s.text) - } + { destinations: subnets.map((s) => s.text) } ); toast({ title: "Subnets saved", @@ -88,7 +148,31 @@ export default function NetworkingPage() { description: formatAxiosError(error) || "Failed to save subnets" }); } finally { - setSaving(false); + 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); } }; @@ -111,17 +195,46 @@ export default function NetworkingPage() { validateTag={(tag) => cidrRegex.test(tag.trim())} activeTagIndex={activeTagIndex} setActiveTagIndex={setActiveTagIndex} - disabled={loading} + 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/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 cb0009a11..2c2cf770e 100644 --- a/src/components/multi-select/multi-select-tag-input.tsx +++ b/src/components/multi-select/multi-select-tag-input.tsx @@ -66,6 +66,14 @@ export function MultiSelectTagInput({ )} onClick={(e) => e.stopPropagation()} > + {option.color && ( + + )} {option.text}