Properly generate all wireguard options

This commit is contained in:
Owen Schwartz 2024-10-26 16:04:01 -04:00
parent 261b3c7e31
commit d78312fad8
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
12 changed files with 303 additions and 65 deletions

View file

@ -15,6 +15,12 @@ traefik:
http_entrypoint: web http_entrypoint: web
https_entrypoint: websecure https_entrypoint: websecure
gerbil:
start_port: 51820
base_endpoint: localhost
block_size: 16
subnet_group: 10.0.0.0/8
rate_limit: rate_limit:
window_minutes: 1 window_minutes: 1
max_requests: 100 max_requests: 100

View file

@ -4,7 +4,6 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { signup } from "./routers/auth";
export const __FILENAME = fileURLToPath(import.meta.url); export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);
@ -33,6 +32,12 @@ const environmentSchema = z.object({
cert_resolver: z.string().optional(), cert_resolver: z.string().optional(),
prefer_wildcard_cert: z.boolean().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({ rate_limit: z.object({
window_minutes: z.number().positive().gt(0), window_minutes: z.number().positive().gt(0),
max_requests: z.number().positive().gt(0), max_requests: z.number().positive().gt(0),

View file

@ -1,7 +1,7 @@
import { join } from "path"; import { join } from "path";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { db } from "@server/db"; import { db } from "@server/db";
import { sites } from "./schema"; import { exitNodes, sites } from "./schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { __DIRNAME } from "@server/config"; import { __DIRNAME } from "@server/config";
@ -9,7 +9,7 @@ import { __DIRNAME } from "@server/config";
const file = join(__DIRNAME, "names.json"); const file = join(__DIRNAME, "names.json");
export const names = JSON.parse(readFileSync(file, "utf-8")); export const names = JSON.parse(readFileSync(file, "utf-8"));
export async function getUniqueName(orgId: string): Promise<string> { export async function getUniqueSiteName(orgId: string): Promise<string> {
let loops = 0; let loops = 0;
while (true) { while (true) {
if (loops > 100) { if (loops > 100) {
@ -28,6 +28,30 @@ export async function getUniqueName(orgId: string): Promise<string> {
} }
} }
export async function getUniqueExitNodeEndpointName(): Promise<string> {
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 { export function generateName(): string {
return ( return (
names.descriptors[ names.descriptors[

View file

@ -13,12 +13,12 @@ export const sites = sqliteTable("sites", {
onDelete: "cascade", onDelete: "cascade",
}), }),
niceId: text("niceId").notNull(), niceId: text("niceId").notNull(),
exitNode: integer("exitNode").references(() => exitNodes.exitNodeId, { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null", onDelete: "set null",
}), }),
name: text("name").notNull(), name: text("name").notNull(),
pubKey: text("pubKey"), pubKey: text("pubKey").notNull(),
subnet: text("subnet"), subnet: text("subnet").notNull(),
megabytesIn: integer("bytesIn"), megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"), megabytesOut: integer("bytesOut"),
}); });
@ -53,16 +53,9 @@ 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(),
endpoint: text("endpoint").notNull(),
publicKey: text("pubicKey").notNull(), publicKey: text("pubicKey").notNull(),
listenPort: integer("listenPort"), listenPort: integer("listenPort").notNull(),
});
export const routes = sqliteTable("routes", {
routeId: integer("routeId").primaryKey({ autoIncrement: true }),
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
onDelete: "cascade",
}),
subnet: text("subnet").notNull(),
}); });
export const users = sqliteTable("user", { export const users = sqliteTable("user", {
@ -217,7 +210,6 @@ export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>; export type Site = InferSelectModel<typeof sites>;
export type Resource = InferSelectModel<typeof resources>; export type Resource = InferSelectModel<typeof resources>;
export type ExitNode = InferSelectModel<typeof exitNodes>; export type ExitNode = InferSelectModel<typeof exitNodes>;
export type Route = InferSelectModel<typeof routes>;
export type Target = InferSelectModel<typeof targets>; export type Target = InferSelectModel<typeof targets>;
export type Session = InferSelectModel<typeof sessions>; export type Session = InferSelectModel<typeof sessions>;
export type EmailVerificationCode = InferSelectModel< export type EmailVerificationCode = InferSelectModel<

View file

@ -50,6 +50,7 @@ authenticated.get("/site/:siteId", verifySiteAccess, site.getSite);
authenticated.get("/site/:siteId/roles", verifySiteAccess, site.listSiteRoles); authenticated.get("/site/:siteId/roles", verifySiteAccess, site.listSiteRoles);
authenticated.post("/site/:siteId", verifySiteAccess, site.updateSite); authenticated.post("/site/:siteId", verifySiteAccess, site.updateSite);
authenticated.delete("/site/:siteId", verifySiteAccess, site.deleteSite); authenticated.delete("/site/:siteId", verifySiteAccess, site.deleteSite);
authenticated.delete("/site/pickSiteDefaults", site.pickSiteDefaults);
authenticated.put( authenticated.put(
"/org/:orgId/site/:siteId/resource", "/org/:orgId/site/:siteId/resource",

View file

@ -1,14 +1,15 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; 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 { db } from '@server/db';
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 logger from '@server/logger'; 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 // Define Zod schema for request validation
const getConfigSchema = z.object({ const getConfigSchema = z.object({
publicKey: z.string(), publicKey: z.string(),
@ -47,19 +48,19 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
if (!exitNode) { if (!exitNode) {
const address = await getNextAvailableSubnet(); const address = await getNextAvailableSubnet();
const listenPort = await getNextAvailablePort();
const subEndpoint = await getUniqueExitNodeEndpointName();
// create a new exit node // create a new exit node
exitNode = await db.insert(exitNodes).values({ exitNode = await db.insert(exitNodes).values({
publicKey, publicKey,
endpoint: `${subEndpoint}.${config.gerbil.base_endpoint}`,
address, address,
listenPort: 51820, listenPort,
name: `Exit Node ${publicKey.slice(0, 8)}`, name: `Exit Node ${publicKey.slice(0, 8)}`,
}).returning().execute(); }).returning().execute();
// create a route logger.info(`Created new exit node ${exitNode[0].name} with address ${exitNode[0].address} and port ${exitNode[0].listenPort}`);
await db.insert(routes).values({
exitNodeId: exitNode[0].exitNodeId,
subnet: address,
}).returning().execute();
} }
if (!exitNode) { if (!exitNode) {
@ -68,7 +69,7 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
// Fetch sites for this exit node // Fetch sites for this exit node
const sitesRes = await db.query.sites.findMany({ 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) => { 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, listenPort: exitNode[0].listenPort || 51820,
ipAddress: exitNode[0].address, ipAddress: exitNode[0].address,
peers, peers,
}; };
return response(res, { return response(res, {
data: config, data: configResponse,
success: true, success: true,
error: false, error: false,
message: "Configuration retrieved successfully", message: "Configuration retrieved successfully",
@ -113,31 +114,35 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
async function getNextAvailableSubnet(): Promise<string> { async function getNextAvailableSubnet(): Promise<string> {
// Get all existing subnets from routes table // Get all existing subnets from routes table
const existingRoutes = await db.select({ const existingAddresses = await db.select({
subnet: routes.subnet address: exitNodes.address,
}).from(routes) }).from(exitNodes);
.innerJoin(exitNodes, eq(routes.exitNodeId, exitNodes.exitNodeId));
// Filter for only /16 subnets and extract the second octet const addresses = existingAddresses.map(a => a.address);
const usedSecondOctets = new Set( const subnet = findNextAvailableCidr(addresses, config.gerbil.block_size, config.gerbil.subnet_group);
existingRoutes if (!subnet) {
.map(route => route.subnet) throw new Error('No available subnets remaining in space');
.filter(subnet => subnet.endsWith('/16'))
.filter(subnet => subnet.startsWith('10.'))
.map(subnet => {
const parts = subnet.split('.');
return parseInt(parts[1]);
})
);
// 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');
} }
} return subnet;
}
return `10.${nextOctet}.0.0/16`;
async function getNextAvailablePort(): Promise<number> {
// 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 nextPort;
} }

View file

@ -9,7 +9,7 @@ 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 { getUniqueName } from '@server/db/names'; import { getUniqueSiteName } from '@server/db/names';
const API_BASE_URL = "http://localhost:3000"; const API_BASE_URL = "http://localhost:3000";
@ -20,9 +20,10 @@ const createSiteParamsSchema = z.object({
// Define Zod schema for request body validation // Define Zod schema for request body validation
const createSiteSchema = z.object({ const createSiteSchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
exitNodeId: z.number().int().positive(),
subdomain: z.string().min(1).max(255).optional(), subdomain: z.string().min(1).max(255).optional(),
pubKey: z.string().optional(), pubKey: z.string(),
subnet: z.string().optional(), subnet: z.string(),
}); });
export type CreateSiteResponse = { 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 // Validate request params
const parsedParams = createSiteParamsSchema.safeParse(req.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')); return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have a role'));
} }
const niceId = await getUniqueName(orgId); const niceId = await getUniqueSiteName(orgId);
// TODO: pick a subnet
// Create new site in the database // Create new site in the database
const [newSite] = await db.insert(sites).values({ const [newSite] = await db.insert(sites).values({
orgId, orgId,
exitNodeId,
name, name,
niceId, niceId,
pubKey, pubKey,

View file

@ -3,4 +3,5 @@ export * from "./createSite";
export * from "./deleteSite"; export * from "./deleteSite";
export * from "./updateSite"; export * from "./updateSite";
export * from "./listSites"; export * from "./listSites";
export * from "./listSiteRoles"; export * from "./listSiteRoles"
export * from "./pickSiteDefaults";

View file

@ -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<any> {
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<PickSiteDefaultsResponse>(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...",
),
);
}
}

85
server/utils/ip.ts Normal file
View file

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

View file

@ -24,6 +24,7 @@ import { api } from "@/api";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Checkbox } from "@app/components/ui/checkbox" import { Checkbox } from "@app/components/ui/checkbox"
import { PickSiteDefaultsResponse } from "@server/routers/site"
const method = [ const method = [
{ label: "Wireguard", value: "wg" }, { label: "Wireguard", value: "wg" },
@ -57,6 +58,7 @@ export function CreateSiteForm() {
const [keypair, setKeypair] = useState<{ publicKey: string; privateKey: string } | null>(null); const [keypair, setKeypair] = useState<{ publicKey: string; privateKey: string } | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isChecked, setIsChecked] = useState(false); const [isChecked, setIsChecked] = useState(false);
const [siteDefaults, setSiteDefaults] = useState<PickSiteDefaultsResponse | null>(null);
const handleCheckboxChange = (checked: boolean) => { const handleCheckboxChange = (checked: boolean) => {
setIsChecked(checked); setIsChecked(checked);
@ -72,6 +74,18 @@ export function CreateSiteForm() {
const generatedKeypair = generateKeypair(); const generatedKeypair = generateKeypair();
setKeypair(generatedKeypair); setKeypair(generatedKeypair);
setIsLoading(false); 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] ? `[Interface]
Address = 10.0.0.2/24 Address = ${siteDefaults.subnet}
ListenPort = 51820 ListenPort = 51820
PrivateKey = ${keypair.privateKey} PrivateKey = ${keypair.privateKey}
[Peer] [Peer]
PublicKey = ${keypair.publicKey} PublicKey = ${siteDefaults.publicKey}
AllowedIPs = 0.0.0.0/0, ::/0 AllowedIPs = ${siteDefaults.address}
Endpoint = myserver.dyndns.org:51820 Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort}
PersistentKeepalive = 5` PersistentKeepalive = 5`
: ""; : "";