diff --git a/web/network_core.py b/web/network_core.py new file mode 100644 index 0000000..74a70f3 --- /dev/null +++ b/web/network_core.py @@ -0,0 +1,217 @@ +import json +import re +import uuid +from pathlib import Path +from typing import Any + +from app_runtime import run_cmd + +CONFIG_DIR = Path('/var/lib/virtuality/config') +NETWORK_DIR = Path('/var/lib/virtuality/network') +NFT_DIR = Path('/etc/virtuality/nftables') +PORT_FORWARDS_FILE = NETWORK_DIR / 'port_forwards.json' +NFT_FILE = NFT_DIR / 'virtuality.nft' +NETWORK_NAME = 'virtuality-nat' +NAT_BRIDGE = 'virbr100' +NAT_SUBNET = '192.168.100.0/24' +NAT_GATEWAY = '192.168.100.1' +DHCP_START = '192.168.100.50' +DHCP_END = '192.168.100.200' + + +class NetworkError(Exception): + pass + + +def ensure_dirs() -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + NETWORK_DIR.mkdir(parents=True, exist_ok=True) + NFT_DIR.mkdir(parents=True, exist_ok=True) + + +def valid_port(value: int) -> bool: + return 1 <= int(value) <= 65535 + + +def valid_ip(value: str) -> bool: + return bool(re.fullmatch(r'(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}', value or '')) + + +def valid_proto(value: str) -> bool: + return value in {'tcp', 'udp'} + + +def external_interface() -> str: + result = run_cmd(['ip', 'route', 'show', 'default'], timeout=5) + if not result['ok']: + return 'eth0' + match = re.search(r'\bdev\s+([^\s]+)', result['stdout']) + return match.group(1) if match else 'eth0' + + +def ip_forward_state() -> str: + path = Path('/proc/sys/net/ipv4/ip_forward') + if not path.exists(): + return 'unknown' + return 'enabled' if path.read_text().strip() == '1' else 'disabled' + + +def nat_network_xml() -> str: + return f""" + {NETWORK_NAME} + + + + + + + + +""" + + +def libvirt_network_info() -> dict[str, Any]: + info = run_cmd(['virsh', 'net-info', NETWORK_NAME], timeout=8) + leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8) + return { + 'name': NETWORK_NAME, + 'bridge': NAT_BRIDGE, + 'subnet': NAT_SUBNET, + 'gateway': NAT_GATEWAY, + 'dhcp': f'{DHCP_START} - {DHCP_END}', + 'exists': info['ok'], + 'info': info['stdout'] if info['ok'] else info['stderr'], + 'leases': leases['stdout'] if leases['ok'] else leases['stderr'], + } + + +def list_libvirt_networks() -> list[dict[str, str]]: + result = run_cmd(['virsh', 'net-list', '--all'], timeout=8) + rows = [] + if not result['ok']: + return rows + for line in result['stdout'].splitlines()[2:]: + parts = line.split() + if len(parts) >= 3: + rows.append({'name': parts[0], 'state': parts[1], 'autostart': parts[2]}) + return rows + + +def create_nat_network() -> dict[str, Any]: + ensure_dirs() + Path('/tmp/virtuality-nat.xml').write_text(nat_network_xml()) + existing = run_cmd(['virsh', 'net-info', NETWORK_NAME], timeout=8) + if not existing['ok']: + defined = run_cmd(['virsh', 'net-define', '/tmp/virtuality-nat.xml'], timeout=15) + if not defined['ok']: + raise NetworkError(defined['stderr'] or defined['stdout'] or 'Не удалось создать libvirt NAT-сеть') + started = run_cmd(['virsh', 'net-start', NETWORK_NAME], timeout=15) + if not started['ok'] and 'already active' not in (started['stderr'] + started['stdout']).lower(): + raise NetworkError(started['stderr'] or started['stdout'] or 'Не удалось запустить libvirt NAT-сеть') + autostart = run_cmd(['virsh', 'net-autostart', NETWORK_NAME], timeout=15) + if not autostart['ok']: + raise NetworkError(autostart['stderr'] or autostart['stdout'] or 'Не удалось включить autostart для NAT-сети') + enable_ip_forward() + return libvirt_network_info() + + +def enable_ip_forward() -> None: + Path('/etc/sysctl.d/99-virtuality-forward.conf').write_text('net.ipv4.ip_forward=1\n') + run_cmd(['sysctl', '-p', '/etc/sysctl.d/99-virtuality-forward.conf'], timeout=10) + + +def load_port_forwards() -> list[dict[str, Any]]: + ensure_dirs() + if not PORT_FORWARDS_FILE.exists(): + PORT_FORWARDS_FILE.write_text('[]') + try: + data = json.loads(PORT_FORWARDS_FILE.read_text()) + except Exception: + data = [] + return data if isinstance(data, list) else [] + + +def save_port_forwards(items: list[dict[str, Any]]) -> None: + ensure_dirs() + PORT_FORWARDS_FILE.write_text(json.dumps(items, ensure_ascii=False, indent=2)) + + +def add_port_forward(vm_name: str, guest_ip: str, external_port: int, guest_port: int, protocol: str, note: str = '') -> dict[str, Any]: + if not vm_name or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name): + raise NetworkError('Некорректное имя VM') + if not valid_ip(guest_ip): + raise NetworkError('Некорректный внутренний IP VM') + if not valid_port(external_port) or not valid_port(guest_port): + raise NetworkError('Порт должен быть от 1 до 65535') + if not valid_proto(protocol): + raise NetworkError('Протокол должен быть tcp или udp') + items = load_port_forwards() + for item in items: + if int(item['external_port']) == int(external_port) and item['protocol'] == protocol: + raise NetworkError(f'Внешний порт {external_port}/{protocol} уже занят') + forward = { + 'id': str(uuid.uuid4()), + 'vm_name': vm_name, + 'guest_ip': guest_ip, + 'external_port': int(external_port), + 'guest_port': int(guest_port), + 'protocol': protocol, + 'note': note.strip()[:120], + } + items.append(forward) + save_port_forwards(items) + apply_port_forwards() + return forward + + +def delete_port_forward(forward_id: str) -> None: + items = [item for item in load_port_forwards() if item.get('id') != forward_id] + save_port_forwards(items) + apply_port_forwards() + + +def render_nft_rules() -> str: + ext = external_interface() + lines = [ + 'table ip virtuality {', + ' chain prerouting {', + ' type nat hook prerouting priority dstnat; policy accept;', + ] + for item in load_port_forwards(): + lines.append(f" iifname \"{ext}\" {item['protocol']} dport {int(item['external_port'])} dnat to {item['guest_ip']}:{int(item['guest_port'])}") + lines += [ + ' }', + ' chain postrouting {', + ' type nat hook postrouting priority srcnat; policy accept;', + f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade', + ' }', + ' chain forward {', + ' type filter hook forward priority filter; policy accept;', + f' ip saddr {NAT_SUBNET} accept', + f' ip daddr {NAT_SUBNET} accept', + ' }', + '}', + ] + return '\n'.join(lines) + '\n' + + +def apply_port_forwards() -> dict[str, Any]: + ensure_dirs() + enable_ip_forward() + NFT_FILE.write_text(render_nft_rules()) + run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8) + result = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15) + if not result['ok']: + raise NetworkError(result['stderr'] or result['stdout'] or 'Не удалось применить nftables-правила') + return {'ok': True, 'file': str(NFT_FILE), 'rules': render_nft_rules()} + + +def network_context() -> dict[str, Any]: + return { + 'nat': libvirt_network_info(), + 'networks': list_libvirt_networks(), + 'forwards': load_port_forwards(), + 'external_interface': external_interface(), + 'ip_forward': ip_forward_state(), + 'nft_rules': render_nft_rules(), + }