Files
p2ns/includes/networking/virtual_interfaces.js
T
2025-12-17 20:05:50 -05:00

567 lines
23 KiB
JavaScript

const { exec } = require('child_process');
const os = require('os');
const util = require('util');
const state = require('../infrastructure/state');
const { logDebug, logError, logWarn, logInfo } = require('../infrastructure/logger');
const execAsync = util.promisify(exec);
// Helper function to calculate available IPs for a subnet
function getAvailableIPsForSubnet(subnet) {
const maxIPs = Math.pow(2, 32 - subnet.cidr) - 2; // Subtract network and broadcast
const startIndex = subnet.startIndex || 2;
const endIndex = Math.min(254, maxIPs); // Cap at 254 for /24 and smaller
return Math.max(0, endIndex - startIndex + 1);
}
// Helper function to check if an IP belongs to any configured subnet
function ipBelongsToSubnet(ip, subnet) {
const ipParts = ip.split('.').map(p => parseInt(p, 10));
const subnetParts = subnet.base.split('.').map(p => parseInt(p, 10));
// Calculate network mask
const maskBits = subnet.cidr;
const maskParts = [];
for (let i = 0; i < 4; i++) {
const bits = Math.min(8, Math.max(0, maskBits - i * 8));
maskParts.push(bits === 8 ? 255 : bits === 0 ? 0 : (255 << (8 - bits)) & 255);
}
// Check if IP is in subnet
for (let i = 0; i < 4; i++) {
if ((ipParts[i] & maskParts[i]) !== (subnetParts[i] & maskParts[i])) {
return false;
}
}
return true;
}
// Helper function to generate IP from subnet and index
function generateIPFromSubnet(subnet, index) {
const baseParts = subnet.base.split('.').map(p => parseInt(p, 10));
const lastOctet = index;
return `${baseParts[0]}.${baseParts[1]}.${baseParts[2]}.${lastOctet}`;
}
// Helper function to check if base interface is up
async function ensureBaseInterfaceUp() {
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
return true;
}
if (!state.subnetName) {
return false;
}
try {
let command;
if (os.platform() === 'darwin') {
command = `ifconfig ${state.subnetName} | grep -q 'status: active' || ifconfig ${state.subnetName} | grep -q 'UP'`;
} else if (os.platform() === 'linux') {
command = `ip link show ${state.subnetName} | grep -q 'state UP'`;
} else if (os.platform() === 'win32') {
// Windows loopback interfaces are typically always up
return true;
} else {
logWarn('VirtualInterface', `Unsupported platform: ${os.platform()}, cannot verify interface state`);
return false;
}
await execAsync(command);
return true;
} catch (err) {
// Interface is not up, try to bring it up
logDebug('VirtualInterface', `Base interface ${state.subnetName} is not up, attempting to bring it up`);
try {
let upCommand;
if (os.platform() === 'darwin') {
// macOS loopback interfaces are typically always up, but we can verify
upCommand = `ifconfig ${state.subnetName} up`;
} else if (os.platform() === 'linux') {
upCommand = `sudo ip link set ${state.subnetName} up`;
} else {
return false;
}
await execAsync(upCommand);
logInfo('VirtualInterface', `Brought base interface ${state.subnetName} up`);
return true;
} catch (upErr) {
logWarn('VirtualInterface', `Could not bring base interface ${state.subnetName} up: ${upErr.message}`);
return false;
}
}
}
// Helper function to verify interface state after IP assignment
async function verifyInterfaceState(ip) {
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
return true;
}
try {
let command;
if (os.platform() === 'darwin') {
command = `ifconfig ${state.subnetName} | grep 'inet ${ip}'`;
} else if (os.platform() === 'linux') {
command = `ip addr show ${state.subnetName} | grep 'inet ${ip}/'`;
} else if (os.platform() === 'win32') {
// Windows: Verify IP is configured
command = `netsh interface ip show addresses "${state.subnetName || 'Loopback Pseudo-Interface 1'}" | findstr "${ip}"`;
} else {
return false;
}
await execAsync(command);
return true;
} catch (err) {
logWarn('VirtualInterface', `Could not verify interface state for IP ${ip}: ${err.message}`);
return false;
}
}
// Helper function to get all configured IPs on the interface
async function getConfiguredIPs() {
// If virtual interfaces are disabled, return empty array
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
return [];
}
try {
let command;
if (os.platform() === 'darwin') {
command = `ifconfig ${state.subnetName} | grep 'inet ' | awk '{print $2}'`;
} else if (os.platform() === 'linux') {
command = `ip addr show ${state.subnetName} | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1`;
} else if (os.platform() === 'win32') {
// Windows: Get IP addresses using netsh
command = `netsh interface ip show addresses "${state.subnetName || 'Loopback Pseudo-Interface 1'}" | findstr "IP Address"`;
} else {
logWarn('VirtualInterface', `Unsupported platform: ${os.platform()}, assuming no configured IPs`);
return [];
}
const { stdout } = await execAsync(command);
if (os.platform() === 'win32') {
// Parse Windows output: "IP Address: 192.168.3.2"
return stdout.split('\n')
.map(line => {
const match = line.match(/IP Address:\s*(\d+\.\d+\.\d+\.\d+)/);
return match ? match[1] : null;
})
.filter(ip => ip);
}
return stdout.split('\n').map(ip => ip.trim()).filter(ip => ip);
} catch (err) {
logError('VirtualInterface', `Error listing configured IPs: ${err.message}`);
return [];
}
}
// Clean up invalid or stale IPs from state.domainToIPMap at startup
async function cleanInvalidIPs() {
// Skip cleanup if virtual interfaces are disabled
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
logDebug('VirtualInterface', 'Virtual interfaces disabled, skipping IP cleanup');
return;
}
// Get list of plugin/internal domains (these use 127.0.0.1)
let pluginDomains = [];
try {
const { getInternalDomains } = require('../plugins/plugin-handler');
pluginDomains = await getInternalDomains();
} catch (err) {
logDebug('VirtualInterface', `Could not get plugin domains during cleanup: ${err.message}`);
}
const pluginDomainsSet = new Set(pluginDomains);
const configuredIPs = await getConfiguredIPs();
const validSubnets = state.subnets || [];
// If no subnets configured, fall back to legacy SUBNET_BASE check
const legacySubnetBase = process.env.SUBNET_BASE || '192.168.3.';
for (const [domain, ip] of state.domainToIPMap) {
// Skip cleanup for plugin/internal domains (they use 127.0.0.1)
if (pluginDomainsSet.has(domain)) {
if (ip === '127.0.0.1') {
logDebug('VirtualInterface', `Preserving plugin domain ${domain} with IP 127.0.0.1`);
continue;
} else {
// Plugin domain had wrong IP, update it to 127.0.0.1
logWarn('VirtualInterface', `Plugin domain ${domain} had wrong IP ${ip}, updating to 127.0.0.1`);
if (configuredIPs.includes(ip)) {
await removeVirtualInterface(ip);
}
state.domainToIPMap.set(domain, '127.0.0.1');
continue;
}
}
// For non-plugin domains, validate IP belongs to configured subnets
const ipParts = ip.split('.');
let isValid = false;
// Basic IP format validation
if (ipParts.length !== 4 || ipParts.some(part => isNaN(part) || part < 0 || part > 255)) {
isValid = false;
} else if (validSubnets.length > 0) {
// Check if IP belongs to any configured subnet
isValid = validSubnets.some(subnet => ipBelongsToSubnet(ip, subnet));
} else {
// Legacy: check if IP starts with SUBNET_BASE
isValid = ip.startsWith(legacySubnetBase);
}
if (!isValid) {
logWarn('VirtualInterface', `Removing invalid or stale IP ${ip} for domain ${domain} from state`);
if (configuredIPs.includes(ip)) {
await removeVirtualInterface(ip);
}
state.domainToIPMap.delete(domain);
} else if (configuredIPs.includes(ip)) {
logDebug('VirtualInterface', `IP ${ip} for domain ${domain} is valid and configured`);
} else {
logWarn('VirtualInterface', `IP ${ip} for domain ${domain} not configured, removing from state`);
state.domainToIPMap.delete(domain);
}
}
}
cleanInvalidIPs();
// Helper function to remove virtual interface
async function removeVirtualInterface(ip) {
return new Promise((resolve) => {
// Check if virtual interfaces are disabled
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
logDebug('VirtualInterface', 'Virtual interfaces disabled, skipping interface removal');
resolve();
return;
}
if (!state.subnetName) {
logWarn('VirtualInterface', 'SUBNET_NAME not defined, skipping interface removal');
resolve();
return;
}
// Validate IP format
const ipParts = ip.split('.');
if (ipParts.length !== 4 || ipParts.some(part => isNaN(part) || part < 0 || part > 255)) {
logWarn('VirtualInterface', `Skipping removal of invalid IP ${ip}`);
resolve();
return;
}
// Check if IP is configured
getConfiguredIPs().then(configuredIPs => {
if (!configuredIPs.includes(ip)) {
logDebug('VirtualInterface', `IP ${ip} not configured on system, skipping removal`);
resolve();
return;
}
const cidrIp = `${ip}/24`;
let command;
if (os.platform() === 'darwin') {
command = `sudo ifconfig ${state.subnetName} -alias ${ip}`;
} else if (os.platform() === 'linux') {
command = `sudo ip addr del ${cidrIp} dev ${state.subnetName}`;
} else if (os.platform() === 'win32') {
// Windows: Remove IP address using netsh
const interfaceName = state.subnetName || 'Loopback Pseudo-Interface 1';
command = `netsh interface ip delete address "${interfaceName}" ${ip}`;
} else {
logWarn('VirtualInterface', `Unsupported platform: ${os.platform()}, skipping interface removal`);
resolve();
return;
}
exec(command, (err, stdout, stderr) => {
if (err) {
logError('VirtualInterface', `Error removing virtual interface ${ip}: ${stderr || err.message}`);
} else {
logInfo('VirtualInterface', `Removed virtual interface IP: ${ip}`);
}
resolve();
});
}).catch(err => {
logError('VirtualInterface', `Error checking if IP ${ip} exists: ${err.message}`);
resolve();
});
});
}
// Helper function to create virtual interface
async function createVirtualInterface(ip) {
// Check if virtual interfaces are disabled
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
logDebug('VirtualInterface', 'Virtual interfaces disabled, skipping interface creation');
return ip; // Return the IP even though we're not creating the interface
}
// Check if IP is already configured
const configuredIPs = await getConfiguredIPs();
if (configuredIPs.includes(ip)) {
logWarn('VirtualInterface', `IP ${ip} already configured, using it`);
// Verify interface is up for ICMP
const isUp = await ensureBaseInterfaceUp();
if (isUp) {
logDebug('VirtualInterface', `Interface ${state.subnetName} is up, ICMP should be functional for ${ip}`);
}
return ip;
}
await removeVirtualInterface(ip); // Ensure IP is not already assigned
// Ensure base interface is up before adding IPs
const baseInterfaceUp = await ensureBaseInterfaceUp();
if (!baseInterfaceUp) {
logWarn('VirtualInterface', `Base interface ${state.subnetName} is not up, ICMP may not work for ${ip}`);
}
if (!state.subnetName) {
logWarn('VirtualInterface', 'Virtual interfaces not supported, skipping');
return ip;
}
const cidr = '/24';
const mask = '255.255.255.0';
let command;
if (os.platform() === 'darwin') {
command = `sudo ifconfig ${state.subnetName} alias ${ip} ${mask}`;
} else if (os.platform() === 'linux') {
command = `sudo ip addr add ${ip}${cidr} dev ${state.subnetName}`;
} else if (os.platform() === 'win32') {
// Windows: Add IP address using netsh
const interfaceName = state.subnetName || 'Loopback Pseudo-Interface 1';
command = `netsh interface ip add address "${interfaceName}" ${ip} ${mask}`;
} else {
logWarn('VirtualInterface', `Unsupported platform: ${os.platform()}, skipping interface creation`);
return ip;
}
try {
const { stdout, stderr } = await execAsync(command);
logInfo('VirtualInterface', `Created virtual interface with IP ${ip}`);
// Platform-specific post-creation steps for ICMP support
try {
if (os.platform() === 'linux') {
// On Linux, ensure the interface is up after adding IP
// This is critical for ICMP to work
const upCommand = `sudo ip link set ${state.subnetName} up`;
try {
await execAsync(upCommand);
logDebug('VirtualInterface', `Ensured interface ${state.subnetName} is up for ICMP support`);
} catch (upErr) {
logWarn('VirtualInterface', `Could not bring interface up: ${upErr.message}, ICMP may not work`);
}
} else if (os.platform() === 'darwin') {
// On macOS, verify the interface state after alias creation
const verified = await verifyInterfaceState(ip);
if (verified) {
logDebug('VirtualInterface', `Verified interface state for ${ip}, ICMP should be functional`);
} else {
logWarn('VirtualInterface', `Could not verify interface state for ${ip}, ICMP may not work`);
}
} else if (os.platform() === 'win32') {
// Windows loopback interfaces typically respond to ICMP automatically
const verified = await verifyInterfaceState(ip);
if (verified) {
logDebug('VirtualInterface', `Verified IP ${ip} is configured, ICMP should be functional`);
}
}
// Log that ICMP should now be functional
// Note: Firewall rules may still block ICMP, but the interface is configured correctly
logInfo('VirtualInterface', `Interface ${ip} is configured and ready for ICMP (ping) requests. Note: Ensure firewall allows ICMP if ping fails.`);
} catch (verifyErr) {
logWarn('VirtualInterface', `Error during post-creation verification for ${ip}: ${verifyErr.message}`);
}
return ip;
} catch (err) {
const errorMessage = err.stderr || err.message || '';
if (errorMessage.includes('Address already assigned') || errorMessage.includes('File exists')) {
logWarn('VirtualInterface', `IP ${ip} already assigned, using it`);
return ip;
} else {
logError('VirtualInterface', `Error creating virtual interface ${ip}: ${errorMessage}`);
throw new Error(`Error creating virtual interface ${ip}: ${errorMessage}`);
}
}
}
// Function to create interface for domain with round-robin across multiple subnets
async function createInterfaceForDomain(domain) {
// Check if this is a plugin/internal domain (these run on 127.0.0.1 and don't need virtual interfaces)
try {
const { getInternalDomains } = require('../plugins/plugin-handler');
const internalDomains = await getInternalDomains();
if (internalDomains.includes(domain)) {
const pluginIP = '127.0.0.1';
const existingIP = state.domainToIPMap.get(domain);
// If domain already has a virtual IP, remove it and update to 127.0.0.1
if (existingIP && existingIP !== pluginIP) {
logWarn('VirtualInterface', `Plugin domain ${domain} had virtual IP ${existingIP}, removing virtual interface and updating to 127.0.0.1`);
try {
const configuredIPs = await getConfiguredIPs();
if (configuredIPs.includes(existingIP)) {
await removeVirtualInterface(existingIP);
}
} catch (removeErr) {
logWarn('VirtualInterface', `Error removing virtual interface for plugin domain: ${removeErr.message}`);
}
}
// Always set plugin domains to 127.0.0.1
state.domainToIPMap.set(domain, pluginIP);
logDebug('VirtualInterface', `Domain ${domain} is a plugin/internal domain, using 127.0.0.1 (no virtual interface created)`);
return pluginIP;
}
} catch (err) {
// If plugin handler is not available, continue with normal flow
logDebug('VirtualInterface', `Could not check if ${domain} is a plugin domain: ${err.message}`);
}
// Check if virtual interfaces are disabled
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
logDebug('VirtualInterface', `Virtual interfaces disabled, skipping interface creation for domain: ${domain}`);
// Still return an IP for the domain mapping, but don't create the interface
// Generate a placeholder IP that won't be used for actual interface creation
if (!state.domainToIPMap.has(domain)) {
// Generate a placeholder IP based on domain hash to ensure consistency
const crypto = require('crypto');
const hash = crypto.createHash('md5').update(domain).digest('hex');
const ipIndex = parseInt(hash.substring(0, 2), 16) % 253 + 2; // Ensure it's between 2-254
const subnetBase = process.env.SUBNET_BASE || '192.168.3.';
const placeholderIP = `${subnetBase}${ipIndex}`;
state.domainToIPMap.set(domain, placeholderIP);
logDebug('VirtualInterface', `Assigned placeholder IP: ${placeholderIP} for domain: ${domain} (interface not created)`);
}
return state.domainToIPMap.get(domain);
}
if (state.domainToIPMap.has(domain)) {
const existingIP = state.domainToIPMap.get(domain);
// Validate existing IP
const ipParts = existingIP.split('.');
if (ipParts.length === 4 && ipParts.every(part => !isNaN(part) && part >= 0 && part <= 255)) {
// Check if IP is still configured
const configuredIPs = await getConfiguredIPs();
if (configuredIPs.includes(existingIP)) {
logDebug('VirtualInterface', `Interface already assigned for domain: ${domain}, IP: ${existingIP}`);
return existingIP;
} else {
logWarn('VirtualInterface', `IP ${existingIP} for domain ${domain} no longer configured, reassigning`);
state.domainToIPMap.delete(domain);
}
} else {
logWarn('VirtualInterface', `Removing invalid existing IP ${existingIP} for domain ${domain}`);
await removeVirtualInterface(existingIP);
state.domainToIPMap.delete(domain);
}
}
const subnets = state.subnets || [];
let attempts = 0;
const maxAttempts = 50; // Increased for multiple subnets
const maxSubnetAttempts = 10; // Max attempts per subnet before moving to next
// If no subnets configured, fall back to legacy behavior
if (subnets.length === 0) {
const subnetBase = process.env.SUBNET_BASE || '192.168.3.';
while (attempts < maxAttempts) {
const subnetID = state.currentIP;
const ip = `${subnetBase}${subnetID}`;
try {
const ipParts = ip.split('.');
if (ipParts.length !== 4 || ipParts.some(part => isNaN(part) || part < 0 || part > 255)) {
throw new Error(`Invalid IP format: ${ip}`);
}
for (const [existingDomain, existingIP] of state.domainToIPMap) {
if (existingIP === ip && existingDomain !== domain) {
throw new Error(`IP ${ip} already assigned to ${existingDomain}`);
}
}
const assignedIP = await createVirtualInterface(ip);
state.domainToIPMap.set(domain, assignedIP);
logInfo('VirtualInterface', `Assigned virtual interface IP: ${assignedIP} for domain: ${domain}`);
state.currentIP++;
return assignedIP;
} catch (error) {
logError('VirtualInterface', `Failed to assign IP ${ip} for ${domain}: ${error.message}`);
state.currentIP++;
attempts++;
}
}
logError('VirtualInterface', `Exceeded max attempts to assign IP for domain: ${domain}`);
return null;
}
// Round-robin across multiple subnets
let subnetAttempts = 0;
let startSubnetIndex = state.currentSubnetIndex;
while (attempts < maxAttempts && subnetAttempts < maxSubnetAttempts * subnets.length) {
const subnetIndex = state.currentSubnetIndex;
const subnet = subnets[subnetIndex];
if (!subnet) {
logError('VirtualInterface', `Invalid subnet index: ${subnetIndex}`);
state.currentSubnetIndex = (state.currentSubnetIndex + 1) % subnets.length;
subnetAttempts++;
continue;
}
// Get current IP counter for this subnet
const currentIPIndex = state.subnetIPCounters.get(subnetIndex) || subnet.startIndex;
const maxIPs = Math.pow(2, 32 - subnet.cidr) - 2;
const maxIndex = Math.min(254, maxIPs); // Cap at 254
// Check if subnet is exhausted
if (currentIPIndex > maxIndex) {
logWarn('VirtualInterface', `Subnet ${subnet.name} (${subnet.base}/${subnet.cidr}) exhausted, moving to next`);
state.currentSubnetIndex = (state.currentSubnetIndex + 1) % subnets.length;
subnetAttempts++;
// If we've tried all subnets, break
if (state.currentSubnetIndex === startSubnetIndex && subnetAttempts > 0) {
logError('VirtualInterface', `All subnets exhausted, cannot assign IP for domain: ${domain}`);
break;
}
continue;
}
const ip = generateIPFromSubnet(subnet, currentIPIndex);
try {
// Validate IP format
const ipParts = ip.split('.');
if (ipParts.length !== 4 || ipParts.some(part => isNaN(part) || part < 0 || part > 255)) {
throw new Error(`Invalid IP format: ${ip}`);
}
// Check if IP is already assigned to another domain
for (const [existingDomain, existingIP] of state.domainToIPMap) {
if (existingIP === ip && existingDomain !== domain) {
throw new Error(`IP ${ip} already assigned to ${existingDomain}`);
}
}
const assignedIP = await createVirtualInterface(ip);
state.domainToIPMap.set(domain, assignedIP);
logInfo('VirtualInterface', `Assigned virtual interface IP: ${assignedIP} for domain: ${domain} from subnet ${subnet.name}`);
// Increment IP counter for this subnet and move to next subnet (round-robin)
state.subnetIPCounters.set(subnetIndex, currentIPIndex + 1);
state.currentSubnetIndex = (state.currentSubnetIndex + 1) % subnets.length;
return assignedIP;
} catch (error) {
logError('VirtualInterface', `Failed to assign IP ${ip} for ${domain}: ${error.message}`);
// Increment IP counter and try next IP in same subnet
state.subnetIPCounters.set(subnetIndex, currentIPIndex + 1);
attempts++;
subnetAttempts++;
}
}
logError('VirtualInterface', `Exceeded max attempts to assign IP for domain: ${domain}`);
return null;
}
module.exports = {
removeVirtualInterface,
createVirtualInterface,
createInterfaceForDomain,
getAvailableIPsForSubnet,
ipBelongsToSubnet,
generateIPFromSubnet
};