fosrl.pangolin/server/lib/ip.ts

295 lines
9.3 KiB
TypeScript
Raw Normal View History

import { db } from "@server/db";
import { clients, orgs, sites } from "@server/db";
import { and, eq, isNotNull } from "drizzle-orm";
import config from "@server/lib/config";
interface IPRange {
start: bigint;
end: bigint;
}
2025-02-14 12:32:10 -05:00
type IPVersion = 4 | 6;
/**
* Detects IP version from address string
*/
function detectIpVersion(ip: string): IPVersion {
2025-07-13 21:57:24 -07:00
return ip.includes(":") ? 6 : 4;
2025-02-14 12:32:10 -05:00
}
/**
2025-02-14 12:32:10 -05:00
* Converts IPv4 or IPv6 address string to BigInt for numerical operations
*/
function ipToBigInt(ip: string): bigint {
2025-02-14 12:32:10 -05:00
const version = detectIpVersion(ip);
2025-05-03 12:21:43 -04:00
2025-02-14 12:32:10 -05:00
if (version === 4) {
2025-07-13 21:57:24 -07:00
return ip.split(".").reduce((acc, octet) => {
const num = parseInt(octet);
if (isNaN(num) || num < 0 || num > 255) {
throw new Error(`Invalid IPv4 octet: ${octet}`);
}
return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num));
}, BigInt(0));
2025-02-14 12:32:10 -05:00
} else {
// Handle IPv6
// Expand :: notation
let fullAddress = ip;
2025-07-13 21:57:24 -07:00
if (ip.includes("::")) {
const parts = ip.split("::");
if (parts.length > 2)
throw new Error("Invalid IPv6 address: multiple :: found");
const missing =
8 - (parts[0].split(":").length + parts[1].split(":").length);
const padding = Array(missing).fill("0").join(":");
2025-02-14 12:32:10 -05:00
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
}
2025-07-13 21:57:24 -07:00
return fullAddress.split(":").reduce((acc, hextet) => {
const num = parseInt(hextet || "0", 16);
if (isNaN(num) || num < 0 || num > 65535) {
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
}
return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num));
}, BigInt(0));
2025-02-14 12:32:10 -05:00
}
}
/**
* Converts BigInt to IP address string
*/
2025-02-14 12:32:10 -05:00
function bigIntToIp(num: bigint, version: IPVersion): string {
if (version === 4) {
const octets: number[] = [];
for (let i = 0; i < 4; i++) {
octets.unshift(Number(num & BigInt(255)));
num = num >> BigInt(8);
}
2025-07-13 21:57:24 -07:00
return octets.join(".");
2025-02-14 12:32:10 -05:00
} else {
const hextets: string[] = [];
for (let i = 0; i < 8; i++) {
2025-07-13 21:57:24 -07:00
hextets.unshift(
Number(num & BigInt(65535))
.toString(16)
.padStart(4, "0")
);
2025-02-14 12:32:10 -05:00
num = num >> BigInt(16);
}
// Compress zero sequences
let maxZeroStart = -1;
let maxZeroLength = 0;
let currentZeroStart = -1;
let currentZeroLength = 0;
for (let i = 0; i < hextets.length; i++) {
2025-07-13 21:57:24 -07:00
if (hextets[i] === "0000") {
2025-02-14 12:32:10 -05:00
if (currentZeroStart === -1) currentZeroStart = i;
currentZeroLength++;
if (currentZeroLength > maxZeroLength) {
maxZeroLength = currentZeroLength;
maxZeroStart = currentZeroStart;
}
} else {
currentZeroStart = -1;
currentZeroLength = 0;
}
}
if (maxZeroLength > 1) {
2025-07-13 21:57:24 -07:00
hextets.splice(maxZeroStart, maxZeroLength, "");
if (maxZeroStart === 0) hextets.unshift("");
if (maxZeroStart + maxZeroLength === 8) hextets.push("");
2025-02-14 12:32:10 -05:00
}
2025-07-13 21:57:24 -07:00
return hextets
.map((h) => (h === "0000" ? "0" : h.replace(/^0+/, "")))
.join(":");
}
}
/**
* Converts CIDR to IP range
*/
export function cidrToRange(cidr: string): IPRange {
2025-07-13 21:57:24 -07:00
const [ip, prefix] = cidr.split("/");
2025-02-14 12:32:10 -05:00
const version = detectIpVersion(ip);
const prefixBits = parseInt(prefix);
const ipBigInt = ipToBigInt(ip);
2025-05-03 12:21:43 -04:00
2025-02-14 12:32:10 -05:00
// Validate prefix length
const maxPrefix = version === 4 ? 32 : 128;
if (prefixBits < 0 || prefixBits > maxPrefix) {
throw new Error(`Invalid prefix length for IPv${version}: ${prefix}`);
}
const shiftBits = BigInt(maxPrefix - prefixBits);
2025-07-13 21:57:24 -07:00
const mask = BigInt.asUintN(
version === 4 ? 64 : 128,
(BigInt(1) << shiftBits) - BigInt(1)
);
const start = ipBigInt & ~mask;
const end = start | mask;
2025-05-03 12:21:43 -04:00
return { start, end };
}
/**
* Finds the next available CIDR block given existing allocations
* @param existingCidrs Array of existing CIDR blocks
2025-02-14 12:32:10 -05:00
* @param blockSize Desired prefix length for the new block
* @param startCidr Optional CIDR to start searching from
* @returns Next available CIDR block or null if none found
*/
export function findNextAvailableCidr(
existingCidrs: string[],
blockSize: number,
2025-02-14 12:32:10 -05:00
startCidr?: string
): string | null {
if (!startCidr && existingCidrs.length === 0) {
return null;
}
2025-05-03 12:21:43 -04:00
// If no existing CIDRs, use the IP version from startCidr
2025-07-13 21:57:24 -07:00
const version = startCidr ? detectIpVersion(startCidr.split("/")[0]) : 4; // Default to IPv4 if no startCidr provided
2025-05-03 12:21:43 -04:00
2025-02-14 12:32:10 -05:00
// Use appropriate default startCidr if none provided
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
2025-05-03 12:21:43 -04:00
// If there are existing CIDRs, ensure all are same version
2025-07-13 21:57:24 -07:00
if (
existingCidrs.length > 0 &&
existingCidrs.some(
(cidr) => detectIpVersion(cidr.split("/")[0]) !== version
)
) {
throw new Error("All CIDRs must be of the same IP version");
2025-02-14 12:32:10 -05:00
}
2025-07-13 21:57:24 -07:00
// Extract the network part from startCidr to ensure we stay in the right subnet
const startCidrRange = cidrToRange(startCidr);
2025-07-13 21:57:24 -07:00
// Convert existing CIDRs to ranges and sort them
const existingRanges = existingCidrs
2025-07-13 21:57:24 -07:00
.map((cidr) => cidrToRange(cidr))
.sort((a, b) => (a.start < b.start ? -1 : 1));
2025-07-13 21:57:24 -07:00
// Calculate block size
2025-02-14 12:32:10 -05:00
const maxPrefix = version === 4 ? 32 : 128;
const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize);
2025-07-13 21:57:24 -07:00
// Start from the beginning of the given CIDR
let current = startCidrRange.start;
const maxIp = startCidrRange.end;
2025-07-13 21:57:24 -07:00
// Iterate through existing ranges
for (let i = 0; i <= existingRanges.length; i++) {
const nextRange = existingRanges[i];
2025-07-13 21:57:24 -07:00
// Align current to block size
2025-07-13 21:57:24 -07:00
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;
}
2025-07-13 21:57:24 -07:00
// If we're at the end of existing ranges or found a gap
2025-07-13 21:57:24 -07:00
if (
!nextRange ||
alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start
) {
2025-02-14 12:32:10 -05:00
return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
}
2025-07-13 21:57:24 -07:00
// If next range overlaps with our search space, move past it
if (nextRange.end >= startCidrRange.start && nextRange.start <= maxIp) {
// Move current pointer to after the current range
current = nextRange.end + BigInt(1);
}
}
2025-07-13 21:57:24 -07:00
return null;
}
/**
2025-02-14 12:32:10 -05:00
* 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 {
2025-02-14 12:32:10 -05:00
const ipVersion = detectIpVersion(ip);
2025-07-13 21:57:24 -07:00
const cidrVersion = detectIpVersion(cidr.split("/")[0]);
2025-05-03 12:21:43 -04:00
// If IP versions don't match, the IP cannot be in the CIDR range
2025-02-14 12:32:10 -05:00
if (ipVersion !== cidrVersion) {
2025-05-03 12:21:43 -04:00
// throw new Erorr
return false;
2025-02-14 12:32:10 -05:00
}
const ipBigInt = ipToBigInt(ip);
const range = cidrToRange(cidr);
return ipBigInt >= range.start && ipBigInt <= range.end;
}
2025-07-13 21:57:24 -07:00
export async function getNextAvailableClientSubnet(
orgId: string
): Promise<string> {
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
const existingAddressesSites = await db
.select({
address: sites.address
})
.from(sites)
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
const existingAddressesClients = await db
.select({
address: clients.subnet
})
.from(clients)
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
const addresses = [
2025-07-13 21:57:24 -07:00
...existingAddressesSites.map(
(site) => `${site.address?.split("/")[0]}/32`
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map(
(client) => `${client.address.split("/")}/32`
)
].filter((address) => address !== null) as string[];
2025-07-13 21:57:24 -07:00
let subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
return subnet;
}
export async function getNextAvailableOrgSubnet(): Promise<string> {
const existingAddresses = await db
.select({
subnet: orgs.subnet
})
.from(orgs)
.where(isNotNull(orgs.subnet));
const addresses = existingAddresses.map((org) => org.subnet);
let subnet = findNextAvailableCidr(
addresses,
config.getRawConfig().orgs.block_size,
config.getRawConfig().orgs.subnet_group
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
return subnet;
}