Files
proxpanel/backend/src/services/network.service.ts

1403 lines
39 KiB
TypeScript

import { isIP } from "node:net";
import {
IpAllocationStrategy,
IpAddressStatus,
IpAssignmentType,
IpScope,
IpVersion,
Prisma,
PrivateNetworkAttachmentStatus,
PrivateNetworkType,
VmType
} from "@prisma/client";
import { prisma } from "../lib/prisma";
import { HttpError } from "../lib/http-error";
import { toPrismaJsonValue } from "../lib/prisma-json";
import { reconfigureVmNetwork, updateVmConfiguration } from "./proxmox.service";
type ListIpAddressInput = {
status?: IpAddressStatus;
version?: IpVersion;
scope?: IpScope;
nodeHostname?: string;
bridge?: string;
vlanTag?: number;
assignedVmId?: string;
limit?: number;
offset?: number;
};
type ImportIpAddressesInput = {
addresses?: string[];
cidr_blocks?: string[];
scope?: IpScope;
server?: string;
node_id?: string;
node_hostname?: string;
bridge?: string;
vlan_tag?: number;
sdn_zone?: string;
gateway?: string;
subnet?: string;
tags?: string[];
metadata?: Record<string, unknown>;
imported_by?: string;
};
type AssignIpInput = {
vm_id: string;
ip_address_id?: string;
address?: string;
scope?: IpScope;
version?: IpVersion;
assignment_type?: IpAssignmentType;
interface_name?: string;
notes?: string;
actor_email?: string;
metadata?: Record<string, unknown>;
};
type ReturnIpInput = {
assignment_id?: string;
ip_address_id?: string;
};
type CreatePrivateNetworkInput = {
name: string;
slug?: string;
network_type?: PrivateNetworkType;
cidr: string;
gateway?: string;
bridge?: string;
vlan_tag?: number;
sdn_zone?: string;
server?: string;
node_hostname?: string;
metadata?: Record<string, unknown>;
created_by?: string;
};
type AttachPrivateNetworkInput = {
network_id: string;
vm_id: string;
interface_name?: string;
requested_ip?: string;
actor_email?: string;
metadata?: Record<string, unknown>;
};
type DetachPrivateNetworkInput = {
attachment_id: string;
actor_email?: string;
};
type UpsertTenantQuotaInput = {
tenant_id: string;
ipv4_limit?: number | null;
ipv6_limit?: number | null;
reserved_ipv4?: number;
reserved_ipv6?: number;
burst_allowed?: boolean;
burst_ipv4_limit?: number | null;
burst_ipv6_limit?: number | null;
is_active?: boolean;
metadata?: Record<string, unknown>;
created_by?: string;
};
type CreateReservedRangeInput = {
name: string;
cidr: string;
scope?: IpScope;
tenant_id?: string;
reason?: string;
node_hostname?: string;
bridge?: string;
vlan_tag?: number;
sdn_zone?: string;
is_active?: boolean;
metadata?: Record<string, unknown>;
created_by?: string;
};
type UpsertIpPoolPolicyInput = {
policy_id?: string;
name: string;
tenant_id?: string;
scope?: IpScope;
version?: IpVersion;
node_hostname?: string;
bridge?: string;
vlan_tag?: number;
sdn_zone?: string;
allocation_strategy?: IpAllocationStrategy;
enforce_quota?: boolean;
disallow_reserved_use?: boolean;
is_active?: boolean;
priority?: number;
metadata?: Record<string, unknown>;
created_by?: string;
};
function normalizeSlug(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
}
function parseIpVersion(address: string): IpVersion {
const version = isIP(address);
if (version === 4) return IpVersion.IPV4;
if (version === 6) return IpVersion.IPV6;
throw new HttpError(400, `Invalid IP address: ${address}`, "INVALID_IP_ADDRESS");
}
function parseCidr(cidr: string): { address: string; prefix: number; version: IpVersion } {
const trimmed = cidr.trim();
const [address, rawPrefix] = trimmed.split("/");
if (!address || !rawPrefix) {
throw new HttpError(400, `Invalid CIDR block: ${cidr}`, "INVALID_CIDR");
}
const version = parseIpVersion(address);
const prefix = Number(rawPrefix);
const maxPrefix = version === IpVersion.IPV4 ? 32 : 128;
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) {
throw new HttpError(400, `CIDR prefix out of range: ${cidr}`, "INVALID_CIDR");
}
return { address, prefix, version };
}
function ipv4ToInt(address: string) {
const parts = address.split(".").map((value) => Number(value));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
throw new HttpError(400, `Invalid IPv4 address: ${address}`, "INVALID_IPV4");
}
return (((parts[0] << 24) >>> 0) + ((parts[1] << 16) >>> 0) + ((parts[2] << 8) >>> 0) + (parts[3] >>> 0)) >>> 0;
}
function intToIpv4(value: number) {
return `${(value >>> 24) & 255}.${(value >>> 16) & 255}.${(value >>> 8) & 255}.${value & 255}`;
}
function expandIpv4Cidr(address: string, prefix: number): string[] {
const hostBits = 32 - prefix;
if (hostBits > 12) {
throw new HttpError(400, `CIDR block too large for bulk import (${address}/${prefix})`, "CIDR_TOO_LARGE");
}
const size = 2 ** hostBits;
const mask = prefix === 0 ? 0 : (0xffffffff << hostBits) >>> 0;
const base = ipv4ToInt(address) & mask;
const output: string[] = [];
for (let index = 0; index < size; index += 1) {
output.push(intToIpv4((base + index) >>> 0));
}
return output;
}
function normalizeIpv6(address: string) {
if (!address.includes(":")) {
throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6");
}
const [leftPart, rightPart] = address.split("::");
if (address.split("::").length > 2) {
throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6");
}
const left = leftPart ? leftPart.split(":").filter((item) => item.length > 0) : [];
const right = rightPart ? rightPart.split(":").filter((item) => item.length > 0) : [];
const missing = 8 - (left.length + right.length);
if (missing < 0) {
throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6");
}
const groups = [...left, ...Array.from({ length: missing }, () => "0"), ...right];
if (groups.length !== 8) {
throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6");
}
return groups.map((group) => {
const parsed = Number.parseInt(group, 16);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 0xffff) {
throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6");
}
return parsed;
});
}
function ipv6ToBigInt(address: string) {
const groups = normalizeIpv6(address);
let output = 0n;
for (const group of groups) {
output = (output << 16n) + BigInt(group);
}
return output;
}
function ipToBigInt(address: string, version: IpVersion) {
if (version === IpVersion.IPV4) {
return BigInt(ipv4ToInt(address));
}
return ipv6ToBigInt(address);
}
function cidrBounds(cidr: string) {
const parsed = parseCidr(cidr);
const bits = parsed.version === IpVersion.IPV4 ? 32 : 128;
const base = ipToBigInt(parsed.address, parsed.version);
const hostBits = BigInt(bits - parsed.prefix);
const size = 1n << hostBits;
const mask = ((1n << BigInt(bits)) - 1n) ^ (size - 1n);
const network = base & mask;
const broadcast = network + size - 1n;
return {
...parsed,
network,
broadcast
};
}
function addressInCidr(address: string, cidr: string) {
const bounds = cidrBounds(cidr);
const addressVersion = parseIpVersion(address);
if (addressVersion !== bounds.version) {
return false;
}
const numeric = ipToBigInt(address, addressVersion);
return numeric >= bounds.network && numeric <= bounds.broadcast;
}
function samePlacementContext(
candidate: { node_hostname: string | null; bridge: string | null; vlan_tag: number | null; sdn_zone: string | null },
selector: { node_hostname?: string | null; bridge?: string | null; vlan_tag?: number | null; sdn_zone?: string | null }
) {
if (selector.node_hostname && candidate.node_hostname && selector.node_hostname !== candidate.node_hostname) return false;
if (selector.bridge && candidate.bridge && selector.bridge !== candidate.bridge) return false;
if (typeof selector.vlan_tag === "number" && typeof candidate.vlan_tag === "number" && selector.vlan_tag !== candidate.vlan_tag) return false;
if (selector.sdn_zone && candidate.sdn_zone && selector.sdn_zone !== candidate.sdn_zone) return false;
return true;
}
async function fetchVmForNetwork(vmId: string) {
const vm = await prisma.virtualMachine.findUnique({
where: { id: vmId },
select: {
id: true,
vmid: true,
name: true,
node: true,
type: true,
tenant_id: true,
ip_address: true
}
});
if (!vm) {
throw new HttpError(404, "VM not found", "VM_NOT_FOUND");
}
return vm;
}
function vmRuntime(vmType: VmType): "qemu" | "lxc" {
return vmType === VmType.LXC ? "lxc" : "qemu";
}
async function resolveAllocationPolicy(input: {
tenant_id: string;
scope?: IpScope;
version?: IpVersion;
node_hostname?: string | null;
bridge?: string | null;
vlan_tag?: number | null;
sdn_zone?: string | null;
}) {
const policies = await prisma.ipPoolPolicy.findMany({
where: {
is_active: true,
OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }]
},
orderBy: [{ priority: "asc" }, { created_at: "asc" }]
});
const matches = policies.filter((policy) => {
if (policy.tenant_id && policy.tenant_id !== input.tenant_id) return false;
if (policy.scope && input.scope && policy.scope !== input.scope) return false;
if (policy.version && input.version && policy.version !== input.version) return false;
if (policy.node_hostname && input.node_hostname && policy.node_hostname !== input.node_hostname) return false;
if (policy.bridge && input.bridge && policy.bridge !== input.bridge) return false;
if (typeof policy.vlan_tag === "number" && typeof input.vlan_tag === "number" && policy.vlan_tag !== input.vlan_tag) return false;
if (policy.sdn_zone && input.sdn_zone && policy.sdn_zone !== input.sdn_zone) return false;
return true;
});
const scored = matches
.map((policy) => {
let score = 0;
if (policy.tenant_id) score += 100;
if (policy.scope) score += 10;
if (policy.version) score += 10;
if (policy.node_hostname) score += 4;
if (policy.bridge) score += 3;
if (typeof policy.vlan_tag === "number") score += 2;
if (policy.sdn_zone) score += 1;
score -= policy.priority / 1000;
return { policy, score };
})
.sort((a, b) => b.score - a.score);
return scored[0]?.policy ?? null;
}
async function enforceTenantQuota(input: { tenant_id: string; version: IpVersion; policyEnforced: boolean }) {
if (!input.policyEnforced) {
return;
}
const quota = await prisma.tenantIpQuota.findUnique({
where: { tenant_id: input.tenant_id }
});
if (!quota || !quota.is_active) {
return;
}
const [assignedV4, assignedV6] = await Promise.all([
prisma.ipAssignment.count({
where: {
tenant_id: input.tenant_id,
is_active: true,
ip_address: {
version: IpVersion.IPV4
}
}
}),
prisma.ipAssignment.count({
where: {
tenant_id: input.tenant_id,
is_active: true,
ip_address: {
version: IpVersion.IPV6
}
}
})
]);
const v4Limit = quota.ipv4_limit ?? null;
const v6Limit = quota.ipv6_limit ?? null;
const v4Effective = quota.burst_allowed ? quota.burst_ipv4_limit ?? v4Limit : v4Limit;
const v6Effective = quota.burst_allowed ? quota.burst_ipv6_limit ?? v6Limit : v6Limit;
if (input.version === IpVersion.IPV4 && typeof v4Effective === "number" && assignedV4 >= v4Effective) {
throw new HttpError(409, "Tenant IPv4 quota exhausted", "TENANT_IPV4_QUOTA_EXCEEDED");
}
if (input.version === IpVersion.IPV6 && typeof v6Effective === "number" && assignedV6 >= v6Effective) {
throw new HttpError(409, "Tenant IPv6 quota exhausted", "TENANT_IPV6_QUOTA_EXCEEDED");
}
}
function isCandidateReservedForOtherTenant(
candidate: {
address: string;
scope: IpScope;
version: IpVersion;
node_hostname: string | null;
bridge: string | null;
vlan_tag: number | null;
sdn_zone: string | null;
},
ranges: Array<{
tenant_id: string | null;
cidr: string;
scope: IpScope;
version: IpVersion;
node_hostname: string | null;
bridge: string | null;
vlan_tag: number | null;
sdn_zone: string | null;
}>,
tenantId: string
) {
const matches = ranges.filter((range) => {
if (range.scope !== candidate.scope) return false;
if (range.version !== candidate.version) return false;
if (!samePlacementContext(candidate, range)) return false;
return addressInCidr(candidate.address, range.cidr);
});
if (matches.length === 0) return { blocked: false, tenantReserved: false };
if (matches.some((range) => range.tenant_id === tenantId)) return { blocked: false, tenantReserved: true };
return { blocked: true, tenantReserved: false };
}
function pickBestFitCandidate(
candidates: Array<{
id: string;
address: string;
cidr: number;
version: IpVersion;
subnet: string | null;
imported_at: Date;
tenantReserved: boolean;
}>,
strategy: IpAllocationStrategy
) {
if (candidates.length === 0) return null;
if (strategy === IpAllocationStrategy.FIRST_AVAILABLE) {
return [...candidates].sort((a, b) => a.imported_at.getTime() - b.imported_at.getTime() || a.address.localeCompare(b.address))[0];
}
const groupAvailability = candidates.reduce<Record<string, number>>((acc, item) => {
const key = item.subnet ?? `${item.address}/${item.cidr}`;
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {});
return [...candidates].sort((a, b) => {
if (a.tenantReserved !== b.tenantReserved) return a.tenantReserved ? -1 : 1;
const aGroup = groupAvailability[a.subnet ?? `${a.address}/${a.cidr}`] ?? 0;
const bGroup = groupAvailability[b.subnet ?? `${b.address}/${b.cidr}`] ?? 0;
if (aGroup !== bGroup) return aGroup - bGroup;
if (a.cidr !== b.cidr) return b.cidr - a.cidr;
return a.imported_at.getTime() - b.imported_at.getTime() || a.address.localeCompare(b.address);
})[0];
}
export async function listIpAddresses(input: ListIpAddressInput) {
const where: Prisma.IpAddressPoolWhereInput = {};
if (input.status) where.status = input.status;
if (input.version) where.version = input.version;
if (input.scope) where.scope = input.scope;
if (input.nodeHostname) where.node_hostname = input.nodeHostname;
if (input.bridge) where.bridge = input.bridge;
if (typeof input.vlanTag === "number") where.vlan_tag = input.vlanTag;
if (input.assignedVmId) where.assigned_vm_id = input.assignedVmId;
const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
const offset = Math.max(input.offset ?? 0, 0);
const [data, total] = await Promise.all([
prisma.ipAddressPool.findMany({
where,
include: {
assigned_vm: {
select: {
id: true,
name: true,
tenant_id: true
}
}
},
orderBy: [{ status: "asc" }, { address: "asc" }],
take: limit,
skip: offset
}),
prisma.ipAddressPool.count({ where })
]);
return {
data,
meta: {
total,
limit,
offset
}
};
}
export async function importIpAddresses(input: ImportIpAddressesInput) {
const baseTags = Array.isArray(input.tags) ? input.tags.filter((item) => item.trim().length > 0) : [];
const metadata = input.metadata ? toPrismaJsonValue(input.metadata) : {};
const directAddresses = Array.isArray(input.addresses) ? input.addresses : [];
const cidrBlocks = Array.isArray(input.cidr_blocks) ? input.cidr_blocks : [];
const prepared: Array<{
address: string;
cidr: number;
version: IpVersion;
status: IpAddressStatus;
subnet: string | null;
}> = [];
for (const raw of directAddresses) {
const address = raw.trim();
const version = parseIpVersion(address);
prepared.push({
address,
cidr: version === IpVersion.IPV4 ? 32 : 128,
version,
status: IpAddressStatus.AVAILABLE,
subnet: input.subnet ?? null
});
}
for (const block of cidrBlocks) {
const parsed = parseCidr(block);
if (parsed.version === IpVersion.IPV4) {
const expanded = expandIpv4Cidr(parsed.address, parsed.prefix);
for (const address of expanded) {
prepared.push({
address,
cidr: parsed.prefix,
version: parsed.version,
status: IpAddressStatus.AVAILABLE,
subnet: block
});
}
continue;
}
if (parsed.prefix === 128) {
prepared.push({
address: parsed.address,
cidr: parsed.prefix,
version: parsed.version,
status: IpAddressStatus.AVAILABLE,
subnet: block
});
} else {
prepared.push({
address: parsed.address,
cidr: parsed.prefix,
version: parsed.version,
status: IpAddressStatus.RESERVED,
subnet: block
});
}
}
if (prepared.length === 0) {
throw new HttpError(400, "No addresses or CIDR blocks supplied", "EMPTY_IMPORT");
}
const uniqueMap = new Map<string, (typeof prepared)[number]>();
for (const item of prepared) {
const key = `${item.address}/${item.cidr}`;
if (!uniqueMap.has(key)) {
uniqueMap.set(key, item);
}
}
const values = [...uniqueMap.values()];
const existing = await prisma.ipAddressPool.findMany({
where: {
OR: values.map((item) => ({
address: item.address,
cidr: item.cidr
}))
},
select: { address: true, cidr: true }
});
const existingKey = new Set(existing.map((item) => `${item.address}/${item.cidr}`));
const toInsert = values.filter((item) => !existingKey.has(`${item.address}/${item.cidr}`));
if (toInsert.length > 0) {
await prisma.ipAddressPool.createMany({
data: toInsert.map((item) => ({
address: item.address,
cidr: item.cidr,
version: item.version,
scope: input.scope ?? IpScope.PUBLIC,
status: item.status,
gateway: input.gateway,
subnet: item.subnet,
server: input.server,
node_id: input.node_id,
node_hostname: input.node_hostname,
bridge: input.bridge,
vlan_tag: input.vlan_tag,
sdn_zone: input.sdn_zone,
tags: baseTags,
metadata,
imported_by: input.imported_by
}))
});
}
return {
imported: toInsert.length,
skipped_existing: values.length - toInsert.length,
total_candidates: values.length
};
}
export async function assignIpToVm(input: AssignIpInput) {
const vm = await fetchVmForNetwork(input.vm_id);
const assignmentType = input.assignment_type ?? IpAssignmentType.ADDITIONAL;
const metadata = input.metadata ? toPrismaJsonValue(input.metadata) : {};
const resolvedPolicy = await resolveAllocationPolicy({
tenant_id: vm.tenant_id,
scope: input.scope,
version: input.version,
node_hostname: vm.node,
bridge: null,
vlan_tag: null,
sdn_zone: null
});
const where: Prisma.IpAddressPoolWhereInput = {
status: IpAddressStatus.AVAILABLE
};
if (input.ip_address_id) where.id = input.ip_address_id;
if (input.address) where.address = input.address;
if (input.scope) where.scope = input.scope;
if (input.version) where.version = input.version;
if (resolvedPolicy?.scope) where.scope = resolvedPolicy.scope;
if (resolvedPolicy?.version) where.version = resolvedPolicy.version;
if (resolvedPolicy?.node_hostname) where.node_hostname = resolvedPolicy.node_hostname;
if (resolvedPolicy?.bridge) where.bridge = resolvedPolicy.bridge;
if (typeof resolvedPolicy?.vlan_tag === "number") where.vlan_tag = resolvedPolicy.vlan_tag;
if (resolvedPolicy?.sdn_zone) where.sdn_zone = resolvedPolicy.sdn_zone;
const candidates = await prisma.ipAddressPool.findMany({
where,
select: {
id: true,
address: true,
cidr: true,
version: true,
scope: true,
subnet: true,
node_hostname: true,
bridge: true,
vlan_tag: true,
sdn_zone: true,
imported_at: true
},
orderBy: [{ imported_at: "asc" }, { address: "asc" }],
take: input.ip_address_id || input.address ? 5 : 3000
});
if (candidates.length === 0) {
throw new HttpError(404, "No available IP address found", "IP_NOT_AVAILABLE");
}
const reservedRanges = await prisma.ipReservedRange.findMany({
where: {
is_active: true,
OR: [{ tenant_id: vm.tenant_id }, { tenant_id: null }]
},
select: {
tenant_id: true,
cidr: true,
scope: true,
version: true,
node_hostname: true,
bridge: true,
vlan_tag: true,
sdn_zone: true
}
});
const candidateFlags = candidates
.map((candidate) => {
const reservation = isCandidateReservedForOtherTenant(candidate, reservedRanges, vm.tenant_id);
return {
...candidate,
tenantReserved: reservation.tenantReserved,
blocked: reservation.blocked
};
})
.filter((candidate) => !(resolvedPolicy?.disallow_reserved_use ?? true ? candidate.blocked : false));
if (candidateFlags.length === 0) {
throw new HttpError(409, "All candidate IPs are reserved by policy", "IP_RESERVED_BY_POLICY");
}
const strategy = resolvedPolicy?.allocation_strategy ?? IpAllocationStrategy.BEST_FIT;
const picked = pickBestFitCandidate(candidateFlags, strategy);
if (!picked) {
throw new HttpError(404, "No allocatable IP candidate found", "IP_NOT_AVAILABLE");
}
await enforceTenantQuota({
tenant_id: vm.tenant_id,
version: picked.version,
policyEnforced: resolvedPolicy?.enforce_quota ?? true
});
return prisma.$transaction(async (tx) => {
const updatedIp = await tx.ipAddressPool.update({
where: { id: picked.id },
data: {
status: IpAddressStatus.ASSIGNED,
assigned_vm_id: vm.id,
assigned_tenant_id: vm.tenant_id,
assigned_at: new Date(),
returned_at: null
}
});
const assignment = await tx.ipAssignment.create({
data: {
ip_address_id: updatedIp.id,
vm_id: vm.id,
tenant_id: vm.tenant_id,
assignment_type: assignmentType,
interface_name: input.interface_name,
notes: input.notes,
metadata,
assigned_by: input.actor_email
},
include: {
ip_address: true,
vm: {
select: {
id: true,
name: true,
tenant_id: true
}
}
}
});
if (assignmentType === IpAssignmentType.PRIMARY) {
await tx.virtualMachine.update({
where: { id: vm.id },
data: {
ip_address: updatedIp.address
}
});
}
return assignment;
});
}
export async function returnAssignedIp(input: ReturnIpInput) {
let assignment = input.assignment_id
? await prisma.ipAssignment.findUnique({
where: { id: input.assignment_id },
include: {
ip_address: true,
vm: {
select: {
id: true,
ip_address: true
}
}
}
})
: null;
if (!assignment && input.ip_address_id) {
assignment = await prisma.ipAssignment.findFirst({
where: {
ip_address_id: input.ip_address_id,
is_active: true
},
include: {
ip_address: true,
vm: {
select: {
id: true,
ip_address: true
}
}
},
orderBy: {
assigned_at: "desc"
}
});
}
if (!assignment) {
throw new HttpError(404, "Active IP assignment not found", "IP_ASSIGNMENT_NOT_FOUND");
}
if (!assignment.is_active) {
return assignment;
}
return prisma.$transaction(async (tx) => {
const released = await tx.ipAssignment.update({
where: { id: assignment!.id },
data: {
is_active: false,
released_at: new Date()
},
include: {
ip_address: true,
vm: {
select: {
id: true,
ip_address: true
}
}
}
});
await tx.ipAddressPool.update({
where: { id: assignment!.ip_address_id },
data: {
status: IpAddressStatus.AVAILABLE,
assigned_vm_id: null,
assigned_tenant_id: null,
assigned_at: null,
returned_at: new Date()
}
});
if (
assignment!.assignment_type === IpAssignmentType.PRIMARY &&
released.vm.ip_address &&
released.vm.ip_address === released.ip_address.address
) {
await tx.virtualMachine.update({
where: { id: released.vm.id },
data: {
ip_address: null
}
});
}
return released;
});
}
export async function listIpAssignments(params?: { vm_id?: string; tenant_id?: string; active_only?: boolean }) {
const where: Prisma.IpAssignmentWhereInput = {};
if (params?.vm_id) where.vm_id = params.vm_id;
if (params?.tenant_id) where.tenant_id = params.tenant_id;
if (params?.active_only) where.is_active = true;
return prisma.ipAssignment.findMany({
where,
include: {
ip_address: true,
vm: {
select: {
id: true,
name: true,
tenant_id: true,
node: true
}
}
},
orderBy: [{ is_active: "desc" }, { assigned_at: "desc" }]
});
}
export async function subnetUtilizationDashboard(input?: {
scope?: IpScope;
version?: IpVersion;
node_hostname?: string;
bridge?: string;
vlan_tag?: number;
tenant_id?: string;
}) {
const where: Prisma.IpAddressPoolWhereInput = {};
if (input?.scope) where.scope = input.scope;
if (input?.version) where.version = input.version;
if (input?.node_hostname) where.node_hostname = input.node_hostname;
if (input?.bridge) where.bridge = input.bridge;
if (typeof input?.vlan_tag === "number") where.vlan_tag = input.vlan_tag;
if (input?.tenant_id) {
where.OR = [{ assigned_tenant_id: input.tenant_id }, { status: IpAddressStatus.AVAILABLE }];
}
const [ips, quotas, activeAssignments, reservedRanges] = await Promise.all([
prisma.ipAddressPool.findMany({
where,
select: {
id: true,
address: true,
cidr: true,
subnet: true,
scope: true,
version: true,
status: true,
assigned_tenant_id: true,
node_hostname: true,
bridge: true,
vlan_tag: true
}
}),
prisma.tenantIpQuota.findMany({
where: input?.tenant_id ? { tenant_id: input.tenant_id } : undefined,
include: {
tenant: {
select: {
id: true,
name: true
}
}
}
}),
prisma.ipAssignment.findMany({
where: {
is_active: true,
...(input?.tenant_id ? { tenant_id: input.tenant_id } : {})
},
include: {
ip_address: {
select: {
version: true
}
}
}
}),
prisma.ipReservedRange.findMany({
where: {
is_active: true,
...(input?.tenant_id ? { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } : {})
}
})
]);
const subnetMap = new Map<
string,
{
subnet: string;
scope: IpScope;
version: IpVersion;
node_hostname: string | null;
bridge: string | null;
vlan_tag: number | null;
total: number;
available: number;
assigned: number;
reserved: number;
retired: number;
}
>();
for (const ip of ips) {
const subnet = ip.subnet ?? `${ip.address}/${ip.cidr}`;
const key = `${ip.scope}:${ip.version}:${ip.node_hostname ?? "-"}:${ip.bridge ?? "-"}:${ip.vlan_tag ?? "-"}:${subnet}`;
if (!subnetMap.has(key)) {
subnetMap.set(key, {
subnet,
scope: ip.scope,
version: ip.version,
node_hostname: ip.node_hostname,
bridge: ip.bridge,
vlan_tag: ip.vlan_tag,
total: 0,
available: 0,
assigned: 0,
reserved: 0,
retired: 0
});
}
const entry = subnetMap.get(key)!;
entry.total += 1;
if (ip.status === IpAddressStatus.AVAILABLE) entry.available += 1;
if (ip.status === IpAddressStatus.ASSIGNED) entry.assigned += 1;
if (ip.status === IpAddressStatus.RESERVED) entry.reserved += 1;
if (ip.status === IpAddressStatus.RETIRED) entry.retired += 1;
}
const subnets = [...subnetMap.values()]
.map((item) => {
const utilization_pct = item.total > 0 ? Number(((item.assigned / item.total) * 100).toFixed(2)) : 0;
const pressure_pct = item.total > 0 ? Number((((item.assigned + item.reserved) / item.total) * 100).toFixed(2)) : 0;
return {
...item,
utilization_pct,
pressure_pct
};
})
.sort((a, b) => b.utilization_pct - a.utilization_pct || a.subnet.localeCompare(b.subnet));
const assignmentSummary = activeAssignments.reduce(
(acc, item) => {
if (item.ip_address.version === IpVersion.IPV4) acc.ipv4 += 1;
if (item.ip_address.version === IpVersion.IPV6) acc.ipv6 += 1;
return acc;
},
{ total: activeAssignments.length, ipv4: 0, ipv6: 0 }
);
return {
subnets,
quota_summary: quotas.map((quota) => ({
tenant_id: quota.tenant_id,
tenant_name: quota.tenant.name,
ipv4_limit: quota.ipv4_limit,
ipv6_limit: quota.ipv6_limit,
burst_allowed: quota.burst_allowed
})),
assignment_summary: assignmentSummary,
reserved_range_count: reservedRanges.length
};
}
export async function upsertTenantIpQuota(input: UpsertTenantQuotaInput) {
return prisma.tenantIpQuota.upsert({
where: {
tenant_id: input.tenant_id
},
update: {
ipv4_limit: input.ipv4_limit ?? undefined,
ipv6_limit: input.ipv6_limit ?? undefined,
reserved_ipv4: input.reserved_ipv4,
reserved_ipv6: input.reserved_ipv6,
burst_allowed: input.burst_allowed,
burst_ipv4_limit: input.burst_ipv4_limit ?? undefined,
burst_ipv6_limit: input.burst_ipv6_limit ?? undefined,
is_active: input.is_active,
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : undefined
},
create: {
tenant_id: input.tenant_id,
ipv4_limit: input.ipv4_limit ?? null,
ipv6_limit: input.ipv6_limit ?? null,
reserved_ipv4: input.reserved_ipv4 ?? 0,
reserved_ipv6: input.reserved_ipv6 ?? 0,
burst_allowed: input.burst_allowed ?? false,
burst_ipv4_limit: input.burst_ipv4_limit ?? null,
burst_ipv6_limit: input.burst_ipv6_limit ?? null,
is_active: input.is_active ?? true,
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {},
created_by: input.created_by
},
include: {
tenant: {
select: {
id: true,
name: true
}
}
}
});
}
export async function listTenantIpQuotas(tenantId?: string) {
return prisma.tenantIpQuota.findMany({
where: tenantId ? { tenant_id: tenantId } : undefined,
include: {
tenant: {
select: {
id: true,
name: true,
slug: true
}
}
},
orderBy: [{ created_at: "desc" }]
});
}
export async function createIpReservedRange(input: CreateReservedRangeInput) {
const parsed = parseCidr(input.cidr);
return prisma.ipReservedRange.create({
data: {
name: input.name,
cidr: input.cidr,
version: parsed.version,
scope: input.scope ?? IpScope.PUBLIC,
tenant_id: input.tenant_id,
reason: input.reason,
node_hostname: input.node_hostname,
bridge: input.bridge,
vlan_tag: input.vlan_tag,
sdn_zone: input.sdn_zone,
is_active: input.is_active ?? true,
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {},
created_by: input.created_by
}
});
}
export async function listIpReservedRanges() {
return prisma.ipReservedRange.findMany({
include: {
tenant: {
select: {
id: true,
name: true
}
}
},
orderBy: [{ is_active: "desc" }, { created_at: "desc" }]
});
}
export async function updateIpReservedRange(rangeId: string, input: Partial<CreateReservedRangeInput>) {
const parsed = input.cidr ? parseCidr(input.cidr) : null;
return prisma.ipReservedRange.update({
where: { id: rangeId },
data: {
name: input.name,
cidr: input.cidr,
version: parsed?.version,
scope: input.scope,
tenant_id: input.tenant_id,
reason: input.reason,
node_hostname: input.node_hostname,
bridge: input.bridge,
vlan_tag: input.vlan_tag,
sdn_zone: input.sdn_zone,
is_active: input.is_active,
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : undefined
}
});
}
export async function upsertIpPoolPolicy(input: UpsertIpPoolPolicyInput) {
if (input.policy_id) {
return prisma.ipPoolPolicy.update({
where: { id: input.policy_id },
data: {
name: input.name,
tenant_id: input.tenant_id,
scope: input.scope,
version: input.version,
node_hostname: input.node_hostname,
bridge: input.bridge,
vlan_tag: input.vlan_tag,
sdn_zone: input.sdn_zone,
allocation_strategy: input.allocation_strategy,
enforce_quota: input.enforce_quota,
disallow_reserved_use: input.disallow_reserved_use,
is_active: input.is_active,
priority: input.priority,
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : undefined
}
});
}
return prisma.ipPoolPolicy.create({
data: {
name: input.name,
tenant_id: input.tenant_id,
scope: input.scope,
version: input.version,
node_hostname: input.node_hostname,
bridge: input.bridge,
vlan_tag: input.vlan_tag,
sdn_zone: input.sdn_zone,
allocation_strategy: input.allocation_strategy ?? IpAllocationStrategy.BEST_FIT,
enforce_quota: input.enforce_quota ?? true,
disallow_reserved_use: input.disallow_reserved_use ?? true,
is_active: input.is_active ?? true,
priority: input.priority ?? 100,
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {},
created_by: input.created_by
}
});
}
export async function listIpPoolPolicies() {
return prisma.ipPoolPolicy.findMany({
include: {
tenant: {
select: {
id: true,
name: true
}
}
},
orderBy: [{ priority: "asc" }, { created_at: "asc" }]
});
}
export async function createPrivateNetwork(input: CreatePrivateNetworkInput) {
parseCidr(input.cidr);
const slug = input.slug && input.slug.trim().length > 0 ? normalizeSlug(input.slug) : normalizeSlug(input.name);
return prisma.privateNetwork.create({
data: {
name: input.name,
slug,
network_type: input.network_type ?? PrivateNetworkType.VLAN,
cidr: input.cidr,
gateway: input.gateway,
bridge: input.bridge,
vlan_tag: input.vlan_tag,
sdn_zone: input.sdn_zone,
server: input.server,
node_hostname: input.node_hostname,
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {},
created_by: input.created_by
}
});
}
export async function listPrivateNetworks() {
return prisma.privateNetwork.findMany({
include: {
attachments: {
where: {
status: PrivateNetworkAttachmentStatus.ATTACHED
},
select: {
id: true,
vm_id: true,
interface_name: true,
attached_at: true
}
}
},
orderBy: [{ created_at: "desc" }]
});
}
export async function attachPrivateNetwork(input: AttachPrivateNetworkInput) {
const [network, vm] = await Promise.all([
prisma.privateNetwork.findUnique({ where: { id: input.network_id } }),
fetchVmForNetwork(input.vm_id)
]);
if (!network) {
throw new HttpError(404, "Private network not found", "PRIVATE_NETWORK_NOT_FOUND");
}
const interfaceName = input.interface_name ?? "net1";
const runtime = vmRuntime(vm.type);
const bridge = network.bridge ?? (typeof network.vlan_tag === "number" ? `vmbr${network.vlan_tag}` : "vmbr0");
const upid = await reconfigureVmNetwork(vm.node, vm.vmid, runtime, {
interface_name: interfaceName,
bridge,
vlan_tag: network.vlan_tag ?? undefined,
ip_mode: input.requested_ip ? "static" : "dhcp",
ip_cidr: input.requested_ip,
gateway: input.requested_ip ? network.gateway ?? undefined : undefined
});
const existing = await prisma.privateNetworkAttachment.findFirst({
where: {
network_id: network.id,
vm_id: vm.id,
interface_name: interfaceName
}
});
if (existing) {
return prisma.privateNetworkAttachment.update({
where: { id: existing.id },
data: {
tenant_id: vm.tenant_id,
requested_ip: input.requested_ip,
status: PrivateNetworkAttachmentStatus.ATTACHED,
detached_at: null,
attached_by: input.actor_email,
attached_at: new Date(),
metadata: toPrismaJsonValue({
...(input.metadata ?? {}),
proxmox_upid: upid
})
},
include: {
network: true,
vm: {
select: {
id: true,
name: true,
tenant_id: true,
node: true
}
}
}
});
}
return prisma.privateNetworkAttachment.create({
data: {
network_id: network.id,
vm_id: vm.id,
tenant_id: vm.tenant_id,
interface_name: interfaceName,
requested_ip: input.requested_ip,
status: PrivateNetworkAttachmentStatus.ATTACHED,
attached_by: input.actor_email,
metadata: toPrismaJsonValue({
...(input.metadata ?? {}),
proxmox_upid: upid
})
},
include: {
network: true,
vm: {
select: {
id: true,
name: true,
tenant_id: true,
node: true
}
}
}
});
}
export async function detachPrivateNetwork(input: DetachPrivateNetworkInput) {
const attachment = await prisma.privateNetworkAttachment.findUnique({
where: { id: input.attachment_id },
include: {
vm: {
select: {
id: true,
name: true,
tenant_id: true,
vmid: true,
node: true,
type: true
}
}
}
});
if (!attachment) {
throw new HttpError(404, "Private network attachment not found", "PRIVATE_NETWORK_ATTACHMENT_NOT_FOUND");
}
if (attachment.status === PrivateNetworkAttachmentStatus.DETACHED) {
return attachment;
}
const runtime = vmRuntime(attachment.vm.type);
const interfaceName = attachment.interface_name ?? "net1";
const upid = await updateVmConfiguration(attachment.vm.node, attachment.vm.vmid, runtime, {
delete: interfaceName
});
return prisma.privateNetworkAttachment.update({
where: { id: attachment.id },
data: {
status: PrivateNetworkAttachmentStatus.DETACHED,
detached_at: new Date(),
metadata: toPrismaJsonValue({
...(attachment.metadata as Record<string, unknown>),
detached_by: input.actor_email,
detach_upid: upid
})
},
include: {
network: true,
vm: {
select: {
id: true,
name: true,
tenant_id: true,
node: true
}
}
}
});
}