Add basic resources input on the remote node

This commit is contained in:
Owen
2026-05-29 11:33:48 -07:00
parent e408e735be
commit 726deb4152
10 changed files with 413 additions and 15 deletions

View File

@@ -19,12 +19,12 @@ import {
roles, roles,
users, users,
exitNodes, exitNodes,
sessions,
clients,
resources, resources,
siteResources, siteResources,
targetHealthCheck, targetHealthCheck,
sites sites,
clients,
sessions
} from "./schema"; } from "./schema";
export const certificates = pgTable("certificates", { 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", { export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
sessionId: varchar("id").primaryKey(), sessionId: varchar("id").primaryKey(),
remoteExitNodeId: varchar("remoteExitNodeId") remoteExitNodeId: varchar("remoteExitNodeId")

View File

@@ -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", { export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
sessionId: text("id").primaryKey(), sessionId: text("id").primaryKey(),
remoteExitNodeId: text("remoteExitNodeId") remoteExitNodeId: text("remoteExitNodeId")

View File

@@ -22,7 +22,6 @@ export function noop() {
} }
export class UsageService { export class UsageService {
constructor() { constructor() {
if (noop()) { if (noop()) {
return; return;
@@ -57,7 +56,10 @@ export class UsageService {
try { try {
let usage; let usage;
if (transaction) { if (transaction) {
const orgIdToUse = await this.getBillingOrg(orgId, transaction); const orgIdToUse = await this.getBillingOrg(
orgId,
transaction
);
usage = await this.internalAddUsage( usage = await this.internalAddUsage(
orgIdToUse, orgIdToUse,
featureId, featureId,
@@ -274,11 +276,12 @@ export class UsageService {
return null; return null;
} }
const orgIdToUse = await this.getBillingOrg(orgId, trx); let orgIdToUse = orgId;
try {
orgIdToUse = await this.getBillingOrg(orgId, trx);
const usageId = `${orgIdToUse}-${featureId}`; const usageId = `${orgIdToUse}-${featureId}`;
try {
const [result] = await trx const [result] = await trx
.select() .select()
.from(usage) .from(usage)
@@ -338,10 +341,14 @@ export class UsageService {
`Failed to get usage for ${orgIdToUse}/${featureId}:`, `Failed to get usage for ${orgIdToUse}/${featureId}:`,
error error
); );
if (process.env.NODE_ENV !== "development") {
throw error; throw error;
} }
} }
return null;
}
public async getBillingOrg( public async getBillingOrg(
orgId: string, orgId: string,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
@@ -382,13 +389,13 @@ export class UsageService {
return false; return false;
} }
const orgIdToUse = await this.getBillingOrg(orgId, trx);
// This method should check the current usage against the limits set for the organization // This method should check the current usage against the limits set for the organization
// and kick out all of the sites on the org // and kick out all of the sites on the org
let hasExceededLimits = false; let hasExceededLimits = false;
let orgIdToUse = orgId;
try { try {
orgIdToUse = await this.getBillingOrg(orgId, trx);
let orgLimits: Limit[] = []; let orgLimits: Limit[] = [];
if (featureId) { if (featureId) {
// Get all limits set for this organization // Get all limits set for this organization

View File

@@ -330,6 +330,25 @@ authenticated.delete(
remoteExitNode.deleteRemoteExitNode 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( authenticated.put(
"/org/:orgId/login-page", "/org/:orgId/login-page",
verifyValidLicense, verifyValidLicense,

View File

@@ -22,3 +22,5 @@ export * from "./listRemoteExitNodes";
export * from "./pickRemoteExitNodeDefaults"; export * from "./pickRemoteExitNodeDefaults";
export * from "./quickStartRemoteExitNode"; export * from "./quickStartRemoteExitNode";
export * from "./offlineChecker"; export * from "./offlineChecker";
export * from "./listRemoteExitNodeResources";
export * from "./setRemoteExitNodeResources";

View File

@@ -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<any> {
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<ListRemoteExitNodeResourcesResponse>(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")
);
}
}

View File

@@ -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<typeof bodySchema>;
export type SetRemoteExitNodeResourcesResponse = {
resources: {
remoteExitNodeResourceId: number;
remoteExitNodeId: string;
destination: string;
}[];
};
export async function setRemoteExitNodeResources(
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 { 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<SetRemoteExitNodeResourcesResponse>(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")
);
}
}

View File

@@ -34,6 +34,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const t = await getTranslations(); const t = await getTranslations();
const navItems = [ const navItems = [
{
title: "Networking",
href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/networking"
},
{ {
title: t("credentials"), title: t("credentials"),
href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials" href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials"

View File

@@ -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<Tag[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
async function loadResources() {
try {
const res = await api.get<
AxiosResponse<ListRemoteExitNodeResourcesResponse>
>(
`/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<AxiosResponse<SetRemoteExitNodeResourcesResponse>>(
`/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 (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Remote Subnets</SettingsSectionTitle>
<SettingsSectionDescription>
Define the CIDR ranges that this remote exit node will
route traffic to. Type a valid CIDR (e.g.{" "}
<code>10.0.0.0/8</code>) and press Enter to add.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<TagInput
tags={subnets}
setTags={setSubnets}
placeholder="Add a CIDR range (e.g. 10.0.0.0/8)"
validateTag={(tag) => cidrRegex.test(tag.trim())}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
disabled={loading}
allowDuplicates={false}
inlineTags={true}
/>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button onClick={handleSave} loading={saving}>
Save Subnets
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -35,8 +35,6 @@ import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; 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 HeaderTitle from "@app/components/SettingsSectionTitle";
import { StrategySelect } from "@app/components/StrategySelect"; import { StrategySelect } from "@app/components/StrategySelect";