This commit is contained in:
Milo Schwartz 2024-10-26 23:37:33 -04:00
commit 8a8c42e3b1
No known key found for this signature in database
10 changed files with 270 additions and 103 deletions

View file

@ -52,10 +52,11 @@ export const targets = sqliteTable("targets", {
export const exitNodes = sqliteTable("exitNodes", { export const exitNodes = sqliteTable("exitNodes", {
exitNodeId: integer("exitNodeId").primaryKey({ autoIncrement: true }), exitNodeId: integer("exitNodeId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").notNull(),
address: text("address").notNull(), address: text("address").notNull(), // this is the address of the wireguard interface in gerbil
endpoint: text("endpoint").notNull(), endpoint: text("endpoint").notNull(), // this is how to reach gerbil externally - gets put into the wireguard config
publicKey: text("pubicKey").notNull(), publicKey: text("pubicKey").notNull(),
listenPort: integer("listenPort").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", { export const users = sqliteTable("user", {

View file

@ -13,6 +13,7 @@ import { findNextAvailableCidr } from "@server/utils/ip";
// Define Zod schema for request validation // Define Zod schema for request validation
const getConfigSchema = z.object({ const getConfigSchema = z.object({
publicKey: z.string(), publicKey: z.string(),
reachableAt: z.string().optional(),
}); });
export type GetConfigResponse = { 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) { if (!publicKey) {
return next(createHttpError(HttpCode.BAD_REQUEST, 'publicKey is required')); 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}`, endpoint: `${subEndpoint}.${config.gerbil.base_endpoint}`,
address, address,
listenPort, listenPort,
reachableAt,
name: `Exit Node ${publicKey.slice(0, 8)}`, name: `Exit Node ${publicKey.slice(0, 8)}`,
}).returning().execute(); }).returning().execute();

View file

@ -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;
}
}

View file

@ -1,15 +1,15 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@server/db'; 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 response from "@server/utils/response";
import HttpCode from '@server/types/HttpCode'; import HttpCode from '@server/types/HttpCode';
import createHttpError from 'http-errors'; import createHttpError from 'http-errors';
import fetch from 'node-fetch';
import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions';
import logger from '@server/logger'; import logger from '@server/logger';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { getUniqueSiteName } from '@server/db/names'; import { getUniqueSiteName } from '@server/db/names';
import { addPeer } from '../gerbil/peers';
const API_BASE_URL = "http://localhost:3000"; 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, { return response(res, {
data: { data: {
name: newSite.name, name: newSite.name,
@ -128,31 +134,7 @@ export async function createSite(req: Request, res: Response, next: NextFunction
status: HttpCode.CREATED, status: HttpCode.CREATED,
}); });
} catch (error) { } catch (error) {
throw error; logger.error(error);
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred...")); 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;
}
}

View file

@ -8,6 +8,7 @@ import HttpCode from '@server/types/HttpCode';
import createHttpError from 'http-errors'; import createHttpError from 'http-errors';
import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions';
import logger from '@server/logger'; import logger from '@server/logger';
import { deletePeer } from '../gerbil/peers';
const API_BASE_URL = "http://localhost:3000"; 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 // Delete the site from the database
const deletedSite = await db.delete(sites) const [deletedSite] = await db.delete(sites)
.where(eq(sites.siteId, siteId)) .where(eq(sites.siteId, siteId))
.returning(); .returning();
if (deletedSite.length === 0) { if (!deletedSite) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, 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, { return response(res, {
data: null, data: null,
success: true, success: true,

View file

@ -1,12 +1,15 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@server/db'; 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 response from "@server/utils/response";
import HttpCode from '@server/types/HttpCode'; import HttpCode from '@server/types/HttpCode';
import createHttpError from 'http-errors'; import createHttpError from 'http-errors';
import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions';
import logger from '@server/logger'; 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({ const createTargetParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()), 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')); 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({ const newTarget = await db.insert(targets).values({
resourceId, resourceId,
...targetData ...targetData
}).returning(); }).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, { return response(res, {
data: newTarget[0], data: newTarget[0],
success: true, success: true,

View file

@ -1,13 +1,14 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@server/db'; 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 { eq } from 'drizzle-orm';
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from '@server/types/HttpCode'; import HttpCode from '@server/types/HttpCode';
import createHttpError from 'http-errors'; import createHttpError from 'http-errors';
import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions';
import logger from '@server/logger'; import logger from '@server/logger';
import { addPeer } from '../gerbil/peers';
const deleteTargetSchema = z.object({ const deleteTargetSchema = z.object({
targetId: z.string().transform(Number).pipe(z.number().int().positive()) 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')); 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)) .where(eq(targets.targetId, targetId))
.returning(); .returning();
if (deletedTarget.length === 0) { if (!deletedTarget) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, 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, { return response(res, {
data: null, data: null,

View file

@ -14,7 +14,7 @@ const updateTargetParamsSchema = z.object({
}); });
const updateTargetBodySchema = 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(), method: z.string().min(1).max(10).optional(),
port: z.number().int().min(1).max(65535).optional(), port: z.number().int().min(1).max(65535).optional(),
protocol: z.string().optional(), protocol: z.string().optional(),

View file

@ -1,32 +1,32 @@
interface IPRange { interface IPRange {
start: bigint; start: bigint;
end: bigint; end: bigint;
} }
/** /**
* Converts IP address string to BigInt for numerical operations * Converts IP address string to BigInt for numerical operations
*/ */
function ipToBigInt(ip: string): bigint { function ipToBigInt(ip: string): bigint {
return ip.split('.') return ip.split('.')
.reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0)); .reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0));
} }
/** /**
* Converts BigInt to IP address string * Converts BigInt to IP address string
*/ */
function bigIntToIp(num: bigint): string { function bigIntToIp(num: bigint): string {
const octets: number[] = []; const octets: number[] = [];
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
octets.unshift(Number(num & BigInt(255))); octets.unshift(Number(num & BigInt(255)));
num = num >> BigInt(8); num = num >> BigInt(8);
} }
return octets.join('.'); return octets.join('.');
} }
/** /**
* Converts CIDR to IP range * Converts CIDR to IP range
*/ */
function cidrToRange(cidr: string): IPRange { function cidrToRange(cidr: string): IPRange {
const [ip, prefix] = cidr.split('/'); const [ip, prefix] = cidr.split('/');
const prefixBits = parseInt(prefix); const prefixBits = parseInt(prefix);
const ipBigInt = ipToBigInt(ip); const ipBigInt = ipToBigInt(ip);
@ -34,52 +34,64 @@ interface IPRange {
const start = ipBigInt & ~mask; const start = ipBigInt & ~mask;
const end = start | mask; const end = start | mask;
return { start, end }; return { start, end };
} }
/** /**
* Finds the next available CIDR block given existing allocations * Finds the next available CIDR block given existing allocations
* @param existingCidrs Array of existing CIDR blocks * @param existingCidrs Array of existing CIDR blocks
* @param blockSize Desired prefix length for the new block (e.g., 24 for /24) * @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") * @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0")
* @returns Next available CIDR block or null if none found * @returns Next available CIDR block or null if none found
*/ */
export function findNextAvailableCidr( export function findNextAvailableCidr(
existingCidrs: string[], existingCidrs: string[],
blockSize: number, blockSize: number,
startCidr: string = "0.0.0.0/0" startCidr: string = "0.0.0.0/0"
): string | null { ): string | null {
// Convert existing CIDRs to ranges and sort them // Convert existing CIDRs to ranges and sort them
const existingRanges = existingCidrs const existingRanges = existingCidrs
.map(cidr => cidrToRange(cidr)) .map(cidr => cidrToRange(cidr))
.sort((a, b) => (a.start < b.start ? -1 : 1)); .sort((a, b) => (a.start < b.start ? -1 : 1));
// Calculate block size // Calculate block size
const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize); const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize);
// Start from the beginning of the given CIDR // Start from the beginning of the given CIDR
let current = cidrToRange(startCidr).start; let current = cidrToRange(startCidr).start;
const maxIp = cidrToRange(startCidr).end; const maxIp = cidrToRange(startCidr).end;
// Iterate through existing ranges // Iterate through existing ranges
for (let i = 0; i <= existingRanges.length; i++) { for (let i = 0; i <= existingRanges.length; i++) {
const nextRange = existingRanges[i]; const nextRange = existingRanges[i];
// Align current to block size // Align current to block size
const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt); const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
// Check if we've gone beyond the maximum allowed IP // Check if we've gone beyond the maximum allowed IP
if (alignedCurrent + blockSizeBigInt - BigInt(1) > maxIp) { if (alignedCurrent + blockSizeBigInt - BigInt(1) > maxIp) {
return null; return null;
} }
// If we're at the end of existing ranges or found a gap // If we're at the end of existing ranges or found a gap
if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) { if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) {
return `${bigIntToIp(alignedCurrent)}/${blockSize}`; return `${bigIntToIp(alignedCurrent)}/${blockSize}`;
} }
// Move current pointer to after the current range // Move current pointer to after the current range
current = nextRange.end + BigInt(1); current = nextRange.end + BigInt(1);
} }
return null; return null;
} }
/**
* 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;
}

View file

@ -1,8 +1,5 @@
import { Metadata } from "next";
import Image from "next/image"; import Image from "next/image";
import { Separator } from "@/components/ui/separator";
import { SidebarNav } from "@/components/sidebar-nav";
import SiteProvider from "@app/providers/SiteProvider"; import SiteProvider from "@app/providers/SiteProvider";
import { internal } from "@app/api"; import { internal } from "@app/api";
import { GetSiteResponse } from "@server/routers/site"; import { GetSiteResponse } from "@server/routers/site";
@ -10,9 +7,6 @@ import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import Link from "next/link"; 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"; import { ClientLayout } from "./components/ClientLayout";
// export const metadata: Metadata = { // export const metadata: Metadata = {
@ -75,10 +69,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<SiteProvider site={site}> <SiteProvider site={site}>
<ClientLayout <ClientLayout
isCreate={params.niceId === "create"} isCreate={params.niceId === "create"}>
>
{children} {children}
</ClientLayout></SiteProvider> </ClientLayout>
</SiteProvider>
</> </>
); );
} }