Files
herolib/lib/virt/heropods/network.v
Mahmoud-Emad 9af9ab40b5 feat: Add iptables FORWARD rules for bridge
- Allow traffic from bridge to external interface
- Allow established traffic from external to bridge
- Allow traffic between containers on same bridge
2025-11-19 10:50:09 +02:00

595 lines
18 KiB
V

module heropods
import incubaid.herolib.osal.core as osal
import os
import crypto.sha256
// Network configuration for HeroPods
//
// This module provides container networking similar to Docker/Podman:
// - Bridge networking with automatic IP allocation
// - NAT for outbound internet access
// - DNS configuration
// - veth pair management
//
// Thread Safety:
// All network_config operations are protected by HeroPods.network_mutex.
// The struct is not marked as `shared` to maintain compatibility with
// paramsparser's compile-time reflection.
//
// Future extension possibilities:
// - IPv6 support
// - Custom per-container DNS servers
// - iptables isolation (firewall per container)
// - Multiple bridges for isolated networks
// - Port forwarding/mapping
// - Network policies and traffic shaping
// NetworkConfig holds network configuration for HeroPods containers
struct NetworkConfig {
pub mut:
bridge_name string // Name of the bridge (e.g., "heropods0")
subnet string // Subnet for the bridge (e.g., "10.10.0.0/24")
gateway_ip string // Gateway IP for the bridge
dns_servers []string // List of DNS servers
allocated_ips map[string]string // container_name -> IP address
freed_ip_pool []int // Pool of freed IP offsets for reuse (e.g., [15, 23, 42])
next_ip_offset int = 10 // Start allocating from 10.10.0.10 (only used when pool is empty)
}
// Initialize network configuration in HeroPods factory
fn (mut self HeroPods) network_init() ! {
self.logger.log(
cat: 'network'
log: 'START network_init() - Initializing HeroPods network layer'
) or {}
// Setup host bridge if it doesn't exist
self.logger.log(
cat: 'network'
log: 'Calling network_setup_bridge()...'
logtype: .stdout
) or {}
self.network_setup_bridge()!
self.logger.log(
cat: 'network'
log: 'END network_init() - HeroPods network layer initialized successfully'
logtype: .stdout
) or {}
}
// Setup the host bridge network (one-time setup, idempotent)
fn (mut self HeroPods) network_setup_bridge() ! {
bridge_name := self.network_config.bridge_name
gateway_ip := '${self.network_config.gateway_ip}/${self.network_config.subnet.split('/')[1]}'
subnet := self.network_config.subnet
self.logger.log(
cat: 'network'
log: 'START network_setup_bridge() - bridge=${bridge_name}, gateway=${gateway_ip}, subnet=${subnet}'
logtype: .stdout
) or {}
// Check if bridge already exists using os.execute (more reliable than osal.exec)
self.logger.log(
cat: 'network'
log: 'Checking if bridge ${bridge_name} exists (running: ip link show ${bridge_name})...'
logtype: .stdout
) or {}
check_result := os.execute('ip link show ${bridge_name} 2>/dev/null')
self.logger.log(
cat: 'network'
log: 'Bridge check result: exit_code=${check_result.exit_code}'
logtype: .stdout
) or {}
if check_result.exit_code == 0 {
self.logger.log(
cat: 'network'
log: 'Bridge ${bridge_name} already exists - skipping creation'
logtype: .stdout
) or {}
return
}
self.logger.log(
cat: 'network'
log: 'Bridge ${bridge_name} does not exist - creating new bridge'
logtype: .stdout
) or {}
// Create bridge
self.logger.log(
cat: 'network'
log: 'Step 1: Creating bridge (running: ip link add name ${bridge_name} type bridge)...'
logtype: .stdout
) or {}
osal.exec(
cmd: 'ip link add name ${bridge_name} type bridge'
stdout: false
)!
self.logger.log(
cat: 'network'
log: 'Step 1: Bridge created successfully'
logtype: .stdout
) or {}
// Assign IP to bridge
self.logger.log(
cat: 'network'
log: 'Step 2: Assigning IP to bridge (running: ip addr add ${gateway_ip} dev ${bridge_name})...'
logtype: .stdout
) or {}
osal.exec(
cmd: 'ip addr add ${gateway_ip} dev ${bridge_name}'
stdout: false
)!
self.logger.log(
cat: 'network'
log: 'Step 2: IP assigned successfully'
logtype: .stdout
) or {}
// Bring bridge up
self.logger.log(
cat: 'network'
log: 'Step 3: Bringing bridge up (running: ip link set ${bridge_name} up)...'
logtype: .stdout
) or {}
osal.exec(
cmd: 'ip link set ${bridge_name} up'
stdout: false
)!
self.logger.log(
cat: 'network'
log: 'Step 3: Bridge brought up successfully'
logtype: .stdout
) or {}
// Enable IP forwarding
self.logger.log(
cat: 'network'
log: 'Step 4: Enabling IP forwarding (running: sysctl -w net.ipv4.ip_forward=1)...'
logtype: .stdout
) or {}
forward_result := os.execute('sysctl -w net.ipv4.ip_forward=1 2>/dev/null')
if forward_result.exit_code != 0 {
self.logger.log(
cat: 'network'
log: 'Step 4: WARNING - Failed to enable IPv4 forwarding (exit_code=${forward_result.exit_code})'
logtype: .error
) or {}
} else {
self.logger.log(
cat: 'network'
log: 'Step 4: IP forwarding enabled successfully'
logtype: .stdout
) or {}
}
// Get primary network interface for NAT
self.logger.log(
cat: 'network'
log: 'Step 5: Detecting primary network interface...'
logtype: .stdout
) or {}
primary_iface := self.network_get_primary_interface() or {
self.logger.log(
cat: 'network'
log: 'Step 5: WARNING - Could not detect primary interface: ${err}, using fallback eth0'
logtype: .error
) or {}
'eth0' // fallback
}
self.logger.log(
cat: 'network'
log: 'Step 5: Primary interface detected: ${primary_iface}'
logtype: .stdout
) or {}
// Setup NAT for outbound traffic
self.logger.log(
cat: 'network'
log: 'Step 6: Setting up NAT rules for ${primary_iface} (running iptables command)...'
logtype: .stdout
) or {}
nat_result := os.execute('iptables -t nat -C POSTROUTING -s ${subnet} -o ${primary_iface} -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s ${subnet} -o ${primary_iface} -j MASQUERADE')
if nat_result.exit_code != 0 {
self.logger.log(
cat: 'network'
log: 'Step 6: WARNING - Failed to setup NAT rules (exit_code=${nat_result.exit_code})'
logtype: .error
) or {}
} else {
self.logger.log(
cat: 'network'
log: 'Step 6: NAT rules configured successfully'
logtype: .stdout
) or {}
}
// Setup FORWARD rules to allow traffic from/to the bridge
self.logger.log(
cat: 'network'
log: 'Step 7: Setting up FORWARD rules for ${bridge_name}...'
logtype: .stdout
) or {}
// Allow forwarding from bridge to external interface
forward_out_result := os.execute('iptables -C FORWARD -i ${bridge_name} -o ${primary_iface} -j ACCEPT 2>/dev/null || iptables -A FORWARD -i ${bridge_name} -o ${primary_iface} -j ACCEPT')
if forward_out_result.exit_code != 0 {
self.logger.log(
cat: 'network'
log: 'Step 7: WARNING - Failed to setup FORWARD rule (bridge -> external) (exit_code=${forward_out_result.exit_code})'
logtype: .error
) or {}
}
// Allow forwarding from external interface to bridge (for established connections)
forward_in_result := os.execute('iptables -C FORWARD -i ${primary_iface} -o ${bridge_name} -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || iptables -A FORWARD -i ${primary_iface} -o ${bridge_name} -m state --state RELATED,ESTABLISHED -j ACCEPT')
if forward_in_result.exit_code != 0 {
self.logger.log(
cat: 'network'
log: 'Step 7: WARNING - Failed to setup FORWARD rule (external -> bridge) (exit_code=${forward_in_result.exit_code})'
logtype: .error
) or {}
}
// Allow forwarding between containers on the same bridge
forward_bridge_result := os.execute('iptables -C FORWARD -i ${bridge_name} -o ${bridge_name} -j ACCEPT 2>/dev/null || iptables -A FORWARD -i ${bridge_name} -o ${bridge_name} -j ACCEPT')
if forward_bridge_result.exit_code != 0 {
self.logger.log(
cat: 'network'
log: 'Step 7: WARNING - Failed to setup FORWARD rule (bridge -> bridge) (exit_code=${forward_bridge_result.exit_code})'
logtype: .error
) or {}
}
self.logger.log(
cat: 'network'
log: 'Step 7: FORWARD rules configured successfully'
logtype: .stdout
) or {}
self.logger.log(
cat: 'network'
log: 'END network_setup_bridge() - Bridge ${bridge_name} created and configured successfully'
logtype: .stdout
) or {}
}
// Get the primary network interface for NAT
fn (mut self HeroPods) network_get_primary_interface() !string {
self.logger.log(
cat: 'network'
log: 'START network_get_primary_interface() - Detecting primary interface'
logtype: .stdout
) or {}
// Try to get the default route interface
cmd := "ip route | grep default | awk '{print \$5}' | head -n1"
self.logger.log(
cat: 'network'
log: 'Running command: ${cmd}'
logtype: .stdout
) or {}
result := osal.exec(
cmd: cmd
stdout: false
)!
self.logger.log(
cat: 'network'
log: 'Command completed, output: "${result.output.trim_space()}"'
logtype: .stdout
) or {}
iface := result.output.trim_space()
if iface == '' {
self.logger.log(
cat: 'network'
log: 'ERROR: Could not determine primary network interface (empty output)'
logtype: .error
) or {}
return error('Could not determine primary network interface')
}
self.logger.log(
cat: 'network'
log: 'END network_get_primary_interface() - Detected interface: ${iface}'
logtype: .stdout
) or {}
return iface
}
// Allocate an IP address for a container (thread-safe)
//
// IP REUSE STRATEGY:
// 1. First, try to reuse an IP from the freed_ip_pool (recycled IPs from deleted containers)
// 2. If pool is empty, allocate a new IP by incrementing next_ip_offset
// 3. This prevents IP exhaustion in a /24 subnet (254 usable IPs)
//
// Thread Safety:
// This function uses network_mutex to ensure atomic IP allocation.
// Multiple concurrent container starts will be serialized at the IP allocation step,
// preventing race conditions where two containers could receive the same IP.
fn (mut self HeroPods) network_allocate_ip(container_name string) !string {
self.logger.log(
cat: 'network'
log: 'START network_allocate_ip() for container: ${container_name}'
logtype: .stdout
) or {}
self.logger.log(
cat: 'network'
log: 'Acquiring network_mutex lock...'
logtype: .stdout
) or {}
self.network_mutex.@lock()
self.logger.log(
cat: 'network'
log: 'network_mutex lock acquired'
logtype: .stdout
) or {}
defer {
self.logger.log(
cat: 'network'
log: 'Releasing network_mutex lock...'
logtype: .stdout
) or {}
self.network_mutex.unlock()
self.logger.log(
cat: 'network'
log: 'network_mutex lock released'
logtype: .stdout
) or {}
}
// Check if already allocated
if container_name in self.network_config.allocated_ips {
existing_ip := self.network_config.allocated_ips[container_name]
self.logger.log(
cat: 'network'
log: 'Container ${container_name} already has IP: ${existing_ip}'
logtype: .stdout
) or {}
return existing_ip
}
// Extract base IP from subnet (e.g., "10.10.0.0/24" -> "10.10.0")
subnet_parts := self.network_config.subnet.split('/')
base_ip_parts := subnet_parts[0].split('.')
base_ip := '${base_ip_parts[0]}.${base_ip_parts[1]}.${base_ip_parts[2]}'
// Determine IP offset: reuse from pool first, then increment
mut ip_offset := 0
if self.network_config.freed_ip_pool.len > 0 {
// Reuse a freed IP from the pool (LIFO - pop from end)
ip_offset = self.network_config.freed_ip_pool.last()
self.network_config.freed_ip_pool.delete_last()
self.logger.log(
cat: 'network'
log: 'Reusing IP offset ${ip_offset} from freed pool (pool size: ${self.network_config.freed_ip_pool.len})'
logtype: .stdout
) or {}
} else {
// No freed IPs available, allocate a new one
// This increment is atomic within the mutex lock
ip_offset = self.network_config.next_ip_offset
self.network_config.next_ip_offset++
// Check if we're approaching the subnet limit (254 usable IPs in /24)
if ip_offset > 254 {
return error('IP address pool exhausted: subnet ${self.network_config.subnet} has no more available IPs. Consider using a larger subnet or multiple bridges.')
}
self.logger.log(
cat: 'network'
log: 'Allocated new IP offset ${ip_offset} (next: ${self.network_config.next_ip_offset})'
logtype: .stdout
) or {}
}
// Build the full IP address
ip := '${base_ip}.${ip_offset}'
self.network_config.allocated_ips[container_name] = ip
self.logger.log(
cat: 'network'
log: 'Allocated IP ${ip} to container ${container_name}'
logtype: .stdout
) or {}
return ip
}
// Setup network for a container (creates veth pair, assigns IP, configures routing)
fn (mut self HeroPods) network_setup_container(container_name string, container_pid int) ! {
// Allocate IP address (thread-safe)
container_ip := self.network_allocate_ip(container_name)!
bridge_name := self.network_config.bridge_name
subnet_mask := self.network_config.subnet.split('/')[1]
gateway_ip := self.network_config.gateway_ip
// Create veth pair with unique names using hash to avoid collisions
// Interface names are limited to 15 chars, so we use a hash suffix
short_hash := sha256.hexhash(container_name)[..6]
veth_container_short := 'veth-${short_hash}'
veth_bridge_short := 'vbr-${short_hash}'
// Delete veth pair if it already exists (cleanup from previous run)
osal.exec(cmd: 'ip link delete ${veth_container_short} 2>/dev/null', stdout: false) or {}
osal.exec(cmd: 'ip link delete ${veth_bridge_short} 2>/dev/null', stdout: false) or {}
// Create veth pair
osal.exec(
cmd: 'ip link add ${veth_container_short} type veth peer name ${veth_bridge_short}'
stdout: false
)!
// Attach bridge end to bridge
osal.exec(
cmd: 'ip link set ${veth_bridge_short} master ${bridge_name}'
stdout: false
)!
osal.exec(
cmd: 'ip link set ${veth_bridge_short} up'
stdout: false
)!
// Move container end into container's network namespace
osal.exec(
cmd: 'ip link set ${veth_container_short} netns ${container_pid}'
stdout: false
)!
// Configure network inside container
// Rename veth to eth0 inside container for consistency
osal.exec(
cmd: 'nsenter -t ${container_pid} -n ip link set ${veth_container_short} name eth0'
stdout: false
)!
// Assign IP address
osal.exec(
cmd: 'nsenter -t ${container_pid} -n ip addr add ${container_ip}/${subnet_mask} dev eth0'
stdout: false
)!
// Bring interface up
osal.exec(
cmd: 'nsenter -t ${container_pid} -n ip link set dev eth0 up'
stdout: false
)!
// Add default route using gateway IP
osal.exec(
cmd: 'nsenter -t ${container_pid} -n ip route add default via ${gateway_ip}'
stdout: false
)!
}
// Configure DNS inside container by writing resolv.conf
fn (self HeroPods) network_configure_dns(container_name string, rootfs_path string) ! {
resolv_conf_path := '${rootfs_path}/etc/resolv.conf'
// Ensure /etc directory exists
etc_dir := '${rootfs_path}/etc'
if !os.exists(etc_dir) {
os.mkdir_all(etc_dir)!
}
// Build DNS configuration from configured DNS servers
mut dns_lines := []string{}
for dns_server in self.network_config.dns_servers {
dns_lines << 'nameserver ${dns_server}'
}
dns_content := dns_lines.join('\n') + '\n'
os.write_file(resolv_conf_path, dns_content)!
}
// Cleanup network for a container (removes veth pair and deallocates IP)
//
// Thread Safety:
// IP deallocation is protected by network_mutex to prevent race conditions
// when multiple containers are being deleted concurrently.
fn (mut self HeroPods) network_cleanup_container(container_name string) ! {
// Remove veth interfaces (they should be auto-removed when container stops, but cleanup anyway)
// Use same hash logic as setup to ensure we delete the correct interface
short_hash := sha256.hexhash(container_name)[..6]
veth_bridge_short := 'vbr-${short_hash}'
osal.exec(
cmd: 'ip link delete ${veth_bridge_short} 2>/dev/null'
stdout: false
) or {}
// Deallocate IP address and return it to the freed pool for reuse (thread-safe)
self.network_mutex.@lock()
defer {
self.network_mutex.unlock()
}
if container_name in self.network_config.allocated_ips {
ip := self.network_config.allocated_ips[container_name]
// Extract the IP offset from the full IP address (e.g., "10.10.0.42" -> 42)
ip_parts := ip.split('.')
if ip_parts.len == 4 {
ip_offset := ip_parts[3].int()
// Add to freed pool for reuse (avoid duplicates)
if ip_offset !in self.network_config.freed_ip_pool {
self.network_config.freed_ip_pool << ip_offset
}
}
// Remove from allocated IPs
self.network_config.allocated_ips.delete(container_name)
}
}
// Cleanup all network resources (called on reset)
//
// Parameters:
// - full: if true, also removes the bridge (for complete teardown)
// if false, keeps the bridge for reuse (default)
//
// Thread Safety:
// Uses separate lock/unlock calls for read and write operations to minimize
// lock contention. The container cleanup loop runs without holding the lock.
fn (mut self HeroPods) network_cleanup_all(full bool) ! {
// Get list of containers to cleanup (thread-safe read)
self.network_mutex.@lock()
container_names := self.network_config.allocated_ips.keys()
self.network_mutex.unlock()
// Remove all veth interfaces (no lock needed - operates on local copy)
for container_name in container_names {
self.network_cleanup_container(container_name) or {
}
}
// Clear allocated IPs and freed pool (thread-safe write)
self.network_mutex.@lock()
self.network_config.allocated_ips.clear()
self.network_config.freed_ip_pool.clear()
self.network_config.next_ip_offset = 10
self.network_mutex.unlock()
// Optionally remove the bridge for full cleanup
if full {
bridge_name := self.network_config.bridge_name
osal.exec(
cmd: 'ip link delete ${bridge_name}'
stdout: false
) or {}
}
}