From d78312fad84b3c0c7f879cc859f6ab76b6fd9423 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 26 Oct 2024 16:04:01 -0400 Subject: [PATCH] Properly generate all wireguard options --- config/config.example.yml | 6 + server/config.ts | 7 +- server/db/names.ts | 28 ++++- server/db/schema.ts | 18 +-- server/routers/external.ts | 1 + server/routers/gerbil/getConfig.ts | 75 +++++++------ server/routers/site/createSite.ts | 14 +-- server/routers/site/index.ts | 3 +- server/routers/site/pickSiteDefaults.ts | 105 ++++++++++++++++++ server/utils/ip.ts | 85 ++++++++++++++ src/app/[orgId]/resources/page.tsx | 2 +- .../sites/[niceId]/components/CreateSite.tsx | 24 +++- 12 files changed, 303 insertions(+), 65 deletions(-) create mode 100644 server/routers/site/pickSiteDefaults.ts create mode 100644 server/utils/ip.ts diff --git a/config/config.example.yml b/config/config.example.yml index b989cb2..5dcab23 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -15,6 +15,12 @@ traefik: http_entrypoint: web https_entrypoint: websecure +gerbil: + start_port: 51820 + base_endpoint: localhost + block_size: 16 + subnet_group: 10.0.0.0/8 + rate_limit: window_minutes: 1 max_requests: 100 \ No newline at end of file diff --git a/server/config.ts b/server/config.ts index 560b540..ce6e76f 100644 --- a/server/config.ts +++ b/server/config.ts @@ -4,7 +4,6 @@ import path from "path"; import fs from "fs"; import yaml from "js-yaml"; import { fileURLToPath } from "url"; -import { signup } from "./routers/auth"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); @@ -33,6 +32,12 @@ const environmentSchema = z.object({ cert_resolver: z.string().optional(), prefer_wildcard_cert: z.boolean().optional(), }), + gerbil: z.object({ + start_port: z.number().positive().gt(0), + base_endpoint: z.string(), + subnet_group: z.string(), + block_size: z.number().positive().gt(0), + }), rate_limit: z.object({ window_minutes: z.number().positive().gt(0), max_requests: z.number().positive().gt(0), diff --git a/server/db/names.ts b/server/db/names.ts index cd6d890..28fda9b 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -1,7 +1,7 @@ import { join } from "path"; import { readFileSync } from "fs"; import { db } from "@server/db"; -import { sites } from "./schema"; +import { exitNodes, sites } from "./schema"; import { eq, and } from "drizzle-orm"; import { __DIRNAME } from "@server/config"; @@ -9,7 +9,7 @@ import { __DIRNAME } from "@server/config"; const file = join(__DIRNAME, "names.json"); export const names = JSON.parse(readFileSync(file, "utf-8")); -export async function getUniqueName(orgId: string): Promise { +export async function getUniqueSiteName(orgId: string): Promise { let loops = 0; while (true) { if (loops > 100) { @@ -28,6 +28,30 @@ export async function getUniqueName(orgId: string): Promise { } } +export async function getUniqueExitNodeEndpointName(): Promise { + let loops = 0; + const count = await db + .select() + .from(exitNodes); + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + const name = generateName(); + + for (const node of count) { + if (node.endpoint.includes(name)) { + loops++; + continue; + } + } + + return name; + } +} + + export function generateName(): string { return ( names.descriptors[ diff --git a/server/db/schema.ts b/server/db/schema.ts index 0f0f05b..ac213f7 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -13,12 +13,12 @@ export const sites = sqliteTable("sites", { onDelete: "cascade", }), niceId: text("niceId").notNull(), - exitNode: integer("exitNode").references(() => exitNodes.exitNodeId, { + exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null", }), name: text("name").notNull(), - pubKey: text("pubKey"), - subnet: text("subnet"), + pubKey: text("pubKey").notNull(), + subnet: text("subnet").notNull(), megabytesIn: integer("bytesIn"), megabytesOut: integer("bytesOut"), }); @@ -53,16 +53,9 @@ export const exitNodes = sqliteTable("exitNodes", { exitNodeId: integer("exitNodeId").primaryKey({ autoIncrement: true }), name: text("name").notNull(), address: text("address").notNull(), + endpoint: text("endpoint").notNull(), publicKey: text("pubicKey").notNull(), - listenPort: integer("listenPort"), -}); - -export const routes = sqliteTable("routes", { - routeId: integer("routeId").primaryKey({ autoIncrement: true }), - exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { - onDelete: "cascade", - }), - subnet: text("subnet").notNull(), + listenPort: integer("listenPort").notNull(), }); export const users = sqliteTable("user", { @@ -217,7 +210,6 @@ export type User = InferSelectModel; export type Site = InferSelectModel; export type Resource = InferSelectModel; export type ExitNode = InferSelectModel; -export type Route = InferSelectModel; export type Target = InferSelectModel; export type Session = InferSelectModel; export type EmailVerificationCode = InferSelectModel< diff --git a/server/routers/external.ts b/server/routers/external.ts index 2cb7ba6..4a269f6 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -50,6 +50,7 @@ authenticated.get("/site/:siteId", verifySiteAccess, site.getSite); authenticated.get("/site/:siteId/roles", verifySiteAccess, site.listSiteRoles); authenticated.post("/site/:siteId", verifySiteAccess, site.updateSite); authenticated.delete("/site/:siteId", verifySiteAccess, site.deleteSite); +authenticated.delete("/site/pickSiteDefaults", site.pickSiteDefaults); authenticated.put( "/org/:orgId/site/:siteId/resource", diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index bf2f80b..6b41c30 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -1,14 +1,15 @@ import { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; -import { sites, resources, targets, exitNodes, routes } from '@server/db/schema'; +import { sites, resources, targets, exitNodes } from '@server/db/schema'; import { db } from '@server/db'; import { eq } from 'drizzle-orm'; import response from "@server/utils/response"; import HttpCode from '@server/types/HttpCode'; import createHttpError from 'http-errors'; import logger from '@server/logger'; -import stoi from '@server/utils/stoi'; - +import config from "@server/config"; +import { getUniqueExitNodeEndpointName } from '@server/db/names'; +import { findNextAvailableCidr } from "@server/utils/ip"; // Define Zod schema for request validation const getConfigSchema = z.object({ publicKey: z.string(), @@ -47,19 +48,19 @@ export async function getConfig(req: Request, res: Response, next: NextFunction) if (!exitNode) { const address = await getNextAvailableSubnet(); + const listenPort = await getNextAvailablePort(); + const subEndpoint = await getUniqueExitNodeEndpointName(); + // create a new exit node exitNode = await db.insert(exitNodes).values({ publicKey, + endpoint: `${subEndpoint}.${config.gerbil.base_endpoint}`, address, - listenPort: 51820, + listenPort, name: `Exit Node ${publicKey.slice(0, 8)}`, }).returning().execute(); - // create a route - await db.insert(routes).values({ - exitNodeId: exitNode[0].exitNodeId, - subnet: address, - }).returning().execute(); + logger.info(`Created new exit node ${exitNode[0].name} with address ${exitNode[0].address} and port ${exitNode[0].listenPort}`); } if (!exitNode) { @@ -68,7 +69,7 @@ export async function getConfig(req: Request, res: Response, next: NextFunction) // Fetch sites for this exit node const sitesRes = await db.query.sites.findMany({ - where: eq(sites.exitNode, exitNode[0].exitNodeId), + where: eq(sites.exitNodeId, exitNode[0].exitNodeId), }); const peers = await Promise.all(sitesRes.map(async (site) => { @@ -91,14 +92,14 @@ export async function getConfig(req: Request, res: Response, next: NextFunction) }; })); - const config: GetConfigResponse = { + const configResponse: GetConfigResponse = { listenPort: exitNode[0].listenPort || 51820, ipAddress: exitNode[0].address, peers, }; return response(res, { - data: config, + data: configResponse, success: true, error: false, message: "Configuration retrieved successfully", @@ -113,31 +114,35 @@ export async function getConfig(req: Request, res: Response, next: NextFunction) async function getNextAvailableSubnet(): Promise { // Get all existing subnets from routes table - const existingRoutes = await db.select({ - subnet: routes.subnet - }).from(routes) - .innerJoin(exitNodes, eq(routes.exitNodeId, exitNodes.exitNodeId)); + const existingAddresses = await db.select({ + address: exitNodes.address, + }).from(exitNodes); - // Filter for only /16 subnets and extract the second octet - const usedSecondOctets = new Set( - existingRoutes - .map(route => route.subnet) - .filter(subnet => subnet.endsWith('/16')) - .filter(subnet => subnet.startsWith('10.')) - .map(subnet => { - const parts = subnet.split('.'); - return parseInt(parts[1]); - }) - ); + const addresses = existingAddresses.map(a => a.address); + const subnet = findNextAvailableCidr(addresses, config.gerbil.block_size, config.gerbil.subnet_group); + if (!subnet) { + throw new Error('No available subnets remaining in space'); + } + return subnet; +} - // Find the first available number between 0 and 255 - let nextOctet = 0; - while (usedSecondOctets.has(nextOctet)) { - nextOctet++; - if (nextOctet > 255) { - throw new Error('No available /16 subnets remaining in 10.0.0.0/8 space'); +async function getNextAvailablePort(): Promise { + // Get all existing ports from exitNodes table + const existingPorts = await db.select({ + listenPort: exitNodes.listenPort, + }).from(exitNodes); + + // Find the first available port between 1024 and 65535 + let nextPort = config.gerbil.start_port; + for (const port of existingPorts) { + if (port.listenPort > nextPort) { + break; + } + nextPort++; + if (nextPort > 65535) { + throw new Error('No available ports remaining in space'); } } - return `10.${nextOctet}.0.0/16`; -} + return nextPort; +} \ No newline at end of file diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 0cad9b3..27467db 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -9,7 +9,7 @@ import fetch from 'node-fetch'; import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; import logger from '@server/logger'; import { eq, and } from 'drizzle-orm'; -import { getUniqueName } from '@server/db/names'; +import { getUniqueSiteName } from '@server/db/names'; const API_BASE_URL = "http://localhost:3000"; @@ -20,9 +20,10 @@ const createSiteParamsSchema = z.object({ // Define Zod schema for request body validation const createSiteSchema = z.object({ name: z.string().min(1).max(255), + exitNodeId: z.number().int().positive(), subdomain: z.string().min(1).max(255).optional(), - pubKey: z.string().optional(), - subnet: z.string().optional(), + pubKey: z.string(), + subnet: z.string(), }); export type CreateSiteResponse = { @@ -48,7 +49,7 @@ export async function createSite(req: Request, res: Response, next: NextFunction ); } - const { name, subdomain, pubKey, subnet } = parsedBody.data; + const { name, subdomain, exitNodeId, pubKey, subnet } = parsedBody.data; // Validate request params const parsedParams = createSiteParamsSchema.safeParse(req.params); @@ -73,13 +74,12 @@ export async function createSite(req: Request, res: Response, next: NextFunction return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have a role')); } - const niceId = await getUniqueName(orgId); - - // TODO: pick a subnet + const niceId = await getUniqueSiteName(orgId); // Create new site in the database const [newSite] = await db.insert(sites).values({ orgId, + exitNodeId, name, niceId, pubKey, diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 944a72a..6350599 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -3,4 +3,5 @@ export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; export * from "./listSites"; -export * from "./listSiteRoles"; \ No newline at end of file +export * from "./listSiteRoles" +export * from "./pickSiteDefaults"; \ No newline at end of file diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts new file mode 100644 index 0000000..f12c720 --- /dev/null +++ b/server/routers/site/pickSiteDefaults.ts @@ -0,0 +1,105 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { exitNodes, Org, orgs, sites } 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 { findNextAvailableCidr } from "@server/utils/ip"; + +export type PickSiteDefaultsResponse = { + exitNodeId: number; + address: string; + publicKey: string; + name: string; + listenPort: number; + endpoint: string; + subnet: string; +} + +export async function pickSiteDefaults( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + + // Check if the user has permission to list sites + const hasPermission = await checkUserActionPermission( + ActionsEnum.createSite, + req, + ); + if (!hasPermission) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission to perform this action", + ), + ); + } + + // TODO: more intelligent way to pick the exit node + + // make sure there is an exit node by counting the exit nodes table + const nodes = await db.select().from(exitNodes); + if (nodes.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No exit nodes available", + ), + ); + } + + // get the first exit node + const exitNode = nodes[0]; + + // TODO: this probably can be optimized... + // list all of the sites on that exit node + const sitesQuery = await db.select({ + subnet: sites.subnet + }) + .from(sites) + .where(eq(sites.exitNodeId, exitNode.exitNodeId)); + + // TODO: we need to lock this subnet for some time so someone else does not take it + const subnets = sitesQuery.map((site) => site.subnet); + const newSubnet = findNextAvailableCidr(subnets, 28, exitNode.address); + if (!newSubnet) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "No available subnets", + ), + ); + } + + return response(res, { + data: { + exitNodeId: exitNode.exitNodeId, + address: exitNode.address, + publicKey: exitNode.publicKey, + name: exitNode.name, + listenPort: exitNode.listenPort, + endpoint: exitNode.endpoint, + subnet: newSubnet, + }, + success: true, + error: false, + message: "Organization retrieved successfully", + status: HttpCode.OK, + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred...", + ), + ); + } +} + diff --git a/server/utils/ip.ts b/server/utils/ip.ts new file mode 100644 index 0000000..9e48ee0 --- /dev/null +++ b/server/utils/ip.ts @@ -0,0 +1,85 @@ +interface IPRange { + start: bigint; + end: 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 { + const octets: number[] = []; + for (let i = 0; i < 4; i++) { + octets.unshift(Number(num & BigInt(255))); + num = num >> BigInt(8); + } + return octets.join('.'); + } + + /** + * Converts CIDR to IP range + */ + function cidrToRange(cidr: string): IPRange { + const [ip, prefix] = cidr.split('/'); + const prefixBits = parseInt(prefix); + const ipBigInt = ipToBigInt(ip); + const mask = BigInt.asUintN(64, (BigInt(1) << BigInt(32 - prefixBits)) - BigInt(1)); + 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( + existingCidrs: string[], + blockSize: number, + startCidr: string = "0.0.0.0/0" + ): 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)); + + // 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); + } + + return null; + } \ No newline at end of file diff --git a/src/app/[orgId]/resources/page.tsx b/src/app/[orgId]/resources/page.tsx index 018368f..1fb8765 100644 --- a/src/app/[orgId]/resources/page.tsx +++ b/src/app/[orgId]/resources/page.tsx @@ -45,4 +45,4 @@ export default async function Page(props: ResourcesPageProps) { ); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/sites/[niceId]/components/CreateSite.tsx b/src/app/[orgId]/sites/[niceId]/components/CreateSite.tsx index e83d36c..ae2f03e 100644 --- a/src/app/[orgId]/sites/[niceId]/components/CreateSite.tsx +++ b/src/app/[orgId]/sites/[niceId]/components/CreateSite.tsx @@ -24,6 +24,7 @@ import { api } from "@/api"; import { useParams } from "next/navigation"; import { useRouter } from "next/navigation"; import { Checkbox } from "@app/components/ui/checkbox" +import { PickSiteDefaultsResponse } from "@server/routers/site" const method = [ { label: "Wireguard", value: "wg" }, @@ -57,6 +58,7 @@ export function CreateSiteForm() { const [keypair, setKeypair] = useState<{ publicKey: string; privateKey: string } | null>(null); const [isLoading, setIsLoading] = useState(true); const [isChecked, setIsChecked] = useState(false); + const [siteDefaults, setSiteDefaults] = useState(null); const handleCheckboxChange = (checked: boolean) => { setIsChecked(checked); @@ -72,6 +74,18 @@ export function CreateSiteForm() { const generatedKeypair = generateKeypair(); setKeypair(generatedKeypair); setIsLoading(false); + + api + .get(`/site/pickSiteDefaults`) + .catch((e) => { + toast({ + title: "Error creating site..." + }); + }).then((res) => { + if (res && res.status === 200) { + setSiteDefaults(res.data.data); + } + }); } }, []); @@ -95,16 +109,16 @@ export function CreateSiteForm() { } } - const wgConfig = keypair + const wgConfig = keypair && siteDefaults ? `[Interface] -Address = 10.0.0.2/24 +Address = ${siteDefaults.subnet} ListenPort = 51820 PrivateKey = ${keypair.privateKey} [Peer] -PublicKey = ${keypair.publicKey} -AllowedIPs = 0.0.0.0/0, ::/0 -Endpoint = myserver.dyndns.org:51820 +PublicKey = ${siteDefaults.publicKey} +AllowedIPs = ${siteDefaults.address} +Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort} PersistentKeepalive = 5` : "";