mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-06 15:50:42 +00:00
Add basic resources input on the remote node
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user