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", {
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", {

View file

@ -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();

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

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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(),

View file

@ -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;
}
}
/**
* 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 { 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) {
<SiteProvider site={site}>
<ClientLayout
isCreate={params.niceId === "create"}
>
isCreate={params.niceId === "create"}>
{children}
</ClientLayout></SiteProvider>
</ClientLayout>
</SiteProvider>
</>
);
}