/** * IPAM (IP Address Management) Allocator * Automatically allocates VMID and container IP addresses from zone subnets * Prevents conflicts and tracks allocations */ import { and, eq, like, or } from 'drizzle-orm'; import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite'; import type { DbClient } from '../db/client'; import { ipAllocations, ipReservations, systemConfig, vmidReservations } from '../db/schema'; import type { NewIpAllocation, NewIpReservation, NewVmidReservation } from '../db/schema'; import { ALLOCATABLE_ZONES, type AllocatableZone } from '../db/schema'; import type * as schema from '../db/schema'; import { generateIPsInSubnet, isIPInRange, isInSubnet, stripCIDR } from './subnet-parser'; // Type that accepts both database client and transaction type DbOrTransaction = BunSQLiteDatabase | DbClient; /** * Zones that support IPAM auto-allocation of VMID and container IP. * * ALIASED to `AllocatableZone`, not hand-written. This was its own union * (`'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'internal'`) that happened to * agree with `AllocatableZone` — a fourth hand-maintained copy of the zone list, * in a repo where three earlier copies had already drifted and left comments * saying so. Deriving means a zone celilo stops allocating for (as `vpn` is, * since the tunnel module assigns client addresses) cannot be missed here. */ export type IpamZone = AllocatableZone; const IPAM_ZONES: IpamZone[] = ALLOCATABLE_ZONES; /** * Infer which zone an IP address belongs to by checking configured zone subnets. * Returns null if the IP doesn't match any known zone. */ export async function inferZoneFromIP(ip: string, db: DbOrTransaction): Promise { const bareIp = stripCIDR(ip); for (const zone of IPAM_ZONES) { const subnetKey = `network.${zone}.subnet`; const records = await db .select() .from(systemConfig) .where(eq(systemConfig.key, subnetKey)) .all(); if (records.length > 0 && isInSubnet(bareIp, records[0].value)) { return zone; } } return null; } export interface IPAMAllocation { vmid: number; containerIp: string; // CIDR format (e.g., "10.0.10.10/24") } /** * Allocate VMID and container IP for a module * Automatically selects next available from zone subnet */ export async function allocateResources( moduleId: string, zone: IpamZone, db: DbOrTransaction, ): Promise { // Allocate VMID (sequential from 2100) const vmid = await allocateVMID(db); // Get zone subnet from system config const subnetKey = `network.${zone}.subnet`; const subnetRecords = await db .select() .from(systemConfig) .where(eq(systemConfig.key, subnetKey)) .all(); if (subnetRecords.length === 0) { throw new Error( `Network zone '${zone}' not configured. Run: celilo system config set ${subnetKey} "10.0.X.0/24"`, ); } const subnet = subnetRecords[0].value; // Allocate IP from zone subnet const containerIp = await allocateIPFromSubnet(subnet, zone, db); // Record allocation const newAllocation: NewIpAllocation = { moduleId, vmid, containerIp, zone, }; await db.insert(ipAllocations).values(newAllocation).run(); return { vmid, containerIp }; } /** * Allocate next available VMID * Starts from 2100 and increments sequentially * Skips reserved VMIDs */ export async function allocateVMID(db: DbOrTransaction): Promise { // Get all allocated VMIDs const allocations = await db.select().from(ipAllocations).all(); const allocatedVMIDs = new Set(allocations.map((a: typeof ipAllocations.$inferSelect) => a.vmid)); // Get all reserved VMIDs const reservations = await db.select().from(vmidReservations).all(); const reservedVMIDs = new Set( reservations.map((r: typeof vmidReservations.$inferSelect) => r.vmid), ); // Find next available VMID starting from 2100 let candidateVMID = allocations.length === 0 ? 2100 : Math.max(...allocations.map((a: typeof ipAllocations.$inferSelect) => a.vmid)) + 1; // Keep incrementing until we find an unreserved VMID while (allocatedVMIDs.has(candidateVMID) || reservedVMIDs.has(candidateVMID)) { candidateVMID++; } return candidateVMID; } /** * Allocate next available IP from subnet * Skips allocated IPs and reserved IPs * Reserves .1-.9 for infrastructure */ export async function allocateIPFromSubnet( subnet: string, zone: IpamZone, db: DbOrTransaction, ): Promise { // Get all allocated IPs in this subnet const allocations = await db .select() .from(ipAllocations) .where(eq(ipAllocations.zone, zone)) .all(); const allocatedIPs = new Set( allocations.map((a: typeof ipAllocations.$inferSelect) => a.containerIp), ); // Get all reservations in this zone const reservations = await db .select() .from(ipReservations) .where(eq(ipReservations.zone, zone)) .all(); // Find first available IP for (const candidateIP of generateIPsInSubnet(subnet)) { // Skip if already allocated if (allocatedIPs.has(candidateIP)) { continue; } // Skip if reserved const isReserved = reservations.some((r: typeof ipReservations.$inferSelect) => isIPInRange(stripCIDR(candidateIP), r.ipStart, r.ipEnd), ); if (isReserved) { continue; } // Found available IP return candidateIP; } throw new Error(`No available IPs in subnet ${subnet} (zone: ${zone})`); } /** * Deallocate resources for a module * Removes VMID and IP allocation */ export async function deallocateResources(moduleId: string, db: DbOrTransaction): Promise { await db.delete(ipAllocations).where(eq(ipAllocations.moduleId, moduleId)).run(); } /** * Get allocation for a module */ export async function getAllocation( moduleId: string, db: DbOrTransaction, ): Promise { const allocations = await db .select() .from(ipAllocations) .where(eq(ipAllocations.moduleId, moduleId)) .all(); if (allocations.length === 0) { return null; } const allocation = allocations[0]; return { vmid: allocation.vmid, containerIp: allocation.containerIp, }; } /** * Reserve an IP or IP range * Prevents IPAM from allocating reserved IPs */ export async function reserveIP( ipStart: string, zone: IpamZone, reason: string, ipEnd: string | null, db: DbOrTransaction, ): Promise { const newReservation: NewIpReservation = { ipStart: stripCIDR(ipStart), ipEnd: ipEnd ? stripCIDR(ipEnd) : null, zone, reason, }; await db.insert(ipReservations).values(newReservation).run(); } /** * Remove IP reservation */ export async function unreserveIP( ipStart: string, zone: IpamZone, db: DbOrTransaction, ): Promise { const ip = stripCIDR(ipStart); await db .delete(ipReservations) .where(and(eq(ipReservations.ipStart, ip), eq(ipReservations.zone, zone))); } /** * Release every ingress-IP reservation a module holds (celilo#892). * * `ensureIngressIps` reserves an `internal`-subnet address at generate time and * nothing released it at removal, so each install/remove cycle permanently * burned one address from the static range — with no error and no way to tell * the dead row from the live one, since both carry the same reason string. * * TWO reason formats exist in live databases and both must go: the current * `ingress::` (since celilo#879) and the pre-879 * `dns-ingress:`, which celilo-mgr still holds. Matching only the * current one would leave the installed base leaking. * * Deletion is by REASON, not by the stored config value: a module can hold an * ingress reservation with no `ip_allocations` row (it deploys onto a machine * rather than a container), and the config rows are about to be cascade-deleted * anyway. The reason string is the only thing that names the owner. * * @returns The addresses released, for reporting. */ export async function releaseIngressReservations( moduleId: string, db: DbOrTransaction, ): Promise { // Module IDs are validated kebab-case, so they carry no LIKE wildcards. const ownedByModule = or( like(ipReservations.reason, `ingress:${moduleId}:%`), eq(ipReservations.reason, `dns-ingress:${moduleId}`), ); const held = await db.select().from(ipReservations).where(ownedByModule).all(); if (held.length === 0) return []; await db.delete(ipReservations).where(ownedByModule); return held.map((r: typeof ipReservations.$inferSelect) => r.ipStart); } /** * Change an existing reservation's reason in place. * * The alternative — include then re-exclude — drops the row for a moment and * can race an allocation into the address it was holding. * * @returns False when no reservation exists for that IP in that zone. */ export async function updateReservationReason( ipStart: string, zone: IpamZone, reason: string, db: DbOrTransaction, ): Promise { const ip = stripCIDR(ipStart); const match = and(eq(ipReservations.ipStart, ip), eq(ipReservations.zone, zone)); const existing = await db.select().from(ipReservations).where(match).all(); if (existing.length === 0) return false; await db.update(ipReservations).set({ reason }).where(match); return true; } /** * List all IP reservations */ export async function listReservations(db: DbOrTransaction) { return await db.select().from(ipReservations).all(); } /** * Get all allocated IPs in a subnet (for debugging/status) */ export async function getAllocatedIPsInSubnet( subnet: string, db: DbOrTransaction, ): Promise { const allocations = await db.select().from(ipAllocations).all(); return allocations .filter((a: typeof ipAllocations.$inferSelect) => isInSubnet(a.containerIp, subnet)) .map((a: typeof ipAllocations.$inferSelect) => a.containerIp); } /** * Check if a specific IP is available */ export async function isIPAvailable( ip: string, zone: IpamZone, db: DbOrTransaction, ): Promise { // Check if allocated const allocations = await db .select() .from(ipAllocations) .where(eq(ipAllocations.containerIp, ip)) .all(); if (allocations.length > 0) { return false; } // Check if reserved const reservations = await db .select() .from(ipReservations) .where(eq(ipReservations.zone, zone)) .all(); const isReserved = reservations.some((r: typeof ipReservations.$inferSelect) => isIPInRange(stripCIDR(ip), r.ipStart, r.ipEnd), ); return !isReserved; } /** * Reserve a VMID * Prevents IPAM from allocating reserved VMIDs */ export async function reserveVMID( vmid: number, reason: string, db: DbOrTransaction, ): Promise { // Check if VMID is already allocated const allocations = await db .select() .from(ipAllocations) .where(eq(ipAllocations.vmid, vmid)) .all(); if (allocations.length > 0) { throw new Error(`VMID ${vmid} is already allocated to module ${allocations[0].moduleId}`); } // Check if VMID is already reserved const reservations = await db .select() .from(vmidReservations) .where(eq(vmidReservations.vmid, vmid)) .all(); if (reservations.length > 0) { throw new Error(`VMID ${vmid} is already reserved: ${reservations[0].reason}`); } const newReservation: NewVmidReservation = { vmid, reason, }; await db.insert(vmidReservations).values(newReservation).run(); } /** * Remove VMID reservation */ export async function unreserveVMID(vmid: number, db: DbOrTransaction): Promise { await db.delete(vmidReservations).where(eq(vmidReservations.vmid, vmid)).run(); } /** * List all VMID reservations */ export async function listVMIDReservations(db: DbOrTransaction) { return await db.select().from(vmidReservations).all(); } /** * Check if a specific VMID is available */ export async function isVMIDAvailable(vmid: number, db: DbOrTransaction): Promise { // Check if allocated const allocations = await db .select() .from(ipAllocations) .where(eq(ipAllocations.vmid, vmid)) .all(); if (allocations.length > 0) { return false; } // Check if reserved const reservations = await db .select() .from(vmidReservations) .where(eq(vmidReservations.vmid, vmid)) .all(); return reservations.length === 0; }