diff --git a/server/db/schema.ts b/server/db/schema.ts index ac213f7f..01bb2fdf 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -52,10 +52,11 @@ export const targets = sqliteTable("targets", { export const exitNodes = sqliteTable("exitNodes", { exitNodeId: integer("exitNodeId").primaryKey({ autoIncrement: true }), name: text("name").notNull(), - address: text("address").notNull(), - endpoint: text("endpoint").notNull(), + address: text("address").notNull(), // this is the address of the wireguard interface in gerbil + endpoint: text("endpoint").notNull(), // this is how to reach gerbil externally - gets put into the wireguard config publicKey: text("pubicKey").notNull(), listenPort: integer("listenPort").notNull(), + reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control }); export const users = sqliteTable("user", { diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index 808f593e..b1f68e36 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -13,6 +13,7 @@ import { findNextAvailableCidr } from "@server/utils/ip"; // Define Zod schema for request validation const getConfigSchema = z.object({ publicKey: z.string(), + reachableAt: z.string().optional(), }); export type GetConfigResponse = { @@ -37,7 +38,7 @@ export async function getConfig(req: Request, res: Response, next: NextFunction) ); } - const { publicKey } = parsedParams.data; + const { publicKey, reachableAt } = parsedParams.data; if (!publicKey) { return next(createHttpError(HttpCode.BAD_REQUEST, 'publicKey is required')); @@ -57,6 +58,7 @@ export async function getConfig(req: Request, res: Response, next: NextFunction) endpoint: `${subEndpoint}.${config.gerbil.base_endpoint}`, address, listenPort, + reachableAt, name: `Exit Node ${publicKey.slice(0, 8)}`, }).returning().execute(); diff --git a/server/routers/gerbil/peers.ts b/server/routers/gerbil/peers.ts new file mode 100644 index 00000000..525dced5 --- /dev/null +++ b/server/routers/gerbil/peers.ts @@ -0,0 +1,57 @@ +import axios from 'axios'; +import logger from '@server/logger'; +import db from '@server/db'; +import { exitNodes } from '@server/db/schema'; +import { eq } from 'drizzle-orm'; + +export async function addPeer(exitNodeId: number, peer: { + publicKey: string; + allowedIps: string[]; +}) { + + const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1); + if (!exitNode) { + throw new Error(`Exit node with ID ${exitNodeId} not found`); + } + if (!exitNode.reachableAt) { + throw new Error(`Exit node with ID ${exitNodeId} is not reachable`); + } + + try { + const response = await axios.post(`${exitNode.reachableAt}/peer`, peer, { + headers: { + 'Content-Type': 'application/json', + } + }); + + logger.info('Peer added successfully:', response.data.status); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error(`HTTP error! status: ${error.response?.status}`); + } + throw error; + } +} + +export async function deletePeer(exitNodeId: number, publicKey: string) { + const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1); + if (!exitNode) { + throw new Error(`Exit node with ID ${exitNodeId} not found`); + } + if (!exitNode.reachableAt) { + throw new Error(`Exit node with ID ${exitNodeId} is not reachable`); + } + try { + const response = await axios.delete(`${exitNode.reachableAt}/peer`, { + data: { publicKey } // Send public key in request body + }); + logger.info('Peer deleted successfully:', response.data.status); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error(`HTTP error! status: ${error.response?.status}`); + } + throw error; + } +} \ No newline at end of file diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 27467dba..ee8759ea 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -1,15 +1,15 @@ import { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import { db } from '@server/db'; -import { roles, userSites, sites, roleSites } from '@server/db/schema'; +import { roles, userSites, sites, roleSites, exitNodes } from '@server/db/schema'; import response from "@server/utils/response"; import HttpCode from '@server/types/HttpCode'; import createHttpError from 'http-errors'; -import fetch from 'node-fetch'; import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import logger from '@server/logger'; import { eq, and } from 'drizzle-orm'; import { getUniqueSiteName } from '@server/db/names'; +import { addPeer } from '../gerbil/peers'; const API_BASE_URL = "http://localhost:3000"; @@ -113,6 +113,12 @@ export async function createSite(req: Request, res: Response, next: NextFunction }); } + // Add the peer to the exit node + await addPeer(exitNodeId, { + publicKey: pubKey, + allowedIps: [], + }); + return response(res, { data: { name: newSite.name, @@ -128,31 +134,7 @@ export async function createSite(req: Request, res: Response, next: NextFunction status: HttpCode.CREATED, }); } catch (error) { - throw error; + logger.error(error); return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred...")); } -} - - -async function addPeer(peer: string) { - try { - const response = await fetch(`${API_BASE_URL}/peer`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(peer), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data: any = await response.json(); - logger.info('Peer added successfully:', data.status); - return data; - } catch (error: any) { - throw error; - } -} - +} \ No newline at end of file diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 3a0acfa5..399e7d0d 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -8,6 +8,7 @@ import HttpCode from '@server/types/HttpCode'; import createHttpError from 'http-errors'; import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import logger from '@server/logger'; +import { deletePeer } from '../gerbil/peers'; const API_BASE_URL = "http://localhost:3000"; @@ -38,11 +39,11 @@ export async function deleteSite(req: Request, res: Response, next: NextFunction } // Delete the site from the database - const deletedSite = await db.delete(sites) + const [deletedSite] = await db.delete(sites) .where(eq(sites.siteId, siteId)) .returning(); - if (deletedSite.length === 0) { + if (!deletedSite) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -51,6 +52,8 @@ export async function deleteSite(req: Request, res: Response, next: NextFunction ); } + await deletePeer(deletedSite.exitNodeId!, deletedSite.pubKey); + return response(res, { data: null, success: true, diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index f776e3b1..58344219 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -1,12 +1,15 @@ import { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import { db } from '@server/db'; -import { targets } from '@server/db/schema'; +import { resources, sites, targets } from '@server/db/schema'; import response from "@server/utils/response"; import HttpCode from '@server/types/HttpCode'; import createHttpError from 'http-errors'; import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import logger from '@server/logger'; +import { addPeer } from '../gerbil/peers'; +import { eq, and } from 'drizzle-orm'; +import { isIpInCidr } from '@server/utils/ip'; const createTargetParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.number().int().positive()), @@ -52,11 +55,72 @@ export async function createTarget(req: Request, res: Response, next: NextFuncti return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have permission to perform this action')); } + // get the resource + const [resource] = await db.select({ + siteId: resources.siteId, + }) + .from(resources) + .where(eq(resources.resourceId, resourceId)); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + // TODO: is this all inefficient? + + // get the site + const [site] = await db.select() + .from(sites) + .where(eq(sites.siteId, resource.siteId!)) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${resource.siteId} not found` + ) + ); + } + + // make sure the target is within the site subnet + if (!isIpInCidr(targetData.ip, site.subnet!)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Target IP is not within the site subnet` + ) + ); + } + const newTarget = await db.insert(targets).values({ resourceId, ...targetData }).returning(); + // Fetch resources for this site + const resourcesRes = await db.query.resources.findMany({ + where: eq(resources.siteId, site.siteId), + }); + + // Fetch targets for all resources of this site + const targetIps = await Promise.all(resourcesRes.map(async (resource) => { + const targetsRes = await db.query.targets.findMany({ + where: eq(targets.resourceId, resource.resourceId), + }); + return targetsRes.map(target => `${target.ip}/32`); + })); + + await addPeer(site.exitNodeId!, { + publicKey: site.pubKey, + allowedIps: targetIps.flat() + }); + return response(res, { data: newTarget[0], success: true, diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 40f9af2b..f74b7bbe 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -1,13 +1,14 @@ import { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import { db } from '@server/db'; -import { targets } from '@server/db/schema'; +import { resources, sites, targets } from '@server/db/schema'; import { eq } from 'drizzle-orm'; import response from "@server/utils/response"; import HttpCode from '@server/types/HttpCode'; import createHttpError from 'http-errors'; import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import logger from '@server/logger'; +import { addPeer } from '../gerbil/peers'; const deleteTargetSchema = z.object({ targetId: z.string().transform(Number).pipe(z.number().int().positive()) @@ -33,11 +34,12 @@ export async function deleteTarget(req: Request, res: Response, next: NextFuncti return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have permission to perform this action')); } - const deletedTarget = await db.delete(targets) + + const [deletedTarget] = await db.delete(targets) .where(eq(targets.targetId, targetId)) .returning(); - if (deletedTarget.length === 0) { + if (!deletedTarget) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -45,6 +47,56 @@ export async function deleteTarget(req: Request, res: Response, next: NextFuncti ) ); } + // get the resource + const [resource] = await db.select({ + siteId: resources.siteId, + }) + .from(resources) + .where(eq(resources.resourceId, deletedTarget.resourceId!)); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${deletedTarget.resourceId} not found` + ) + ); + } + + // TODO: is this all inefficient? + + // get the site + const [site] = await db.select() + .from(sites) + .where(eq(sites.siteId, resource.siteId!)) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${resource.siteId} not found` + ) + ); + } + + // Fetch resources for this site + const resourcesRes = await db.query.resources.findMany({ + where: eq(resources.siteId, site.siteId), + }); + + // Fetch targets for all resources of this site + const targetIps = await Promise.all(resourcesRes.map(async (resource) => { + const targetsRes = await db.query.targets.findMany({ + where: eq(targets.resourceId, resource.resourceId), + }); + return targetsRes.map(target => `${target.ip}/32`); + })); + + await addPeer(site.exitNodeId!, { + publicKey: site.pubKey, + allowedIps: targetIps.flat() + }); return response(res, { data: null, diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index d0486995..a02e11e3 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -14,7 +14,7 @@ const updateTargetParamsSchema = z.object({ }); const updateTargetBodySchema = z.object({ - ip: z.string().ip().optional(), + // ip: z.string().ip().optional(), // for now we cant update the ip; you will have to delete method: z.string().min(1).max(10).optional(), port: z.number().int().min(1).max(65535).optional(), protocol: z.string().optional(), diff --git a/server/utils/ip.ts b/server/utils/ip.ts index 9e48ee0f..88c64acc 100644 --- a/server/utils/ip.ts +++ b/server/utils/ip.ts @@ -1,32 +1,32 @@ interface IPRange { start: bigint; end: bigint; - } - - /** - * Converts IP address string to BigInt for numerical operations - */ - function ipToBigInt(ip: string): bigint { +} + +/** + * Converts IP address string to BigInt for numerical operations + */ +function ipToBigInt(ip: string): bigint { return ip.split('.') - .reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0)); - } - - /** - * Converts BigInt to IP address string - */ - function bigIntToIp(num: bigint): string { + .reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0)); +} + +/** + * Converts BigInt to IP address string + */ +function bigIntToIp(num: bigint): string { const octets: number[] = []; for (let i = 0; i < 4; i++) { - octets.unshift(Number(num & BigInt(255))); - num = num >> BigInt(8); + octets.unshift(Number(num & BigInt(255))); + num = num >> BigInt(8); } return octets.join('.'); - } - - /** - * Converts CIDR to IP range - */ - function cidrToRange(cidr: string): IPRange { +} + +/** + * Converts CIDR to IP range + */ +function cidrToRange(cidr: string): IPRange { const [ip, prefix] = cidr.split('/'); const prefixBits = parseInt(prefix); const ipBigInt = ipToBigInt(ip); @@ -34,52 +34,64 @@ interface IPRange { const start = ipBigInt & ~mask; const end = start | mask; return { start, end }; - } - - /** - * Finds the next available CIDR block given existing allocations - * @param existingCidrs Array of existing CIDR blocks - * @param blockSize Desired prefix length for the new block (e.g., 24 for /24) - * @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0") - * @returns Next available CIDR block or null if none found - */ - export function findNextAvailableCidr( +} + +/** + * Finds the next available CIDR block given existing allocations + * @param existingCidrs Array of existing CIDR blocks + * @param blockSize Desired prefix length for the new block (e.g., 24 for /24) + * @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0") + * @returns Next available CIDR block or null if none found + */ +export function findNextAvailableCidr( existingCidrs: string[], blockSize: number, startCidr: string = "0.0.0.0/0" - ): string | null { +): string | null { // Convert existing CIDRs to ranges and sort them const existingRanges = existingCidrs - .map(cidr => cidrToRange(cidr)) - .sort((a, b) => (a.start < b.start ? -1 : 1)); - + .map(cidr => cidrToRange(cidr)) + .sort((a, b) => (a.start < b.start ? -1 : 1)); + // Calculate block size const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize); - + // Start from the beginning of the given CIDR let current = cidrToRange(startCidr).start; const maxIp = cidrToRange(startCidr).end; - + // Iterate through existing ranges for (let i = 0; i <= existingRanges.length; i++) { - const nextRange = existingRanges[i]; - - // Align current to block size - const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt); - - // Check if we've gone beyond the maximum allowed IP - if (alignedCurrent + blockSizeBigInt - BigInt(1) > maxIp) { - return null; - } - - // If we're at the end of existing ranges or found a gap - if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) { - return `${bigIntToIp(alignedCurrent)}/${blockSize}`; - } - - // Move current pointer to after the current range - current = nextRange.end + BigInt(1); + const nextRange = existingRanges[i]; + + // Align current to block size + const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt); + + // Check if we've gone beyond the maximum allowed IP + if (alignedCurrent + blockSizeBigInt - BigInt(1) > maxIp) { + return null; + } + + // If we're at the end of existing ranges or found a gap + if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) { + return `${bigIntToIp(alignedCurrent)}/${blockSize}`; + } + + // Move current pointer to after the current range + current = nextRange.end + BigInt(1); } - + return null; - } \ No newline at end of file +} + +/** +* Checks if a given IP address is within a CIDR range +* @param ip IP address to check +* @param cidr CIDR range to check against +* @returns boolean indicating if IP is within the CIDR range +*/ +export function isIpInCidr(ip: string, cidr: string): boolean { + const ipBigInt = ipToBigInt(ip); + const range = cidrToRange(cidr); + return ipBigInt >= range.start && ipBigInt <= range.end; +} \ No newline at end of file diff --git a/src/app/[orgId]/sites/[niceId]/layout.tsx b/src/app/[orgId]/sites/[niceId]/layout.tsx index 670b05b4..fe151a4f 100644 --- a/src/app/[orgId]/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/sites/[niceId]/layout.tsx @@ -1,8 +1,5 @@ -import { Metadata } from "next"; import Image from "next/image"; -import { Separator } from "@/components/ui/separator"; -import { SidebarNav } from "@/components/sidebar-nav"; import SiteProvider from "@app/providers/SiteProvider"; import { internal } from "@app/api"; import { GetSiteResponse } from "@server/routers/site"; @@ -10,9 +7,6 @@ import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/api/cookies"; import Link from "next/link"; -import { ArrowLeft, ChevronLeft } from "lucide-react"; -import { useEffect, useState } from "react"; -import { toast } from "@app/hooks/use-toast"; import { ClientLayout } from "./components/ClientLayout"; // export const metadata: Metadata = { @@ -75,10 +69,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { + isCreate={params.niceId === "create"}> {children} - + + ); }